dsh-hitl 0.1.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/LICENSE +21 -0
- package/README.md +415 -0
- package/README.zh.md +490 -0
- package/client.js +1918 -0
- package/cordis.patch.yml +17 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
- package/icon.svg +6 -0
- package/index.js +629 -0
- package/lib/fields.js +244 -0
- package/lib/pending.js +217 -0
- package/lib/protocol.js +182 -0
- package/lib/resolve.js +302 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
- package/tests/client.test.js +818 -0
- package/tests/docs.test.js +116 -0
- package/tests/fields.test.js +269 -0
- package/tests/manifest.test.js +61 -0
- package/tests/pending.test.js +240 -0
- package/tests/protocol.test.js +117 -0
- package/tests/resolve.test.js +162 -0
package/lib/fields.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal construction: turning one pending tool execution plus its mount into
|
|
3
|
+
* the wire request the browser renders, and turning a human decision back into
|
|
4
|
+
* the text the model reads.
|
|
5
|
+
*
|
|
6
|
+
* Defaults follow the plugin's core promise: with no configuration at all, the
|
|
7
|
+
* proposal is the call's own parameters — one field per parameter, titled by the
|
|
8
|
+
* parameter's key, rendered as an editable markdown box.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { LIMITS, truncate } from './protocol.js'
|
|
12
|
+
import { defaultEditable } from './resolve.js'
|
|
13
|
+
|
|
14
|
+
/** Model-facing prefix every reason this plugin produces starts with. */
|
|
15
|
+
export const REASON_PREFIX = 'HITL'
|
|
16
|
+
|
|
17
|
+
/** Render a parameter value as proposal text. */
|
|
18
|
+
export function renderValue(value) {
|
|
19
|
+
if (typeof value === 'string') return value
|
|
20
|
+
if (value === undefined) return '(undefined)'
|
|
21
|
+
try {
|
|
22
|
+
const json = JSON.stringify(value, null, 2)
|
|
23
|
+
return json === undefined ? String(value) : json
|
|
24
|
+
} catch {
|
|
25
|
+
return String(value)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default renderer of one parameter value: an editable markdown box, for
|
|
31
|
+
* every value. A non-string value reaches it as pretty-printed JSON text, and a
|
|
32
|
+
* mount that prefers the read-only JSON inspector says `render: 'json'`.
|
|
33
|
+
* @returns the render mode, always `'markdown'`.
|
|
34
|
+
*/
|
|
35
|
+
export function inferRender() {
|
|
36
|
+
return 'markdown'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function schemaOf(execution) {
|
|
40
|
+
const parameters = execution.schema?.parameters
|
|
41
|
+
return typeof parameters === 'object' && parameters !== null ? parameters : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function schemaEntry(execution, param) {
|
|
45
|
+
const schema = schemaOf(execution)
|
|
46
|
+
const entry = schema === undefined ? undefined : schema[param]
|
|
47
|
+
return typeof entry === 'object' && entry !== null ? entry : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function argumentsOf(execution) {
|
|
51
|
+
const args = execution.arguments
|
|
52
|
+
return typeof args === 'object' && args !== null && !Array.isArray(args) ? args : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Derive the default field list of one call: every parameter, in argument order.
|
|
57
|
+
* @param execution - the pending tool execution.
|
|
58
|
+
* @returns field specs (`{ param, title? }`) before values are attached.
|
|
59
|
+
*/
|
|
60
|
+
export function defaultFieldSpecs(execution) {
|
|
61
|
+
const args = argumentsOf(execution)
|
|
62
|
+
if (args === undefined) return [{ param: '(arguments)', render: 'json' }]
|
|
63
|
+
const keys = Object.keys(args)
|
|
64
|
+
if (keys.length === 0) return [{ param: '(arguments)', render: 'json' }]
|
|
65
|
+
return keys.map(param => ({ param }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function diffSpec(spec, mount, execution) {
|
|
69
|
+
const requested = spec.diff ?? mount.options.diff
|
|
70
|
+
if (requested === undefined || requested === null || typeof requested !== 'object') return undefined
|
|
71
|
+
const before = requested.before
|
|
72
|
+
const after = requested.after
|
|
73
|
+
if (typeof before !== 'string' || typeof after !== 'string') return undefined
|
|
74
|
+
const args = argumentsOf(execution)
|
|
75
|
+
if (args === undefined) return undefined
|
|
76
|
+
return {
|
|
77
|
+
path: typeof requested.path === 'string' ? renderValue(args[requested.path]) : undefined,
|
|
78
|
+
oldText: renderValue(args[before]),
|
|
79
|
+
newText: renderValue(args[after]),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the wire fields of one request.
|
|
85
|
+
* @param execution - the pending tool execution.
|
|
86
|
+
* @param mount - the matched mount.
|
|
87
|
+
* @returns an ordered field array, values already rendered and truncated.
|
|
88
|
+
*/
|
|
89
|
+
export function buildFields(execution, mount) {
|
|
90
|
+
const options = mount.options
|
|
91
|
+
const args = argumentsOf(execution)
|
|
92
|
+
const mountDiff = options.diff === undefined || options.diff === null ? undefined : options.diff
|
|
93
|
+
// A mount-level diff pairs two parameters, so those parameters must not also
|
|
94
|
+
// appear as their own fields in the defaulted list.
|
|
95
|
+
const paired = options.fields === undefined && mountDiff !== undefined
|
|
96
|
+
? new Set([mountDiff.before, mountDiff.after].filter(entry => typeof entry === 'string'))
|
|
97
|
+
: new Set()
|
|
98
|
+
const specs = options.fields
|
|
99
|
+
?? defaultFieldSpecs(execution).filter(spec => !paired.has(spec.param))
|
|
100
|
+
const fields = []
|
|
101
|
+
for (const spec of specs) {
|
|
102
|
+
const param = spec.param
|
|
103
|
+
const schema = schemaEntry(execution, param)
|
|
104
|
+
// The scalar fallback field reads the whole argument value; every other
|
|
105
|
+
// field reads its own parameter out of the argument record.
|
|
106
|
+
const raw = param === '(arguments)' && args === undefined
|
|
107
|
+
? execution.arguments
|
|
108
|
+
: (args === undefined ? undefined : args[param])
|
|
109
|
+
const render = spec.render ?? (diffSpec(spec, mount, execution) === undefined ? inferRender(raw) : 'diff')
|
|
110
|
+
const diff = render === 'diff' ? diffSpec(spec, mount, execution) : undefined
|
|
111
|
+
const title = spec.title
|
|
112
|
+
?? (typeof schema?.title === 'string' ? schema.title : undefined)
|
|
113
|
+
?? param
|
|
114
|
+
const description = spec.description
|
|
115
|
+
?? (typeof schema?.description === 'string' ? schema.description : undefined)
|
|
116
|
+
const editable = spec.editable ?? defaultEditable(render)
|
|
117
|
+
// A hidden field carries no value: it exists only to stay out of the panel.
|
|
118
|
+
const value = diff === undefined && render !== 'hidden'
|
|
119
|
+
? truncate(renderValue(raw), options.maxFieldChars)
|
|
120
|
+
: { text: '', truncated: false }
|
|
121
|
+
fields.push({
|
|
122
|
+
param,
|
|
123
|
+
title,
|
|
124
|
+
...(description === undefined ? {} : { description }),
|
|
125
|
+
render,
|
|
126
|
+
editable: render === 'diff' || render === 'hidden' ? false : editable,
|
|
127
|
+
labels: spec.labels ?? [],
|
|
128
|
+
...(diff === undefined ? { value: value.text, truncated: value.truncated } : { diff }),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
if (options.fields === undefined && mountDiff !== undefined) {
|
|
132
|
+
const diff = diffSpec({ diff: mountDiff }, mount, execution)
|
|
133
|
+
if (diff !== undefined) {
|
|
134
|
+
fields.push({
|
|
135
|
+
param: `${options.diff.before}→${options.diff.after}`,
|
|
136
|
+
title: typeof options.diff.title === 'string' ? options.diff.title : 'diff',
|
|
137
|
+
render: 'diff',
|
|
138
|
+
editable: false,
|
|
139
|
+
labels: [],
|
|
140
|
+
diff,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (fields.length === 0) {
|
|
145
|
+
return [{ param: '(arguments)', title: '(arguments)', render: 'json', editable: false, labels: [], value: '{}', truncated: false }]
|
|
146
|
+
}
|
|
147
|
+
return fields
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build one complete wire request.
|
|
152
|
+
* @param input - `{ execution, mount, sessionId, id, now }`.
|
|
153
|
+
* @returns the request object broadcast to browsers.
|
|
154
|
+
*/
|
|
155
|
+
export function buildRequest({ execution, mount, sessionId, id, now = Date.now() }) {
|
|
156
|
+
const options = mount.options
|
|
157
|
+
const fields = buildFields(execution, mount)
|
|
158
|
+
const countdown = options.countdown === null ? null : {
|
|
159
|
+
remainingMs: options.countdown.seconds * 1000,
|
|
160
|
+
action: options.countdown.action,
|
|
161
|
+
freezeOnInteract: options.countdown.freezeOnInteract,
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
sessionId,
|
|
166
|
+
toolName: execution.name,
|
|
167
|
+
...(execution.callId === undefined ? {} : { callId: String(execution.callId) }),
|
|
168
|
+
...(options.title === undefined ? {} : { title: options.title }),
|
|
169
|
+
layout: options.layout,
|
|
170
|
+
labels: options.labels,
|
|
171
|
+
fields,
|
|
172
|
+
buttons: {
|
|
173
|
+
...options.buttons,
|
|
174
|
+
feedback: {
|
|
175
|
+
enabled: options.reject.feedback,
|
|
176
|
+
...(options.reject.feedbackPrompt === undefined ? {} : { prompt: options.reject.feedbackPrompt }),
|
|
177
|
+
required: options.reject.requireFeedback,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
countdown,
|
|
181
|
+
createdAt: now,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether a decision carries a human edit of the proposal. */
|
|
186
|
+
export function isRevision(decision) {
|
|
187
|
+
return decision.kind === 'modify' && decision.fields.length > 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render the human's edited fields as text the model can act on.
|
|
192
|
+
* @param decision - a modify decision.
|
|
193
|
+
* @returns one block per changed field.
|
|
194
|
+
*/
|
|
195
|
+
export function revisionText(decision) {
|
|
196
|
+
return decision.fields
|
|
197
|
+
.map(entry => `- ${entry.param}:\n${entry.text}`)
|
|
198
|
+
.join('\n')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fold one settled decision into the single message the model reads.
|
|
203
|
+
* @param request - the request the human decided on.
|
|
204
|
+
* @param decision - the normalized decision.
|
|
205
|
+
* @param source - `user`, `timeout`, `abort`, or `host`.
|
|
206
|
+
* @returns the model-facing reason text.
|
|
207
|
+
*/
|
|
208
|
+
export function describeDecision(request, decision, source) {
|
|
209
|
+
const tool = JSON.stringify(request.toolName)
|
|
210
|
+
if (source === 'timeout') {
|
|
211
|
+
const seconds = Math.round((request.countdown?.remainingMs ?? 0) / 1000)
|
|
212
|
+
if (decision.kind === 'approve') return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} ran under the mount's timeout policy.`
|
|
213
|
+
if (request.countdown?.action === 'notify') {
|
|
214
|
+
return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} did not run. Tell the user this call timed out and wait for an explicit instruction before running it.`
|
|
215
|
+
}
|
|
216
|
+
return `${REASON_PREFIX}: no human decision arrived within ${seconds}s, so tool ${tool} did not run (timeout policy: reject).`
|
|
217
|
+
}
|
|
218
|
+
if (source === 'abort' || source === 'host') {
|
|
219
|
+
return `${REASON_PREFIX}: the decision for tool ${tool} was withdrawn before the user answered, so it did not run.`
|
|
220
|
+
}
|
|
221
|
+
if (decision.kind === 'modify') {
|
|
222
|
+
return `${REASON_PREFIX}: the user revised the proposal, so tool ${tool} did not run. Re-issue the call with these changes:\n${revisionText(decision)}`
|
|
223
|
+
}
|
|
224
|
+
const feedback = decision.kind === 'reject' && typeof decision.feedback === 'string' && decision.feedback !== ''
|
|
225
|
+
? ` User feedback: ${decision.feedback}`
|
|
226
|
+
: ''
|
|
227
|
+
return `${REASON_PREFIX}: the user rejected tool ${tool} and it did not run.${feedback}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Fold one approved-with-edits decision into context the model reads alongside
|
|
232
|
+
* the tool result (`modify.mode: allow-and-inform`).
|
|
233
|
+
* @param request - the request the human approved.
|
|
234
|
+
* @param decision - a modify decision.
|
|
235
|
+
* @returns the follow-up text.
|
|
236
|
+
*/
|
|
237
|
+
export function revisionContext(request, decision) {
|
|
238
|
+
return `${REASON_PREFIX}: the user approved tool ${JSON.stringify(request.toolName)} after editing the proposal. Their revised content:\n${revisionText(decision)}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Cap one model-facing text at the protocol limit. */
|
|
242
|
+
export function capReason(text) {
|
|
243
|
+
return truncate(text, LIMITS.maxDecisionChars).text
|
|
244
|
+
}
|
package/lib/pending.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-decision registry: one state machine per open HITL request,
|
|
3
|
+
* owning its countdown, its interaction hold, and the exactly-once settlement
|
|
4
|
+
* that releases the blocked tool call.
|
|
5
|
+
*
|
|
6
|
+
* The clock is injected so tests drive seconds without waiting for them, and so
|
|
7
|
+
* every timer this plugin arms passes through one place that can be cleared on
|
|
8
|
+
* unload.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ERROR_CODES, OUTCOMES } from './protocol.js'
|
|
12
|
+
|
|
13
|
+
/** Default cap on how long one browser may freeze a countdown by interacting. */
|
|
14
|
+
export const DEFAULT_HOLD_GRACE_MS = 300000
|
|
15
|
+
|
|
16
|
+
/** Decision a countdown action applies when it reaches zero. */
|
|
17
|
+
export function timeoutDecision(action) {
|
|
18
|
+
if (action === 'approve') return { kind: 'approve' }
|
|
19
|
+
return { kind: 'reject' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create one process-wide pending registry.
|
|
24
|
+
* @param options - injected clock/timers, settlement sink, and hold grace.
|
|
25
|
+
* @returns the registry API used by the host half.
|
|
26
|
+
*/
|
|
27
|
+
export function createPendingRegistry({
|
|
28
|
+
now = Date.now,
|
|
29
|
+
setTimer = setTimeout,
|
|
30
|
+
clearTimer = clearTimeout,
|
|
31
|
+
onSettle = () => {},
|
|
32
|
+
holdGraceMs = DEFAULT_HOLD_GRACE_MS,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const entries = new Map()
|
|
35
|
+
const settledOrder = []
|
|
36
|
+
// Settled identities outlive their entry so a late or duplicated decision is
|
|
37
|
+
// reported as already-settled rather than as an unknown request. The set is
|
|
38
|
+
// only a diagnostic, so it is dropped wholesale rather than aged.
|
|
39
|
+
const settledIds = new Set()
|
|
40
|
+
const SETTLED_MEMORY = 4096
|
|
41
|
+
|
|
42
|
+
/** Verdict for an identity that has no open entry. */
|
|
43
|
+
function missing(id) {
|
|
44
|
+
return settledIds.has(id) ? ERROR_CODES.alreadySettled : ERROR_CODES.unknownRequest
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clearEntryTimers(entry) {
|
|
48
|
+
if (entry.timer !== undefined) {
|
|
49
|
+
clearTimer(entry.timer)
|
|
50
|
+
entry.timer = undefined
|
|
51
|
+
}
|
|
52
|
+
if (entry.graceTimer !== undefined) {
|
|
53
|
+
clearTimer(entry.graceTimer)
|
|
54
|
+
entry.graceTimer = undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function armCountdown(entry) {
|
|
59
|
+
if (entry.countdownMs === null || entry.settled) return
|
|
60
|
+
const delay = Math.max(0, entry.remainingMs)
|
|
61
|
+
entry.timer = setTimer(() => {
|
|
62
|
+
entry.timer = undefined
|
|
63
|
+
close(entry.id, OUTCOMES.timeout, timeoutDecision(entry.countdownAction))
|
|
64
|
+
}, delay)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function releaseHold(entry) {
|
|
68
|
+
if (!entry.held) return
|
|
69
|
+
entry.held = false
|
|
70
|
+
entry.holdExpiresAt = undefined
|
|
71
|
+
if (entry.graceTimer !== undefined) {
|
|
72
|
+
clearTimer(entry.graceTimer)
|
|
73
|
+
entry.graceTimer = undefined
|
|
74
|
+
}
|
|
75
|
+
// Re-base the countdown so the frozen remainder is what still runs, and so a
|
|
76
|
+
// later hold computes the remainder from the resumed clock rather than from
|
|
77
|
+
// the original deadline that already passed.
|
|
78
|
+
entry.startedAt = now() + entry.remainingMs - entry.countdownMs
|
|
79
|
+
armCountdown(entry)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function close(id, source, decision) {
|
|
83
|
+
const entry = entries.get(id)
|
|
84
|
+
if (entry === undefined) return { accepted: false, code: missing(id) }
|
|
85
|
+
if (entry.settled) return { accepted: false, code: ERROR_CODES.alreadySettled }
|
|
86
|
+
clearEntryTimers(entry)
|
|
87
|
+
entry.settled = true
|
|
88
|
+
entry.settledAt = now()
|
|
89
|
+
entry.source = source
|
|
90
|
+
entry.decision = decision
|
|
91
|
+
entries.delete(id)
|
|
92
|
+
settledOrder.push(id)
|
|
93
|
+
settledIds.add(id)
|
|
94
|
+
if (settledIds.size > SETTLED_MEMORY) settledIds.clear()
|
|
95
|
+
onSettle(entry, { decision, source })
|
|
96
|
+
return { accepted: true }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
/**
|
|
101
|
+
* Register one open decision.
|
|
102
|
+
* @param input - identity, wire request, countdown policy, and callbacks.
|
|
103
|
+
* @returns `{ ok: true }` or `{ ok: false, code }` for a duplicate id.
|
|
104
|
+
*/
|
|
105
|
+
open({ id, request, countdown, onTimeout }) {
|
|
106
|
+
if (entries.has(id)) return { ok: false, code: ERROR_CODES.alreadySettled }
|
|
107
|
+
const countdownMs = countdown === null || countdown === undefined ? null : countdown.remainingMs
|
|
108
|
+
const entry = {
|
|
109
|
+
id,
|
|
110
|
+
request,
|
|
111
|
+
countdownMs,
|
|
112
|
+
countdownAction: countdown?.action ?? 'reject',
|
|
113
|
+
freezeOnInteract: countdown?.freezeOnInteract !== false,
|
|
114
|
+
onTimeout,
|
|
115
|
+
remainingMs: countdownMs ?? 0,
|
|
116
|
+
startedAt: now(),
|
|
117
|
+
held: false,
|
|
118
|
+
holdExpiresAt: undefined,
|
|
119
|
+
timer: undefined,
|
|
120
|
+
graceTimer: undefined,
|
|
121
|
+
settled: false,
|
|
122
|
+
settledAt: undefined,
|
|
123
|
+
source: undefined,
|
|
124
|
+
decision: undefined,
|
|
125
|
+
}
|
|
126
|
+
entries.set(id, entry)
|
|
127
|
+
armCountdown(entry)
|
|
128
|
+
return { ok: true, expiresAt: countdownMs === null ? undefined : entry.startedAt + countdownMs }
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/** Apply the human's decision. First call wins; later calls are reported. */
|
|
132
|
+
settle(id, decision) {
|
|
133
|
+
return close(id, OUTCOMES.user, decision)
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Freeze or resume one countdown while its browser holds the decision.
|
|
138
|
+
* @param id - request identity.
|
|
139
|
+
* @param held - whether the browser is still interacting.
|
|
140
|
+
* @returns the acceptance, and when a hold stops applying on its own.
|
|
141
|
+
*/
|
|
142
|
+
hold(id, held) {
|
|
143
|
+
const entry = entries.get(id)
|
|
144
|
+
if (entry === undefined) return { ok: false, code: missing(id) }
|
|
145
|
+
if (entry.settled) return { ok: false, code: ERROR_CODES.alreadySettled }
|
|
146
|
+
if (entry.countdownMs === null) return { ok: true, held: false }
|
|
147
|
+
if (!entry.freezeOnInteract) return { ok: true, held: false }
|
|
148
|
+
if (held) {
|
|
149
|
+
if (entry.held) return { ok: true, held: true, expiresAt: entry.holdExpiresAt }
|
|
150
|
+
entry.remainingMs = Math.max(0, entry.startedAt + entry.countdownMs - now())
|
|
151
|
+
entry.held = true
|
|
152
|
+
if (entry.timer !== undefined) {
|
|
153
|
+
clearTimer(entry.timer)
|
|
154
|
+
entry.timer = undefined
|
|
155
|
+
}
|
|
156
|
+
entry.holdExpiresAt = now() + holdGraceMs
|
|
157
|
+
entry.graceTimer = setTimer(() => {
|
|
158
|
+
entry.graceTimer = undefined
|
|
159
|
+
releaseHold(entry)
|
|
160
|
+
}, holdGraceMs)
|
|
161
|
+
return { ok: true, held: true, expiresAt: entry.holdExpiresAt }
|
|
162
|
+
}
|
|
163
|
+
releaseHold(entry)
|
|
164
|
+
return { ok: true, held: false }
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/** Withdraw one request (turn interruption, unload, or host-side cancel). */
|
|
168
|
+
abort(id, source = OUTCOMES.abort) {
|
|
169
|
+
return close(id, source, { kind: 'cancel' })
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/** Withdraw every open request; used on plugin teardown. */
|
|
173
|
+
drain(source = OUTCOMES.host) {
|
|
174
|
+
const ids = [...entries.keys()]
|
|
175
|
+
for (const id of ids) close(id, source, { kind: 'cancel' })
|
|
176
|
+
return ids
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/** Whether one request identity is still open. */
|
|
180
|
+
has(id) {
|
|
181
|
+
return entries.has(id)
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** The wire request behind one identity, or undefined once settled. */
|
|
185
|
+
get(id) {
|
|
186
|
+
return entries.get(id)?.request
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/** Every open request, in registration order. */
|
|
190
|
+
list() {
|
|
191
|
+
return [...entries.values()].map(entry => entry.request)
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/** Count of open requests. */
|
|
195
|
+
size() {
|
|
196
|
+
return entries.size
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/** Identities settled so far, in settlement order (diagnostics and tests). */
|
|
200
|
+
settled() {
|
|
201
|
+
return [...settledOrder]
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/** Whether one request's countdown is currently frozen by a client. */
|
|
205
|
+
held(id) {
|
|
206
|
+
return entries.get(id)?.held === true
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
/** Remaining milliseconds of one countdown, or null when it has none. */
|
|
210
|
+
remaining(id) {
|
|
211
|
+
const entry = entries.get(id)
|
|
212
|
+
if (entry === undefined || entry.countdownMs === null) return null
|
|
213
|
+
if (entry.held) return entry.remainingMs
|
|
214
|
+
return Math.max(0, entry.startedAt + entry.countdownMs - now())
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-hitl wire protocol: the normative definition of every frame the host
|
|
3
|
+
* half broadcasts, every payload the browser half sends back, and the limits
|
|
4
|
+
* both sides agree on.
|
|
5
|
+
*
|
|
6
|
+
* Both halves are plain JavaScript loaded without a build step, so `client.js`
|
|
7
|
+
* cannot import this module. It re-implements the few shapes it must produce;
|
|
8
|
+
* the tests in `tests/protocol.test.js` pin the contract the two halves share.
|
|
9
|
+
* Anything the wire carries is untrusted on arrival and passes a validator here
|
|
10
|
+
* before it reaches the gate.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Bumped when a frame shape changes incompatibly. */
|
|
14
|
+
export const PROTOCOL_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** The `globalThis` property the served index carries this plugin's boot facts in. */
|
|
17
|
+
export const GLOBAL_KEY = '__DSH_HITL__'
|
|
18
|
+
|
|
19
|
+
/** Default route prefix the host half owns on the browser HTTP carrier. */
|
|
20
|
+
export const DEFAULT_ENDPOINT = '/dsh-hitl'
|
|
21
|
+
|
|
22
|
+
/** Discriminator of the pending-interaction values the browser half publishes. */
|
|
23
|
+
export const PENDING_KIND = 'hitl'
|
|
24
|
+
|
|
25
|
+
/** Bounds applied to untrusted input and to what reaches the browser. */
|
|
26
|
+
export const LIMITS = {
|
|
27
|
+
/** Largest uplink body the decide route accepts. */
|
|
28
|
+
maxBodyBytes: 256 * 1024,
|
|
29
|
+
/** Longest rendered value of one field before truncation. */
|
|
30
|
+
maxFieldChars: 20000,
|
|
31
|
+
/** Longest decision text folded into one model-facing message. */
|
|
32
|
+
maxDecisionChars: 8000,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Failure vocabulary shared by the HTTP replies and the panel's error state. */
|
|
36
|
+
export const ERROR_CODES = {
|
|
37
|
+
unauthorized: 'unauthorized',
|
|
38
|
+
badPayload: 'bad-payload',
|
|
39
|
+
unknownRequest: 'unknown-request',
|
|
40
|
+
alreadySettled: 'already-settled',
|
|
41
|
+
hostGone: 'host-gone',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How a pending request left the pending set. */
|
|
45
|
+
export const OUTCOMES = {
|
|
46
|
+
user: 'user',
|
|
47
|
+
timeout: 'timeout',
|
|
48
|
+
abort: 'abort',
|
|
49
|
+
host: 'host',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Structural guard for plain records (not arrays, not null). */
|
|
53
|
+
export function isRecord(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Structural guard for the decision vocabulary the uplink may carry. */
|
|
58
|
+
export function isDecision(value) {
|
|
59
|
+
if (!isRecord(value)) return false
|
|
60
|
+
if (value.kind === 'approve') return true
|
|
61
|
+
if (value.kind === 'reject') {
|
|
62
|
+
return value.feedback === undefined || typeof value.feedback === 'string'
|
|
63
|
+
}
|
|
64
|
+
if (value.kind === 'modify') {
|
|
65
|
+
return Array.isArray(value.fields)
|
|
66
|
+
&& value.fields.every(entry => isRecord(entry)
|
|
67
|
+
&& typeof entry.param === 'string'
|
|
68
|
+
&& typeof entry.text === 'string')
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalize one untrusted uplink payload.
|
|
75
|
+
* @param value - parsed JSON body of a decide/hold request.
|
|
76
|
+
* @returns `{ ok: true, value }` with a trimmed decision, or `{ ok: false, code, message }`.
|
|
77
|
+
*/
|
|
78
|
+
export function parseUplink(value) {
|
|
79
|
+
if (!isRecord(value)) return invalid('the uplink body must be a JSON object')
|
|
80
|
+
if (typeof value.id !== 'string' || value.id === '') return invalid('id must be a non-empty string')
|
|
81
|
+
if (value.type === 'hold') {
|
|
82
|
+
if (typeof value.held !== 'boolean') return invalid('hold.held must be a boolean')
|
|
83
|
+
return { ok: true, value: { type: 'hold', id: value.id, held: value.held } }
|
|
84
|
+
}
|
|
85
|
+
if (value.type !== 'decide') return invalid(`unknown uplink type ${JSON.stringify(value.type)}`)
|
|
86
|
+
if (!isDecision(value.decision)) return invalid('decision must be approve, modify, or reject')
|
|
87
|
+
const decision = value.decision
|
|
88
|
+
if (decision.kind === 'reject') {
|
|
89
|
+
const feedback = typeof decision.feedback === 'string' ? decision.feedback.trim() : ''
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
value: {
|
|
93
|
+
type: 'decide',
|
|
94
|
+
id: value.id,
|
|
95
|
+
decision: feedback === '' ? { kind: 'reject' } : { kind: 'reject', feedback },
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (decision.kind === 'modify') {
|
|
100
|
+
const fields = decision.fields
|
|
101
|
+
.map(entry => ({ param: entry.param, text: entry.text }))
|
|
102
|
+
.filter(entry => entry.param !== '')
|
|
103
|
+
if (fields.length === 0) return invalid('a modify decision must carry at least one field change')
|
|
104
|
+
return { ok: true, value: { type: 'decide', id: value.id, decision: { kind: 'modify', fields } } }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, value: { type: 'decide', id: value.id, decision: { kind: 'approve' } } }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function invalid(message) {
|
|
110
|
+
return { ok: false, code: ERROR_CODES.badPayload, message }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Frame announcing the complete pending set; sent on connect and on demand. */
|
|
114
|
+
export function frameSnapshot(requests) {
|
|
115
|
+
return { type: 'snapshot', version: PROTOCOL_VERSION, requests }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Frame announcing one newly pending request. */
|
|
119
|
+
export function frameRequest(request) {
|
|
120
|
+
return { type: 'request', version: PROTOCOL_VERSION, request }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Frame announcing one request left the pending set. */
|
|
124
|
+
export function frameSettled(id, outcome, decidedBy) {
|
|
125
|
+
return {
|
|
126
|
+
type: 'settled',
|
|
127
|
+
version: PROTOCOL_VERSION,
|
|
128
|
+
id,
|
|
129
|
+
outcome,
|
|
130
|
+
...(decidedBy === undefined ? {} : { decidedBy }),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Liveness frame: it carries nothing, and proves the stream is still attached. */
|
|
135
|
+
export function framePing() {
|
|
136
|
+
return { type: 'ping', version: PROTOCOL_VERSION }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Frame answering a hold request: whether the host froze the countdown, when a
|
|
141
|
+
* hold stops applying on its own, and the remainder the host now holds.
|
|
142
|
+
*
|
|
143
|
+
* The remainder travels with the ack because a freeze and a release both move
|
|
144
|
+
* the deadline; a client that kept counting on its own clock would show a
|
|
145
|
+
* number the host disagrees with on the very frame the pause ends.
|
|
146
|
+
*/
|
|
147
|
+
export function frameHoldAck(id, held, expiresAt, remainingMs) {
|
|
148
|
+
return {
|
|
149
|
+
type: 'holdAck',
|
|
150
|
+
version: PROTOCOL_VERSION,
|
|
151
|
+
id,
|
|
152
|
+
held,
|
|
153
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
154
|
+
...(remainingMs === null || remainingMs === undefined ? {} : { remainingMs }),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One Server-Sent-Event data chunk carrying a JSON frame. */
|
|
159
|
+
export function sseChunk(frame) {
|
|
160
|
+
return `data: ${JSON.stringify(frame)}\n\n`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** JSON body of a failed route call. */
|
|
164
|
+
export function errorBody(code, message) {
|
|
165
|
+
return { ok: false, code, message }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** JSON body of a successful route call. */
|
|
169
|
+
export function okBody(accepted) {
|
|
170
|
+
return { ok: true, accepted }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cap one string at a limit, marking that it was cut.
|
|
175
|
+
* @param text - value to bound.
|
|
176
|
+
* @param limit - maximum character count.
|
|
177
|
+
* @returns the value, cut with a trailing notice when it exceeded the limit.
|
|
178
|
+
*/
|
|
179
|
+
export function truncate(text, limit) {
|
|
180
|
+
if (text.length <= limit) return { text, truncated: false }
|
|
181
|
+
return { text: `${text.slice(0, limit)}\n… [已截断,共 ${text.length} 字]`, truncated: true }
|
|
182
|
+
}
|