dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.20

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.
@@ -0,0 +1,429 @@
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
+ // The host is the user's own session: deliver a plain attributed line into
252
+ // the normal Chat stream instead of an agent-to-agent reply instruction.
253
+ const text =
254
+ member.role === 'host'
255
+ ? `[Room] ${senderLabel}: ${message.text}`
256
+ : [
257
+ `[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
258
+ message.text,
259
+ '',
260
+ `Reply with room_send (roomId="${room.roomId}", to=["${message.from.memberId ?? 'host'}"]) when you have an answer.`,
261
+ ].join('\n')
262
+ const userMessage = this.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
263
+ try {
264
+ member.agent.followup(userMessage)
265
+ return true
266
+ } catch {
267
+ return false
268
+ }
269
+ }
270
+
271
+ async append(room, type, data) {
272
+ const host = room.members.get(hostMemberId(room.hostSessionId))
273
+ const session = host?.agent?.session
274
+ if (session?.append) {
275
+ try {
276
+ await session.append(type, data)
277
+ } catch {
278
+ /* the snapshot below still records the room state */
279
+ }
280
+ }
281
+ this.persist()
282
+ }
283
+
284
+ // --------------------------------------------------------------- room tasks
285
+
286
+ async addTask({ roomId, title, owner, dependsOn = [], description = '' } = {}) {
287
+ const room = this.requireRoom(roomId)
288
+ if (typeof title !== 'string' || title.trim() === '') throw Object.assign(new Error('task needs a title'), { code: 'bad-request' })
289
+ const ownerMember = room.members.get(owner)
290
+ if (!ownerMember) throw Object.assign(new Error(`unknown task owner: ${String(owner)}`), { code: 'unknown-member' })
291
+ const id = randomUUID()
292
+ const task = {
293
+ id,
294
+ roomId: room.roomId,
295
+ title: title.trim(),
296
+ description,
297
+ owner,
298
+ status: 'pending',
299
+ dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
300
+ readyNotified: false,
301
+ createdAt: this.now(),
302
+ updatedAt: this.now(),
303
+ }
304
+ room.tasks.set(id, task)
305
+ await this.append(room, 'room/task', { roomId: room.roomId, action: 'added', task })
306
+ return task
307
+ }
308
+
309
+ async updateTask({ roomId, id, status, note } = {}) {
310
+ const room = this.requireRoom(roomId)
311
+ const task = room.tasks.get(id)
312
+ if (!task) throw Object.assign(new Error(`unknown task: ${String(id)}`), { code: 'unknown-task' })
313
+ const allowed = ['pending', 'running', 'done', 'blocked']
314
+ if (status !== undefined && !allowed.includes(status)) {
315
+ throw Object.assign(new Error(`invalid task status: ${String(status)}`), { code: 'bad-request' })
316
+ }
317
+ if (status !== undefined) task.status = status
318
+ if (typeof note === 'string' && note !== '') task.note = note
319
+ task.updatedAt = this.now()
320
+ await this.append(room, 'room/task', { roomId: room.roomId, action: 'updated', task })
321
+ let handedOff = []
322
+ if (status === 'done') handedOff = await this.handOffReady(room)
323
+ return { task, handedOff }
324
+ }
325
+
326
+ /** Wake owners of pending tasks whose dependencies all completed. */
327
+ async handOffReady(room) {
328
+ const ready = []
329
+ for (const task of room.tasks.values()) {
330
+ if (task.status !== 'pending' || task.readyNotified) continue
331
+ if (task.dependsOn.length === 0) continue
332
+ const depsDone = task.dependsOn.every((dep) => room.tasks.get(dep)?.status === 'done')
333
+ if (!depsDone) continue
334
+ task.readyNotified = true
335
+ task.updatedAt = this.now()
336
+ const owner = room.members.get(task.owner)
337
+ const message = {
338
+ id: randomUUID(),
339
+ roomId: room.roomId,
340
+ from: { kind: 'user', name: 'room' },
341
+ to: [task.owner],
342
+ text: `任务 "${task.title}" 的前置任务已完成,现在可以开始。`,
343
+ createdAt: this.now(),
344
+ }
345
+ room.messages.push(message)
346
+ await this.append(room, 'room/message', { roomId: room.roomId, messageId: message.id, from: message.from, to: message.to, text: message.text })
347
+ const woken = owner ? await this.wake(room, owner, message) : false
348
+ ready.push({ taskId: task.id, owner: task.owner, woken })
349
+ }
350
+ return ready
351
+ }
352
+
353
+ listTasks(roomId) {
354
+ const room = this.requireRoom(roomId)
355
+ return [...room.tasks.values()].sort((a, b) => a.createdAt - b.createdAt)
356
+ }
357
+
358
+ // ------------------------------------------------------------------- reads
359
+
360
+ messages({ roomId, limit = 50 } = {}) {
361
+ const room = this.requireRoom(roomId)
362
+ const items = room.messages.slice(-Math.max(1, Math.min(limit, 200)))
363
+ return items.map((message) => ({
364
+ ...message,
365
+ fromLabel: message.from.kind === 'user' ? 'user' : `${message.from.name ?? 'agent'} (${message.from.memberId ?? '?'})`,
366
+ }))
367
+ }
368
+
369
+ summary(room) {
370
+ return {
371
+ roomId: room.roomId,
372
+ createdAt: room.createdAt,
373
+ closed: room.closed,
374
+ policy: { ...room.policy },
375
+ members: [...room.members.values()].map((member) => this.memberSummary(member)),
376
+ messageCount: room.messages.length,
377
+ tasks: this.listTasks(room.roomId).length,
378
+ }
379
+ }
380
+
381
+ memberSummary(member) {
382
+ return {
383
+ memberId: member.memberId,
384
+ role: member.role,
385
+ kind: member.kind,
386
+ sessionId: member.sessionId,
387
+ name: member.name,
388
+ }
389
+ }
390
+
391
+ status(roomId) {
392
+ return this.summary(this.requireRoom(roomId))
393
+ }
394
+
395
+ /** All active rooms (UI list). */
396
+ list() {
397
+ return [...this.rooms.values()].map((room) => this.summary(room))
398
+ }
399
+
400
+ /** One room plus its transcript and board (UI detail). */
401
+ detail(roomId, { limit = 100 } = {}) {
402
+ const room = this.requireRoom(roomId)
403
+ return {
404
+ ...this.summary(room),
405
+ messages: this.messages({ roomId, limit }),
406
+ tasks: this.listTasks(roomId),
407
+ }
408
+ }
409
+
410
+ /** All open rooms (for cleanup and diagnostics). */
411
+ openRooms() {
412
+ return [...this.rooms.keys()]
413
+ }
414
+
415
+ async close({ roomId, reason = 'closed by host' } = {}) {
416
+ const room = this.requireRoom(roomId)
417
+ room.closed = true
418
+ await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
419
+ this.rooms.delete(room.roomId)
420
+ this.persist()
421
+ return { roomId: room.roomId, closed: true, reason }
422
+ }
423
+
424
+ requireRoom(roomId) {
425
+ const room = this.rooms.get(roomId)
426
+ if (!room) throw Object.assign(new Error(`unknown or closed room: ${String(roomId)}`), { code: 'unknown-room' })
427
+ return room
428
+ }
429
+ }
@@ -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 { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
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)