dsh-punky-swarm 0.3.6
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/CHANGELOG.md +120 -0
- package/LICENSE +661 -0
- package/README.en.md +281 -0
- package/README.md +281 -0
- package/cordis.patch.yml +59 -0
- package/lib/acps/certs.js +310 -0
- package/lib/acps/discovery-client.js +283 -0
- package/lib/acps/registry-client.js +527 -0
- package/lib/acps/server.js +302 -0
- package/lib/aip/agent-descriptor.js +170 -0
- package/lib/aip/identity.js +469 -0
- package/lib/aip/tool-descriptor.js +93 -0
- package/lib/api.js +266 -0
- package/lib/artifact-types.js +48 -0
- package/lib/assembly/audit-blind-review.js +170 -0
- package/lib/assembly/schema.js +253 -0
- package/lib/assembly.js +41 -0
- package/lib/assets.js +103 -0
- package/lib/bridge/trajectory.js +202 -0
- package/lib/client.js +704 -0
- package/lib/comms/acps-bridge.js +219 -0
- package/lib/comms/aip-format.js +141 -0
- package/lib/comms/budget.js +124 -0
- package/lib/comms/mailbox.js +221 -0
- package/lib/comms/topic-runtime.js +76 -0
- package/lib/comms/topic.js +115 -0
- package/lib/discovery/filter.js +199 -0
- package/lib/discovery/schema.js +157 -0
- package/lib/discovery/service.js +251 -0
- package/lib/hot/config-watch.js +252 -0
- package/lib/index.js +433 -0
- package/lib/lock.js +69 -0
- package/lib/panel/batch-detail.js +163 -0
- package/lib/panel/batch-list.js +84 -0
- package/lib/panel/locales.js +93 -0
- package/lib/panel/main.js +270 -0
- package/lib/panel/stream.js +348 -0
- package/lib/panel/theme.js +103 -0
- package/lib/panel/widgets.js +63 -0
- package/lib/schema.d.ts +192 -0
- package/lib/schema.js +259 -0
- package/lib/schema.ts +398 -0
- package/lib/state/archive.js +174 -0
- package/lib/state/command-exec.js +148 -0
- package/lib/state/constants.js +37 -0
- package/lib/state/corrupt-registry.js +103 -0
- package/lib/state/event-types.js +65 -0
- package/lib/state/gates.d.ts +226 -0
- package/lib/state/gates.js +428 -0
- package/lib/state/gates.ts +348 -0
- package/lib/state/machine-rules.d.ts +14 -0
- package/lib/state/machine-rules.js +76 -0
- package/lib/state/machine-rules.ts +88 -0
- package/lib/state/machine.js +68 -0
- package/lib/state/resume.js +201 -0
- package/lib/state/schema-v3.d.ts +10 -0
- package/lib/state/schema-v3.js +57 -0
- package/lib/state/schema-v3.ts +80 -0
- package/lib/state/store.js +607 -0
- package/lib/state/task-utils.js +40 -0
- package/lib/tools/core.js +323 -0
- package/lib/tools/git-utils.js +44 -0
- package/lib/tools/lane-tools.js +455 -0
- package/lib/tools/log-tools.js +173 -0
- package/lib/tools/mailbox-tools.js +121 -0
- package/lib/tools/merge-agent.js +127 -0
- package/lib/tools/register.js +85 -0
- package/lib/tools/shared.js +32 -0
- package/lib/types/contracts.d.ts +277 -0
- package/lib/types/contracts.js +17 -0
- package/lib/types/contracts.ts +279 -0
- package/lib/verify/evidence.js +249 -0
- package/lib/verify/gate.js +161 -0
- package/lib/verify/mount.js +48 -0
- package/lib/verify/selector.js +101 -0
- package/lib/watch/lane-heartbeat.js +333 -0
- package/lib/wave-plan.d.ts +48 -0
- package/lib/wave-plan.js +473 -0
- package/lib/wave-plan.ts +465 -0
- package/package.json +71 -0
- package/presets/jiufeng/NOTICE +5 -0
- package/presets/jiufeng/agent.cordis.yml +275 -0
- package/presets/jiufeng/preset.yml +3 -0
- package/skills/jiufeng-team/SKILL.md +214 -0
- package/skills/jiufeng-team/references/constitution.md +62 -0
- package/skills/jiufeng-team/references/roles/coder.md +17 -0
- package/skills/jiufeng-team/references/roles/coordinator.md +13 -0
- package/skills/jiufeng-team/references/roles/designer.md +14 -0
- package/skills/jiufeng-team/references/roles/doc-manager.md +13 -0
- package/skills/jiufeng-team/references/roles/manager.md +22 -0
- package/skills/jiufeng-team/references/roles/reviewer.md +19 -0
- package/skills/jiufeng-team/references/roles/supervisor.md +13 -0
- package/skills/jiufeng-team/references/roles/tester.md +13 -0
- package/skills/jiufeng-team/references/templates/call-chain-matrix-template.md +121 -0
- package/skills/jiufeng-team/references/templates/endpoint-behavior-template.md +60 -0
- package/skills/jiufeng-team/references/templates/gap-list.json +33 -0
- package/skills/jiufeng-team/references/templates/leader-decision-pack.md +92 -0
- package/skills/jiufeng-team/references/templates/plan-template.md +61 -0
- package/skills/jiufeng-team/references/templates/success-pattern-seeds.md +126 -0
- package/skills/jiufeng-team/references/workflow.md +98 -0
- package/skills/jiufeng-team/scripts/check-v3-density.mjs +98 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// SSE hub(R3 面板推送,设计 exec/panel-design.md §3.3)
|
|
20
|
+
//
|
|
21
|
+
// 职责:会话级订阅集合 + 三路触发源(① topic 事件(enabled 时 attachTopic 接线)
|
|
22
|
+
// ② fs.watch <root>/sessions/<sid> 批次/mailbox 目录(防抖 300ms)
|
|
23
|
+
// ③ 10s 心跳帧(event: heartbeat + 注释帧 : ping 保活))
|
|
24
|
+
// 推送粒度(ADR-5):只推轻量摘要 { sessionId, batchId, eventCount, updatedAt },
|
|
25
|
+
// 客户端收到信号后回拉既有只读 API 取全量——正确性由回拉兜底,本模块零快照逻辑。
|
|
26
|
+
//
|
|
27
|
+
// D6 简化 5 项标注(设计 §3.3.5,逐项落地):
|
|
28
|
+
// 1. 多路复用通道协议 → 不做:单流 + event: batch|mailbox|heartbeat 类型区分
|
|
29
|
+
// (面板只读监控两种事件,单流足够)
|
|
30
|
+
// 2. 断线指数退避重连 → 不做:依赖 EventSource 自动重连 + 客户端回退轮询
|
|
31
|
+
// 3. 服务端背压/流控队列 → 不做:write 失败即断开该订阅者(客户端重连)
|
|
32
|
+
// 4. 事件级增量快照 → 不做:推送信号 + 客户端回拉全量(服务端无快照逻辑)
|
|
33
|
+
// 5. 鉴权/会话扩展 → 不做:沿用既有 /api 会话语义(session query 参数)
|
|
34
|
+
//
|
|
35
|
+
// 零新依赖(D1):node:fs watch + 宿主注入的 node:http ServerResponse(writeHead/write)
|
|
36
|
+
// + 浏览器内置 EventSource(客户端侧)。无独立配置键(D3/D4):随 webServer 挂载,
|
|
37
|
+
// 降级回轮询即运行时自适配开关(ADR-4)。
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
import { watch } from 'node:fs';
|
|
41
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { subscribeTopicPrefix } from '../comms/topic.js';
|
|
44
|
+
|
|
45
|
+
const MAX_CONNS_PER_SESSION = 8; // 每会话连接数上限(防多标签页风暴)
|
|
46
|
+
const HEARTBEAT_MS = 10_000; // 心跳帧周期(10s,对齐设计 §3.3.3 触发源③)
|
|
47
|
+
const DEBOUNCE_MS = 300; // fs.watch 防抖(Windows 目录事件丢失/重复兜底,设计 §5.3-4)
|
|
48
|
+
const POLL_MS = 1000; // stat 轮询兜底周期(8.3 短路径主机无法安全用 fs.watch 目录 watch)
|
|
49
|
+
// 以上为生产缺省值;createStreamHub 接受 heartbeatMs/debounceMs/maxConns 覆盖(单测提速用,缺省不变)
|
|
50
|
+
|
|
51
|
+
// 8.3 短路径段判定(XXXXXX~N):libuv 在 Windows 上对含短路径段的目录做 fs.watch 时,
|
|
52
|
+
// 事件回调带回长路径触发原生断言崩溃(Node v24 本机实测 fs-event.c line 72)——预判规避,改 stat 轮询。
|
|
53
|
+
// 仅命中短路径形态才规避;正常长路径(生产 root=~/.dsh/jiufeng 经 homedir,长路径)走 fs.watch 事件通道。
|
|
54
|
+
export function hasShortNameSegment(p) {
|
|
55
|
+
if (process.platform !== 'win32') return false;
|
|
56
|
+
const segs = String(p).split(/[\\/]/).filter(Boolean);
|
|
57
|
+
return segs.some((s) => /^[A-Za-z0-9]{1,8}~\d{1,3}$/.test(s));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SSE_HEADERS = {
|
|
61
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
62
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
63
|
+
Connection: 'keep-alive',
|
|
64
|
+
'X-Accel-Buffering': 'no',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function frame(event, data) {
|
|
68
|
+
return 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';
|
|
69
|
+
}
|
|
70
|
+
function comment(text) {
|
|
71
|
+
return ': ' + text + '\n\n';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// topic 名 `swarm.<type>.<sid>.<bid>`:type 可含点(member.settled),从尾部取 sid/bid
|
|
75
|
+
function parseTopicIds(topic, prefix) {
|
|
76
|
+
if (typeof topic !== 'string' || !topic.startsWith(prefix)) return null;
|
|
77
|
+
const rest = topic.slice(prefix.length);
|
|
78
|
+
const parts = rest.split('.');
|
|
79
|
+
if (parts.length < 2) return null;
|
|
80
|
+
const bid = parts[parts.length - 1];
|
|
81
|
+
const sid = parts[parts.length - 2];
|
|
82
|
+
if (!sid || !bid) return null;
|
|
83
|
+
return { sessionId: sid, batchId: bid };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createStreamHub({ root, logger, heartbeatMs = HEARTBEAT_MS, debounceMs = DEBOUNCE_MS, maxConns = MAX_CONNS_PER_SESSION } = {}) {
|
|
87
|
+
// sid -> { subs: Set<{res,batchId}>, watchers: Set<FSWatcher>, debounce: timer|null,
|
|
88
|
+
// pendingBid: string|null, pendingKind: 'batch'|'mailbox'|null, polling: bool, sig: string|null }
|
|
89
|
+
const sessions = new Map();
|
|
90
|
+
const topicUnsubs = [];
|
|
91
|
+
let heartbeatTimer = null;
|
|
92
|
+
let pollTimer = null;
|
|
93
|
+
let disposed = false;
|
|
94
|
+
|
|
95
|
+
const log = (msg) => { try { logger?.info?.('[dsh-punky-swarm/stream] ' + msg); } catch {} };
|
|
96
|
+
|
|
97
|
+
// eventCount 只读最小读取(物理事实源 = 批次 JSON,store.js:109 语义)——非快照逻辑,仅计数
|
|
98
|
+
function readEventCount(sessionId, batchId) {
|
|
99
|
+
if (!root || !batchId) return null;
|
|
100
|
+
try {
|
|
101
|
+
const f = join(root, 'sessions', sessionId, 'batches', batchId + '.json');
|
|
102
|
+
if (!existsSync(f)) return null;
|
|
103
|
+
const b = JSON.parse(readFileSync(f, 'utf8'));
|
|
104
|
+
return Array.isArray(b.events) ? b.events.length : null;
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sessionOf(sessionId) {
|
|
109
|
+
let s = sessions.get(sessionId);
|
|
110
|
+
if (!s) {
|
|
111
|
+
s = { subs: new Set(), watchers: new Set(), debounce: null, pendingBid: null, pendingKind: null, polling: false, sig: null };
|
|
112
|
+
sessions.set(sessionId, s);
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeRes(res, chunk) {
|
|
118
|
+
if (!res || res.destroyed || res.writableEnded) return false;
|
|
119
|
+
try { res.write(chunk); return true; } catch { return false; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 推送单帧;write 失败(D6 简化③)→ 断开该订阅者(客户端自动重连/回退轮询)
|
|
123
|
+
function push(s, res, event, data) {
|
|
124
|
+
if (!writeRes(res, frame(event, data))) {
|
|
125
|
+
detach(res);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detach(res) {
|
|
132
|
+
for (const [sid, s] of sessions) {
|
|
133
|
+
for (const sub of s.subs) {
|
|
134
|
+
if (sub.res !== res) continue;
|
|
135
|
+
s.subs.delete(sub);
|
|
136
|
+
try { res.end(); } catch {}
|
|
137
|
+
if (s.subs.size === 0) teardownSession(sid);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// fs.watch:批量目录(含文件名→bid 解析)+ mailbox 目录(帧协议 event: mailbox)+ 目录缺失时兜底 watch 会话根。
|
|
144
|
+
// 8.3 短路径预判(hasShortNameSegment):命中 → 本会话转 stat 轮询兜底(fs.watch 在此路径形态原生崩溃风险)。
|
|
145
|
+
function ensureWatchers(sessionId) {
|
|
146
|
+
const s = sessions.get(sessionId);
|
|
147
|
+
if (!s || s.watchers.size || s.polling || disposed) return;
|
|
148
|
+
const base = join(root, 'sessions', sessionId);
|
|
149
|
+
if (hasShortNameSegment(base)) { s.polling = true; startPoller(); scanChanged(sessionId); return; }
|
|
150
|
+
const targets = [
|
|
151
|
+
{ dir: join(base, 'batches'), withBid: true, kind: 'batch' },
|
|
152
|
+
{ dir: join(base, 'mailbox'), withBid: false, kind: 'mailbox' },
|
|
153
|
+
];
|
|
154
|
+
for (const t of targets) {
|
|
155
|
+
try {
|
|
156
|
+
const w = watch(t.dir, { persistent: false }, (evt, fname) => {
|
|
157
|
+
const bid = t.withBid && fname && /^[\w.-]+\.json$/i.test(fname) ? fname.replace(/\.json$/i, '') : null;
|
|
158
|
+
scheduleNotify(sessionId, bid, t.kind);
|
|
159
|
+
});
|
|
160
|
+
w.on('error', () => {});
|
|
161
|
+
s.watchers.add(w);
|
|
162
|
+
} catch { /* 目录尚未创建 → 兜底 watch 会话根 */ }
|
|
163
|
+
}
|
|
164
|
+
if (!s.watchers.size) {
|
|
165
|
+
try {
|
|
166
|
+
const w = watch(base, { persistent: false }, () => scheduleNotify(sessionId, null, 'batch'));
|
|
167
|
+
w.on('error', () => {});
|
|
168
|
+
s.watchers.add(w);
|
|
169
|
+
} catch { /* 会话根也不存在:静默(订阅仍在,心跳保活) */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// stat 轮询兜底:readdir mtime 签名比对(8.3 短路径主机;功能等价——正确性仍由客户端回拉兜底)
|
|
174
|
+
function scanChanged(sessionId) {
|
|
175
|
+
const s = sessions.get(sessionId);
|
|
176
|
+
if (!s) return false;
|
|
177
|
+
const dirs = [join(root, 'sessions', sessionId, 'batches'), join(root, 'sessions', sessionId, 'mailbox')];
|
|
178
|
+
const parts = [];
|
|
179
|
+
for (const d of dirs) {
|
|
180
|
+
try {
|
|
181
|
+
for (const f of readdirSync(d)) {
|
|
182
|
+
const st = statSync(join(d, f));
|
|
183
|
+
parts.push(f + ':' + st.mtimeMs + ':' + st.size);
|
|
184
|
+
}
|
|
185
|
+
} catch { /* 目录缺失跳过 */ }
|
|
186
|
+
}
|
|
187
|
+
const sig = parts.sort().join('|');
|
|
188
|
+
const changed = s.sig !== null && s.sig !== sig; // 首扫仅建基线(s.sig=null),不触发推送
|
|
189
|
+
s.sig = sig;
|
|
190
|
+
return changed;
|
|
191
|
+
}
|
|
192
|
+
function startPoller() {
|
|
193
|
+
if (pollTimer || disposed) return;
|
|
194
|
+
pollTimer = setInterval(() => {
|
|
195
|
+
for (const [sid, s] of sessions) {
|
|
196
|
+
if (!s.subs.size || !s.polling) continue;
|
|
197
|
+
if (scanChanged(sid)) scheduleNotify(sid, null);
|
|
198
|
+
}
|
|
199
|
+
}, POLL_MS);
|
|
200
|
+
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
201
|
+
}
|
|
202
|
+
function stopPollerIfIdle() {
|
|
203
|
+
let active = false;
|
|
204
|
+
for (const s of sessions.values()) { if (s.subs.size && s.polling) { active = true; break; } }
|
|
205
|
+
if (!active && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function teardownSession(sessionId) {
|
|
209
|
+
const s = sessions.get(sessionId);
|
|
210
|
+
if (!s) return;
|
|
211
|
+
for (const w of s.watchers) closeWatcher(w);
|
|
212
|
+
s.watchers.clear();
|
|
213
|
+
if (s.debounce) { clearTimeout(s.debounce); s.debounce = null; }
|
|
214
|
+
s.pendingBid = null;
|
|
215
|
+
s.pendingKind = null;
|
|
216
|
+
s.polling = false;
|
|
217
|
+
sessions.delete(sessionId);
|
|
218
|
+
stopHeartbeatIfIdle();
|
|
219
|
+
stopPollerIfIdle();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Windows libuv 目录 watch 在事件在途时同步 close 会触发原生断言(fs-event.c)——
|
|
223
|
+
// 延迟一拍关闭,让在途事件排空(幂等:closed 标记防重复 close)
|
|
224
|
+
function closeWatcher(w) {
|
|
225
|
+
if (!w || w.__pswClosed) return;
|
|
226
|
+
w.__pswClosed = true;
|
|
227
|
+
setImmediate(() => { try { w.close(); } catch {} });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function scheduleNotify(sessionId, bid, kind = 'batch') {
|
|
231
|
+
const s = sessions.get(sessionId);
|
|
232
|
+
if (!s || !s.subs.size) return;
|
|
233
|
+
if (bid && !s.pendingBid) s.pendingBid = bid; // 防抖窗口内取首个明确 bid(其余变化归并)
|
|
234
|
+
if (!s.pendingKind) s.pendingKind = kind;
|
|
235
|
+
if (s.debounce) clearTimeout(s.debounce);
|
|
236
|
+
s.debounce = setTimeout(() => {
|
|
237
|
+
s.debounce = null;
|
|
238
|
+
const b = s.pendingBid;
|
|
239
|
+
const k = s.pendingKind || 'batch';
|
|
240
|
+
s.pendingBid = null;
|
|
241
|
+
s.pendingKind = null;
|
|
242
|
+
if (disposed) return;
|
|
243
|
+
notifyAll(sessionId, k, b);
|
|
244
|
+
}, debounceMs);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 推送摘要(信号 + 计数,全量交给客户端回拉);batchId 过滤详情流订阅者
|
|
248
|
+
function notifyAll(sessionId, event, batchId, extra = {}) {
|
|
249
|
+
const s = sessions.get(sessionId);
|
|
250
|
+
if (!s || !s.subs.size) return;
|
|
251
|
+
for (const sub of [...s.subs]) {
|
|
252
|
+
if (sub.batchId && batchId && sub.batchId !== batchId) continue;
|
|
253
|
+
const targetBid = sub.batchId || batchId || null;
|
|
254
|
+
const summary = {
|
|
255
|
+
sessionId,
|
|
256
|
+
batchId: targetBid,
|
|
257
|
+
eventCount: extra.eventCount != null ? extra.eventCount : (targetBid ? readEventCount(sessionId, targetBid) : null),
|
|
258
|
+
updatedAt: extra.updatedAt || new Date().toISOString(),
|
|
259
|
+
};
|
|
260
|
+
push(s, sub.res, event, summary);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 心跳:event: heartbeat(客户端 15s 无心跳降级判据)+ 注释帧 : ping(穿透代理保活)
|
|
265
|
+
function startHeartbeat() {
|
|
266
|
+
if (heartbeatTimer || disposed) return;
|
|
267
|
+
heartbeatTimer = setInterval(() => {
|
|
268
|
+
let total = 0;
|
|
269
|
+
for (const [sid, s] of sessions) {
|
|
270
|
+
// 订阅时目录尚不存在 → 心跳对齐时补挂 watcher(目录后创建兜底)
|
|
271
|
+
if (s.subs.size && !s.watchers.size) ensureWatchers(sid);
|
|
272
|
+
for (const sub of [...s.subs]) {
|
|
273
|
+
total++;
|
|
274
|
+
if (!writeRes(sub.res, comment('ping'))) { detach(sub.res); continue; }
|
|
275
|
+
push(s, sub.res, 'heartbeat', { ts: new Date().toISOString() });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (total === 0) stopHeartbeatIfIdle();
|
|
279
|
+
}, heartbeatMs);
|
|
280
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
281
|
+
}
|
|
282
|
+
function stopHeartbeatIfIdle() {
|
|
283
|
+
let total = 0;
|
|
284
|
+
for (const s of sessions.values()) total += s.subs.size;
|
|
285
|
+
if (total === 0 && heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 订阅:会话级上限 → SSE 握手 → 注册 + close 清理 → 挂 watcher/心跳
|
|
289
|
+
function subscribe(sessionId, batchId, res) {
|
|
290
|
+
if (disposed) return { ok: false, reason: 'disposed' };
|
|
291
|
+
if (typeof sessionId !== 'string' || !sessionId) return { ok: false, reason: 'session required' };
|
|
292
|
+
const s = sessionOf(sessionId);
|
|
293
|
+
if (s.subs.size >= maxConns) return { ok: false, reason: 'limit' };
|
|
294
|
+
try {
|
|
295
|
+
if (typeof res.writeHead === 'function') res.writeHead(200, SSE_HEADERS);
|
|
296
|
+
if (typeof res.flushHeaders === 'function') { try { res.flushHeaders(); } catch {} }
|
|
297
|
+
} catch { return { ok: false, reason: 'handshake' }; }
|
|
298
|
+
const sub = { res, batchId: batchId || null };
|
|
299
|
+
s.subs.add(sub);
|
|
300
|
+
writeRes(res, comment('connected'));
|
|
301
|
+
push(s, res, 'heartbeat', { ts: new Date().toISOString() });
|
|
302
|
+
const onClose = () => detach(res);
|
|
303
|
+
if (typeof res.on === 'function') { res.on('close', onClose); res.on('error', onClose); }
|
|
304
|
+
ensureWatchers(sessionId);
|
|
305
|
+
startHeartbeat();
|
|
306
|
+
log('subscribed session=' + sessionId + (batchId ? ' batch=' + batchId : '') + ' conns=' + s.subs.size);
|
|
307
|
+
return { ok: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// topic 触发源接线(设计 §3.3.3 ①):exec-a merged 后由装配点(index.js 层)调用——
|
|
311
|
+
// 订阅 `swarm.` 前缀(swarm.<type>.<sid>.<bid>),按 payload/主题名提取会话与批次路由推送
|
|
312
|
+
function attachTopic(prefix = 'swarm.', handler) {
|
|
313
|
+
if (disposed) return () => {};
|
|
314
|
+
const un = subscribeTopicPrefix(prefix, (topic, payload) => {
|
|
315
|
+
try {
|
|
316
|
+
if (handler) { handler(topic, payload); return; }
|
|
317
|
+
const ids = parseTopicIds(topic, prefix);
|
|
318
|
+
const sessionId = payload?.sessionId || payload?.session || ids?.sessionId;
|
|
319
|
+
const batchId = payload?.batchId || ids?.batchId;
|
|
320
|
+
if (!sessionId) return;
|
|
321
|
+
notifyAll(sessionId, 'batch', batchId || null, {
|
|
322
|
+
eventCount: payload?.eventCount,
|
|
323
|
+
updatedAt: payload?.updatedAt,
|
|
324
|
+
});
|
|
325
|
+
} catch { /* 隔离 */ }
|
|
326
|
+
});
|
|
327
|
+
topicUnsubs.push(un);
|
|
328
|
+
return un;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function stats() {
|
|
332
|
+
let conns = 0;
|
|
333
|
+
for (const s of sessions.values()) conns += s.subs.size;
|
|
334
|
+
return { sessions: sessions.size, conns };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function dispose() {
|
|
338
|
+
disposed = true;
|
|
339
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
340
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
341
|
+
for (const un of topicUnsubs) { try { un(); } catch {} }
|
|
342
|
+
topicUnsubs.length = 0;
|
|
343
|
+
for (const sid of [...sessions.keys()]) teardownSession(sid);
|
|
344
|
+
sessions.clear();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { subscribe, detach, notify: notifyAll, attachTopic, stats, dispose };
|
|
348
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ===== [panel-segment] theme.js =====
|
|
19
|
+
// ================= theme-aware palette =================
|
|
20
|
+
// DSH 主题开关:body[data-ds-dark-theme](深色)vs 默认浅色。令牌 var(--dsw-alias-*)
|
|
21
|
+
// 会随主题自动切换;这里只负责「令牌缺失时」的兜底色板,并按主题切换重渲染。
|
|
22
|
+
let CURRENT_THEME = 'dark';
|
|
23
|
+
function detectTheme() {
|
|
24
|
+
try {
|
|
25
|
+
if (typeof document !== 'undefined' && document.body) {
|
|
26
|
+
return document.body.hasAttribute('data-ds-dark-theme') ? 'dark' : 'light';
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return 'dark';
|
|
30
|
+
}
|
|
31
|
+
try { CURRENT_THEME = detectTheme(); } catch {}
|
|
32
|
+
|
|
33
|
+
const P = {
|
|
34
|
+
light: {
|
|
35
|
+
card: '#ffffff', border: '#dfe5ee',
|
|
36
|
+
text: '#1f2937', text2: '#475569', text3: '#64748b', dim: '#64748b',
|
|
37
|
+
accent: '#3b82f6', success: '#15803d', warn: '#b45309', error: '#dc2626', info: '#2563eb',
|
|
38
|
+
skeleton: '#eef2f7', selBg: 'rgba(59,130,246,0.10)',
|
|
39
|
+
chipPending: 'rgba(100,116,139,0.20)', chipRunning: 'rgba(180,83,9,0.12)',
|
|
40
|
+
chipReview: 'rgba(37,99,235,0.10)', chipMerged: 'rgba(21,128,61,0.12)',
|
|
41
|
+
chipFailed: 'rgba(220,38,38,0.10)', chipSkipped: 'rgba(100,116,139,0.16)',
|
|
42
|
+
chipConflict: 'rgba(234,88,12,0.12)', chipIdle: 'rgba(100,116,139,0.14)',
|
|
43
|
+
haloSuccess: 'rgba(21,128,61,0.16)', haloWarn: 'rgba(180,83,9,0.16)',
|
|
44
|
+
gateBg: 'rgba(180,83,9,0.12)', escBg: 'rgba(234,88,12,0.12)', escFg: '#c2410c'
|
|
45
|
+
},
|
|
46
|
+
dark: {
|
|
47
|
+
card: '#141d31', border: '#26304a',
|
|
48
|
+
text: '#e6ebf4', text2: '#a8b3c7', text3: '#7f8ca3', dim: '#8b96ab',
|
|
49
|
+
accent: '#4f8cff', success: '#3fb950', warn: '#d29922', error: '#f85149', info: '#58a6ff',
|
|
50
|
+
skeleton: '#1d2740', selBg: 'rgba(79,140,255,0.12)',
|
|
51
|
+
chipPending: 'rgba(127,140,163,0.22)', chipRunning: 'rgba(210,153,34,0.16)',
|
|
52
|
+
chipReview: 'rgba(88,166,255,0.16)', chipMerged: 'rgba(63,185,80,0.16)',
|
|
53
|
+
chipFailed: 'rgba(248,81,73,0.16)', chipSkipped: 'rgba(127,140,163,0.18)',
|
|
54
|
+
chipConflict: 'rgba(224,104,46,0.18)', chipIdle: 'rgba(127,140,163,0.16)',
|
|
55
|
+
haloSuccess: 'rgba(63,185,80,0.18)', haloWarn: 'rgba(210,153,34,0.18)',
|
|
56
|
+
gateBg: 'rgba(210,153,34,0.16)', escBg: 'rgba(224,104,46,0.16)', escFg: '#e0682e'
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const pal = () => P[CURRENT_THEME] || P.dark;
|
|
60
|
+
|
|
61
|
+
// 令牌优先、兜底随主题:getter 在每次渲染时求值
|
|
62
|
+
const T = {
|
|
63
|
+
get card() { return 'var(--dsw-alias-bg-layer-3, ' + pal().card + ')'; },
|
|
64
|
+
get border() { return 'var(--dsw-alias-border-l1, ' + pal().border + ')'; },
|
|
65
|
+
get text() { return 'var(--dsw-alias-label-primary, ' + pal().text + ')'; },
|
|
66
|
+
get text2() { return 'var(--dsw-alias-label-secondary, ' + pal().text2 + ')'; },
|
|
67
|
+
get text3() { return 'var(--dsw-alias-label-tertiary, ' + pal().text3 + ')'; },
|
|
68
|
+
get dim() { return 'var(--dsw-alias-label-dimmed, ' + pal().dim + ')'; },
|
|
69
|
+
get accent() { return 'var(--dsw-alias-brand-primary, ' + pal().accent + ')'; },
|
|
70
|
+
get success() { return 'var(--dsw-alias-state-success-primary, ' + pal().success + ')'; },
|
|
71
|
+
get warn() { return 'var(--dsw-alias-state-warn-primary, ' + pal().warn + ')'; },
|
|
72
|
+
get error() { return 'var(--dsw-alias-state-error-primary, ' + pal().error + ')'; },
|
|
73
|
+
get info() { return 'var(--dsw-alias-state-business-primary, ' + pal().info + ')'; },
|
|
74
|
+
get skeleton() { return 'var(--dsw-alias-bg-skeleton, ' + pal().skeleton + ')'; },
|
|
75
|
+
get selBg() { return 'var(--dsw-alias-interactive-bg-active, ' + pal().selBg + ')'; },
|
|
76
|
+
get font() { return 'var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)'; },
|
|
77
|
+
get mono() { return 'var(--dsw-font-mono, "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace)'; }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function chip(fgKey, bgKey, fgVar) {
|
|
81
|
+
return {
|
|
82
|
+
get fg() { return fgVar ? 'var(' + fgVar + ', ' + pal()[fgKey] + ')' : pal()[fgKey]; },
|
|
83
|
+
get bg() { return pal()[bgKey]; }
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const STATE = {
|
|
87
|
+
pending: chip('text2', 'chipPending', '--dsw-alias-label-secondary'),
|
|
88
|
+
running: chip('warn', 'chipRunning', '--dsw-alias-state-warn-primary'),
|
|
89
|
+
review: chip('info', 'chipReview', '--dsw-alias-state-business-primary'),
|
|
90
|
+
merged: chip('success', 'chipMerged', '--dsw-alias-state-success-primary'),
|
|
91
|
+
failed: chip('error', 'chipFailed', '--dsw-alias-state-error-primary'),
|
|
92
|
+
skipped: chip('text2', 'chipSkipped', '--dsw-alias-label-secondary'),
|
|
93
|
+
conflict: chip('escFg', 'chipConflict', '--dsw-alias-state-warn-primary'),
|
|
94
|
+
idle: chip('text2', 'chipIdle', '--dsw-alias-label-secondary')
|
|
95
|
+
};
|
|
96
|
+
const PHASE = {
|
|
97
|
+
planning: chip('text2', 'chipSkipped', '--dsw-alias-label-secondary'),
|
|
98
|
+
running: chip('warn', 'chipRunning', '--dsw-alias-state-warn-primary'),
|
|
99
|
+
paused: chip('info', 'chipReview', '--dsw-alias-state-business-primary'),
|
|
100
|
+
aborted: chip('error', 'chipFailed', '--dsw-alias-state-error-primary'),
|
|
101
|
+
complete: chip('success', 'chipMerged', '--dsw-alias-state-success-primary')
|
|
102
|
+
};
|
|
103
|
+
// =======================================================
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (C) 2025-2026 Punky
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify
|
|
5
|
+
it under the terms of the GNU Affero General Public License as
|
|
6
|
+
published by the Free Software Foundation, either version 3 of the
|
|
7
|
+
License, or (at your option) any later version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful,
|
|
10
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
GNU Affero General Public License for more details.
|
|
13
|
+
|
|
14
|
+
You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ===== [panel-segment] widgets.js =====
|
|
19
|
+
function Chip({ st, children, style }) {
|
|
20
|
+
const c = st || chip('text2', 'chipPending', '--dsw-alias-label-secondary');
|
|
21
|
+
return React.createElement('span', {
|
|
22
|
+
style: Object.assign({
|
|
23
|
+
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
24
|
+
color: c.fg, background: c.bg,
|
|
25
|
+
borderRadius: 999, padding: '1px 8px', fontSize: 10.5,
|
|
26
|
+
fontWeight: 600, letterSpacing: 0.2, lineHeight: '16px',
|
|
27
|
+
fontFamily: T.mono, whiteSpace: 'nowrap'
|
|
28
|
+
}, style || null)
|
|
29
|
+
}, children);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Dot({ color }) {
|
|
33
|
+
return React.createElement('span', { style: { width: 6, height: 6, borderRadius: 999, background: color, display: 'inline-block' } });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Progress({ value, color, height }) {
|
|
37
|
+
const h = height || 4;
|
|
38
|
+
return React.createElement('div', {
|
|
39
|
+
style: { height: h, borderRadius: 999, background: T.skeleton, overflow: 'hidden', flex: 1 }
|
|
40
|
+
}, React.createElement('div', {
|
|
41
|
+
style: { width: Math.max(0, Math.min(100, value)) + '%', height: '100%', borderRadius: 999, background: color || T.accent, transition: 'width .3s ease' }
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Stat({ label, value, color }) {
|
|
46
|
+
return React.createElement('div', {
|
|
47
|
+
style: Object.assign({}, cardBase, { padding: '6px 10px', minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: 2 })
|
|
48
|
+
},
|
|
49
|
+
React.createElement('span', { style: { fontSize: 10.5, color: T.text3, lineHeight: 1.2 } }, label),
|
|
50
|
+
React.createElement('span', { style: { fontSize: 17, fontWeight: 700, fontFamily: T.mono, color: color || T.text, lineHeight: 1.15, fontVariantNumeric: 'tabular-nums' } }, value)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function SectionTitle({ children }) {
|
|
55
|
+
return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 } },
|
|
56
|
+
React.createElement('span', { style: { width: 3, height: 12, borderRadius: 999, background: T.accent } }),
|
|
57
|
+
React.createElement('span', { style: { fontSize: 11.5, fontWeight: 600, color: T.text, letterSpacing: 0.3 } }, children)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Skeleton({ h, w, style }) {
|
|
62
|
+
return React.createElement('div', { className: 'psw-shimmer', style: Object.assign({ height: h || 12, width: w || '100%', borderRadius: 6 }, style || null) });
|
|
63
|
+
}
|