dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.21
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 +7 -3
- package/client.js +1176 -0
- package/cordis.patch.yml +3 -3
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/approval-panel-ui.md +38 -0
- package/docs/notes/dev-release.md +93 -4
- package/docs/notes/fleet-console-ui.md +44 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/ui-surface-audit.md +93 -0
- package/package.json +10 -1
- package/src/host/approval-inbox.js +163 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/config-web.js +84 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +27 -8
- package/src/host/execution-slot.js +27 -5
- package/src/host/index.js +7 -0
- package/src/host/room-service.js +431 -0
- package/src/host/runtime-config.js +192 -0
- package/src/host/store.js +31 -3
- package/src/host/tool-policy.js +13 -10
- package/src/host/tools.js +376 -1
- package/src/host/ui-web.js +133 -0
- package/src/net/fleet-node.js +118 -1
- package/src/net/identity.js +12 -1
- package/src/plugin/index.js +366 -15
package/src/net/fleet-node.js
CHANGED
|
@@ -45,6 +45,7 @@ export class FleetNode extends EventEmitter {
|
|
|
45
45
|
discovery = false,
|
|
46
46
|
relay = null,
|
|
47
47
|
roster = [],
|
|
48
|
+
peerHints = [],
|
|
48
49
|
trustOnFirstUse = true,
|
|
49
50
|
acceptRelayConnections = true,
|
|
50
51
|
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
@@ -74,6 +75,10 @@ export class FleetNode extends EventEmitter {
|
|
|
74
75
|
this.roster = new Map()
|
|
75
76
|
for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
|
|
76
77
|
this.discovered = new Map()
|
|
78
|
+
this.peerHints = []
|
|
79
|
+
for (const hint of peerHints) this.addPeerHint(hint)
|
|
80
|
+
this.hintTimer = null
|
|
81
|
+
this.hintAttempt = 0
|
|
77
82
|
this.channels = new Map()
|
|
78
83
|
this.pendingRpc = new Map()
|
|
79
84
|
this.requestHandler = null
|
|
@@ -163,9 +168,81 @@ export class FleetNode extends EventEmitter {
|
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
this.emit('started', { direct: this.directAddress ?? null })
|
|
171
|
+
this.scheduleHintConnects()
|
|
166
172
|
return this
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
/** Remember an address hint for a peer (from an invite or a manual `fleet_connect`). */
|
|
176
|
+
addPeerHint(hint) {
|
|
177
|
+
if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
|
|
178
|
+
const entry = {
|
|
179
|
+
deviceId: typeof hint.deviceId === 'string' ? hint.deviceId : undefined,
|
|
180
|
+
host: hint.host,
|
|
181
|
+
port: hint.port,
|
|
182
|
+
}
|
|
183
|
+
if (entry.deviceId === this.identity.deviceId) return
|
|
184
|
+
const exists = this.peerHints.some(
|
|
185
|
+
(item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId,
|
|
186
|
+
)
|
|
187
|
+
if (!exists) this.peerHints.push(entry)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Connect to a raw address; learns the peer id from the handshake. */
|
|
191
|
+
async connectAddress(host, port, { expectedDeviceId = null, timeoutMs = this.connectTimeoutMs } = {}) {
|
|
192
|
+
if (!host || !Number.isInteger(port) || port <= 0) throw new Error('connectAddress needs host and port')
|
|
193
|
+
const expected = expectedDeviceId ? { deviceId: expectedDeviceId } : null
|
|
194
|
+
const socket = await connectDirect({ host, port, timeoutMs })
|
|
195
|
+
const channel = this.createInitiator(socket, expected)
|
|
196
|
+
await this.waitReady(channel, timeoutMs)
|
|
197
|
+
return channel.peer
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Retry the invite/manual hints in the background until one peer connects. */
|
|
201
|
+
scheduleHintConnects() {
|
|
202
|
+
if (this.hintTimer || this.closed) return
|
|
203
|
+
const attempt = async () => {
|
|
204
|
+
this.hintTimer = null
|
|
205
|
+
if (this.closed) return
|
|
206
|
+
if (this.peerHints.length === 0 || this.channels.size > 0) return
|
|
207
|
+
try {
|
|
208
|
+
for (const hint of this.peerHints) {
|
|
209
|
+
if (this.closed || this.channels.size > 0) return
|
|
210
|
+
try {
|
|
211
|
+
const peer = await this.connectAddress(hint.host, hint.port, { expectedDeviceId: hint.deviceId })
|
|
212
|
+
this.emit('hint-connected', { hint, peer })
|
|
213
|
+
return
|
|
214
|
+
} catch {
|
|
215
|
+
// try the next hint; the timer below retries
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
if (!this.closed && this.channels.size === 0) {
|
|
220
|
+
this.hintAttempt += 1
|
|
221
|
+
const delay = Math.min(10_000, 1000 * 2 ** Math.min(this.hintAttempt, 4))
|
|
222
|
+
this.hintTimer = setTimeout(() => void attempt(), delay)
|
|
223
|
+
this.hintTimer.unref?.()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.hintTimer = setTimeout(() => void attempt(), 1500)
|
|
228
|
+
this.hintTimer.unref?.()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
status() {
|
|
232
|
+
return {
|
|
233
|
+
deviceId: this.identity.deviceId,
|
|
234
|
+
name: this.name,
|
|
235
|
+
pluginVersion: this.pluginVersion,
|
|
236
|
+
dshVersion: this.dshVersion,
|
|
237
|
+
direct: this.directAddress ? { host: this.directAddress.host, port: this.directAddress.port } : null,
|
|
238
|
+
relay: this.relayClient ? { online: this.relayClient.online === true } : null,
|
|
239
|
+
discovery: this.discovery ? { enabled: true, peers: [...this.discovered.keys()] } : { enabled: false, peers: [] },
|
|
240
|
+
roster: this.rosterEntries().map((entry) => ({ deviceId: entry.deviceId, name: entry.name ?? 'device' })),
|
|
241
|
+
connected: this.peerIds(),
|
|
242
|
+
peerHints: [...this.peerHints],
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
169
246
|
async acceptIncomingRelay(controller) {
|
|
170
247
|
if (!this.acceptRelayConnections) {
|
|
171
248
|
controller.reject()
|
|
@@ -204,6 +281,14 @@ export class FleetNode extends EventEmitter {
|
|
|
204
281
|
if (current === channel) this.channels.delete(channel.peer.deviceId)
|
|
205
282
|
this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
|
|
206
283
|
this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
|
|
284
|
+
// Recover automatically: the peer may have restarted or moved; retry the
|
|
285
|
+
// known addresses instead of waiting for the next process start.
|
|
286
|
+
if (!this.closed && this.channels.size === 0 && this.peerHints.length > 0) {
|
|
287
|
+
clearTimeout(this.hintTimer)
|
|
288
|
+
this.hintTimer = null
|
|
289
|
+
this.hintAttempt = 0
|
|
290
|
+
this.scheduleHintConnects()
|
|
291
|
+
}
|
|
207
292
|
})
|
|
208
293
|
}
|
|
209
294
|
|
|
@@ -238,7 +323,15 @@ export class FleetNode extends EventEmitter {
|
|
|
238
323
|
|
|
239
324
|
const expected = this.roster.get(deviceId) ?? { deviceId }
|
|
240
325
|
const discovered = this.discovered.get(deviceId)
|
|
241
|
-
const
|
|
326
|
+
const hinted = this.peerHints.find((entry) => entry.deviceId === deviceId)
|
|
327
|
+
const endpoint =
|
|
328
|
+
host && port
|
|
329
|
+
? { host, port }
|
|
330
|
+
: discovered
|
|
331
|
+
? { host: discovered.host, port: discovered.port }
|
|
332
|
+
: hinted
|
|
333
|
+
? { host: hinted.host, port: hinted.port }
|
|
334
|
+
: null
|
|
242
335
|
let directError = null
|
|
243
336
|
if (endpoint) {
|
|
244
337
|
try {
|
|
@@ -396,8 +489,32 @@ export class FleetNode extends EventEmitter {
|
|
|
396
489
|
return [...this.channels.keys()]
|
|
397
490
|
}
|
|
398
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
|
+
|
|
399
514
|
async close() {
|
|
400
515
|
this.closed = true
|
|
516
|
+
clearTimeout(this.hintTimer)
|
|
517
|
+
this.hintTimer = null
|
|
401
518
|
for (const channel of this.channels.values()) channel.close()
|
|
402
519
|
this.channels.clear()
|
|
403
520
|
for (const [id, pending] of this.pendingRpc) {
|
package/src/net/identity.js
CHANGED
|
@@ -70,7 +70,7 @@ export function createFleet({ name = 'fleet' } = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function encodeInvite({ fleet, accountFingerprint = null, relay = null }) {
|
|
73
|
+
export function encodeInvite({ fleet, accountFingerprint = null, relay = null, endpoints = [] }) {
|
|
74
74
|
const payload = {
|
|
75
75
|
v: 1,
|
|
76
76
|
fleetId: fleet.fleetId,
|
|
@@ -78,6 +78,7 @@ export function encodeInvite({ fleet, accountFingerprint = null, relay = null })
|
|
|
78
78
|
name: fleet.name,
|
|
79
79
|
accountFingerprint,
|
|
80
80
|
relay,
|
|
81
|
+
endpoints: Array.isArray(endpoints) ? endpoints : [],
|
|
81
82
|
createdAt: Date.now(),
|
|
82
83
|
}
|
|
83
84
|
return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
@@ -99,6 +100,16 @@ export function decodeInvite(code) {
|
|
|
99
100
|
fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
|
|
100
101
|
accountFingerprint: payload.accountFingerprint ?? null,
|
|
101
102
|
relay: payload.relay ?? null,
|
|
103
|
+
endpoints: Array.isArray(payload.endpoints)
|
|
104
|
+
? payload.endpoints.filter(
|
|
105
|
+
(entry) =>
|
|
106
|
+
entry &&
|
|
107
|
+
typeof entry.host === 'string' &&
|
|
108
|
+
Number.isInteger(entry.port) &&
|
|
109
|
+
entry.port > 0 &&
|
|
110
|
+
(entry.deviceId === undefined || typeof entry.deviceId === 'string'),
|
|
111
|
+
)
|
|
112
|
+
: [],
|
|
102
113
|
createdAt: payload.createdAt ?? null,
|
|
103
114
|
}
|
|
104
115
|
}
|