@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.5
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 +90 -0
- package/cordis.patch.yml +27 -4
- package/lib/client.js +515 -0
- package/lib/index.js +16 -11
- package/lib/tools.js +9 -3
- package/package.json +7 -3
- package/src/plugin/fleet-target.mjs +667 -0
- package/src/plugin/tools.mjs +152 -26
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M fleet targeting: the session's chosen machine, and the browser-facing half of it.
|
|
3
|
+
*
|
|
4
|
+
* # What this is for
|
|
5
|
+
*
|
|
6
|
+
* The plugin's eight tools all converse with the relay, and none of them takes a machine argument:
|
|
7
|
+
* `w2m_run` fans out to every machine the relay holds. That is the right default for a fleet-wide
|
|
8
|
+
* comparison and the wrong one for "run this on the Mac". This module is the missing half of the
|
|
9
|
+
* second case — a *target selection* the operator makes in the composer, which is then applied to
|
|
10
|
+
* the next dispatch **from that session** when the caller names no machines of its own.
|
|
11
|
+
*
|
|
12
|
+
* # Why the selection lives here and not in the browser
|
|
13
|
+
*
|
|
14
|
+
* Two things have to agree on it: the control the operator clicks, and `w2m_run`, which runs in the
|
|
15
|
+
* host process with no browser in reach. The browser cannot be the source of truth for state the
|
|
16
|
+
* dispatch path must read, so the selection is written here, in this process, and the browser is a
|
|
17
|
+
* view over it. The side effect that matters: a selection survives a page reload, and the same
|
|
18
|
+
* session id in a second window reads the same target.
|
|
19
|
+
*
|
|
20
|
+
* # Scope, and why it is per session
|
|
21
|
+
*
|
|
22
|
+
* `conversation.input.left` is a `session`-scoped seat, so the control renders once per session and
|
|
23
|
+
* the browser sends that session's id on every call. The store is keyed by it, which is what stops
|
|
24
|
+
* one chat's target from silently steering another's dispatch. A dispatch with no session in reach
|
|
25
|
+
* (a bare `execute()` in a test, a future non-session caller) falls back to {@link NO_SESSION}, so
|
|
26
|
+
* there is exactly one bucket for "no session" rather than an exception.
|
|
27
|
+
*
|
|
28
|
+
* # The wire, and why it is this small
|
|
29
|
+
*
|
|
30
|
+
* Two exact Fetch routes on the host's `/api` carrier, mounted inside its authentication fence.
|
|
31
|
+
* The `/api` prefix is the registry's own channel — see {@link TARGET_ROUTE_PATH} — so these are the
|
|
32
|
+
* paths as the host registers them, and the page fetches the same path document-relative:
|
|
33
|
+
*
|
|
34
|
+
* GET /api/w2m/target?session_id=… the fleet and this session's selection, as one snapshot
|
|
35
|
+
* POST /api/w2m/target { session_id, machine_id|null } → the same snapshot
|
|
36
|
+
*
|
|
37
|
+
* Plain `Response.json`, no Typert descriptor: this package is dependency-free, and a generated
|
|
38
|
+
* strict Remote contribution is neither reachable from plain JavaScript nor worth a codec for one
|
|
39
|
+
* object. Nothing else in the protocol changes — no new relay endpoint, no new tool parameter, no
|
|
40
|
+
* `envelope_version` movement. The dispatch path is where the selection becomes real, and it is
|
|
41
|
+
* spent on fields the relay already accepts (§4.1 `target_machines`, §4.3 `executor_machine_id`).
|
|
42
|
+
*
|
|
43
|
+
* # Failure is a value here, never an exception
|
|
44
|
+
*
|
|
45
|
+
* The relay may be down, this machine may be unpaired, the host may not provide a fetch-route
|
|
46
|
+
* registry at all. Each of those becomes a field on the snapshot (`fleet.error`,
|
|
47
|
+
* `detail_unavailable`) so the control can say what is wrong instead of rendering an empty list that
|
|
48
|
+
* looks like an empty fleet. Only a *write* of a selection the relay does not list is refused with a
|
|
49
|
+
* non-2xx status, because that is a caller error and the browser must not print a success it did not
|
|
50
|
+
* get.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Both routes hang off this exact path; the browser half reads the same constant.
|
|
55
|
+
*
|
|
56
|
+
* The `/api` prefix is not decoration and not ours: `@deepseek-ai/dsh-client-connection` mounts the
|
|
57
|
+
* whole fetch-route registry under its `/api` channel and validates every registration with
|
|
58
|
+
* `endpointFromPath('/api', path)`, which returns undefined for anything that does not start with
|
|
59
|
+
* `/api/`. A `/w2m/target` registration is therefore refused — `connection: invalid exact Fetch route
|
|
60
|
+
* "/w2m/target"` — inside the injected callback, where it failed the *child* fiber and left the host
|
|
61
|
+
* entry looking perfectly healthy. The shipped browser halves all follow the same rule
|
|
62
|
+
* (`dsh-client-file-upload` uses `/api/session/uploadFileBinary`), and the page fetches the same
|
|
63
|
+
* document-relative path.
|
|
64
|
+
*/
|
|
65
|
+
export const TARGET_ROUTE_PATH = '/api/w2m/target';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The store bucket used when a dispatch carries no session.
|
|
69
|
+
*
|
|
70
|
+
* A named constant rather than `null`/`undefined`: the fallback is a real bucket with real
|
|
71
|
+
* behaviour ("one target for callers that are not a session"), and naming it keeps that visible in
|
|
72
|
+
* `listSelections()` instead of hiding an accidental key.
|
|
73
|
+
*/
|
|
74
|
+
export const NO_SESSION = '(no-session)';
|
|
75
|
+
|
|
76
|
+
/** Session buckets retained before the oldest is evicted; a browse session cannot grow this. */
|
|
77
|
+
export const MAX_SELECTIONS = 512;
|
|
78
|
+
|
|
79
|
+
/** How long the fleet roster may be reused by the control before it is asked for again. */
|
|
80
|
+
export const FLEET_CACHE_MS = 10_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Is a fleet entry one this control may dispatch to?
|
|
84
|
+
*
|
|
85
|
+
* The relay reports both `online` (a boolean it computes) and `state` (`online` | `expired` | …).
|
|
86
|
+
* `online` wins when present because it is the closer measurement; `state` is the fallback, and a
|
|
87
|
+
* device with neither is **not** treated as online — "unknown" is not "reachable", and a target list
|
|
88
|
+
* that quietly includes an unknown machine is how a dispatch goes somewhere nobody chose.
|
|
89
|
+
*
|
|
90
|
+
* @param {object|null|undefined} device One entry of `GET /v1/devices`'s `devices`.
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function isSelectableDevice(device) {
|
|
94
|
+
if (device === null || typeof device !== 'object') return false;
|
|
95
|
+
if (typeof device.online === 'boolean') return device.online;
|
|
96
|
+
return String(device.state ?? '') === 'online';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize one relay device record into the shape the control renders.
|
|
101
|
+
*
|
|
102
|
+
* `id`/`name` are the only fields the UI depends on; everything else is detail it may show. Nulls
|
|
103
|
+
* are kept rather than omitted so the browser never has to distinguish "absent" from "null" to
|
|
104
|
+
* decide whether to render a line.
|
|
105
|
+
*
|
|
106
|
+
* @param {object|null|undefined} device One entry of `GET /v1/devices`'s `devices`.
|
|
107
|
+
* @param {string|null} localMachineId This machine's id, for the "this machine" mark.
|
|
108
|
+
* @returns {object}
|
|
109
|
+
*/
|
|
110
|
+
export function describeTargetMachine(device, localMachineId = null) {
|
|
111
|
+
const machineId = typeof device?.machine_id === 'string' && device.machine_id !== '' ? device.machine_id : null;
|
|
112
|
+
return {
|
|
113
|
+
machine_id: machineId,
|
|
114
|
+
name: typeof device?.machine_name === 'string' && device.machine_name !== ''
|
|
115
|
+
? device.machine_name
|
|
116
|
+
: (typeof device?.name === 'string' && device.name !== '' ? device.name : null),
|
|
117
|
+
os: device?.platform?.os ?? device?.os ?? null,
|
|
118
|
+
arch: device?.platform?.arch ?? device?.arch ?? null,
|
|
119
|
+
state: typeof device?.state === 'string' && device.state !== '' ? device.state : (isSelectableDevice(device) ? 'online' : 'unknown'),
|
|
120
|
+
online: isSelectableDevice(device),
|
|
121
|
+
last_seen: device?.last_seen ?? device?.last_seen_at ?? null,
|
|
122
|
+
rtt_ms: typeof device?.rtt_ms === 'number' ? device.rtt_ms : null,
|
|
123
|
+
local: machineId !== null && machineId === localMachineId,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A selection store: one chosen machine per session, in this process.
|
|
129
|
+
*
|
|
130
|
+
* The clock is injectable so a test can assert `selected_at` without sleeping, and the cap is a
|
|
131
|
+
* constructor argument so the eviction order is testable at two entries instead of 513.
|
|
132
|
+
*
|
|
133
|
+
* @param {{now?: () => number, max?: number}} [options]
|
|
134
|
+
* @returns {object} The store described by {@link SelectionStore} below.
|
|
135
|
+
*/
|
|
136
|
+
export function createSelectionStore(options = {}) {
|
|
137
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
138
|
+
const max = Number.isInteger(options.max) && options.max > 0 ? options.max : MAX_SELECTIONS;
|
|
139
|
+
/** @type {Map<string, {machine_id: string, machine_name: string|null, selected_at: number, selected_by: string|null}>} */
|
|
140
|
+
const selections = new Map();
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read one session's selection.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} sessionId
|
|
146
|
+
* @returns {object|null}
|
|
147
|
+
*/
|
|
148
|
+
function get(sessionId) {
|
|
149
|
+
return selections.get(sessionId) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Record a selection, replacing any earlier one for that session.
|
|
154
|
+
*
|
|
155
|
+
* Re-inserting on write (rather than leaving the original position) is what makes the eviction
|
|
156
|
+
* order least-recently-*chosen*; a session that keeps switching targets is exactly the one that
|
|
157
|
+
* must not be evicted first.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} sessionId
|
|
160
|
+
* @param {{machine_id: string, machine_name?: string|null}} machine
|
|
161
|
+
* @param {{selectedBy?: string|null}} [context]
|
|
162
|
+
* @returns {object} The stored selection.
|
|
163
|
+
*/
|
|
164
|
+
function set(sessionId, machine, context = {}) {
|
|
165
|
+
const selection = {
|
|
166
|
+
machine_id: String(machine.machine_id),
|
|
167
|
+
machine_name: typeof machine.machine_name === 'string' && machine.machine_name !== '' ? machine.machine_name : null,
|
|
168
|
+
selected_at: now(),
|
|
169
|
+
selected_by: typeof context.selectedBy === 'string' && context.selectedBy !== '' ? context.selectedBy : null,
|
|
170
|
+
};
|
|
171
|
+
selections.delete(sessionId);
|
|
172
|
+
selections.set(sessionId, selection);
|
|
173
|
+
while (selections.size > max) {
|
|
174
|
+
const oldest = selections.keys().next();
|
|
175
|
+
if (oldest.done === true) break;
|
|
176
|
+
selections.delete(oldest.value);
|
|
177
|
+
}
|
|
178
|
+
return selection;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Drop one session's selection: dispatch goes back to every machine the relay holds.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} sessionId
|
|
185
|
+
* @returns {boolean} Whether anything was removed.
|
|
186
|
+
*/
|
|
187
|
+
function clear(sessionId) {
|
|
188
|
+
return selections.delete(sessionId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Snapshot every selection, oldest chosen first. For tests and for a host-side diagnostic; no
|
|
193
|
+
* tool exposes it, because who selected what is the operator's business, not the model's.
|
|
194
|
+
*
|
|
195
|
+
* @returns {Array<{session_id: string} & object>}
|
|
196
|
+
*/
|
|
197
|
+
function list() {
|
|
198
|
+
return [...selections.entries()].map(([session_id, selection]) => ({ session_id, ...selection }));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Drop every selection. A test-facing reset: the installed plugin never calls it, because
|
|
203
|
+
* forgetting one session's target when another asks for a fleet read would be a bug, not a
|
|
204
|
+
* feature.
|
|
205
|
+
*
|
|
206
|
+
* @returns {number} How many selections were dropped.
|
|
207
|
+
*/
|
|
208
|
+
function clearAll() {
|
|
209
|
+
const count = selections.size;
|
|
210
|
+
selections.clear();
|
|
211
|
+
return count;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { get, set, clear, clearAll, list, get size() { return selections.size; } };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The process-wide store, used by the installed plugin.
|
|
219
|
+
*
|
|
220
|
+
* A module-level singleton rather than a service, because Cordis contexts are per-row and the
|
|
221
|
+
* dispatch path reaches this from a tool definition, not from a service lookup; one plugin is one
|
|
222
|
+
* store. Tests call {@link createSelectionStore} directly, so nothing here is load-bearing for them.
|
|
223
|
+
*/
|
|
224
|
+
export const defaultSelectionStore = createSelectionStore();
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the session id a dispatch belongs to.
|
|
228
|
+
*
|
|
229
|
+
* Reached through `exec.agent.session`, whose `id` is a getter over the session header. A missing
|
|
230
|
+
* agent is not an error: it means "not a session", which is {@link NO_SESSION}.
|
|
231
|
+
*
|
|
232
|
+
* @param {object|null|undefined} exec The tool execution context.
|
|
233
|
+
* @returns {string} A session id, or {@link NO_SESSION}.
|
|
234
|
+
*/
|
|
235
|
+
export function sessionKeyFor(exec) {
|
|
236
|
+
const session = exec?.agent?.session ?? null;
|
|
237
|
+
const id = session === null ? null : (typeof session.id === 'string' && session.id !== ''
|
|
238
|
+
? session.id
|
|
239
|
+
: (typeof session.header?.id === 'string' && session.header.id !== '' ? session.header.id : null));
|
|
240
|
+
return id ?? NO_SESSION;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Turn one selection into the dispatch fields that make it real.
|
|
245
|
+
*
|
|
246
|
+
* The mapping is per mode because the relay's two selection fields are not interchangeable:
|
|
247
|
+
*
|
|
248
|
+
* replicate / split / pipeline → `target_machines: [id]` constrains **which machines run**.
|
|
249
|
+
* broadcast → `executor_machine_id: id` names **which one runs**, while the
|
|
250
|
+
* others stay in the task as observers. Sending
|
|
251
|
+
* `target_machines` here would *remove* the observers, which is a
|
|
252
|
+
* different task than the caller asked for.
|
|
253
|
+
*
|
|
254
|
+
* Both fields already exist in the relay's `POST /v1/task` (§4.1, §4.3) and neither is invented
|
|
255
|
+
* here, so no protocol moves. `null` in a field means "the caller said nothing"; the caller's own
|
|
256
|
+
* value always wins, which is enforced by the call site, not here.
|
|
257
|
+
*
|
|
258
|
+
* @param {object|null} selection A stored selection, or null for "no selection".
|
|
259
|
+
* @param {string} mode The dispatch mode.
|
|
260
|
+
* @returns {{fields: object, applied_as: string|null}}
|
|
261
|
+
*/
|
|
262
|
+
export function dispatchSelectionFields(selection, mode) {
|
|
263
|
+
const machineId = typeof selection?.machine_id === 'string' && selection.machine_id !== ''
|
|
264
|
+
? selection.machine_id
|
|
265
|
+
: null;
|
|
266
|
+
if (machineId === null) return { fields: {}, applied_as: null };
|
|
267
|
+
if (mode === 'broadcast') {
|
|
268
|
+
return { fields: { executor_machine_id: machineId }, applied_as: 'executor_machine_id' };
|
|
269
|
+
}
|
|
270
|
+
return { fields: { target_machines: [machineId] }, applied_as: 'target_machines' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read the fleet from the relay, in the shape the control renders.
|
|
275
|
+
*
|
|
276
|
+
* One helper behind both `w2m_devices` and the control, so the two can never disagree about who is
|
|
277
|
+
* online — the failure that would matter most here, because the control's promise is "this is where
|
|
278
|
+
* your next dispatch goes". The tool re-renders the machine records it gets back into its own
|
|
279
|
+
* published shape; what is shared is the read, the liveness rule, and the "the local machine has no
|
|
280
|
+
* identity yet" case.
|
|
281
|
+
*
|
|
282
|
+
* Never throws. A relay that refuses, a machine that is unpaired, a socket that is down: each is
|
|
283
|
+
* reported as `{ok: false, error}` and the caller decides whether that is fatal (`w2m_devices`
|
|
284
|
+
* throws a typed error) or merely reported (the control says so and shows nothing selectable).
|
|
285
|
+
*
|
|
286
|
+
* @param {object} deps
|
|
287
|
+
* @param {string} deps.rabbitUrl Base relay URL.
|
|
288
|
+
* @param {string|null} [deps.token] Device token, or null for an unpaired machine.
|
|
289
|
+
* @param {string|null} [deps.localMachineId] This machine's id, for the "this machine" mark.
|
|
290
|
+
* @param {AbortSignal} [deps.signal]
|
|
291
|
+
* @param {(options: object) => Promise<object>} deps.request The plugin's bounded HTTP helper.
|
|
292
|
+
* @returns {Promise<object>} `{ok, error, status, machines, records, online, total, rabbit_time, excluded}`.
|
|
293
|
+
* `records` are the relay's own device records, passed through for callers that report the fleet's
|
|
294
|
+
* fields rather than this module's projection of them.
|
|
295
|
+
*/
|
|
296
|
+
export async function readFleet(deps) {
|
|
297
|
+
const { rabbitUrl, token = null, localMachineId = null, signal, request } = deps;
|
|
298
|
+
if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: 'no Rabbit URL is configured, so the fleet cannot be listed',
|
|
302
|
+
hint: 'set `rabbitUrl` in this plugin\'s profile patch, for example http://127.0.0.1:8787',
|
|
303
|
+
status: 0,
|
|
304
|
+
machines: [],
|
|
305
|
+
records: [],
|
|
306
|
+
online: 0,
|
|
307
|
+
total: 0,
|
|
308
|
+
rabbit_time: null,
|
|
309
|
+
excluded: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
let result;
|
|
313
|
+
try {
|
|
314
|
+
result = await request({ rabbitUrl, token, signal, pathname: '/v1/devices' });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: error instanceof Error ? error.message : String(error),
|
|
319
|
+
// The helper's own typed error carries the remediation in its `hint` ("check that the Rabbit
|
|
320
|
+
// is running and that `rabbitUrl` points at it"). Dropping it here would turn a fixable
|
|
321
|
+
// unreachable-relay failure into "something went wrong", so it is carried through and the
|
|
322
|
+
// callers that throw re-attach it verbatim.
|
|
323
|
+
hint: typeof error?.hint === 'string' && error.hint !== '' ? error.hint : null,
|
|
324
|
+
status: 0,
|
|
325
|
+
machines: [],
|
|
326
|
+
records: [],
|
|
327
|
+
online: 0,
|
|
328
|
+
total: 0,
|
|
329
|
+
rabbit_time: null,
|
|
330
|
+
excluded: 0,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (result?.ok !== true) {
|
|
334
|
+
const detail = result?.error ? ` (${result.error.code}: ${result.error.message})` : '';
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: `the Rabbit refused GET /v1/devices with HTTP ${result?.status ?? 'unknown'}${detail}`,
|
|
338
|
+
hint: result?.status === 401 ? 'this machine is not paired, or its device_token is stale; pair the agent again' : 'check the Rabbit log for the reason it gave',
|
|
339
|
+
status: Number.isInteger(result?.status) ? result.status : 0,
|
|
340
|
+
machines: [],
|
|
341
|
+
records: [],
|
|
342
|
+
online: 0,
|
|
343
|
+
total: 0,
|
|
344
|
+
rabbit_time: null,
|
|
345
|
+
excluded: 0,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const records = Array.isArray(result.json?.devices)
|
|
349
|
+
? result.json.devices
|
|
350
|
+
: (Array.isArray(result.json?.machines) ? result.json.machines : []);
|
|
351
|
+
const machines = records.map((record) => describeTargetMachine(record, localMachineId));
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
error: null,
|
|
355
|
+
status: Number.isInteger(result.status) ? result.status : 200,
|
|
356
|
+
machines,
|
|
357
|
+
records,
|
|
358
|
+
online: machines.filter((machine) => machine.online).length,
|
|
359
|
+
total: machines.length,
|
|
360
|
+
rabbit_time: result.json?.rabbit_time ?? null,
|
|
361
|
+
// Machines the relay still lists but whose lease is not live. Reported so "3 machines" is never
|
|
362
|
+
// mistaken for "3 machines you can use".
|
|
363
|
+
excluded: machines.filter((machine) => !machine.online).length,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build the snapshot the browser renders.
|
|
369
|
+
*
|
|
370
|
+
* @param {object} deps
|
|
371
|
+
* @param {object} deps.store A selection store.
|
|
372
|
+
* @param {string} deps.sessionId The session the control belongs to.
|
|
373
|
+
* @param {{ok: boolean, error: string|null, machines: object[], online: number, total: number,
|
|
374
|
+
* rabbit_time: string|null, excluded: number}} deps.fleet Output of {@link readFleet}.
|
|
375
|
+
* @param {string|null} deps.localMachineName This machine's configured fleet name.
|
|
376
|
+
* @param {boolean} deps.detailUnavailable True when the host provides no fetch-route registry.
|
|
377
|
+
* @returns {object}
|
|
378
|
+
*/
|
|
379
|
+
export function buildUiState(deps) {
|
|
380
|
+
const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false } = deps;
|
|
381
|
+
const selection = store.get(sessionId);
|
|
382
|
+
const machines = Array.isArray(fleet?.machines) ? fleet.machines : [];
|
|
383
|
+
const selected = selection === null ? null : machines.find((machine) => machine.machine_id === selection.machine_id) ?? null;
|
|
384
|
+
return {
|
|
385
|
+
session_id: sessionId,
|
|
386
|
+
selected: selection === null
|
|
387
|
+
? { machine_id: null, name: null, selected_at: null, online: null, stale: false }
|
|
388
|
+
: {
|
|
389
|
+
machine_id: selection.machine_id,
|
|
390
|
+
name: selected?.name ?? selection.machine_name ?? null,
|
|
391
|
+
selected_at: selection.selected_at,
|
|
392
|
+
// `online: null` is "the fleet could not be read", which is not "offline". The control
|
|
393
|
+
// must be able to say "the target I am holding was not confirmed", not invent a state.
|
|
394
|
+
online: selected === null ? (fleet?.ok === true ? false : null) : selected.online,
|
|
395
|
+
stale: fleet?.ok === true && (selected === null || selected.online !== true),
|
|
396
|
+
},
|
|
397
|
+
machines,
|
|
398
|
+
fleet: {
|
|
399
|
+
ok: fleet?.ok === true,
|
|
400
|
+
error: fleet?.error ?? null,
|
|
401
|
+
online: fleet?.online ?? 0,
|
|
402
|
+
total: fleet?.total ?? 0,
|
|
403
|
+
excluded: fleet?.excluded ?? 0,
|
|
404
|
+
rabbit_time: fleet?.rabbit_time ?? null,
|
|
405
|
+
},
|
|
406
|
+
host: {
|
|
407
|
+
local_machine_name: localMachineName,
|
|
408
|
+
// True when the host process has no Connection fetch registry at all: the control is rendered
|
|
409
|
+
// but cannot reach this half, and the honest answer is to say so rather than to look empty.
|
|
410
|
+
detail_unavailable: detailUnavailable === true,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Parse the JSON body of a selection write, refusing anything outside the declared shape.
|
|
417
|
+
*
|
|
418
|
+
* @param {unknown} body
|
|
419
|
+
* @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}|{ok: false, error: string}}
|
|
420
|
+
*/
|
|
421
|
+
export function parseSelectionWrite(body) {
|
|
422
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
423
|
+
return { ok: false, error: 'body must be a JSON object' };
|
|
424
|
+
}
|
|
425
|
+
const record = /** @type {Record<string, unknown>} */ (body);
|
|
426
|
+
const sessionId = record.session_id;
|
|
427
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
428
|
+
return { ok: false, error: '`session_id` must be a non-empty string' };
|
|
429
|
+
}
|
|
430
|
+
const machineId = record.machine_id;
|
|
431
|
+
if (machineId !== null && machineId !== undefined && typeof machineId !== 'string') {
|
|
432
|
+
return { ok: false, error: '`machine_id` must be a string or null' };
|
|
433
|
+
}
|
|
434
|
+
const machineName = record.machine_name;
|
|
435
|
+
if (machineName !== null && machineName !== undefined && typeof machineName !== 'string') {
|
|
436
|
+
return { ok: false, error: '`machine_name` must be a string or null' };
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
value: {
|
|
441
|
+
session_id: sessionId,
|
|
442
|
+
machine_id: typeof machineId === 'string' && machineId !== '' ? machineId : null,
|
|
443
|
+
machine_name: typeof machineName === 'string' && machineName !== '' ? machineName : null,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Resolve this machine's fleet identity, once per process.
|
|
450
|
+
*
|
|
451
|
+
* Memoized on a **successful** read only. A machine that is not paired yet resolves to
|
|
452
|
+
* `{machine_id: null}` and is re-read on the next call, because the fix for that is pairing the
|
|
453
|
+
* agent — which can happen while this process is running, and a cached failure would leave the
|
|
454
|
+
* control insisting this machine has no identity long after it got one.
|
|
455
|
+
*
|
|
456
|
+
* @param {() => Promise<{machine_id: string|null, machine_name: string|null, machineName?: string|null}>} read
|
|
457
|
+
* A reader that returns the device's identity and the configured fleet name.
|
|
458
|
+
* @returns {() => Promise<{machine_id: string|null, machine_name: string|null}>}
|
|
459
|
+
*/
|
|
460
|
+
export function createLocalIdentity(read) {
|
|
461
|
+
let cached = null;
|
|
462
|
+
return async () => {
|
|
463
|
+
if (cached !== null) return cached;
|
|
464
|
+
const identity = await read();
|
|
465
|
+
const resolved = {
|
|
466
|
+
machine_id: typeof identity?.machine_id === 'string' && identity.machine_id !== '' ? identity.machine_id : null,
|
|
467
|
+
machine_name: typeof identity?.machine_name === 'string' && identity.machine_name !== ''
|
|
468
|
+
? identity.machine_name
|
|
469
|
+
: (typeof identity?.machineName === 'string' && identity.machineName !== '' ? identity.machineName : null),
|
|
470
|
+
};
|
|
471
|
+
if (resolved.machine_id !== null) cached = resolved;
|
|
472
|
+
return resolved;
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The one diagnostic for "this host cannot carry the composer control", shared by both causes. */
|
|
477
|
+
const NO_ROUTE_REGISTRY =
|
|
478
|
+
'this host provides no `connection.fetch` route registry, so the composer control cannot reach the plugin';
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Mount the two browser routes, if this host has a fetch-route registry to mount them on.
|
|
482
|
+
*
|
|
483
|
+
* Returning a status object instead of throwing keeps the one interesting case loud *and*
|
|
484
|
+
* survivable: a host built without `@deepseek-ai/dsh-client-connection` still gets a working plugin
|
|
485
|
+
* with all eight tools and no composer control, which is strictly better than a plugin that refuses
|
|
486
|
+
* to load because a UI nicety has nowhere to attach.
|
|
487
|
+
*
|
|
488
|
+
* # Why the service is declared with `ctx.inject`, and never read as `ctx.connection`
|
|
489
|
+
*
|
|
490
|
+
* Cordis resolves services per fiber: a property read of a name this plugin did not declare in its
|
|
491
|
+
* `inject` list **throws** — `cannot get property "connection" without inject` — and it throws
|
|
492
|
+
* whether or not some other plugin provides that service. `ctx.inject(names, callback)` is the
|
|
493
|
+
* declaration that makes the read legal, and it is the *optional* form: the plugin's own list stays
|
|
494
|
+
* `['tools']`, so a host with no Connection service still activates and still registers all eight
|
|
495
|
+
* tools — the callback below simply never runs. v0.5.4 shipped the plain property read, and the whole
|
|
496
|
+
* entry then failed on a web host and on a base-only host alike, because the throw does not depend on
|
|
497
|
+
* the service existing.
|
|
498
|
+
*
|
|
499
|
+
* `ctx.inject` also drops the ordering assumption a one-shot read would carry: the Loader creates
|
|
500
|
+
* sibling entries concurrently (`EntryGroup.update` → `Promise.all`), so a host that *does* provide
|
|
501
|
+
* Connection may still be activating it while this plugin's `apply` runs. A synchronous read would
|
|
502
|
+
* report "no registry" for a control that was about to become attachable.
|
|
503
|
+
*
|
|
504
|
+
* The status object is mutated in place when the callback runs, so `w2m_status` — called long after
|
|
505
|
+
* activation — reads what happened rather than what was knowable at `apply` time.
|
|
506
|
+
*
|
|
507
|
+
* @param {object|null|undefined} ctx Cordis context; `ctx.inject(['connection'], …)` is how the
|
|
508
|
+
* optional service is declared and read. A context without `inject` gets no route and no warning,
|
|
509
|
+
* which is the same status as a host that has no Connection at all.
|
|
510
|
+
* @param {object} deps
|
|
511
|
+
* @param {() => object} deps.cfg Current merged plugin config.
|
|
512
|
+
* @param {object} deps.store The selection store.
|
|
513
|
+
* @param {() => Promise<{machine_id: string|null, machine_name: string|null}>} deps.localIdentity
|
|
514
|
+
* @param {(options: object) => Promise<object>} deps.request The plugin's bounded HTTP helper.
|
|
515
|
+
* @param {(cfg: object) => Promise<string|null>} deps.resolveToken
|
|
516
|
+
* @returns {{mounted: boolean, unexpected: boolean, reason: string|null}} `unexpected` is true only
|
|
517
|
+
* when the host has a Connection service that lacks the registry — the one case worth a warning.
|
|
518
|
+
*/
|
|
519
|
+
export function registerTargetRoutes(ctx, deps) {
|
|
520
|
+
const status = {
|
|
521
|
+
mounted: false,
|
|
522
|
+
unexpected: false,
|
|
523
|
+
reason: NO_ROUTE_REGISTRY,
|
|
524
|
+
};
|
|
525
|
+
if (typeof ctx?.inject === 'function') {
|
|
526
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
527
|
+
mountTargetRoutes(webCtx.connection, deps, status, ctx.logger);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return status;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Attach the route pair to one host's fetch registry, recording the outcome on `status`.
|
|
535
|
+
*
|
|
536
|
+
* The registration is owned by the context it is made through — `register` is a Connection method
|
|
537
|
+
* bound to the calling fiber, so it is released with the injected child fiber — which is why there
|
|
538
|
+
* is no `ctx.effect` wrapper here.
|
|
539
|
+
*
|
|
540
|
+
* @param {object|null|undefined} connection The `connection` service, when the host has one.
|
|
541
|
+
* @param {object} deps
|
|
542
|
+
* @param {{mounted: boolean, unexpected: boolean, reason: string|null}} status Mutated in place.
|
|
543
|
+
* @param {object} [logger] The plugin's logger, for the one mismatch worth a line.
|
|
544
|
+
* @returns {void}
|
|
545
|
+
*/
|
|
546
|
+
function mountTargetRoutes(connection, deps, status, logger) {
|
|
547
|
+
const registry = connection?.fetch;
|
|
548
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
549
|
+
// The distinction matters to the log: a host with **no** Connection at all is simply not a web
|
|
550
|
+
// composition, and warning about it would be noise on every headless run. A host that has
|
|
551
|
+
// Connection but no fetch registry is a real mismatch worth one line.
|
|
552
|
+
status.unexpected = connection !== null && connection !== undefined;
|
|
553
|
+
if (status.unexpected) {
|
|
554
|
+
logger?.warn?.(`w2m: the composer target control is inactive — ${NO_ROUTE_REGISTRY}`);
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let cache = { at: 0, value: null };
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The fleet for one call, cached for {@link FLEET_CACHE_MS}.
|
|
563
|
+
*
|
|
564
|
+
* The control asks again every time it opens; a click-happy operator must not turn into a burst of
|
|
565
|
+
* relay reads, and the relay's own answer is already seconds old by the time it arrives.
|
|
566
|
+
*/
|
|
567
|
+
const fleetFor = async (signal) => {
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (cache.value !== null && now - cache.at < FLEET_CACHE_MS) return cache.value;
|
|
570
|
+
const cfg = deps.cfg();
|
|
571
|
+
const identity = await deps.localIdentity();
|
|
572
|
+
const value = await readFleet({
|
|
573
|
+
rabbitUrl: cfg.rabbitUrl,
|
|
574
|
+
token: await deps.resolveToken(cfg),
|
|
575
|
+
localMachineId: identity.machine_id,
|
|
576
|
+
signal,
|
|
577
|
+
request: deps.request,
|
|
578
|
+
});
|
|
579
|
+
cache = { at: now, value };
|
|
580
|
+
return value;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const snapshot = async (sessionId, signal) => {
|
|
584
|
+
const cfg = deps.cfg();
|
|
585
|
+
const identity = await deps.localIdentity();
|
|
586
|
+
const fleet = await fleetFor(signal);
|
|
587
|
+
return buildUiState({
|
|
588
|
+
store: deps.store,
|
|
589
|
+
sessionId,
|
|
590
|
+
fleet,
|
|
591
|
+
localMachineName: identity.machine_name ?? cfg.machineName ?? null,
|
|
592
|
+
detailUnavailable: false,
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// One route object for both verbs, not two: the registry is keyed by path and a second
|
|
597
|
+
// registration for the same path is refused outright (`exact Fetch route … is already
|
|
598
|
+
// registered`), so the method split has to happen inside the handler.
|
|
599
|
+
registry.register({
|
|
600
|
+
path: TARGET_ROUTE_PATH,
|
|
601
|
+
methods: ['GET', 'POST'],
|
|
602
|
+
requestBody: 'buffered',
|
|
603
|
+
fetch: async (request) => {
|
|
604
|
+
const url = new URL(request.url);
|
|
605
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
606
|
+
|
|
607
|
+
if (request.method === 'GET') {
|
|
608
|
+
const sessionId = url.searchParams.get('session_id');
|
|
609
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
610
|
+
return Response.json({ ok: false, error: '`session_id` query parameter is required' }, { status: 400 });
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return Response.json(
|
|
616
|
+
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
617
|
+
{ status: 502 },
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let body;
|
|
623
|
+
try {
|
|
624
|
+
body = await request.json();
|
|
625
|
+
} catch {
|
|
626
|
+
return Response.json({ ok: false, error: 'body is not JSON' }, { status: 400 });
|
|
627
|
+
}
|
|
628
|
+
const parsed = parseSelectionWrite(body);
|
|
629
|
+
if (!parsed.ok) return Response.json({ ok: false, error: parsed.error }, { status: 400 });
|
|
630
|
+
const { session_id: sessionId, machine_id: machineId, machine_name: machineName } = parsed.value;
|
|
631
|
+
|
|
632
|
+
if (machineId === null) {
|
|
633
|
+
deps.store.clear(sessionId);
|
|
634
|
+
return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A selection is only accepted for a machine the relay *currently* lists as online. Refusing
|
|
638
|
+
// here rather than at dispatch time is the whole point of the control: "the machine I chose"
|
|
639
|
+
// must not be a claim the fleet cannot honour, and the refusal reaches the operator while they
|
|
640
|
+
// are still looking at the list.
|
|
641
|
+
const identity = await deps.localIdentity();
|
|
642
|
+
const cfg = deps.cfg();
|
|
643
|
+
const fleet = await fleetFor(request.signal);
|
|
644
|
+
if (fleet.ok !== true) {
|
|
645
|
+
return Response.json(
|
|
646
|
+
{ ok: false, error: `the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}` },
|
|
647
|
+
{ status: 503 },
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const machine = fleet.machines.find((entry) => entry.machine_id === machineId) ?? null;
|
|
651
|
+
if (machine !== null && machine.online !== true) {
|
|
652
|
+
return Response.json(
|
|
653
|
+
{ ok: false, error: `${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected` },
|
|
654
|
+
{ status: 409 },
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
deps.store.set(sessionId, {
|
|
658
|
+
machine_id: machineId,
|
|
659
|
+
machine_name: machine?.name ?? machineName,
|
|
660
|
+
}, { selectedBy: identity.machine_name ?? cfg.machineName ?? null });
|
|
661
|
+
return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
status.mounted = true;
|
|
666
|
+
status.reason = null;
|
|
667
|
+
}
|