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
|
@@ -39,19 +39,19 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
39
39
|
- 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
|
|
40
40
|
- 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
|
|
41
41
|
- `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
|
|
42
|
-
## 0.1.0-dev.
|
|
42
|
+
## 0.1.0-dev.13(稳定化)
|
|
43
43
|
|
|
44
44
|
- 直连端口可固定(profile `direct.port`),插件重载不再换端口;
|
|
45
45
|
- 连接断开后自动重试已知地址(不再只在启动时试一次);
|
|
46
46
|
- `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
|
|
47
47
|
- 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
|
|
48
|
-
## 0.1.0-dev.
|
|
48
|
+
## 0.1.0-dev.13(审批收件箱,逻辑层)
|
|
49
49
|
|
|
50
50
|
- 新增 `ApprovalInbox`:ask 档请求排队、超时 fail-closed、approve(once/ttl)/deny、关闭时全部失败关闭;
|
|
51
51
|
- 直调与委派的 ask 档改为等待本机收件箱结果(不再一律拒绝);
|
|
52
52
|
- 新工具:`fleet_approvals` / `fleet_approve` / `fleet_deny`;`fleet_simulate_request`(仅 `approvals.simulation: true`)用于本机无第二台设备时演练流程;
|
|
53
53
|
- 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
|
|
54
|
-
## 0.1.0-dev.
|
|
54
|
+
## 0.1.0-dev.13(Room 逻辑层,本机模拟)
|
|
55
55
|
|
|
56
56
|
- `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
|
|
57
57
|
- 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
|
|
@@ -59,13 +59,13 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
59
59
|
- Room Board:`room_task_add/update/list`,`dependsOn` 串行交接(前置完成时唤醒下一个 owner),无依赖任务并行不受影响;
|
|
60
60
|
- 工具:`room_create / room_add_local / room_remove_member / room_send / room_messages / room_status / room_task_* / room_close`;
|
|
61
61
|
- 远程参与者传输与 Client UI 未做(下一步)。
|
|
62
|
-
## 0.1.0-dev.
|
|
62
|
+
## 0.1.0-dev.13(审批面板 UI,第一版)
|
|
63
63
|
|
|
64
64
|
- Host 新增本地 HTTP 数据面:GET `/device-fleet/approvals`、POST `/device-fleet/approvals/decide`(仅本机 web 载体,决策仍走 ApprovalInbox);
|
|
65
65
|
- Client half:`client.js` 闭包工厂,注册 `shell.overlay` 浮动「Fleet 审批」按钮 + 面板,轮询待审批列表,支持允许一次 / 允许 15 分钟 / 拒绝;
|
|
66
66
|
- package.json 增加 `dsh.client` 与 `./client` 导出;`files` 增加 client.js;
|
|
67
67
|
- Client 视觉验证需要刷新页面;当前无浏览器控制,仅验证了 bundle 语法与 Host 路由注册。
|
|
68
|
-
## 0.1.0-dev.
|
|
68
|
+
## 0.1.0-dev.13(审批入口不遮挡)
|
|
69
69
|
|
|
70
70
|
- 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
|
|
71
71
|
- 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Fleet 控制台 UI(Phase A,dev.13)
|
|
2
|
+
|
|
3
|
+
## 用户决策(2026-11-09)
|
|
4
|
+
|
|
5
|
+
1. **配置保存必须即时生效**:不重启插件、不重启 DSH。→ Phase B 采用"运行时配置 + 持久化 overrides"设计(下一阶段实现)。
|
|
6
|
+
2. 房间 UI 动作身份:以**用户**身份(ADR-0007)。
|
|
7
|
+
3. 实施顺序:A → B → C → D。
|
|
8
|
+
|
|
9
|
+
## 本阶段实现
|
|
10
|
+
|
|
11
|
+
### Host 读模型
|
|
12
|
+
|
|
13
|
+
`src/host/ui-web.js`:
|
|
14
|
+
|
|
15
|
+
- `GET /device-fleet/overview`:`FleetNode.status()` + rooms 摘要 + approvals 列表;
|
|
16
|
+
- `GET /device-fleet/rooms`:房间摘要列表;
|
|
17
|
+
- `GET /device-fleet/rooms/:id`:房间详情(成员、策略、transcript、看板任务)。
|
|
18
|
+
|
|
19
|
+
### Room 持久化 / rehydrate
|
|
20
|
+
|
|
21
|
+
`RoomService` 新增 `stateFile`(`$DSH_HOME/device-fleet/rooms.json`):
|
|
22
|
+
|
|
23
|
+
- 每次 append(创建/成员/消息/任务/关闭)后原子写快照;
|
|
24
|
+
- 启动时 `load()` 重建 rooms/members/messages/tasks;
|
|
25
|
+
- 成员 agent 不序列化,唤醒时通过 `sessionController.resolveAgent(sessionId)` 懒解析;
|
|
26
|
+
- 插件卸载不再关闭房间(避免重载即销毁)。
|
|
27
|
+
|
|
28
|
+
**验证**:创建 room + worker-c + 任务 + 1 条消息 → 插件重载 → `room_status` 仍显示 2 名成员、1 条消息、1 个任务;`room_task_list` 保留任务。
|
|
29
|
+
|
|
30
|
+
### Client
|
|
31
|
+
|
|
32
|
+
`client.js` 新增:
|
|
33
|
+
|
|
34
|
+
- `sidebar.panellist` 图标(id `fleet`,label `设备联邦`,圆形网格图标);
|
|
35
|
+
- `main` keyed 面板 `fleet` = Fleet 控制台,三个 Tab:
|
|
36
|
+
- **设备**:本机名称/id、直连地址、relay、发现数量;Fleet 成员列表(在线/离线);回连地址;
|
|
37
|
+
- **房间**:房间列表(成员数/消息数/任务数),选中后展示成员、看板、时间线;
|
|
38
|
+
- **审批**:待审批列表 + 允许一次/15 分钟/拒绝(与快捷面板共用 Host API)。
|
|
39
|
+
|
|
40
|
+
## 未完成(Phase B/C/D)
|
|
41
|
+
|
|
42
|
+
- 配置页与即时生效(Phase B);
|
|
43
|
+
- 房间交互:以用户身份发消息、看板操作、邀请/移除成员(Phase C);
|
|
44
|
+
- 配对向导、设备操作、审批历史/授权撤销、SSE、通知、i18n、a11y(Phase C/D)。
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# UI 呈现缺口审计(2026-11-09)
|
|
2
|
+
|
|
3
|
+
## 现状盘点
|
|
4
|
+
|
|
5
|
+
当前插件已实现的 UI 只有**审批面板**(dev.12):
|
|
6
|
+
|
|
7
|
+
- 入口:`sidebar.footer.action` 的「🛰️ 审批」;
|
|
8
|
+
- 内容:`shell.overlay` 居中对话框,待审批列表 + 允许一次/15 分钟/拒绝;
|
|
9
|
+
- 数据:Host `/device-fleet/approvals`、`/device-fleet/approvals/decide`。
|
|
10
|
+
|
|
11
|
+
其余**全部**依靠模型工具文本输出或 profile YAML 配置,没有任何可视化:
|
|
12
|
+
|
|
13
|
+
| 设计要素 | 当前呈现方式 | 缺口 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| 设备列表 / 在线状态 / 直连地址 / relay / 发现 | `fleet_status` 文本 | 无设备页,无法看设备拓扑与连接路径 |
|
|
16
|
+
| 配对:邀请码、加入、手动地址、设备移除 | `fleet_invite`/`fleet_join`/`fleet_connect` 工具 | 无配对向导,邀请码需在聊天里传 |
|
|
17
|
+
| 插件配置(deviceName/direct/discovery/relay/execution/policy/approvals/prompt) | profile `cordis.patch.yml` | 完全无 UI,改配置要手改 YAML + 重启 |
|
|
18
|
+
| Room 列表 / 房间状态 | `room_status`、`room_messages` 文本 | 无房间列表/视图 |
|
|
19
|
+
| Room 成员(设备 × agent、host/local/remote) | `room_status` JSON | 无成员可视化,看不出"不同设备不同 agent" |
|
|
20
|
+
| Room 时间线(用户可见完整对话) | `room_messages` 文本 | 无时间线 UI,无消息流式更新 |
|
|
21
|
+
| Room 发消息 / 点名 | `room_send` 工具 | 无输入框/@成员操作 |
|
|
22
|
+
| Room Board(任务、owner、状态、依赖、交接) | `room_task_*` 文本 | 无看板视图 |
|
|
23
|
+
| 房间策略(invite/join/maxMembers/boardWrite) | 创建参数 | 无查看/编辑 |
|
|
24
|
+
| 审批历史 / TTL 授权管理 | 收件箱列表(仅 pending) | 无历史、无授权撤销 |
|
|
25
|
+
| 委派(fleet_delegate) | 工具结果文本 | 无历史/运行状态/中断入口 |
|
|
26
|
+
| 直调审计(谁调了哪台设备什么工具) | 会话轨迹里 | 无 Fleet 维度审计页 |
|
|
27
|
+
| 首次配对引导 | 无 | 新设备没有任何向导 |
|
|
28
|
+
| 错误/诊断 | `fleet_status` + 日志 | 无连接诊断页 |
|
|
29
|
+
| 通知 | 无 | 新审批/新消息没有系统通知 |
|
|
30
|
+
| 国际化 | 中文硬编码 | 未接 Client locale 服务 |
|
|
31
|
+
| 多窗口/实时 | 审批 2.5s 轮询 | Room/设备状态无推送,多窗口不同步 |
|
|
32
|
+
|
|
33
|
+
## 关键基础设施缺口(UI 之前必须先补)
|
|
34
|
+
|
|
35
|
+
1. **Host 读模型 API 不足**:目前只有 approvals 两条路由;需要
|
|
36
|
+
- `GET /device-fleet/overview`:本机身份/版本/直连/relay/discovery/roster/connected/peerHints;
|
|
37
|
+
- `GET /device-fleet/rooms`、`GET /device-fleet/rooms/:id`:成员、策略、transcript、tasks;
|
|
38
|
+
- `GET /device-fleet/events`(SSE)或长轮询:状态变化推送。
|
|
39
|
+
2. **配置写路径缺失**:Cordis config 来自 profile patch,运行时不可写。需要确定:
|
|
40
|
+
- 方案 A(推荐):`$DSH_HOME/device-fleet/config-overrides.json` + apply 时合并,UI 写该文件;保存后提示"重载插件生效"(或调用 Host 重新加载);
|
|
41
|
+
- 方案 B:直接改写 profile `cordis.patch.yml`(风险高:破坏注释/结构,需备份)。
|
|
42
|
+
3. **Room 状态无持久化/rehydrate**:RoomService 是内存 Map,插件重载/DSH 重启后房间消失。UI 之前应先做基于 host session 事件的 rehydrate,否则"房间视图"打开是空的。
|
|
43
|
+
4. **UI 操作的身份与授权**:UI 里的"发消息/改任务/关房"是以**用户**身份还是 host agent 身份?需要 Room 服务暴露 user 动作并校验房主/成员权限。
|
|
44
|
+
5. **Slot/设计系统**:已确认可用的官方扩展点:
|
|
45
|
+
- `sidebar.panellist`(全局面板图标)+ `main`(keyed 主面板)→ Fleet 控制台;
|
|
46
|
+
- `settings.section`(设置页)或 `plugins.bundle.config`(插件配置页,key = 包名)→ 配置 UI;
|
|
47
|
+
- `shell.overlay` → 对话框/房间详情;
|
|
48
|
+
- `sidebar.footer.action` → 快捷入口(审批已用)。
|
|
49
|
+
6. **多设备语义呈现**:房间成员需要 "设备(名称/在线/连接路径)× agent(session/角色)" 两级标签;当前 member 只有 `local:session-…`/`host:…` 字符串,远程成员还没有实体,需要先接远程参与者传输。
|
|
50
|
+
|
|
51
|
+
## 建议的信息架构
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
侧栏
|
|
55
|
+
├─ 全局面板图标「Fleet」 (sidebar.panellist: id=fleet)
|
|
56
|
+
│ └─ main keyed "fleet" 主面板
|
|
57
|
+
│ ├─ 设备 Tab:本机状态、roster、在线/离线、直连/relay、配对按钮、诊断
|
|
58
|
+
│ ├─ 房间 Tab:房间列表 → 房间详情(成员树、时间线、看板、策略)
|
|
59
|
+
│ └─ 审批 Tab:待审批 + 历史 + 授权管理
|
|
60
|
+
├─ Settings 上方「🛰️ 审批」快捷入口(已实现)
|
|
61
|
+
└─ Settings → 插件 → Device Fleet 配置页
|
|
62
|
+
(plugins.bundle.config key "dsh-device-fleet" 或 plugins.row.config "dsh-device-fleet#device-fleet")
|
|
63
|
+
分块:设备/网络 · 执行上下文 · 远端权限(Tool Policy) · 本地审批 · 高级
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 分阶段实施计划
|
|
67
|
+
|
|
68
|
+
**Phase A:Fleet 控制台(只读)**
|
|
69
|
+
- Host `/overview`、`/rooms`、`/rooms/:id` API;
|
|
70
|
+
- `sidebar.panellist` + `main` 主面板,三个 Tab:设备 / 房间 / 审批(待审批 + 只读时间线/看板);
|
|
71
|
+
- 先解决 Room rehydrate,否则只读视图无内容。
|
|
72
|
+
|
|
73
|
+
**Phase B:配置 UI**
|
|
74
|
+
- `plugins.bundle.config` 注册配置页;
|
|
75
|
+
- 字段分组 + 校验 + 当前值显示;
|
|
76
|
+
- 保存走 overrides 文件(方案 A),保存后提示重载插件;policy 的 roots/tools 用列表编辑器。
|
|
77
|
+
|
|
78
|
+
**Phase C:交互动作**
|
|
79
|
+
- 房间:以用户身份发消息(点名选择成员)、看板任务增改/交接、关房、邀请/移除成员(权限校验);
|
|
80
|
+
- 设备:配对向导(邀请码生成/扫码式复制、手动地址连接、移除设备);
|
|
81
|
+
- 审批:历史列表 + ttl 授权撤销。
|
|
82
|
+
|
|
83
|
+
**Phase D:体验与可靠性**
|
|
84
|
+
- SSE/事件推送替代轮询,多窗口同步;
|
|
85
|
+
- 通知(新审批/新房间消息);
|
|
86
|
+
- Client locale(zh/en),a11y,空状态/引导;
|
|
87
|
+
- 插件重载 rehydrate、房间日志持久补发(ADR-0018)。
|
|
88
|
+
|
|
89
|
+
## 需要你确认的决策
|
|
90
|
+
|
|
91
|
+
1. 配置写路径:选 **overrides 文件(推荐)** 还是直接改 profile YAML?
|
|
92
|
+
2. 房间 UI 动作的身份:以 **用户** 身份(推荐,符合 ADR-0007 用户发言与设备解耦)还是 host agent 身份?
|
|
93
|
+
3. 先做 Phase A(控制台只读)还是先做 Phase B(配置页)?我建议按 A → B → C → D。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.14",
|
|
4
4
|
"description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin/index.js",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Runtime config HTTP surface (dev.14): read the effective config and apply a
|
|
2
|
+
// patch live. The UI posts here; no plugin or DSH restart is involved.
|
|
3
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
function json(res, status, value) {
|
|
6
|
+
res.statusCode = status
|
|
7
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
8
|
+
res.setHeader('cache-control', 'no-store')
|
|
9
|
+
res.end(JSON.stringify(value))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readJsonBody(req) {
|
|
13
|
+
const chunks = []
|
|
14
|
+
let size = 0
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
size += chunk.length
|
|
17
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
|
|
18
|
+
chunks.push(chunk)
|
|
19
|
+
}
|
|
20
|
+
if (chunks.length === 0) return {}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
23
|
+
} catch {
|
|
24
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerConfigRoutes({ webServer, runtime, logger = null }) {
|
|
29
|
+
if (!webServer?.register || !runtime) return () => {}
|
|
30
|
+
const disposers = []
|
|
31
|
+
disposers.push(
|
|
32
|
+
webServer.register({
|
|
33
|
+
kind: 'exact',
|
|
34
|
+
path: '/device-fleet/config',
|
|
35
|
+
handler: async (req, res) => {
|
|
36
|
+
if (req.method === 'GET') {
|
|
37
|
+
try {
|
|
38
|
+
json(res, 200, runtime.snapshot())
|
|
39
|
+
} catch (error) {
|
|
40
|
+
json(res, 500, { error: String(error?.message ?? error) })
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
45
|
+
try {
|
|
46
|
+
const body = await readJsonBody(req)
|
|
47
|
+
const patch = body?.patch ?? body
|
|
48
|
+
const config = await runtime.update(patch)
|
|
49
|
+
json(res, 200, { ok: true, config, appliedAt: Date.now() })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const status = ['bad-request', 'bad-json', 'body-too-large', 'readonly'].includes(error?.code) ? 400 : 500
|
|
52
|
+
if (status === 500) logger?.warn?.(`[device-fleet] config update failed: ${String(error?.message ?? error)}`)
|
|
53
|
+
json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
return () => {
|
|
59
|
+
for (const dispose of disposers) dispose()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -18,6 +18,14 @@ export class ExecutionSlot {
|
|
|
18
18
|
this.initializing = null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Replace the slot's creation config; the next call lazily recreates the agent. */
|
|
22
|
+
setConfig(next = {}) {
|
|
23
|
+
this.config = { ...this.config, ...(next ?? {}) }
|
|
24
|
+
this.agent = null
|
|
25
|
+
this.sessionId = null
|
|
26
|
+
this.initializing = null
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
async ensureAgent() {
|
|
22
30
|
if (this.agent) return this.agent
|
|
23
31
|
if (this.initializing) return this.initializing
|
package/src/host/index.js
CHANGED
|
@@ -6,5 +6,8 @@ export * from './direct-call.js'
|
|
|
6
6
|
export * from './delegation.js'
|
|
7
7
|
export * from './approval-inbox.js'
|
|
8
8
|
export * from './approval-web.js'
|
|
9
|
+
export * from './ui-web.js'
|
|
10
|
+
export * from './config-web.js'
|
|
11
|
+
export * from './runtime-config.js'
|
|
9
12
|
export * from './room-service.js'
|
|
10
13
|
export * from './tools.js'
|
package/src/host/room-service.js
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
// addressed message, so addressing/wake/visibility and board handoff can be
|
|
5
5
|
// exercised before the remote transport and the client UI exist.
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
7
9
|
|
|
8
10
|
const MAX_MESSAGE_CHARS = 20_000
|
|
11
|
+
const STATE_VERSION = 1
|
|
9
12
|
|
|
10
13
|
const DEFAULT_POLICY = Object.freeze({
|
|
11
14
|
invite: 'members',
|
|
@@ -29,13 +32,85 @@ export class RoomService {
|
|
|
29
32
|
* @param {object} options.sessionController ctx.sessionController
|
|
30
33
|
* @param {(input: object) => object} [options.createUserMessage] dsh-llm helper
|
|
31
34
|
* @param {(now?: number) => number} [options.now]
|
|
35
|
+
* @param {string} [options.stateFile] JSON file used to rehydrate rooms after a reload
|
|
32
36
|
*/
|
|
33
|
-
constructor({ sessionController, createUserMessage = null, now = Date.now } = {}) {
|
|
37
|
+
constructor({ sessionController, createUserMessage = null, now = Date.now, stateFile = null } = {}) {
|
|
34
38
|
this.sessionController = sessionController
|
|
35
39
|
this.createUserMessage = createUserMessage
|
|
36
40
|
this.now = now
|
|
41
|
+
this.stateFile = stateFile
|
|
37
42
|
/** @type {Map<string, any>} */
|
|
38
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
|
+
}
|
|
39
114
|
}
|
|
40
115
|
|
|
41
116
|
async upgradeHost({ agent, policy = {} } = {}) {
|
|
@@ -169,7 +244,9 @@ export class RoomService {
|
|
|
169
244
|
|
|
170
245
|
/** Wake one local participant by injecting the addressed message. */
|
|
171
246
|
async wake(room, member, message) {
|
|
172
|
-
if (!member
|
|
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
|
|
173
250
|
const senderLabel = message.from.kind === 'user' ? 'user' : (message.from.name ?? 'agent')
|
|
174
251
|
const text = [
|
|
175
252
|
`[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
|
|
@@ -189,7 +266,14 @@ export class RoomService {
|
|
|
189
266
|
async append(room, type, data) {
|
|
190
267
|
const host = room.members.get(hostMemberId(room.hostSessionId))
|
|
191
268
|
const session = host?.agent?.session
|
|
192
|
-
if (session?.append)
|
|
269
|
+
if (session?.append) {
|
|
270
|
+
try {
|
|
271
|
+
await session.append(type, data)
|
|
272
|
+
} catch {
|
|
273
|
+
/* the snapshot below still records the room state */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.persist()
|
|
193
277
|
}
|
|
194
278
|
|
|
195
279
|
// --------------------------------------------------------------- room tasks
|
|
@@ -303,6 +387,21 @@ export class RoomService {
|
|
|
303
387
|
return this.summary(this.requireRoom(roomId))
|
|
304
388
|
}
|
|
305
389
|
|
|
390
|
+
/** All active rooms (UI list). */
|
|
391
|
+
list() {
|
|
392
|
+
return [...this.rooms.values()].map((room) => this.summary(room))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** One room plus its transcript and board (UI detail). */
|
|
396
|
+
detail(roomId, { limit = 100 } = {}) {
|
|
397
|
+
const room = this.requireRoom(roomId)
|
|
398
|
+
return {
|
|
399
|
+
...this.summary(room),
|
|
400
|
+
messages: this.messages({ roomId, limit }),
|
|
401
|
+
tasks: this.listTasks(roomId),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
306
405
|
/** All open rooms (for cleanup and diagnostics). */
|
|
307
406
|
openRooms() {
|
|
308
407
|
return [...this.rooms.keys()]
|
|
@@ -313,6 +412,7 @@ export class RoomService {
|
|
|
313
412
|
room.closed = true
|
|
314
413
|
await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
|
|
315
414
|
this.rooms.delete(room.roomId)
|
|
415
|
+
this.persist()
|
|
316
416
|
return { roomId: room.roomId, closed: true, reason }
|
|
317
417
|
}
|
|
318
418
|
|
|
@@ -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
|
+
}
|