dsh-pause-resume 0.4.0 → 0.4.2
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 +16 -0
- package/index.js +1 -1
- package/lib/engine.js +40 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- The dot now means one thing: something is waiting on you (a paused plan with commands that will not restart, or a stop/restore that failed). A clean idle page has no dot, and killed jobs live in the restore record instead of becoming a permanent red light.
|
|
6
|
+
- Restore mounts sessions in bounded parallel batches, so wall time is the slowest batch instead of the sum of every session.
|
|
7
|
+
- Duplicate attention rows for the same item are collapsed.
|
|
8
|
+
|
|
9
|
+
## 0.4.1
|
|
10
|
+
|
|
11
|
+
A stop that could not finish no longer traps the button, and the dot stops lying after a resume.
|
|
12
|
+
|
|
13
|
+
* An unfinished stop now always offers the resume that releases it. The button used to prefer
|
|
14
|
+
`pause` whenever live work remained, so a partial stop re-ran the same stop on the same
|
|
15
|
+
uncancellable turn: the icon never changed, the attention dot stayed, and pressing it did
|
|
16
|
+
nothing a person could see.
|
|
17
|
+
* Work that starts while a plan waits no longer flips the button back to pause. The plan owns the
|
|
18
|
+
action until it is consumed.
|
|
3
19
|
## 0.4.0
|
|
4
20
|
|
|
5
21
|
The crash path is now a first-class restore path, and a dead button after a restart is fixed.
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createHeartbeat } from './lib/heartbeat.js'
|
|
|
7
7
|
import { createProbe, listRunning } from './lib/probe.js'
|
|
8
8
|
|
|
9
9
|
export const name = 'dsh-pause-resume'
|
|
10
|
-
export const VERSION = '0.4.
|
|
10
|
+
export const VERSION = '0.4.1'
|
|
11
11
|
|
|
12
12
|
const BASE_PATH = '/dsh-pause-resume'
|
|
13
13
|
const BUTTON_HEADER = 'x-dsh-pause-resume'
|
package/lib/engine.js
CHANGED
|
@@ -28,6 +28,18 @@ function emptyLive() {
|
|
|
28
28
|
return { sessions: [], goals: [], jobs: [], readable: false, source: 'none' }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** One row per item: a retried stop must not multiply the same unfinished job in the dot. */
|
|
32
|
+
function dedupeRows(rows) {
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
return rows.filter((row) => {
|
|
35
|
+
if (row === null || row === undefined) return false
|
|
36
|
+
const key = `${row.kind ?? ''}|${row.id ?? ''}|${row.detail ?? ''}`
|
|
37
|
+
if (seen.has(key)) return false
|
|
38
|
+
seen.add(key)
|
|
39
|
+
return true
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
export function createEngine({ store, probe, heartbeat = null, bootId = 'boot', epoch = null, now = () => Date.now(), graceMs = 2000, log = () => {} }) {
|
|
32
44
|
/**
|
|
33
45
|
* A reload creates a new instance and bumps the shared epoch. An old instance's press may still
|
|
@@ -139,11 +151,19 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
139
151
|
const work = workOf(current)
|
|
140
152
|
const busy = TRANSIENT.has(phase)
|
|
141
153
|
const workCount = liveWorkCount(work)
|
|
154
|
+
// A plan is an unfinished stop: the only way forward is to resume it, even when the stop left
|
|
155
|
+
// work behind. Preferring `pause` there used to trap a partial stop forever: every press
|
|
156
|
+
// re-ran the same stop, the residue could never be cancelled, and the button never offered the
|
|
157
|
+
// resume that would have released the plan.
|
|
142
158
|
let action = 'none'
|
|
143
159
|
if (busy) action = 'busy'
|
|
144
|
-
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
145
160
|
else if (plan !== null) action = 'resume'
|
|
146
|
-
|
|
161
|
+
else if (current.readable === true && workCount > 0) action = 'pause'
|
|
162
|
+
// The dot means "something still needs you". A clean idle page has nothing to point at: the
|
|
163
|
+
// jobs a pause killed stay in the last-restore record, but they are not an open obligation.
|
|
164
|
+
const attentionRows = plan !== null
|
|
165
|
+
? dedupeRows(Array.isArray(plan.attention) ? plan.attention : [])
|
|
166
|
+
: phase === 'idle' && lastError === null ? null : result?.attention
|
|
147
167
|
const attention = Array.isArray(attentionRows) ? attentionRows.length : 0
|
|
148
168
|
const first = attention > 0 ? attentionRows[0] : null
|
|
149
169
|
const focus = first === null ? null : (typeof first === 'string' ? first : (first.id || first.detail || null))
|
|
@@ -338,16 +358,17 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
338
358
|
const match = new RegExp(`^${kind} ([^:]+):`).exec(String(detail))
|
|
339
359
|
return match === null ? '' : match[1].trim()
|
|
340
360
|
}
|
|
341
|
-
plan.attention = [
|
|
361
|
+
plan.attention = dedupeRows([
|
|
342
362
|
...(Array.isArray(basePlan?.attention) ? basePlan.attention : []),
|
|
343
363
|
...stillRunning.map((id) => ({ kind: 'session', id, detail: 'still running' })),
|
|
364
|
+
...(stopped.killedJobs ?? []).map((job) => ({ kind: 'job', id: job.id, detail: 'stopped, not restarted' })),
|
|
344
365
|
...(stopped.goalErrors ?? []).map((detail) => {
|
|
345
366
|
const goalId = errorId('goal', detail)
|
|
346
367
|
const owner = [...(basePlan?.goals ?? []), ...(current.goals ?? [])].find((row) => row.goalId === goalId)
|
|
347
368
|
return { kind: 'goal', id: owner?.sessionId ?? goalId, detail }
|
|
348
369
|
}),
|
|
349
370
|
...(stopped.jobErrors ?? []).map((detail) => ({ kind: 'job', id: errorId('job', detail), detail })),
|
|
350
|
-
]
|
|
371
|
+
])
|
|
351
372
|
if (isCrashPlan(basePlan) === true) {
|
|
352
373
|
// The crash record described work that was not paused. This press did pause it, so the plan
|
|
353
374
|
// becomes a normal pause plan and the crash evidence is no longer a separate obligation.
|
|
@@ -372,7 +393,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
372
393
|
}
|
|
373
394
|
|
|
374
395
|
function summaryOf(plan) {
|
|
375
|
-
return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, at: new Date(now()).toISOString() }
|
|
396
|
+
return { planId: plan.planId, source: plan.source ?? 'pause', restored: [], skipped: [], failed: [], attention: [], jobs: 0, jobsStopped: [], at: new Date(now()).toISOString() }
|
|
376
397
|
}
|
|
377
398
|
|
|
378
399
|
/**
|
|
@@ -423,9 +444,20 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
423
444
|
return outcome
|
|
424
445
|
}
|
|
425
446
|
|
|
426
|
-
|
|
427
|
-
|
|
447
|
+
const sessionRows = plan.sessions ?? []
|
|
448
|
+
const sessionDone = new Array(sessionRows.length)
|
|
449
|
+
let nextSession = 0
|
|
450
|
+
const sessionWorker = async () => {
|
|
451
|
+
while (nextSession < sessionRows.length) {
|
|
452
|
+
const index = nextSession
|
|
453
|
+
nextSession += 1
|
|
454
|
+
const session = sessionRows[index]
|
|
455
|
+
sessionDone[index] = await step(`s:${session.sessionId}`, `session ${session.sessionId}`, () => probe.restoreSession(session, { plan }))
|
|
456
|
+
}
|
|
428
457
|
}
|
|
458
|
+
// Bounded fan-out: every mount asks the same registry for its own session, so a handful in
|
|
459
|
+
// flight turns a long serial restore into one batch without flooding the Harness.
|
|
460
|
+
await Promise.all(Array.from({ length: Math.min(4, sessionRows.length) }, sessionWorker))
|
|
429
461
|
for (const goal of plan.goals ?? []) {
|
|
430
462
|
const key = `g:${goal.sessionId}:${goal.goalId ?? ''}`
|
|
431
463
|
const outcome = await step(key, `goal ${goal.goalId ?? goal.sessionId}`, () => probe.restoreGoal(goal, { plan }))
|
|
@@ -440,7 +472,7 @@ export function createEngine({ store, probe, heartbeat = null, bootId = 'boot',
|
|
|
440
472
|
}
|
|
441
473
|
// A killed job cannot be replayed: the same command may have had an unknown effect, so it is
|
|
442
474
|
// reported once and left to the person. It never blocks the plan and is never restarted.
|
|
443
|
-
for (const job of plan.jobs ?? []) summary.
|
|
475
|
+
for (const job of plan.jobs ?? []) summary.jobsStopped.push(`job ${job.id}: stopped, not restarted`)
|
|
444
476
|
|
|
445
477
|
result = summary
|
|
446
478
|
if (summary.failed.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
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",
|