dsh-pause-resume 0.1.0 → 0.2.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 +67 -35
- package/client.js +101 -28
- package/index.js +102 -31
- package/lib/engine.js +235 -72
- package/lib/heartbeat.js +180 -49
- package/lib/probe.js +234 -113
- package/lib/snapshot.js +143 -78
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,34 +1,62 @@
|
|
|
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 finished item is journaled before the plan is archived, so a crash in the middle of a restore
|
|
44
|
+
continues with the remainder instead of restoring the same session twice. A failure leaves the plan
|
|
45
|
+
and the journal on disk: the next press retries only the failed item.
|
|
25
46
|
|
|
26
47
|
## Crash, not just a press
|
|
27
48
|
|
|
28
49
|
A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
|
|
29
50
|
`running.json` holds the sessions whose turn is open right now and the id of the process that wrote
|
|
30
|
-
it. A new process finds the previous
|
|
31
|
-
|
|
51
|
+
it. A new process finds the previous process's record and offers ▶ with no press needed.
|
|
52
|
+
|
|
53
|
+
A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
|
|
54
|
+
wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
|
|
55
|
+
type a continuation message on their behalf. Sessions that ended normally never enter the file.
|
|
56
|
+
|
|
57
|
+
The same process reloading the plugin (hot reload) sees its own id and knows there is nothing to
|
|
58
|
+
restore. A `pausing` plan found on disk means the press itself was interrupted: those sessions are
|
|
59
|
+
mounted but never continued, and the button carries a dot until a person presses restore.
|
|
32
60
|
|
|
33
61
|
## Installing
|
|
34
62
|
|
|
@@ -37,24 +65,25 @@ plugin_manager install_bundle dsh-pause-resume
|
|
|
37
65
|
```
|
|
38
66
|
|
|
39
67
|
or install the bundle from the Harness plugin manager with the package name. The plugin needs no
|
|
40
|
-
configuration and holds no credentials.
|
|
68
|
+
configuration and holds no credentials. `allowRemote` exists for a Harness reached from another
|
|
69
|
+
machine; local-only is the default.
|
|
41
70
|
|
|
42
71
|
## Guarantees and limits
|
|
43
72
|
|
|
44
73
|
* **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
|
|
45
74
|
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
|
-
|
|
75
|
+
* **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
|
|
76
|
+
nothing was stopped.
|
|
77
|
+
* **Idempotent.** A restore journals every finished item; the continuation request id is derived from
|
|
78
|
+
the plan, so a retry cannot post the same message twice.
|
|
79
|
+
* **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
|
|
80
|
+
hand, a session already running: each is skipped and named in the outcome.
|
|
81
|
+
* **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
|
|
82
|
+
"turns cancelled, goals paused, jobs killed" — not "the process is frozen". A goal continues because
|
|
83
|
+
the Harness has a mechanism for that; a plain session continues through one visible user message.
|
|
84
|
+
* **No third-party plugin state.** A plugin's in-process state belongs to that plugin's mount.
|
|
85
|
+
* **Local by default.** The routes require a same-origin request and a plugin header. Remote use must
|
|
86
|
+
be opted into and still cannot skip the header.
|
|
58
87
|
|
|
59
88
|
## Files
|
|
60
89
|
|
|
@@ -62,28 +91,31 @@ All state lives under `$DSH_HOME/pause-resume/`:
|
|
|
62
91
|
|
|
63
92
|
| File | Purpose |
|
|
64
93
|
| --- | --- |
|
|
65
|
-
| `snapshot.json` | the work a press stopped |
|
|
94
|
+
| `snapshot.json` | the work a press stopped, `pausing` or `paused` |
|
|
95
|
+
| `journal/<planId>.jsonl` | restore items already finished |
|
|
96
|
+
| `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
|
|
97
|
+
| `last-restore.json` | summary of the last restore |
|
|
66
98
|
| `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
99
|
|
|
70
|
-
Every write is temp file
|
|
100
|
+
Every write is a unique temp file, `fsync`, `rename`, then a directory `fsync`. State is never inside
|
|
101
|
+
the package.
|
|
71
102
|
|
|
72
103
|
## Development
|
|
73
104
|
|
|
74
105
|
```bash
|
|
75
|
-
|
|
106
|
+
npm test
|
|
107
|
+
npm run audit
|
|
76
108
|
```
|
|
77
109
|
|
|
78
110
|
`lib/engine.js` performs no I/O, `lib/snapshot.js` persists, `lib/probe.js` holds every Harness service
|
|
79
|
-
contract (
|
|
80
|
-
routes. All
|
|
81
|
-
|
|
111
|
+
contract (agent identity, goal revision, per-owner jobs, continuation prompts), and `index.js` only
|
|
112
|
+
wires them to the web routes. All of them are testable without a Harness: `test/apply.test.mjs` mounts
|
|
113
|
+
the plugin against a fake registry.
|
|
82
114
|
|
|
83
115
|
MIT.
|
|
84
116
|
|
|
85
117
|
---
|
|
86
118
|
|
|
87
119
|
一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
接回来。正常结束的会话不记录、不恢复;崩溃靠心跳文件识别,但只挂载不代发消息;日程不会被动
|
|
121
|
+
(该服务只有删除,没有停用),后台命令只记录不重跑。
|
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,93 @@ 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
|
-
if (
|
|
125
|
-
|
|
175
|
+
let current = null
|
|
176
|
+
const beat = () => {
|
|
177
|
+
if (document.visibilityState !== 'visible' || inflight.current !== null) return
|
|
178
|
+
try {
|
|
179
|
+
current?.abort()
|
|
180
|
+
} catch {}
|
|
181
|
+
current = typeof AbortController === 'function' ? new AbortController() : null
|
|
182
|
+
void pull(current?.signal)
|
|
126
183
|
}
|
|
127
|
-
|
|
128
|
-
timer = setInterval(
|
|
129
|
-
if (document.visibilityState === 'visible') pull()
|
|
130
|
-
}, POLL_MS)
|
|
184
|
+
beat()
|
|
185
|
+
timer = setInterval(beat, POLL_MS)
|
|
131
186
|
const onVisible = () => {
|
|
132
|
-
if (document.visibilityState === 'visible')
|
|
187
|
+
if (document.visibilityState === 'visible') beat()
|
|
133
188
|
}
|
|
134
189
|
document.addEventListener('visibilitychange', onVisible)
|
|
135
190
|
return () => {
|
|
136
191
|
mounted.current = false
|
|
137
192
|
if (timer !== null) clearInterval(timer)
|
|
193
|
+
try {
|
|
194
|
+
current?.abort()
|
|
195
|
+
} catch {}
|
|
138
196
|
document.removeEventListener('visibilitychange', onVisible)
|
|
139
197
|
}
|
|
140
|
-
}, [])
|
|
198
|
+
}, [pull])
|
|
141
199
|
|
|
142
|
-
const busy = state.action === 'busy'
|
|
200
|
+
const busy = pressing || state.action === 'busy'
|
|
143
201
|
const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
|
|
144
202
|
const disabled = action === null && !busy
|
|
145
203
|
|
|
146
204
|
const onClick = async () => {
|
|
147
|
-
if (busy || action === null) return
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
205
|
+
if (busy || action === null || inflight.current !== null) return
|
|
206
|
+
// One press at a time, in this tab and across tabs: the Host also refuses an overlapping // ui-copy-ignore
|
|
207
|
+
// press, so a second window's button settles on the same answer instead of starting work.
|
|
208
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
209
|
+
inflight.current = controller
|
|
210
|
+
setPressing(true)
|
|
211
|
+
try {
|
|
212
|
+
const answer = await press(action, controller?.signal)
|
|
213
|
+
const next = answer ?? (await readState())
|
|
214
|
+
if (mounted.current) {
|
|
215
|
+
if (next !== null) {
|
|
216
|
+
seq.current += 1
|
|
217
|
+
setState({ ...EMPTY, ...next })
|
|
218
|
+
} else {
|
|
219
|
+
// The press may still have landed: ask again rather than guessing the phase.
|
|
220
|
+
void pull(controller?.signal)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
inflight.current = null
|
|
225
|
+
if (mounted.current) setPressing(false)
|
|
226
|
+
}
|
|
156
227
|
}
|
|
157
228
|
|
|
158
229
|
const style = busy || disabled
|
|
@@ -163,6 +234,7 @@ window.__ModuleLoader__.load({
|
|
|
163
234
|
|
|
164
235
|
const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
|
|
165
236
|
const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
|
|
237
|
+
const hint = hintOf(state)
|
|
166
238
|
|
|
167
239
|
return h('button', {
|
|
168
240
|
type: 'button', // ui-copy-ignore
|
|
@@ -171,9 +243,10 @@ window.__ModuleLoader__.load({
|
|
|
171
243
|
disabled,
|
|
172
244
|
'aria-label': label,
|
|
173
245
|
'aria-busy': busy,
|
|
246
|
+
title: hint === '' ? label : `${label} · ${hint}`,
|
|
174
247
|
onMouseEnter: () => setHover(true),
|
|
175
248
|
onMouseLeave: () => setHover(false),
|
|
176
|
-
}, glyph,
|
|
249
|
+
}, glyph, hint === '' ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
|
|
177
250
|
}
|
|
178
251
|
|
|
179
252
|
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.2.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
150
|
if (outcome.status === 'paused') heartbeat.release()
|
|
139
|
-
sendJson(res, 200, { ...
|
|
140
|
-
},
|
|
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,102 @@ 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,
|
|
185
219
|
graceMs: Number.isFinite(config?.graceMs) ? config.graceMs : 2000,
|
|
186
|
-
gapMs: Number.isFinite(config?.gapMs) ? config.gapMs : 150,
|
|
187
220
|
log,
|
|
188
221
|
})
|
|
189
222
|
|
|
223
|
+
let disposed = false
|
|
190
224
|
let disposeRoutes = () => {}
|
|
191
225
|
const cleanup = () => {
|
|
226
|
+
disposed = true
|
|
192
227
|
try {
|
|
193
228
|
disposeRoutes()
|
|
194
229
|
} catch {}
|
|
230
|
+
// A reload is not a crash: keep the heartbeat's last write as evidence, but let go of timers.
|
|
195
231
|
heartbeat.stop()
|
|
196
232
|
}
|
|
197
233
|
|
|
198
234
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup)
|
|
199
235
|
else if (typeof ctx.on === 'function') ctx.on('dispose', cleanup)
|
|
200
236
|
|
|
237
|
+
// Turn edges are what a crash loses first, so the heartbeat follows them instead of waiting for its
|
|
238
|
+
// own interval. The interval stays as the fallback for a registry that changes shape silently.
|
|
239
|
+
let liveness = null
|
|
240
|
+
const noteRunning = () => {
|
|
241
|
+
if (disposed) return
|
|
242
|
+
try {
|
|
243
|
+
heartbeat.note(listRunning(ctx))
|
|
244
|
+
} catch (error) {
|
|
245
|
+
log(`cannot note running work: ${error?.message ?? error}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const watchStatus = () => {
|
|
249
|
+
if (liveness !== null) clearTimeout(liveness)
|
|
250
|
+
liveness = setTimeout(() => {
|
|
251
|
+
liveness = null
|
|
252
|
+
noteRunning()
|
|
253
|
+
}, LIVENESS_MS)
|
|
254
|
+
if (typeof liveness?.unref === 'function') liveness.unref()
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
if (typeof ctx.on === 'function') {
|
|
258
|
+
ctx.on('agent/status', watchStatus)
|
|
259
|
+
ctx.on('agent/dispose', watchStatus)
|
|
260
|
+
ctx.on('agent/register', watchStatus)
|
|
261
|
+
}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
log(`cannot follow agent events: ${error?.message ?? error}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
201
266
|
void (async () => {
|
|
202
267
|
try {
|
|
203
|
-
|
|
268
|
+
// The heartbeat claims the previous process's record in `start`, and the engine's crash plan
|
|
269
|
+
// is derived from that claim: start first, or the residue is read as absent and lost.
|
|
204
270
|
heartbeat.start()
|
|
205
|
-
|
|
271
|
+
if (disposed) return
|
|
272
|
+
await engine.hydrate()
|
|
273
|
+
if (disposed) return
|
|
274
|
+
const dispose = serve(ctx, engine, heartbeat, { allowRemote })
|
|
275
|
+
if (disposed) dispose()
|
|
276
|
+
else disposeRoutes = dispose
|
|
206
277
|
log(`ready (${engine.phase})`)
|
|
207
278
|
} catch (error) {
|
|
208
279
|
log(`cannot start: ${error?.message ?? error}`)
|