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
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Room logic layer (ADR-0002/0005/0006/0011/0021), with local participants for
|
|
2
|
+
// simulation inside one DSH. The Host session log carries the authoritative
|
|
3
|
+
// room events; participant agents are real local DSH sessions woken with an
|
|
4
|
+
// addressed message, so addressing/wake/visibility and board handoff can be
|
|
5
|
+
// exercised before the remote transport and the client UI exist.
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const MAX_MESSAGE_CHARS = 20_000
|
|
11
|
+
const STATE_VERSION = 1
|
|
12
|
+
|
|
13
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
14
|
+
invite: 'members',
|
|
15
|
+
join: 'auto',
|
|
16
|
+
maxMembers: 8,
|
|
17
|
+
boardWrite: 'members',
|
|
18
|
+
remove: 'members',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function hostMemberId(sessionId) {
|
|
22
|
+
return `host:${sessionId}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function localMemberId(sessionId) {
|
|
26
|
+
return `local:${sessionId}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RoomService {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {object} options.sessionController ctx.sessionController
|
|
33
|
+
* @param {(input: object) => object} [options.createUserMessage] dsh-llm helper
|
|
34
|
+
* @param {(now?: number) => number} [options.now]
|
|
35
|
+
* @param {string} [options.stateFile] JSON file used to rehydrate rooms after a reload
|
|
36
|
+
*/
|
|
37
|
+
constructor({ sessionController, createUserMessage = null, now = Date.now, stateFile = null } = {}) {
|
|
38
|
+
this.sessionController = sessionController
|
|
39
|
+
this.createUserMessage = createUserMessage
|
|
40
|
+
this.now = now
|
|
41
|
+
this.stateFile = stateFile
|
|
42
|
+
/** @type {Map<string, any>} */
|
|
43
|
+
this.rooms = new Map()
|
|
44
|
+
if (stateFile) this.load()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rehydrate active rooms from the persisted snapshot (agents resolve lazily). */
|
|
48
|
+
load() {
|
|
49
|
+
if (!this.stateFile || !existsSync(this.stateFile)) return
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(readFileSync(this.stateFile, 'utf8'))
|
|
52
|
+
if (!data || data.version !== STATE_VERSION || !Array.isArray(data.rooms)) return
|
|
53
|
+
for (const stored of data.rooms) {
|
|
54
|
+
if (!stored || typeof stored.roomId !== 'string' || !Array.isArray(stored.members)) continue
|
|
55
|
+
const members = new Map()
|
|
56
|
+
for (const member of stored.members) {
|
|
57
|
+
if (!member || typeof member.memberId !== 'string') continue
|
|
58
|
+
members.set(member.memberId, { ...member, agent: undefined })
|
|
59
|
+
}
|
|
60
|
+
if (!members.has(hostMemberId(stored.roomId))) continue
|
|
61
|
+
this.rooms.set(stored.roomId, {
|
|
62
|
+
roomId: stored.roomId,
|
|
63
|
+
createdAt: Number.isFinite(stored.createdAt) ? stored.createdAt : this.now(),
|
|
64
|
+
hostSessionId: typeof stored.hostSessionId === 'string' ? stored.hostSessionId : stored.roomId,
|
|
65
|
+
policy: { ...DEFAULT_POLICY, ...(stored.policy ?? {}) },
|
|
66
|
+
members,
|
|
67
|
+
messages: Array.isArray(stored.messages) ? stored.messages : [],
|
|
68
|
+
tasks: new Map((Array.isArray(stored.tasks) ? stored.tasks : []).map((task) => [task.id, task])),
|
|
69
|
+
closed: false,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* a corrupt snapshot is ignored; the host session log stays authoritative */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
persist() {
|
|
78
|
+
if (!this.stateFile) return
|
|
79
|
+
try {
|
|
80
|
+
const snapshot = {
|
|
81
|
+
version: STATE_VERSION,
|
|
82
|
+
savedAt: this.now(),
|
|
83
|
+
rooms: [...this.rooms.values()].map((room) => ({
|
|
84
|
+
roomId: room.roomId,
|
|
85
|
+
createdAt: room.createdAt,
|
|
86
|
+
hostSessionId: room.hostSessionId,
|
|
87
|
+
policy: room.policy,
|
|
88
|
+
members: [...room.members.values()].map(({ agent, ...rest }) => rest),
|
|
89
|
+
messages: room.messages,
|
|
90
|
+
tasks: [...room.tasks.values()],
|
|
91
|
+
})),
|
|
92
|
+
}
|
|
93
|
+
mkdirSync(dirname(this.stateFile), { recursive: true })
|
|
94
|
+
const temporary = `${this.stateFile}.tmp`
|
|
95
|
+
writeFileSync(temporary, JSON.stringify(snapshot, null, 2), 'utf8')
|
|
96
|
+
renameSync(temporary, this.stateFile)
|
|
97
|
+
} catch {
|
|
98
|
+
/* persistence is best effort; the session log remains authoritative */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve a live agent for a persisted member on demand. */
|
|
103
|
+
async ensureAgent(member) {
|
|
104
|
+
if (member?.agent) return true
|
|
105
|
+
if (!member?.sessionId || !this.sessionController?.resolveAgent) return false
|
|
106
|
+
try {
|
|
107
|
+
const resolved = await this.sessionController.resolveAgent(member.sessionId)
|
|
108
|
+
if (!resolved?.agent) return false
|
|
109
|
+
member.agent = resolved.agent
|
|
110
|
+
return true
|
|
111
|
+
} catch {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async upgradeHost({ agent, policy = {} } = {}) {
|
|
117
|
+
if (!agent?.session?.id) throw Object.assign(new Error('room upgrade needs the calling agent session'), { code: 'bad-request' })
|
|
118
|
+
const sessionId = agent.session.id
|
|
119
|
+
const existing = this.rooms.get(sessionId)
|
|
120
|
+
if (existing) return this.summary(existing)
|
|
121
|
+
const memberId = hostMemberId(sessionId)
|
|
122
|
+
const room = {
|
|
123
|
+
roomId: sessionId,
|
|
124
|
+
createdAt: this.now(),
|
|
125
|
+
hostSessionId: sessionId,
|
|
126
|
+
policy: { ...DEFAULT_POLICY, ...policy },
|
|
127
|
+
members: new Map(),
|
|
128
|
+
messages: [],
|
|
129
|
+
tasks: new Map(),
|
|
130
|
+
closed: false,
|
|
131
|
+
}
|
|
132
|
+
room.members.set(memberId, {
|
|
133
|
+
memberId,
|
|
134
|
+
role: 'host',
|
|
135
|
+
kind: 'local',
|
|
136
|
+
sessionId,
|
|
137
|
+
name: agent.session.header?.title ?? 'host',
|
|
138
|
+
agent,
|
|
139
|
+
})
|
|
140
|
+
this.rooms.set(sessionId, room)
|
|
141
|
+
await this.append(room, 'room/created', { roomId: room.roomId, policy: room.policy, hostMemberId: memberId })
|
|
142
|
+
return this.summary(room)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async addLocalParticipant({ roomId, name = 'worker', agentPreset, cwd } = {}) {
|
|
146
|
+
const room = this.requireRoom(roomId)
|
|
147
|
+
if (room.members.size >= room.policy.maxMembers) {
|
|
148
|
+
throw Object.assign(new Error(`room member limit reached (${room.policy.maxMembers})`), { code: 'room-full' })
|
|
149
|
+
}
|
|
150
|
+
if (!this.sessionController?.create) {
|
|
151
|
+
throw Object.assign(new Error('participant sessions are unavailable'), { code: 'unsupported' })
|
|
152
|
+
}
|
|
153
|
+
const created = await this.sessionController.create({
|
|
154
|
+
...(cwd ? { cwd } : {}),
|
|
155
|
+
...(agentPreset ? { agentPreset } : {}),
|
|
156
|
+
})
|
|
157
|
+
const sessionId = created?.sessionId
|
|
158
|
+
if (!sessionId) throw Object.assign(new Error('could not create the participant session'), { code: 'room-participant-failed' })
|
|
159
|
+
const resolved = await this.sessionController.resolveAgent(sessionId)
|
|
160
|
+
if (!resolved?.agent) throw Object.assign(new Error('participant agent could not be resolved'), { code: 'room-participant-failed' })
|
|
161
|
+
const memberId = localMemberId(sessionId)
|
|
162
|
+
const member = { memberId, role: 'participant', kind: 'local', sessionId, name, agent: resolved.agent }
|
|
163
|
+
room.members.set(memberId, member)
|
|
164
|
+
await this.append(room, 'room/member', {
|
|
165
|
+
roomId: room.roomId,
|
|
166
|
+
action: 'added',
|
|
167
|
+
memberId,
|
|
168
|
+
role: 'participant',
|
|
169
|
+
name,
|
|
170
|
+
sessionId,
|
|
171
|
+
})
|
|
172
|
+
return this.memberSummary(member)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async removeMember({ roomId, memberId, reason = 'removed' } = {}) {
|
|
176
|
+
const room = this.requireRoom(roomId)
|
|
177
|
+
const member = room.members.get(memberId)
|
|
178
|
+
if (!member) throw Object.assign(new Error(`unknown member: ${String(memberId)}`), { code: 'unknown-member' })
|
|
179
|
+
if (member.role === 'host') throw Object.assign(new Error('the host cannot be removed; close the room instead'), { code: 'forbidden' })
|
|
180
|
+
room.members.delete(memberId)
|
|
181
|
+
await this.append(room, 'room/member', { roomId: room.roomId, action: 'removed', memberId, reason })
|
|
182
|
+
return { roomId: room.roomId, memberId }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send one addressed message. The Host appends it to the canonical log and
|
|
187
|
+
* wakes only the addressed local members (never the sender).
|
|
188
|
+
*/
|
|
189
|
+
async send({ roomId, from, to, text } = {}) {
|
|
190
|
+
const room = this.requireRoom(roomId)
|
|
191
|
+
const body = typeof text === 'string' ? text.trim() : ''
|
|
192
|
+
if (body === '') throw Object.assign(new Error('room message needs text'), { code: 'bad-request' })
|
|
193
|
+
if (body.length > MAX_MESSAGE_CHARS) throw Object.assign(new Error('room message is too long'), { code: 'bad-request' })
|
|
194
|
+
const recipients = Array.isArray(to) ? to.filter((id) => typeof id === 'string') : []
|
|
195
|
+
if (recipients.length === 0) throw Object.assign(new Error('room message needs at least one recipient'), { code: 'bad-request' })
|
|
196
|
+
for (const memberId of recipients) {
|
|
197
|
+
if (!room.members.has(memberId)) throw Object.assign(new Error(`unknown member: ${memberId}`), { code: 'unknown-member' })
|
|
198
|
+
}
|
|
199
|
+
const sender = this.resolveSender(room, from)
|
|
200
|
+
const message = {
|
|
201
|
+
id: randomUUID(),
|
|
202
|
+
roomId: room.roomId,
|
|
203
|
+
from: sender,
|
|
204
|
+
to: recipients,
|
|
205
|
+
text: body,
|
|
206
|
+
createdAt: this.now(),
|
|
207
|
+
}
|
|
208
|
+
room.messages.push(message)
|
|
209
|
+
await this.append(room, 'room/message', {
|
|
210
|
+
roomId: room.roomId,
|
|
211
|
+
messageId: message.id,
|
|
212
|
+
from: sender,
|
|
213
|
+
to: recipients,
|
|
214
|
+
text: body,
|
|
215
|
+
})
|
|
216
|
+
const woken = []
|
|
217
|
+
for (const memberId of recipients) {
|
|
218
|
+
if (sender.memberId === memberId) continue
|
|
219
|
+
const member = room.members.get(memberId)
|
|
220
|
+
if (await this.wake(room, member, message)) woken.push(memberId)
|
|
221
|
+
}
|
|
222
|
+
return { messageId: message.id, to: recipients, woken }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
resolveSender(room, from) {
|
|
226
|
+
if (from && typeof from === 'object') {
|
|
227
|
+
if (typeof from.memberId === 'string') {
|
|
228
|
+
const member = room.members.get(from.memberId)
|
|
229
|
+
if (!member) throw Object.assign(new Error(`unknown sender member: ${from.memberId}`), { code: 'unknown-member' })
|
|
230
|
+
return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
|
|
231
|
+
}
|
|
232
|
+
if (typeof from.sessionId === 'string') {
|
|
233
|
+
for (const member of room.members.values()) {
|
|
234
|
+
if (member.sessionId === from.sessionId) {
|
|
235
|
+
return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw Object.assign(new Error(`sender session is not a room member: ${from.sessionId}`), { code: 'unknown-member' })
|
|
239
|
+
}
|
|
240
|
+
if (from.kind === 'user') return { kind: 'user', name: 'user' }
|
|
241
|
+
}
|
|
242
|
+
return { kind: 'user', name: 'user' }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Wake one local participant by injecting the addressed message. */
|
|
246
|
+
async wake(room, member, message) {
|
|
247
|
+
if (!member || typeof this.createUserMessage !== 'function') return false
|
|
248
|
+
if (!(await this.ensureAgent(member))) return false
|
|
249
|
+
if (typeof member.agent?.followup !== 'function') return false
|
|
250
|
+
const senderLabel = message.from.kind === 'user' ? 'user' : (message.from.name ?? 'agent')
|
|
251
|
+
// A user sender has no member id; the reply target is always the Host member.
|
|
252
|
+
const replyTarget = message.from.memberId ?? hostMemberId(room.hostSessionId)
|
|
253
|
+
// The host is the user's own session: deliver a plain attributed line into
|
|
254
|
+
// the normal Chat stream instead of an agent-to-agent reply instruction.
|
|
255
|
+
const text =
|
|
256
|
+
member.role === 'host'
|
|
257
|
+
? `[Room] ${senderLabel}: ${message.text}`
|
|
258
|
+
: [
|
|
259
|
+
`[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
|
|
260
|
+
message.text,
|
|
261
|
+
'',
|
|
262
|
+
`Reply with room_send (roomId="${room.roomId}", to=["${replyTarget}"]) when you have an answer.`,
|
|
263
|
+
].join('\n')
|
|
264
|
+
const userMessage = this.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
265
|
+
try {
|
|
266
|
+
member.agent.followup(userMessage)
|
|
267
|
+
return true
|
|
268
|
+
} catch {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async append(room, type, data) {
|
|
274
|
+
const host = room.members.get(hostMemberId(room.hostSessionId))
|
|
275
|
+
const session = host?.agent?.session
|
|
276
|
+
if (session?.append) {
|
|
277
|
+
try {
|
|
278
|
+
await session.append(type, data)
|
|
279
|
+
} catch {
|
|
280
|
+
/* the snapshot below still records the room state */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
this.persist()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --------------------------------------------------------------- room tasks
|
|
287
|
+
|
|
288
|
+
async addTask({ roomId, title, owner, dependsOn = [], description = '' } = {}) {
|
|
289
|
+
const room = this.requireRoom(roomId)
|
|
290
|
+
if (typeof title !== 'string' || title.trim() === '') throw Object.assign(new Error('task needs a title'), { code: 'bad-request' })
|
|
291
|
+
const ownerMember = room.members.get(owner)
|
|
292
|
+
if (!ownerMember) throw Object.assign(new Error(`unknown task owner: ${String(owner)}`), { code: 'unknown-member' })
|
|
293
|
+
const id = randomUUID()
|
|
294
|
+
const task = {
|
|
295
|
+
id,
|
|
296
|
+
roomId: room.roomId,
|
|
297
|
+
title: title.trim(),
|
|
298
|
+
description,
|
|
299
|
+
owner,
|
|
300
|
+
status: 'pending',
|
|
301
|
+
dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
|
|
302
|
+
readyNotified: false,
|
|
303
|
+
createdAt: this.now(),
|
|
304
|
+
updatedAt: this.now(),
|
|
305
|
+
}
|
|
306
|
+
room.tasks.set(id, task)
|
|
307
|
+
await this.append(room, 'room/task', { roomId: room.roomId, action: 'added', task })
|
|
308
|
+
return task
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async updateTask({ roomId, id, status, note } = {}) {
|
|
312
|
+
const room = this.requireRoom(roomId)
|
|
313
|
+
const task = room.tasks.get(id)
|
|
314
|
+
if (!task) throw Object.assign(new Error(`unknown task: ${String(id)}`), { code: 'unknown-task' })
|
|
315
|
+
const allowed = ['pending', 'running', 'done', 'blocked']
|
|
316
|
+
if (status !== undefined && !allowed.includes(status)) {
|
|
317
|
+
throw Object.assign(new Error(`invalid task status: ${String(status)}`), { code: 'bad-request' })
|
|
318
|
+
}
|
|
319
|
+
if (status !== undefined) task.status = status
|
|
320
|
+
if (typeof note === 'string' && note !== '') task.note = note
|
|
321
|
+
task.updatedAt = this.now()
|
|
322
|
+
await this.append(room, 'room/task', { roomId: room.roomId, action: 'updated', task })
|
|
323
|
+
let handedOff = []
|
|
324
|
+
if (status === 'done') handedOff = await this.handOffReady(room)
|
|
325
|
+
return { task, handedOff }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Wake owners of pending tasks whose dependencies all completed. */
|
|
329
|
+
async handOffReady(room) {
|
|
330
|
+
const ready = []
|
|
331
|
+
for (const task of room.tasks.values()) {
|
|
332
|
+
if (task.status !== 'pending' || task.readyNotified) continue
|
|
333
|
+
if (task.dependsOn.length === 0) continue
|
|
334
|
+
const depsDone = task.dependsOn.every((dep) => room.tasks.get(dep)?.status === 'done')
|
|
335
|
+
if (!depsDone) continue
|
|
336
|
+
task.readyNotified = true
|
|
337
|
+
task.updatedAt = this.now()
|
|
338
|
+
const owner = room.members.get(task.owner)
|
|
339
|
+
const message = {
|
|
340
|
+
id: randomUUID(),
|
|
341
|
+
roomId: room.roomId,
|
|
342
|
+
from: { kind: 'user', name: 'room' },
|
|
343
|
+
to: [task.owner],
|
|
344
|
+
text: `任务 "${task.title}" 的前置任务已完成,现在可以开始。`,
|
|
345
|
+
createdAt: this.now(),
|
|
346
|
+
}
|
|
347
|
+
room.messages.push(message)
|
|
348
|
+
await this.append(room, 'room/message', { roomId: room.roomId, messageId: message.id, from: message.from, to: message.to, text: message.text })
|
|
349
|
+
const woken = owner ? await this.wake(room, owner, message) : false
|
|
350
|
+
ready.push({ taskId: task.id, owner: task.owner, woken })
|
|
351
|
+
}
|
|
352
|
+
return ready
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
listTasks(roomId) {
|
|
356
|
+
const room = this.requireRoom(roomId)
|
|
357
|
+
return [...room.tasks.values()].sort((a, b) => a.createdAt - b.createdAt)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ------------------------------------------------------------------- reads
|
|
361
|
+
|
|
362
|
+
messages({ roomId, limit = 50 } = {}) {
|
|
363
|
+
const room = this.requireRoom(roomId)
|
|
364
|
+
const items = room.messages.slice(-Math.max(1, Math.min(limit, 200)))
|
|
365
|
+
return items.map((message) => ({
|
|
366
|
+
...message,
|
|
367
|
+
fromLabel: message.from.kind === 'user' ? 'user' : `${message.from.name ?? 'agent'} (${message.from.memberId ?? '?'})`,
|
|
368
|
+
}))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
summary(room) {
|
|
372
|
+
return {
|
|
373
|
+
roomId: room.roomId,
|
|
374
|
+
createdAt: room.createdAt,
|
|
375
|
+
closed: room.closed,
|
|
376
|
+
policy: { ...room.policy },
|
|
377
|
+
members: [...room.members.values()].map((member) => this.memberSummary(member)),
|
|
378
|
+
messageCount: room.messages.length,
|
|
379
|
+
tasks: this.listTasks(room.roomId).length,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
memberSummary(member) {
|
|
384
|
+
return {
|
|
385
|
+
memberId: member.memberId,
|
|
386
|
+
role: member.role,
|
|
387
|
+
kind: member.kind,
|
|
388
|
+
sessionId: member.sessionId,
|
|
389
|
+
name: member.name,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
status(roomId) {
|
|
394
|
+
return this.summary(this.requireRoom(roomId))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** All active rooms (UI list). */
|
|
398
|
+
list() {
|
|
399
|
+
return [...this.rooms.values()].map((room) => this.summary(room))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One room plus its transcript and board (UI detail). */
|
|
403
|
+
detail(roomId, { limit = 100 } = {}) {
|
|
404
|
+
const room = this.requireRoom(roomId)
|
|
405
|
+
return {
|
|
406
|
+
...this.summary(room),
|
|
407
|
+
messages: this.messages({ roomId, limit }),
|
|
408
|
+
tasks: this.listTasks(roomId),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** All open rooms (for cleanup and diagnostics). */
|
|
413
|
+
openRooms() {
|
|
414
|
+
return [...this.rooms.keys()]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async close({ roomId, reason = 'closed by host' } = {}) {
|
|
418
|
+
const room = this.requireRoom(roomId)
|
|
419
|
+
room.closed = true
|
|
420
|
+
await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
|
|
421
|
+
this.rooms.delete(room.roomId)
|
|
422
|
+
this.persist()
|
|
423
|
+
return { roomId: room.roomId, closed: true, reason }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
requireRoom(roomId) {
|
|
427
|
+
const room = this.rooms.get(roomId)
|
|
428
|
+
if (!room) throw Object.assign(new Error(`unknown or closed room: ${String(roomId)}`), { code: 'unknown-room' })
|
|
429
|
+
return room
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Runtime configuration (ADR-0025): the profile patch stays the base layer,
|
|
2
|
+
// while `$DSH_HOME/device-fleet/config-overrides.json` holds UI edits. Applying
|
|
3
|
+
// a patch merges it, persists it and runs the registered live-apply handlers so
|
|
4
|
+
// no plugin/DSH restart is required.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const READONLY_FIELDS = ['dataDir']
|
|
9
|
+
const CAPABILITY_MODES = ['auto', 'ask', 'deny']
|
|
10
|
+
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deepMerge(base, patch) {
|
|
16
|
+
if (!isPlainObject(patch)) return patch
|
|
17
|
+
const result = isPlainObject(base) ? { ...base } : {}
|
|
18
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
19
|
+
if (value === undefined) continue
|
|
20
|
+
if (Array.isArray(value)) result[key] = value.map((item) => (isPlainObject(item) ? deepMerge({}, item) : item))
|
|
21
|
+
else if (isPlainObject(value)) result[key] = deepMerge(result[key], value)
|
|
22
|
+
else result[key] = value
|
|
23
|
+
}
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJson(file) {
|
|
28
|
+
if (!file || !existsSync(file)) return null
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requirePositiveInteger(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
37
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
38
|
+
throw Object.assign(new Error(`${name} must be an integer between ${min} and ${max}`), { code: 'bad-request' })
|
|
39
|
+
}
|
|
40
|
+
return value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringList(value, name) {
|
|
44
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
45
|
+
throw Object.assign(new Error(`${name} must be an array of strings`), { code: 'bad-request' })
|
|
46
|
+
}
|
|
47
|
+
return value.map((item) => item.trim()).filter((item) => item !== '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function optionalString(value, name) {
|
|
51
|
+
if (value === undefined || value === null) return ''
|
|
52
|
+
if (typeof value !== 'string') throw Object.assign(new Error(`${name} must be a string`), { code: 'bad-request' })
|
|
53
|
+
return value.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate and clean a UI patch. Only known fields survive; unknown keys are
|
|
58
|
+
* dropped instead of leaking arbitrary objects into the effective config.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeConfigPatch(patch) {
|
|
61
|
+
if (!isPlainObject(patch)) throw Object.assign(new Error('config patch must be an object'), { code: 'bad-request' })
|
|
62
|
+
const clean = {}
|
|
63
|
+
if (patch.deviceName !== undefined) {
|
|
64
|
+
const name = optionalString(patch.deviceName, 'deviceName')
|
|
65
|
+
if (name !== '') clean.deviceName = name.slice(0, 80)
|
|
66
|
+
}
|
|
67
|
+
if (patch.promptSection !== undefined) clean.promptSection = patch.promptSection === true
|
|
68
|
+
if (patch.autoApproveAsk !== undefined) clean.autoApproveAsk = patch.autoApproveAsk === true
|
|
69
|
+
if (patch.discovery !== undefined) clean.discovery = patch.discovery === true
|
|
70
|
+
if (patch.dataDir !== undefined) {
|
|
71
|
+
throw Object.assign(new Error('dataDir cannot be changed at runtime'), { code: 'readonly' })
|
|
72
|
+
}
|
|
73
|
+
if (patch.direct !== undefined) {
|
|
74
|
+
if (!isPlainObject(patch.direct)) throw Object.assign(new Error('direct must be an object'), { code: 'bad-request' })
|
|
75
|
+
clean.direct = {
|
|
76
|
+
host: optionalString(patch.direct.host, 'direct.host') || '0.0.0.0',
|
|
77
|
+
port: requirePositiveInteger(Number(patch.direct.port ?? 0), 'direct.port', { min: 0, max: 65535 }),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (patch.relay !== undefined) {
|
|
81
|
+
if (patch.relay === null) clean.relay = null
|
|
82
|
+
else if (isPlainObject(patch.relay)) {
|
|
83
|
+
clean.relay = {
|
|
84
|
+
host: optionalString(patch.relay.host, 'relay.host'),
|
|
85
|
+
port: requirePositiveInteger(Number(patch.relay.port ?? 0), 'relay.port', { min: 0, max: 65535 }),
|
|
86
|
+
dataPort: requirePositiveInteger(Number(patch.relay.dataPort ?? 0), 'relay.dataPort', { min: 0, max: 65535 }),
|
|
87
|
+
reconnect: patch.relay.reconnect !== false,
|
|
88
|
+
}
|
|
89
|
+
if (clean.relay.host === '' || clean.relay.port === 0) clean.relay = null
|
|
90
|
+
} else {
|
|
91
|
+
throw Object.assign(new Error('relay must be an object or null'), { code: 'bad-request' })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (patch.execution !== undefined) {
|
|
95
|
+
if (!isPlainObject(patch.execution)) throw Object.assign(new Error('execution must be an object'), { code: 'bad-request' })
|
|
96
|
+
const execution = {}
|
|
97
|
+
if (patch.execution.cwd !== undefined) execution.cwd = optionalString(patch.execution.cwd, 'execution.cwd')
|
|
98
|
+
if (patch.execution.agentPreset !== undefined) execution.agentPreset = optionalString(patch.execution.agentPreset, 'execution.agentPreset')
|
|
99
|
+
if (patch.execution.shellTool !== undefined) execution.shellTool = optionalString(patch.execution.shellTool, 'execution.shellTool')
|
|
100
|
+
if (patch.execution.subagentProvider !== undefined) execution.subagentProvider = optionalString(patch.execution.subagentProvider, 'execution.subagentProvider')
|
|
101
|
+
if (patch.execution.approvalTimeoutMs !== undefined) {
|
|
102
|
+
execution.approvalTimeoutMs = requirePositiveInteger(Number(patch.execution.approvalTimeoutMs), 'execution.approvalTimeoutMs', { min: 1000 })
|
|
103
|
+
}
|
|
104
|
+
if (patch.execution.delegateTimeoutMs !== undefined) {
|
|
105
|
+
execution.delegateTimeoutMs = requirePositiveInteger(Number(patch.execution.delegateTimeoutMs), 'execution.delegateTimeoutMs', { min: 1000 })
|
|
106
|
+
}
|
|
107
|
+
clean.execution = execution
|
|
108
|
+
}
|
|
109
|
+
if (patch.policy !== undefined) {
|
|
110
|
+
if (!isPlainObject(patch.policy)) throw Object.assign(new Error('policy must be an object'), { code: 'bad-request' })
|
|
111
|
+
const policy = {}
|
|
112
|
+
if (patch.policy.allowedRoots !== undefined) policy.allowedRoots = stringList(patch.policy.allowedRoots, 'policy.allowedRoots')
|
|
113
|
+
if (patch.policy.allowedTools !== undefined) policy.allowedTools = stringList(patch.policy.allowedTools, 'policy.allowedTools')
|
|
114
|
+
if (patch.policy.approvalTtlMs !== undefined) {
|
|
115
|
+
policy.approvalTtlMs = requirePositiveInteger(Number(patch.policy.approvalTtlMs), 'policy.approvalTtlMs', { min: 1000 })
|
|
116
|
+
}
|
|
117
|
+
if (patch.policy.capabilities !== undefined) {
|
|
118
|
+
if (!isPlainObject(patch.policy.capabilities)) throw Object.assign(new Error('policy.capabilities must be an object'), { code: 'bad-request' })
|
|
119
|
+
const capabilities = {}
|
|
120
|
+
for (const [key, value] of Object.entries(patch.policy.capabilities)) {
|
|
121
|
+
if (!CAPABILITY_MODES.includes(value)) {
|
|
122
|
+
throw Object.assign(new Error(`policy.capabilities.${key} must be auto|ask|deny`), { code: 'bad-request' })
|
|
123
|
+
}
|
|
124
|
+
capabilities[key] = value
|
|
125
|
+
}
|
|
126
|
+
policy.capabilities = capabilities
|
|
127
|
+
}
|
|
128
|
+
clean.policy = policy
|
|
129
|
+
}
|
|
130
|
+
if (patch.approvals !== undefined) {
|
|
131
|
+
if (!isPlainObject(patch.approvals)) throw Object.assign(new Error('approvals must be an object'), { code: 'bad-request' })
|
|
132
|
+
const approvals = {}
|
|
133
|
+
if (patch.approvals.ttlMs !== undefined) approvals.ttlMs = requirePositiveInteger(Number(patch.approvals.ttlMs), 'approvals.ttlMs', { min: 1000 })
|
|
134
|
+
if (patch.approvals.maxPending !== undefined) approvals.maxPending = requirePositiveInteger(Number(patch.approvals.maxPending), 'approvals.maxPending', { min: 1 })
|
|
135
|
+
if (patch.approvals.simulation !== undefined) approvals.simulation = patch.approvals.simulation === true
|
|
136
|
+
clean.approvals = approvals
|
|
137
|
+
}
|
|
138
|
+
return clean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class RuntimeConfig {
|
|
142
|
+
/**
|
|
143
|
+
* @param {object} options
|
|
144
|
+
* @param {object} options.base profile (Cordis) config
|
|
145
|
+
* @param {string} [options.overridesFile] JSON file edited from the UI
|
|
146
|
+
*/
|
|
147
|
+
constructor({ base = {}, overridesFile = null } = {}) {
|
|
148
|
+
this.base = base
|
|
149
|
+
this.overridesFile = overridesFile
|
|
150
|
+
this.overrides = readJson(overridesFile) ?? {}
|
|
151
|
+
this.current = deepMerge(this.base, this.overrides)
|
|
152
|
+
this.applyHandlers = []
|
|
153
|
+
this.updating = Promise.resolve()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onChange(handler) {
|
|
157
|
+
this.applyHandlers.push(handler)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
persist() {
|
|
161
|
+
if (!this.overridesFile) return
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(dirname(this.overridesFile), { recursive: true })
|
|
164
|
+
const temporary = `${this.overridesFile}.tmp`
|
|
165
|
+
writeFileSync(temporary, JSON.stringify(this.overrides, null, 2), 'utf8')
|
|
166
|
+
renameSync(temporary, this.overridesFile)
|
|
167
|
+
} catch {
|
|
168
|
+
/* overrides are best effort; the in-memory config is still applied */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Merge, persist and live-apply one patch. Returns the effective config. */
|
|
173
|
+
update(patch) {
|
|
174
|
+
const clean = sanitizeConfigPatch(patch)
|
|
175
|
+
return this.updating.then(async () => {
|
|
176
|
+
const previous = this.current
|
|
177
|
+
this.overrides = deepMerge(this.overrides, clean)
|
|
178
|
+
this.current = deepMerge(this.base, this.overrides)
|
|
179
|
+
this.persist()
|
|
180
|
+
for (const handler of this.applyHandlers) await handler(this.current, previous)
|
|
181
|
+
return this.current
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
snapshot() {
|
|
186
|
+
return {
|
|
187
|
+
config: this.current,
|
|
188
|
+
overrides: this.overrides,
|
|
189
|
+
readonly: READONLY_FIELDS,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/host/store.js
CHANGED
|
@@ -50,6 +50,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
|
|
|
50
50
|
|
|
51
51
|
const roster = readJson(path.join(dir, 'roster.json'), [])
|
|
52
52
|
const config = readJson(path.join(dir, 'config.json'), {})
|
|
53
|
+
const peerHints = readJson(path.join(dir, 'peer-hints.json'), [])
|
|
53
54
|
let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
|
|
54
55
|
let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
|
|
55
56
|
|
|
@@ -64,6 +65,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
|
|
|
64
65
|
joinedAt: Date.now(),
|
|
65
66
|
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
66
67
|
relay: decoded.relay ?? null,
|
|
68
|
+
endpoints: decoded.endpoints ?? [],
|
|
67
69
|
}
|
|
68
70
|
writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
|
|
69
71
|
writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
|
|
@@ -74,7 +76,32 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
dataDir: dir,
|
|
81
|
+
identity,
|
|
82
|
+
fleet,
|
|
83
|
+
roster: Array.isArray(roster) ? roster : [],
|
|
84
|
+
config,
|
|
85
|
+
enrollment,
|
|
86
|
+
pendingEnrollment,
|
|
87
|
+
peerHints: Array.isArray(peerHints) ? peerHints : [],
|
|
88
|
+
createdFleet,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Persist one manually supplied peer address (from fleet_connect) for reconnects. */
|
|
93
|
+
export function savePeerHint(state, hint) {
|
|
94
|
+
if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
|
|
95
|
+
const entry = {
|
|
96
|
+
host: hint.host,
|
|
97
|
+
port: hint.port,
|
|
98
|
+
...(typeof hint.deviceId === 'string' ? { deviceId: hint.deviceId } : {}),
|
|
99
|
+
}
|
|
100
|
+
const hints = Array.isArray(state.peerHints) ? state.peerHints : []
|
|
101
|
+
const exists = hints.some((item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId)
|
|
102
|
+
if (!exists) hints.push(entry)
|
|
103
|
+
state.peerHints = hints
|
|
104
|
+
writeJsonAtomic(path.join(state.dataDir, 'peer-hints.json'), hints)
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
/** Queue an invite for the next plugin load (the running node keeps its fleet). */
|
|
@@ -102,8 +129,8 @@ export function saveConfig(state, config) {
|
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
/** Create an invite for this device's fleet. */
|
|
105
|
-
export function createInvite(state, { accountFingerprint = null, relay = null } = {}) {
|
|
106
|
-
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay })
|
|
132
|
+
export function createInvite(state, { accountFingerprint = null, relay = null, endpoints = [] } = {}) {
|
|
133
|
+
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay, endpoints })
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
/** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
|
|
@@ -114,6 +141,7 @@ export function enrollFromInvite(state, code) {
|
|
|
114
141
|
joinedAt: Date.now(),
|
|
115
142
|
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
116
143
|
relay: decoded.relay ?? null,
|
|
144
|
+
endpoints: decoded.endpoints ?? [],
|
|
117
145
|
}
|
|
118
146
|
saveFleet(state)
|
|
119
147
|
writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
|