dsh-device-fleet 0.1.0-dev.12 → 0.1.0-dev.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/client.js +568 -0
- package/docs/notes/approval-panel-ui.md +21 -21
- package/docs/notes/dev-release.md +5 -5
- package/docs/notes/fleet-console-ui.md +44 -0
- package/docs/notes/ui-surface-audit.md +93 -0
- package/package.json +1 -1
- package/src/host/config-web.js +61 -0
- package/src/host/execution-slot.js +8 -0
- package/src/host/index.js +3 -0
- package/src/host/room-service.js +103 -3
- package/src/host/runtime-config.js +192 -0
- package/src/host/ui-web.js +78 -0
- package/src/net/fleet-node.js +22 -0
- package/src/plugin/index.js +100 -28
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Read-model HTTP surface for the Fleet console (dev.13): device overview,
|
|
2
|
+
// room list and room detail. The approval routes stay in approval-web.js.
|
|
3
|
+
function json(res, status, value) {
|
|
4
|
+
res.statusCode = status
|
|
5
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
6
|
+
res.setHeader('cache-control', 'no-store')
|
|
7
|
+
res.end(JSON.stringify(value))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function pathOf(request) {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(request.url ?? '/', 'http://local').pathname
|
|
13
|
+
} catch {
|
|
14
|
+
return '/'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {object} options.webServer ctx.webServer
|
|
21
|
+
* @param {object} options.inbox ApprovalInbox
|
|
22
|
+
* @param {object} options.rooms RoomService
|
|
23
|
+
* @param {() => object} options.getOverview
|
|
24
|
+
* @returns {() => void} disposer
|
|
25
|
+
*/
|
|
26
|
+
export function registerUiRoutes({ webServer, inbox, rooms, getOverview, logger = null }) {
|
|
27
|
+
if (!webServer?.register || !rooms) return () => {}
|
|
28
|
+
const disposers = []
|
|
29
|
+
disposers.push(
|
|
30
|
+
webServer.register({
|
|
31
|
+
kind: 'exact',
|
|
32
|
+
path: '/device-fleet/overview',
|
|
33
|
+
handler: async (req, res) => {
|
|
34
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
35
|
+
try {
|
|
36
|
+
json(res, 200, { ...getOverview(), approvals: inbox?.list?.() ?? [] })
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger?.warn?.(`[device-fleet] overview failed: ${String(error?.message ?? error)}`)
|
|
39
|
+
json(res, 500, { error: 'internal error' })
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
disposers.push(
|
|
45
|
+
webServer.register({
|
|
46
|
+
kind: 'exact',
|
|
47
|
+
path: '/device-fleet/rooms',
|
|
48
|
+
handler: async (req, res) => {
|
|
49
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
50
|
+
try {
|
|
51
|
+
json(res, 200, { rooms: rooms.list() })
|
|
52
|
+
} catch (error) {
|
|
53
|
+
json(res, 500, { error: String(error?.message ?? error) })
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
disposers.push(
|
|
59
|
+
webServer.register({
|
|
60
|
+
kind: 'prefix',
|
|
61
|
+
path: '/device-fleet/rooms/',
|
|
62
|
+
handler: async (req, res) => {
|
|
63
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
64
|
+
const path = pathOf(req)
|
|
65
|
+
const roomId = decodeURIComponent(path.slice('/device-fleet/rooms/'.length))
|
|
66
|
+
if (roomId === '') return json(res, 400, { error: 'room id is required' })
|
|
67
|
+
try {
|
|
68
|
+
json(res, 200, { room: rooms.detail(roomId, { limit: 100 }) })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
json(res, error?.code === 'unknown-room' ? 404 : 500, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
return () => {
|
|
76
|
+
for (const dispose of disposers) dispose()
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/net/fleet-node.js
CHANGED
|
@@ -489,6 +489,28 @@ export class FleetNode extends EventEmitter {
|
|
|
489
489
|
return [...this.channels.keys()]
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** Apply live network changes without recreating the node or its identity. */
|
|
493
|
+
async reconfigure({ name, direct, discovery, relay } = {}) {
|
|
494
|
+
if (this.closed) return
|
|
495
|
+
if (typeof name === 'string' && name !== '') this.name = name
|
|
496
|
+
const networkChanged = direct !== undefined || discovery !== undefined || relay !== undefined
|
|
497
|
+
if (!networkChanged) return
|
|
498
|
+
const wasStarted = this.started
|
|
499
|
+
await this.close()
|
|
500
|
+
this.closed = false
|
|
501
|
+
this.started = false
|
|
502
|
+
this.hintAttempt = 0
|
|
503
|
+
this.discovered.clear()
|
|
504
|
+
if (direct !== undefined) this.directConfig = direct
|
|
505
|
+
if (discovery !== undefined) this.discoveryConfig = discovery
|
|
506
|
+
if (relay !== undefined) this.relayConfig = relay
|
|
507
|
+
this.directServer = null
|
|
508
|
+
this.discovery = null
|
|
509
|
+
this.relayClient = null
|
|
510
|
+
this.directAddress = null
|
|
511
|
+
if (wasStarted) await this.start()
|
|
512
|
+
}
|
|
513
|
+
|
|
492
514
|
async close() {
|
|
493
515
|
this.closed = true
|
|
494
516
|
clearTimeout(this.hintTimer)
|
package/src/plugin/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Room, Room Board, delegation and the UI land in later steps.
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
8
|
import { networkInterfaces } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
9
10
|
import { fileURLToPath } from 'node:url'
|
|
10
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
11
12
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
@@ -17,6 +18,9 @@ import { DelegationHost } from '../host/delegation.js'
|
|
|
17
18
|
import { ApprovalInbox } from '../host/approval-inbox.js'
|
|
18
19
|
import { RoomService } from '../host/room-service.js'
|
|
19
20
|
import { registerApprovalRoutes } from '../host/approval-web.js'
|
|
21
|
+
import { registerUiRoutes } from '../host/ui-web.js'
|
|
22
|
+
import { registerConfigRoutes } from '../host/config-web.js'
|
|
23
|
+
import { RuntimeConfig } from '../host/runtime-config.js'
|
|
20
24
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
21
25
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
22
26
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
@@ -126,7 +130,7 @@ function withManual(definitions) {
|
|
|
126
130
|
}))
|
|
127
131
|
}
|
|
128
132
|
|
|
129
|
-
export function apply(ctx,
|
|
133
|
+
export function apply(ctx, profileConfig) {
|
|
130
134
|
const pluginVersion = packageVersion()
|
|
131
135
|
const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
|
|
132
136
|
// Optional services: Cordis throws on direct property access for services the
|
|
@@ -142,12 +146,20 @@ export function apply(ctx, config) {
|
|
|
142
146
|
|
|
143
147
|
let state
|
|
144
148
|
try {
|
|
145
|
-
state = loadState({ dshHome: process.env.DSH_HOME, dataDir:
|
|
149
|
+
state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
|
|
146
150
|
} catch (error) {
|
|
147
151
|
logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
|
|
148
152
|
return
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
// Runtime config: profile patch is the base, UI edits are persisted in the
|
|
156
|
+
// state dir and applied live (no plugin/DSH restart).
|
|
157
|
+
const runtime = new RuntimeConfig({
|
|
158
|
+
base: profileConfig ?? {},
|
|
159
|
+
overridesFile: join(state.dataDir, 'config-overrides.json'),
|
|
160
|
+
})
|
|
161
|
+
const config = runtime.current
|
|
162
|
+
|
|
151
163
|
const node = new FleetNode({
|
|
152
164
|
identity: state.identity,
|
|
153
165
|
fleet: state.fleet,
|
|
@@ -163,7 +175,7 @@ export function apply(ctx, config) {
|
|
|
163
175
|
capabilities: CAPABILITIES,
|
|
164
176
|
})
|
|
165
177
|
|
|
166
|
-
|
|
178
|
+
let policy = new ToolPolicy(config.policy)
|
|
167
179
|
const sessionController = service('sessionController')
|
|
168
180
|
const fs = service('fs')
|
|
169
181
|
const subagents = service('subagents')
|
|
@@ -176,34 +188,52 @@ export function apply(ctx, config) {
|
|
|
176
188
|
list: () => inbox.list(),
|
|
177
189
|
approve: (id, options) => inbox.approve(id, options),
|
|
178
190
|
deny: (id, reason) => inbox.deny(id, reason),
|
|
179
|
-
simulate:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
simulate: async ({ fromDeviceId, capability, tool, reason }) => {
|
|
192
|
+
if (runtime.current.approvals?.simulation !== true) {
|
|
193
|
+
throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
|
|
194
|
+
}
|
|
195
|
+
const promise = inbox.request({
|
|
196
|
+
fromDeviceId: fromDeviceId ?? 'simulated-device',
|
|
197
|
+
capability: capability ?? 'run',
|
|
198
|
+
tool: tool ?? null,
|
|
199
|
+
reason: reason ?? 'simulated approval request',
|
|
200
|
+
args: {},
|
|
201
|
+
})
|
|
202
|
+
const summary = inbox.list().at(-1)
|
|
203
|
+
// Keep the promise from becoming an unhandled rejection when the
|
|
204
|
+
// request expires without an answer; the demo only inspects the inbox.
|
|
205
|
+
promise.catch(() => {})
|
|
206
|
+
return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
|
|
207
|
+
},
|
|
195
208
|
}
|
|
196
|
-
const requestApproval =
|
|
197
|
-
|
|
198
|
-
: async (request) => inbox.request(request)
|
|
209
|
+
const requestApproval = async (request) =>
|
|
210
|
+
runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
|
|
199
211
|
|
|
200
|
-
const rooms = new RoomService({
|
|
212
|
+
const rooms = new RoomService({
|
|
213
|
+
sessionController,
|
|
214
|
+
createUserMessage,
|
|
215
|
+
stateFile: join(state.dataDir, 'rooms.json'),
|
|
216
|
+
})
|
|
201
217
|
|
|
202
218
|
// Client approval panel data plane (dev.9). Optional service: host-only
|
|
203
219
|
// deployments simply do not expose the panel routes.
|
|
204
220
|
const webServer = service('webServer')
|
|
205
221
|
if (webServer) {
|
|
206
222
|
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
223
|
+
ctx.effect(() => registerConfigRoutes({ webServer, runtime, logger }))
|
|
224
|
+
ctx.effect(() =>
|
|
225
|
+
registerUiRoutes({
|
|
226
|
+
webServer,
|
|
227
|
+
inbox,
|
|
228
|
+
rooms,
|
|
229
|
+
logger,
|
|
230
|
+
getOverview: () => ({
|
|
231
|
+
...(node.status?.() ?? {}),
|
|
232
|
+
rooms: rooms.list(),
|
|
233
|
+
approvals: inbox.list(),
|
|
234
|
+
}),
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
207
237
|
}
|
|
208
238
|
if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
|
|
209
239
|
const slot = sessionController
|
|
@@ -298,7 +328,7 @@ export function apply(ctx, config) {
|
|
|
298
328
|
: (state.enrollment?.accountFingerprint ?? null)
|
|
299
329
|
return createInvite(state, {
|
|
300
330
|
accountFingerprint: fingerprint,
|
|
301
|
-
relay:
|
|
331
|
+
relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
|
|
302
332
|
endpoints: localEndpoints(),
|
|
303
333
|
})
|
|
304
334
|
},
|
|
@@ -326,13 +356,14 @@ export function apply(ctx, config) {
|
|
|
326
356
|
|
|
327
357
|
// Adoption layer 2: one sentence, only when the Fleet has members.
|
|
328
358
|
const systemPrompt = service('systemPrompt')
|
|
329
|
-
if (systemPrompt
|
|
359
|
+
if (systemPrompt) {
|
|
330
360
|
const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
|
|
331
361
|
ctx.effect(() =>
|
|
332
362
|
systemPrompt.section({
|
|
333
363
|
name: 'device-fleet:members',
|
|
334
364
|
order,
|
|
335
365
|
text: () => {
|
|
366
|
+
if (runtime.current.promptSection === false) return ''
|
|
336
367
|
const members = node.rosterEntries().length
|
|
337
368
|
if (members === 0) return ''
|
|
338
369
|
return `This DSH instance is part of a device Fleet with ${members} other device(s). Use fleet_list/fleet_read/fleet_fetch/fleet_run for work that belongs on another device; direct calls fail fast when that device is offline.`
|
|
@@ -341,6 +372,48 @@ export function apply(ctx, config) {
|
|
|
341
372
|
)
|
|
342
373
|
}
|
|
343
374
|
|
|
375
|
+
// Live config application: every UI save runs these handlers in-process.
|
|
376
|
+
runtime.onChange(async (next, previous) => {
|
|
377
|
+
if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
|
|
378
|
+
policy = new ToolPolicy(next.policy)
|
|
379
|
+
if (host) host.policy = policy
|
|
380
|
+
if (delegation) delegation.policy = policy
|
|
381
|
+
}
|
|
382
|
+
if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
|
|
383
|
+
slot?.setConfig(next.execution)
|
|
384
|
+
if (host) {
|
|
385
|
+
host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
|
|
386
|
+
host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
|
|
387
|
+
}
|
|
388
|
+
if (delegation) {
|
|
389
|
+
delegation.defaultCwd = next.execution?.cwd
|
|
390
|
+
delegation.defaultAgentPreset = next.execution?.agentPreset
|
|
391
|
+
delegation.provider = next.execution?.subagentProvider ?? 'spawn'
|
|
392
|
+
delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
|
|
396
|
+
if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
|
|
397
|
+
if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
|
|
398
|
+
}
|
|
399
|
+
if (next.deviceName !== previous?.deviceName) {
|
|
400
|
+
node.name = next.deviceName
|
|
401
|
+
if (host) host.name = next.deviceName
|
|
402
|
+
}
|
|
403
|
+
const networkChanged =
|
|
404
|
+
JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
|
|
405
|
+
next.discovery !== previous?.discovery ||
|
|
406
|
+
JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
|
|
407
|
+
if (networkChanged) {
|
|
408
|
+
await node.reconfigure({
|
|
409
|
+
name: next.deviceName,
|
|
410
|
+
direct: next.direct,
|
|
411
|
+
discovery: next.discovery ? true : false,
|
|
412
|
+
relay: next.relay ?? null,
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
344
417
|
// Adoption layer 4: one-line nudge when the model starts orchestrating.
|
|
345
418
|
ctx.on?.('tools/post-execute', async (exec, _result, next) => {
|
|
346
419
|
const decision = await next()
|
|
@@ -369,9 +442,8 @@ export function apply(ctx, config) {
|
|
|
369
442
|
|
|
370
443
|
ctx.effect(() => () => {
|
|
371
444
|
inbox.close()
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
445
|
+
// Rooms are intentionally left open (and persisted) so a plugin reload can
|
|
446
|
+
// rehydrate them instead of destroying a running collaboration.
|
|
375
447
|
void node.close()
|
|
376
448
|
})
|
|
377
449
|
|