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