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/README.md
CHANGED
|
@@ -1,34 +1,66 @@
|
|
|
1
1
|
# dsh-pause-resume
|
|
2
2
|
|
|
3
|
-
One button in the DeepSeek Harness session header. It stops
|
|
4
|
-
restart the same button puts that work back.
|
|
3
|
+
One button in the DeepSeek Harness session header. It stops what is running, writes a small plan to
|
|
4
|
+
disk, and after a restart the same button puts that work back.
|
|
5
5
|
|
|
6
6
|
* ⏸ — work is running. Press to stop it.
|
|
7
|
-
* ▶ — work is stopped, or a previous
|
|
7
|
+
* ▶ — work is stopped, or a previous process died mid-turn. Press to restore it.
|
|
8
8
|
* ⏸ dimmed — nothing to stop and nothing to restore.
|
|
9
9
|
|
|
10
10
|
The glyph is the only status. There is no counter, no panel and no toast, because a status badge next
|
|
11
|
-
to the button would be a second source of truth for the same fact.
|
|
11
|
+
to the button would be a second source of truth for the same fact. A red dot is added only when the
|
|
12
|
+
last press left something for a person to look at.
|
|
12
13
|
|
|
13
14
|
## What it restores
|
|
14
15
|
|
|
15
16
|
| Work | Pause | Restore |
|
|
16
17
|
| --- | --- | --- |
|
|
17
|
-
| A session with an unfinished turn | turn cancelled, session recorded | session
|
|
18
|
-
|
|
|
18
|
+
| A session with an unfinished turn | turn cancelled, session recorded | session mounted, then one visible continuation message |
|
|
19
|
+
| A goal that was armed | paused with the revision it had | `goals.resume` with that exact revision; the goal driver continues on its own |
|
|
20
|
+
| A session that only carried that goal | recorded, nothing stopped | mounted; the resumed goal takes over |
|
|
19
21
|
| A running background job | killed and recorded | recorded only: a job is a live process, not replayable |
|
|
20
|
-
| A
|
|
21
|
-
|
|
|
22
|
+
| A session that ended normally | untouched | untouched, and never recorded in the first place |
|
|
23
|
+
| A terminal / finished session | untouched | untouched |
|
|
24
|
+
| A schedule | untouched | untouched |
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a
|
|
27
|
+
person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused
|
|
28
|
+
starts a turn; the button then shows ⏸ again, truthfully.
|
|
29
|
+
|
|
30
|
+
## How resume works
|
|
31
|
+
|
|
32
|
+
A restore is not a claim that work continued. It is three honest steps per item:
|
|
33
|
+
|
|
34
|
+
1. **Mount.** A stopped session is mounted through the Harness resume path, which preserves its agent
|
|
35
|
+
preset. If it is already mounted, that step is skipped.
|
|
36
|
+
2. **Continue, only where the press actually cancelled a turn.** The continuation is one ordinary
|
|
37
|
+
user message sent through `sessionController.prompt`, with a request id derived from the plan and
|
|
38
|
+
the session. The id makes a repeated restore idempotent, and the message is visible in the
|
|
39
|
+
transcript, so the person can see exactly what was resumed.
|
|
40
|
+
3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
|
|
41
|
+
goal changed while paused, it is not ours to resume and the item is `skipped`.
|
|
42
|
+
|
|
43
|
+
Every attempt is journaled with the id of the process that made it, but a journal is reporting, not a
|
|
44
|
+
promise: the next boot asks the live Harness again instead of trusting an old row. The continuation
|
|
45
|
+
request id is derived from the plan and the session, so a retry before a turn starts is deduplicated
|
|
46
|
+
by the Host. A failure leaves the plan on disk and the next press retries every item that still needs
|
|
47
|
+
work.
|
|
25
48
|
|
|
26
49
|
## Crash, not just a press
|
|
27
50
|
|
|
28
51
|
A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
|
|
29
52
|
`running.json` holds the sessions whose turn is open right now and the id of the process that wrote
|
|
30
|
-
it.
|
|
31
|
-
|
|
53
|
+
it. The previous process's record is turned into a crash plan in `pending.json` before the heartbeat
|
|
54
|
+
file is replaced, so a second or third restart still sees the same work and offers ▶ with no press
|
|
55
|
+
needed.
|
|
56
|
+
|
|
57
|
+
A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
|
|
58
|
+
wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
|
|
59
|
+
type a continuation message on their behalf. Sessions that ended normally never enter the file.
|
|
60
|
+
|
|
61
|
+
The same process reloading the plugin (hot reload) sees its own id and knows there is nothing to
|
|
62
|
+
restore. A `pausing` plan found on disk means the press itself was interrupted: those sessions are
|
|
63
|
+
mounted but never continued, and the button carries a dot until a person presses restore.
|
|
32
64
|
|
|
33
65
|
## Installing
|
|
34
66
|
|
|
@@ -37,24 +69,25 @@ plugin_manager install_bundle dsh-pause-resume
|
|
|
37
69
|
```
|
|
38
70
|
|
|
39
71
|
or install the bundle from the Harness plugin manager with the package name. The plugin needs no
|
|
40
|
-
configuration and holds no credentials.
|
|
72
|
+
configuration and holds no credentials. `allowRemote` exists for a Harness reached from another
|
|
73
|
+
machine; local-only is the default.
|
|
41
74
|
|
|
42
75
|
## Guarantees and limits
|
|
43
76
|
|
|
44
77
|
* **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
|
|
45
78
|
a double press and a tool call cannot overlap. The second caller is told `busy`.
|
|
46
|
-
* **
|
|
47
|
-
|
|
48
|
-
* **
|
|
49
|
-
|
|
50
|
-
* **
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* **No
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
* **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
|
|
80
|
+
nothing was stopped.
|
|
81
|
+
* **Idempotent.** The continuation request id is derived from the plan and the session, so a retry
|
|
82
|
+
cannot post the same message twice; a turn that already moved is not continued again.
|
|
83
|
+
* **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
|
|
84
|
+
hand, a session already running: each is skipped and named in the outcome.
|
|
85
|
+
* **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
|
|
86
|
+
"turns cancelled, goals paused, jobs killed" — not "the process is frozen". A goal continues because
|
|
87
|
+
the Harness has a mechanism for that; a plain session continues through one visible user message.
|
|
88
|
+
* **No third-party plugin state.** A plugin's in-process state belongs to that plugin's mount.
|
|
89
|
+
* **Local by default.** The routes require a same-origin request and a plugin header. Remote use must
|
|
90
|
+
be opted into and still cannot skip the header.
|
|
58
91
|
|
|
59
92
|
## Files
|
|
60
93
|
|
|
@@ -62,28 +95,32 @@ All state lives under `$DSH_HOME/pause-resume/`:
|
|
|
62
95
|
|
|
63
96
|
| File | Purpose |
|
|
64
97
|
| --- | --- |
|
|
65
|
-
| `snapshot.json` | the work a press stopped |
|
|
98
|
+
| `snapshot.json` | the work a press stopped, `pausing` or `paused` |
|
|
99
|
+
| `pending.json` | a crash plan, kept until a restore finishes |
|
|
100
|
+
| `journal/<planId>.jsonl` | restore attempts, tagged with the boot that made them |
|
|
101
|
+
| `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
|
|
102
|
+
| `last-restore.json` | summary of the last restore |
|
|
66
103
|
| `running.json` | sessions with an open turn, for crash residue |
|
|
67
|
-
| `restore.jsonl` | restore steps already done |
|
|
68
|
-
| `last-restore.json` | record of the last restore |
|
|
69
104
|
|
|
70
|
-
Every write is temp file
|
|
105
|
+
Every write is a unique temp file, `fsync`, `rename`, then a directory `fsync`. State is never inside
|
|
106
|
+
the package.
|
|
71
107
|
|
|
72
108
|
## Development
|
|
73
109
|
|
|
74
110
|
```bash
|
|
75
|
-
|
|
111
|
+
npm test
|
|
112
|
+
npm run audit
|
|
76
113
|
```
|
|
77
114
|
|
|
78
115
|
`lib/engine.js` performs no I/O, `lib/snapshot.js` persists, `lib/probe.js` holds every Harness service
|
|
79
|
-
contract (
|
|
80
|
-
routes. All
|
|
81
|
-
|
|
116
|
+
contract (agent identity, goal revision, per-owner jobs, continuation prompts), and `index.js` only
|
|
117
|
+
wires them to the web routes. All of them are testable without a Harness: `test/apply.test.mjs` mounts
|
|
118
|
+
the plugin against a fake registry.
|
|
82
119
|
|
|
83
120
|
MIT.
|
|
84
121
|
|
|
85
122
|
---
|
|
86
123
|
|
|
87
124
|
一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
接回来。正常结束的会话不记录、不恢复;崩溃靠心跳文件识别,但只挂载不代发消息;日程不会被动
|
|
126
|
+
(该服务只有删除,没有停用),后台命令只记录不重跑。
|
package/client.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
window.__ModuleLoader__.load({
|
|
2
|
-
id: 'dsh-pause-resume',
|
|
2
|
+
id: 'dsh-pause-resume', // ui-copy-ignore
|
|
3
3
|
factory(require) {
|
|
4
4
|
const React = require('react')
|
|
5
5
|
const h = React.createElement
|
|
6
6
|
|
|
7
7
|
const zh = /^zh/i.test(navigator.language || '')
|
|
8
8
|
const COPY = zh
|
|
9
|
-
? {
|
|
10
|
-
|
|
9
|
+
? {
|
|
10
|
+
pause: '暂停', // ui-copy-ignore
|
|
11
|
+
resume: '继续', // ui-copy-ignore
|
|
12
|
+
busy: '正在处理', // ui-copy-ignore
|
|
13
|
+
error: '上次操作没有完成', // ui-copy-ignore
|
|
14
|
+
partial: '部分内容未能恢复,可以再按一次', // ui-copy-ignore
|
|
15
|
+
stopped: '上次暂停的内容还没有恢复', // ui-copy-ignore
|
|
16
|
+
attention: '有内容需要你处理',
|
|
17
|
+
}
|
|
18
|
+
: {
|
|
19
|
+
pause: 'Pause', // ui-copy-ignore
|
|
20
|
+
resume: 'Resume', // ui-copy-ignore
|
|
21
|
+
busy: 'Working', // ui-copy-ignore
|
|
22
|
+
error: 'The last action did not finish',
|
|
23
|
+
partial: 'Some items were not restored; press again to retry',
|
|
24
|
+
stopped: 'Work is still stopped',
|
|
25
|
+
attention: 'Some items need a look',
|
|
26
|
+
}
|
|
11
27
|
|
|
12
28
|
const HOST_BASE = '/dsh-pause-resume' // ui-copy-ignore
|
|
13
29
|
const POLL_MS = 2000 // ui-copy-ignore
|
|
@@ -18,7 +34,17 @@ window.__ModuleLoader__.load({
|
|
|
18
34
|
* Host owns the phase and this asks for it. Until an answer arrives the button renders the // ui-copy-ignore
|
|
19
35
|
* settled state (`none`), never a spinner, so a slow Host cannot look like a running press. // ui-copy-ignore
|
|
20
36
|
*/
|
|
21
|
-
const EMPTY = {
|
|
37
|
+
const EMPTY = {
|
|
38
|
+
phase: 'idle', // ui-copy-ignore
|
|
39
|
+
action: 'none', // ui-copy-ignore
|
|
40
|
+
source: null, // ui-copy-ignore
|
|
41
|
+
complete: null, // ui-copy-ignore
|
|
42
|
+
outstanding: 0,
|
|
43
|
+
counts: { sessions: 0, goals: 0, jobs: 0 },
|
|
44
|
+
live: { sessions: 0, goals: 0, jobs: 0 },
|
|
45
|
+
attention: 0,
|
|
46
|
+
error: null, // ui-copy-ignore
|
|
47
|
+
}
|
|
22
48
|
|
|
23
49
|
function withTimeout(promise, ms = REQUEST_MS) {
|
|
24
50
|
let id = null
|
|
@@ -28,9 +54,9 @@ window.__ModuleLoader__.load({
|
|
|
28
54
|
return Promise.race([promise, timer]).finally(() => clearTimeout(id))
|
|
29
55
|
}
|
|
30
56
|
|
|
31
|
-
async function readState() {
|
|
57
|
+
async function readState(signal) {
|
|
32
58
|
try {
|
|
33
|
-
const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' }))
|
|
59
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin', signal })) // ui-copy-ignore
|
|
34
60
|
if (!response.ok) return null
|
|
35
61
|
const data = await withTimeout(response.json())
|
|
36
62
|
return data !== null && typeof data === 'object' ? data : null
|
|
@@ -39,13 +65,14 @@ window.__ModuleLoader__.load({
|
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
async function press(action) {
|
|
68
|
+
async function press(action, signal) {
|
|
43
69
|
try {
|
|
44
70
|
const response = await withTimeout(fetch(`${HOST_BASE}/${action}`, {
|
|
45
71
|
method: 'POST', // ui-copy-ignore
|
|
46
72
|
credentials: 'same-origin',
|
|
47
73
|
headers: { 'content-type': 'application/json', 'x-dsh-pause-resume': '1' }, // ui-copy-ignore
|
|
48
74
|
body: '{}', // ui-copy-ignore
|
|
75
|
+
signal,
|
|
49
76
|
}))
|
|
50
77
|
if (!response.ok) return null
|
|
51
78
|
const data = await withTimeout(response.json())
|
|
@@ -110,49 +137,105 @@ window.__ModuleLoader__.load({
|
|
|
110
137
|
}, h('path', { d: 'M5.2 3.4a.7.7 0 0 0-1.05.6v8a.7.7 0 0 0 1.05.6l6.9-4a.7.7 0 0 0 0-1.2l-6.9-4z' }))
|
|
111
138
|
const SPIN_GLYPH = icon(['M8 2.5a5.5 5.5 0 1 1-4.3 2.06'], SPIN_STYLE)
|
|
112
139
|
|
|
140
|
+
/** The one sentence behind the dot, in the tooltip only: the button itself stays a glyph. */
|
|
141
|
+
function hintOf(state) {
|
|
142
|
+
if (state.error === 'restore-partial') return COPY.partial
|
|
143
|
+
if (state.error !== null && state.error !== undefined) return COPY.error
|
|
144
|
+
if ((state.attention ?? 0) > 0) return COPY.attention
|
|
145
|
+
if ((state.outstanding ?? 0) > 0) return COPY.stopped
|
|
146
|
+
return ''
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
function Button() {
|
|
114
150
|
const [state, setState] = React.useState(EMPTY)
|
|
115
151
|
const [hover, setHover] = React.useState(false)
|
|
152
|
+
const [pressing, setPressing] = React.useState(false)
|
|
116
153
|
const mounted = React.useRef(true)
|
|
154
|
+
/** Each answer carries a number: a slow reply may never overwrite a newer one. */
|
|
155
|
+
const seq = React.useRef(0)
|
|
156
|
+
const inflight = React.useRef(null)
|
|
157
|
+
|
|
158
|
+
const apply = React.useCallback((next) => {
|
|
159
|
+
if (!mounted.current || next === null) return
|
|
160
|
+
seq.current += 1
|
|
161
|
+
setState({ ...EMPTY, ...next })
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
const pull = React.useCallback(async (signal) => {
|
|
165
|
+
const mine = seq.current
|
|
166
|
+
const next = await readState(signal)
|
|
167
|
+
if (!mounted.current || seq.current !== mine) return
|
|
168
|
+
if (next !== null) apply(next)
|
|
169
|
+
}, [apply])
|
|
117
170
|
|
|
118
171
|
React.useEffect(() => {
|
|
119
172
|
ensureStyle()
|
|
120
173
|
mounted.current = true
|
|
121
174
|
let timer = null
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
175
|
+
let stopped = false
|
|
176
|
+
let current = null
|
|
177
|
+
// One request at a time, and the next one starts after this one settles. A fixed interval
|
|
178
|
+
// that aborts its own predecessor made any answer slower than the interval invisible.
|
|
179
|
+
const beat = async () => {
|
|
180
|
+
timer = null
|
|
181
|
+
if (stopped || document.visibilityState !== 'visible' || inflight.current !== null) { schedule() ; return }
|
|
182
|
+
current = typeof AbortController === 'function' ? new AbortController() : null
|
|
183
|
+
try {
|
|
184
|
+
await pull(current?.signal)
|
|
185
|
+
} finally {
|
|
186
|
+
current = null
|
|
187
|
+
schedule()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const schedule = () => {
|
|
191
|
+
if (stopped || timer !== null) return
|
|
192
|
+
timer = setTimeout(() => { void beat() }, POLL_MS)
|
|
126
193
|
}
|
|
127
|
-
pull()
|
|
128
|
-
timer = setInterval(() => {
|
|
129
|
-
if (document.visibilityState === 'visible') pull()
|
|
130
|
-
}, POLL_MS)
|
|
131
194
|
const onVisible = () => {
|
|
132
|
-
if (document.visibilityState
|
|
195
|
+
if (document.visibilityState !== 'visible') return
|
|
196
|
+
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
197
|
+
void beat()
|
|
133
198
|
}
|
|
199
|
+
void beat()
|
|
134
200
|
document.addEventListener('visibilitychange', onVisible)
|
|
135
201
|
return () => {
|
|
202
|
+
stopped = true
|
|
136
203
|
mounted.current = false
|
|
137
|
-
if (timer !== null)
|
|
204
|
+
if (timer !== null) clearTimeout(timer)
|
|
205
|
+
try {
|
|
206
|
+
current?.abort()
|
|
207
|
+
} catch {}
|
|
138
208
|
document.removeEventListener('visibilitychange', onVisible)
|
|
139
209
|
}
|
|
140
|
-
}, [])
|
|
210
|
+
}, [pull])
|
|
141
211
|
|
|
142
|
-
const busy = state.action === 'busy'
|
|
212
|
+
const busy = pressing || state.action === 'busy'
|
|
143
213
|
const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
|
|
144
214
|
const disabled = action === null && !busy
|
|
145
215
|
|
|
146
216
|
const onClick = async () => {
|
|
147
|
-
if (busy || action === null) return
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
217
|
+
if (busy || action === null || inflight.current !== null) return
|
|
218
|
+
// One press at a time, in this tab and across tabs: the Host also refuses an overlapping // ui-copy-ignore
|
|
219
|
+
// press, so a second window's button settles on the same answer instead of starting work.
|
|
220
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
221
|
+
inflight.current = controller
|
|
222
|
+
setPressing(true)
|
|
223
|
+
try {
|
|
224
|
+
const answer = await press(action, controller?.signal)
|
|
225
|
+
const next = answer ?? (await readState())
|
|
226
|
+
if (mounted.current) {
|
|
227
|
+
if (next !== null) {
|
|
228
|
+
seq.current += 1
|
|
229
|
+
setState({ ...EMPTY, ...next })
|
|
230
|
+
} else {
|
|
231
|
+
// The press may still have landed: ask again rather than guessing the phase.
|
|
232
|
+
void pull(controller?.signal)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
inflight.current = null
|
|
237
|
+
if (mounted.current) setPressing(false)
|
|
238
|
+
}
|
|
156
239
|
}
|
|
157
240
|
|
|
158
241
|
const style = busy || disabled
|
|
@@ -163,6 +246,7 @@ window.__ModuleLoader__.load({
|
|
|
163
246
|
|
|
164
247
|
const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
|
|
165
248
|
const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
|
|
249
|
+
const hint = hintOf(state)
|
|
166
250
|
|
|
167
251
|
return h('button', {
|
|
168
252
|
type: 'button', // ui-copy-ignore
|
|
@@ -171,9 +255,10 @@ window.__ModuleLoader__.load({
|
|
|
171
255
|
disabled,
|
|
172
256
|
'aria-label': label,
|
|
173
257
|
'aria-busy': busy,
|
|
258
|
+
title: hint === '' ? label : `${label} · ${hint}`,
|
|
174
259
|
onMouseEnter: () => setHover(true),
|
|
175
260
|
onMouseLeave: () => setHover(false),
|
|
176
|
-
}, glyph,
|
|
261
|
+
}, glyph, hint === '' ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
|
|
177
262
|
}
|
|
178
263
|
|
|
179
264
|
return {
|
package/index.js
CHANGED
|
@@ -6,11 +6,12 @@ import { createHeartbeat } from './lib/heartbeat.js'
|
|
|
6
6
|
import { createProbe, listRunning } from './lib/probe.js'
|
|
7
7
|
|
|
8
8
|
export const name = 'dsh-pause-resume'
|
|
9
|
-
export const VERSION = '0.
|
|
9
|
+
export const VERSION = '0.3.0'
|
|
10
10
|
|
|
11
11
|
const BASE_PATH = '/dsh-pause-resume'
|
|
12
12
|
const BUTTON_HEADER = 'x-dsh-pause-resume'
|
|
13
13
|
const BOOT_KEY = Symbol.for('dsh-pause-resume.boot')
|
|
14
|
+
const LIVENESS_MS = 400
|
|
14
15
|
|
|
15
16
|
/** A page this machine may have opened. A public name that resolves here is the rebinding case. */
|
|
16
17
|
const LOCAL_ADDRESS =
|
|
@@ -38,7 +39,6 @@ function bootId() {
|
|
|
38
39
|
return created
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
42
|
function service(ctx, key) {
|
|
43
43
|
try {
|
|
44
44
|
return ctx.get(key)
|
|
@@ -49,8 +49,8 @@ function service(ctx, key) {
|
|
|
49
49
|
|
|
50
50
|
function sendJson(res, status, value) {
|
|
51
51
|
try {
|
|
52
|
-
const body = JSON.stringify(value ?? { status: 'error' })
|
|
53
52
|
if (res?.headersSent === true) return res.end()
|
|
53
|
+
const body = JSON.stringify(value ?? { status: 'error' })
|
|
54
54
|
res.writeHead(status, {
|
|
55
55
|
'content-type': 'application/json; charset=utf-8',
|
|
56
56
|
'cache-control': 'no-store',
|
|
@@ -66,13 +66,18 @@ function sendJson(res, status, value) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Whether one request may act for the button: a header a cross-site form cannot set, and the same
|
|
71
|
+
* origin that served this page. `allowRemote` widens the address check, never the header or origin
|
|
72
|
+
* check, so a hostile page cannot press the button even on a trusted network.
|
|
73
|
+
*/
|
|
74
|
+
function buttonAllowed(req, allowRemote) {
|
|
71
75
|
try {
|
|
72
76
|
const header = req?.headers?.[BUTTON_HEADER]
|
|
73
77
|
if (typeof header !== 'string' || header === '') return false
|
|
74
78
|
const site = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '' // ui-copy-ignore
|
|
75
|
-
if (site
|
|
79
|
+
if (site === '') return false
|
|
80
|
+
if (allowRemote !== true && LOCAL_ADDRESS.test(site) === false) return false
|
|
76
81
|
const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
|
|
77
82
|
if (origin === 'null') return false
|
|
78
83
|
if (origin !== '') {
|
|
@@ -112,50 +117,53 @@ function guard(handler) {
|
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
function serve(ctx, engine, heartbeat) {
|
|
120
|
+
function serve(ctx, engine, heartbeat, options) {
|
|
116
121
|
const server = service(ctx, 'webServer')
|
|
117
122
|
if (server === undefined || server === null || typeof server.register !== 'function') {
|
|
118
123
|
log('no web server: the header button cannot ask for a pause')
|
|
119
124
|
return () => {}
|
|
120
125
|
}
|
|
126
|
+
const allowRemote = options.allowRemote === true
|
|
127
|
+
const route = (handler, read) => guard(async (req, res) => {
|
|
128
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
129
|
+
if (read === true ? method !== 'GET' && method !== 'HEAD' : method !== 'POST') {
|
|
130
|
+
return sendJson(res, 405, { status: 'method' })
|
|
131
|
+
}
|
|
132
|
+
// A read still refuses another origin, but it does not need the button header.
|
|
133
|
+
if (read === true ? sameOrigin(req, allowRemote) === false : buttonAllowed(req, allowRemote) === false) {
|
|
134
|
+
return sendJson(res, 403, { status: 'forbidden' })
|
|
135
|
+
}
|
|
136
|
+
return handler(req, res)
|
|
137
|
+
})
|
|
121
138
|
const handlers = [
|
|
122
139
|
[
|
|
123
140
|
`${BASE_PATH}/state`,
|
|
124
|
-
async (req, res) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
sendJson(res, 200, await engine.describe())
|
|
128
|
-
},
|
|
141
|
+
route(async (req, res) => {
|
|
142
|
+
sendJson(res, 200, engine.describe())
|
|
143
|
+
}, true),
|
|
129
144
|
],
|
|
130
145
|
[
|
|
131
146
|
`${BASE_PATH}/pause`,
|
|
132
|
-
async (req, res) => {
|
|
133
|
-
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
134
|
-
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
135
|
-
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
147
|
+
route(async (req, res) => {
|
|
136
148
|
const outcome = await engine.pause()
|
|
137
149
|
log(`pause: ${outcome.status}${outcome.status === 'busy' ? ` (${outcome.phase})` : ''}`)
|
|
138
|
-
if (outcome.status === 'paused') heartbeat.release()
|
|
139
|
-
sendJson(res, 200, { ...
|
|
140
|
-
},
|
|
150
|
+
if (outcome.status === 'paused' || outcome.status === 'partial') heartbeat.release()
|
|
151
|
+
sendJson(res, 200, { ...engine.describe(), outcome })
|
|
152
|
+
}, false),
|
|
141
153
|
],
|
|
142
154
|
[
|
|
143
155
|
`${BASE_PATH}/resume`,
|
|
144
|
-
async (req, res) => {
|
|
145
|
-
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
146
|
-
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
147
|
-
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
156
|
+
route(async (req, res) => {
|
|
148
157
|
const outcome = await engine.resume()
|
|
149
158
|
log(`resume: ${outcome.status}`)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
},
|
|
159
|
+
sendJson(res, 200, { ...engine.describe(), outcome })
|
|
160
|
+
}, false),
|
|
153
161
|
],
|
|
154
162
|
]
|
|
155
163
|
const disposers = []
|
|
156
164
|
for (const [path, handler] of handlers) {
|
|
157
165
|
try {
|
|
158
|
-
const dispose = server.register({ kind: 'exact', path, handler
|
|
166
|
+
const dispose = server.register({ kind: 'exact', path, handler })
|
|
159
167
|
if (typeof dispose === 'function') disposers.push(dispose)
|
|
160
168
|
} catch (error) {
|
|
161
169
|
log(`cannot answer ${path}: ${error?.message ?? error}`)
|
|
@@ -170,39 +178,104 @@ function serve(ctx, engine, heartbeat) {
|
|
|
170
178
|
}
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
/** Same-origin read: a page on another host may not learn what is running here. */ // ui-copy-ignore
|
|
182
|
+
function sameOrigin(req, allowRemote) {
|
|
183
|
+
try {
|
|
184
|
+
const site = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '' // ui-copy-ignore
|
|
185
|
+
if (site === '') return false
|
|
186
|
+
if (allowRemote !== true && LOCAL_ADDRESS.test(site) === false) return false
|
|
187
|
+
const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
|
|
188
|
+
if (origin === 'null') return false
|
|
189
|
+
if (origin === '') return true
|
|
190
|
+
try {
|
|
191
|
+
return new URL(origin).host.toLowerCase() === site // ui-copy-ignore
|
|
192
|
+
} catch {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
export function apply(ctx, config = {}) {
|
|
174
201
|
const dir = dataDir()
|
|
175
|
-
const
|
|
202
|
+
const allowRemote = config?.allowRemote === true
|
|
203
|
+
const store = createStore(dir, { log })
|
|
176
204
|
const heartbeat = createHeartbeat({
|
|
177
205
|
dir,
|
|
178
206
|
bootId: bootId(),
|
|
207
|
+
intervalMs: Number.isFinite(config?.intervalMs) ? config.intervalMs : 5000,
|
|
179
208
|
listRunning: () => listRunning(ctx),
|
|
180
209
|
log,
|
|
181
210
|
})
|
|
211
|
+
const probe = createProbe(ctx, {
|
|
212
|
+
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
213
|
+
continueText: typeof config?.continueText === 'string' ? config.continueText : undefined,
|
|
214
|
+
})
|
|
182
215
|
const engine = createEngine({
|
|
183
216
|
store,
|
|
184
|
-
probe
|
|
217
|
+
probe,
|
|
218
|
+
heartbeat,
|
|
219
|
+
bootId: bootId(),
|
|
185
220
|
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
186
|
-
gapMs: Number.isFinite(config?.gapMs) ? config.gapMs : 150,
|
|
187
221
|
log,
|
|
188
222
|
})
|
|
189
223
|
|
|
224
|
+
let disposed = false
|
|
190
225
|
let disposeRoutes = () => {}
|
|
191
226
|
const cleanup = () => {
|
|
227
|
+
disposed = true
|
|
192
228
|
try {
|
|
193
229
|
disposeRoutes()
|
|
194
230
|
} catch {}
|
|
231
|
+
// A reload is not a crash: keep the heartbeat's last write as evidence, but let go of timers.
|
|
195
232
|
heartbeat.stop()
|
|
196
233
|
}
|
|
197
234
|
|
|
198
235
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup)
|
|
199
236
|
else if (typeof ctx.on === 'function') ctx.on('dispose', cleanup)
|
|
200
237
|
|
|
238
|
+
// Turn edges are what a crash loses first, so the heartbeat follows them instead of waiting for its
|
|
239
|
+
// own interval. The interval stays as the fallback for a registry that changes shape silently.
|
|
240
|
+
let liveness = null
|
|
241
|
+
const noteRunning = () => {
|
|
242
|
+
if (disposed) return
|
|
243
|
+
try {
|
|
244
|
+
heartbeat.note(listRunning(ctx))
|
|
245
|
+
} catch (error) {
|
|
246
|
+
log(`cannot note running work: ${error?.message ?? error}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const watchStatus = () => {
|
|
250
|
+
if (liveness !== null) clearTimeout(liveness)
|
|
251
|
+
liveness = setTimeout(() => {
|
|
252
|
+
liveness = null
|
|
253
|
+
noteRunning()
|
|
254
|
+
}, LIVENESS_MS)
|
|
255
|
+
if (typeof liveness?.unref === 'function') liveness.unref()
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
if (typeof ctx.on === 'function') {
|
|
259
|
+
ctx.on('agent/status', watchStatus)
|
|
260
|
+
ctx.on('agent/dispose', watchStatus)
|
|
261
|
+
ctx.on('agent/register', watchStatus)
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
log(`cannot follow agent events: ${error?.message ?? error}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
201
267
|
void (async () => {
|
|
202
268
|
try {
|
|
203
|
-
|
|
269
|
+
// Read the previous process's record before `start()` overwrites it, and persist the crash
|
|
270
|
+
// plan before the heartbeat goes away: a second restart then still sees the same work.
|
|
271
|
+
const residue = heartbeat.peek()
|
|
272
|
+
await engine.hydrate({ residue })
|
|
273
|
+
if (disposed) return
|
|
204
274
|
heartbeat.start()
|
|
205
|
-
|
|
275
|
+
if (disposed) return
|
|
276
|
+
const dispose = serve(ctx, engine, heartbeat, { allowRemote })
|
|
277
|
+
if (disposed) dispose()
|
|
278
|
+
else disposeRoutes = dispose
|
|
206
279
|
log(`ready (${engine.phase})`)
|
|
207
280
|
} catch (error) {
|
|
208
281
|
log(`cannot start: ${error?.message ?? error}`)
|