u-foo 1.4.1 → 1.6.0
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 +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/ufoo.js +15 -7
- package/modules/AGENTS.template.md +4 -102
- package/package.json +3 -2
- package/scripts/global-chat-switch-benchmark.js +406 -0
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/chatLogController.js +28 -5
- package/src/chat/commandExecutor.js +127 -3
- package/src/chat/commands.js +8 -0
- package/src/chat/daemonConnection.js +77 -4
- package/src/chat/daemonCoordinator.js +36 -0
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +47 -5
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +89 -1
- package/src/chat/dashboardView.js +312 -93
- package/src/chat/index.js +683 -41
- package/src/chat/inputHistoryController.js +33 -3
- package/src/chat/inputListenerController.js +22 -12
- package/src/chat/layout.js +12 -7
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/streamTracker.js +6 -0
- package/src/chat/transport.js +41 -5
- package/src/cli.js +167 -4
- package/src/daemon/index.js +54 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +245 -35
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/projects/projectId.js +29 -0
- package/src/projects/registry.js +279 -0
- package/src/ufoo/agentsStore.js +44 -0
package/README.md
CHANGED
|
@@ -111,6 +111,26 @@ ucode-core run-once --json
|
|
|
111
111
|
ucode-core list --json
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
## Global Chat (`ufoo -g`)
|
|
115
|
+
|
|
116
|
+
Use `ufoo -g` (or `ufoo --global`) to launch a cross-project chat dashboard. Instead of being scoped to a single project, global mode connects to all running ufoo daemons and lets you switch between projects on the fly.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
$ ufoo -g
|
|
120
|
+
|
|
121
|
+
> /project list # List all running project daemons
|
|
122
|
+
> /project switch 2 # Switch to project #2
|
|
123
|
+
> /launch claude scope=inplace # Launch agent in current context
|
|
124
|
+
> @claude-1 Start reviewing the auth module
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Command | Description |
|
|
128
|
+
|---------|-------------|
|
|
129
|
+
| `/project list` | List running projects from global runtime registry |
|
|
130
|
+
| `/project switch <index\|path>` | Switch active project daemon connection |
|
|
131
|
+
| `/launch <agent> scope=inplace` | Launch agent in current workspace |
|
|
132
|
+
| `/launch <agent> scope=window` | Launch agent in separate terminal window |
|
|
133
|
+
|
|
114
134
|
## Agent Configuration
|
|
115
135
|
|
|
116
136
|
Configure AI providers in `.ufoo/config.json`:
|
|
@@ -187,6 +207,7 @@ Bus state lives in `.ufoo/agent/all-agents.json` (metadata), `.ufoo/bus/*` (queu
|
|
|
187
207
|
|---------|-------------|
|
|
188
208
|
| `ufoo` | Launch chat interface (default) |
|
|
189
209
|
| `ufoo chat` | Launch interactive multi-agent chat UI |
|
|
210
|
+
| `ufoo -g` | Launch global chat mode (cross-project dashboard) |
|
|
190
211
|
| `ufoo init` | Initialize .ufoo in current project |
|
|
191
212
|
| `ufoo status` | Show banner, unread bus messages, open decisions |
|
|
192
213
|
| `ufoo doctor` | Check installation health |
|
package/README.zh-CN.md
CHANGED
|
@@ -111,6 +111,26 @@ ucode-core run-once --json
|
|
|
111
111
|
ucode-core list --json
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
## 全局聊天(`ufoo -g`)
|
|
115
|
+
|
|
116
|
+
使用 `ufoo -g`(或 `ufoo --global`)启动跨项目聊天仪表盘。全局模式会连接所有正在运行的 ufoo 守护进程,支持在不同项目之间快速切换。
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
$ ufoo -g
|
|
120
|
+
|
|
121
|
+
> /project list # 列出所有运行中的项目守护进程
|
|
122
|
+
> /project switch 2 # 切换到第 2 个项目
|
|
123
|
+
> /launch claude scope=inplace # 在当前上下文启动 Agent
|
|
124
|
+
> @claude-1 开始审查 auth 模块
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| 命令 | 说明 |
|
|
128
|
+
|------|------|
|
|
129
|
+
| `/project list` | 列出全局运行时注册的项目 |
|
|
130
|
+
| `/project switch <序号\|路径>` | 切换活动项目的 daemon 连接 |
|
|
131
|
+
| `/launch <agent> scope=inplace` | 在当前工作区启动 Agent |
|
|
132
|
+
| `/launch <agent> scope=window` | 在独立终端窗口启动 Agent |
|
|
133
|
+
|
|
114
134
|
## Agent 配置
|
|
115
135
|
|
|
116
136
|
在 `.ufoo/config.json` 中配置 AI 提供商:
|
|
@@ -187,6 +207,7 @@ Bus 状态存放于 `.ufoo/agent/all-agents.json`(元数据)、`.ufoo/bus/*`
|
|
|
187
207
|
|------|------|
|
|
188
208
|
| `ufoo` | 启动聊天界面(默认) |
|
|
189
209
|
| `ufoo chat` | 启动交互式多 Agent 聊天 UI |
|
|
210
|
+
| `ufoo -g` | 启动全局聊天模式(跨项目仪表盘) |
|
|
190
211
|
| `ufoo init` | 在当前项目初始化 .ufoo |
|
|
191
212
|
| `ufoo status` | 显示 banner、未读消息和未处理决策 |
|
|
192
213
|
| `ufoo doctor` | 检查安装状态 |
|
package/bin/ufoo.js
CHANGED
|
@@ -6,24 +6,32 @@ const { runChat } = require("../src/chat");
|
|
|
6
6
|
const { runInternalRunner } = require("../src/agent/internalRunner");
|
|
7
7
|
const { runPtyRunner } = require("../src/agent/ptyRunner");
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const rawArgv = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
function hasGlobalModeFlag(args = []) {
|
|
12
|
+
return args.includes("-g") || args.includes("--global");
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
async function main() {
|
|
16
|
+
const globalMode = hasGlobalModeFlag(rawArgv);
|
|
17
|
+
const argv = rawArgv.filter((arg) => arg !== "-g" && arg !== "--global");
|
|
18
|
+
const cmd = argv[0];
|
|
19
|
+
|
|
12
20
|
if (!cmd) {
|
|
13
|
-
await runChat(process.cwd());
|
|
21
|
+
await runChat(process.cwd(), { globalMode });
|
|
14
22
|
return;
|
|
15
23
|
}
|
|
16
24
|
if (cmd === "daemon") {
|
|
17
|
-
runDaemonCli(
|
|
25
|
+
runDaemonCli(["daemon", ...argv.slice(1)]);
|
|
18
26
|
return;
|
|
19
27
|
}
|
|
20
28
|
if (cmd === "agent-runner") {
|
|
21
|
-
const agentType =
|
|
29
|
+
const agentType = argv[1] || "codex";
|
|
22
30
|
await runInternalRunner({ projectRoot: process.cwd(), agentType });
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
25
33
|
if (cmd === "agent-pty-runner") {
|
|
26
|
-
const agentType =
|
|
34
|
+
const agentType = argv[1] || "codex";
|
|
27
35
|
try {
|
|
28
36
|
await runPtyRunner({ projectRoot: process.cwd(), agentType });
|
|
29
37
|
} catch (err) {
|
|
@@ -39,13 +47,13 @@ async function main() {
|
|
|
39
47
|
return;
|
|
40
48
|
}
|
|
41
49
|
if (cmd === "chat") {
|
|
42
|
-
await runChat(process.cwd());
|
|
50
|
+
await runChat(process.cwd(), { globalMode });
|
|
43
51
|
return;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
// Handle resume command to resume/launch agent sessions
|
|
47
55
|
if (cmd === "resume") {
|
|
48
|
-
const target =
|
|
56
|
+
const target = argv[1];
|
|
49
57
|
if (!target) {
|
|
50
58
|
console.error("Error: resume requires an agent type or nickname");
|
|
51
59
|
console.error("Usage: ufoo resume <ucode|uclaude|ucodex|nickname>");
|
|
@@ -1,105 +1,7 @@
|
|
|
1
1
|
<!-- ufoo -->
|
|
2
|
-
## ufoo Protocol
|
|
2
|
+
## ufoo Agent Protocol
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
1. **Agents are autonomous** - Execute tasks without asking for permission
|
|
9
|
-
2. **Communication via bus** - Use `ufoo bus` for inter-agent messaging
|
|
10
|
-
3. **Decisions are recorded** - Use `ufoo ctx` for decision tracking
|
|
11
|
-
4. **Context is shared** - All agents read from `.ufoo/context/`
|
|
12
|
-
|
|
13
|
-
### Available Commands
|
|
14
|
-
|
|
15
|
-
| Command | Description |
|
|
16
|
-
|---------|-------------|
|
|
17
|
-
| `uinit` | Initialize/repair .ufoo directory |
|
|
18
|
-
| `uctx` | Check context status and decisions |
|
|
19
|
-
| `ustatus` | Unified status view (banner, unread bus, open decisions) |
|
|
20
|
-
| `ubus` | Check bus messages and **auto-execute** them |
|
|
21
|
-
|
|
22
|
-
### Quick Reference
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
# Context
|
|
26
|
-
ufoo ctx decisions -l # List all decisions
|
|
27
|
-
ufoo ctx decisions -n 1 # Show latest decision
|
|
28
|
-
|
|
29
|
-
# Bus
|
|
30
|
-
SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"
|
|
31
|
-
[ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)
|
|
32
|
-
ufoo bus check $SUBSCRIBER # Check pending messages
|
|
33
|
-
ufoo bus send "<id>" "<msg>" # Send message
|
|
34
|
-
ufoo bus status # Show bus status
|
|
35
|
-
|
|
36
|
-
# Runtime report (shared contract for assistant/ucodex/uclaude)
|
|
37
|
-
ufoo report start "<task>" --task <id> --agent "$SUBSCRIBER" --scope public
|
|
38
|
-
ufoo report progress "<detail>" --task <id> --agent "$SUBSCRIBER" --scope public
|
|
39
|
-
ufoo report done "<summary>" --task <id> --agent "$SUBSCRIBER" --scope public
|
|
40
|
-
ufoo report error "<reason>" --task <id> --agent "$SUBSCRIBER" --scope public
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## ufoo context Protocol
|
|
46
|
-
|
|
47
|
-
On session start, check context status:
|
|
48
|
-
```bash
|
|
49
|
-
ufoo ctx decisions -l
|
|
50
|
-
ufoo ctx decisions -n 1
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
Key files in `.ufoo/context/`:
|
|
54
|
-
- `decisions/` - Decision log (append-only)
|
|
55
|
-
|
|
56
|
-
**Decision recording policy — "If it has information value, write it down":**
|
|
57
|
-
|
|
58
|
-
Record a decision whenever your work produces knowledge that would be useful to your future self, other agents, or the user. The threshold is LOW — when in doubt, record it.
|
|
59
|
-
|
|
60
|
-
- **Always record**: architectural choices, trade-off analysis, research findings, non-obvious gotchas, naming/convention changes, external API behavior discovered, performance observations, bug root causes
|
|
61
|
-
- **Also record**: open questions you couldn't resolve, assumptions you made, approaches you considered and rejected (with reasons), edge cases noticed but not handled
|
|
62
|
-
- **Write the decision BEFORE acting on it** — if your session dies, the knowledge survives
|
|
63
|
-
- **Granularity**: A decision can be one sentence ("X doesn't support Y, use Z instead") or a multi-page analysis. Match the depth to the information value.
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
ufoo ctx decisions new "Short descriptive title"
|
|
67
|
-
# Then edit the created file with Context/Decision/Implications
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
---
|
|
71
|
-
|
|
72
|
-
## ufoo bus Protocol
|
|
73
|
-
|
|
74
|
-
### CRITICAL: `ubus` Command Behavior
|
|
75
|
-
|
|
76
|
-
**When you receive `ubus`, you MUST:**
|
|
77
|
-
1. Resolve subscriber ID first (reuse existing ID, join only as fallback):
|
|
78
|
-
`SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"; [ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)`
|
|
79
|
-
2. Check pending messages: `ufoo bus check $SUBSCRIBER`
|
|
80
|
-
3. **EXECUTE each task immediately** - Do NOT ask the user
|
|
81
|
-
4. Reply to sender: `ufoo bus send "<publisher>" "<result>"`
|
|
82
|
-
5. **CRITICAL: Acknowledge messages after handling**: `ufoo bus ack $SUBSCRIBER`
|
|
83
|
-
|
|
84
|
-
**Rules:**
|
|
85
|
-
- Execute tasks immediately without asking
|
|
86
|
-
- Always reply to the sender
|
|
87
|
-
- Do NOT ask "Want me to...?" or "Should I...?"
|
|
88
|
-
- Do NOT wait for user confirmation
|
|
89
|
-
|
|
90
|
-
### Message Format
|
|
91
|
-
|
|
92
|
-
```
|
|
93
|
-
@you from claude-code:abc123
|
|
94
|
-
Type: message/targeted
|
|
95
|
-
Content: {"message":"Please analyze the project structure"}
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Extract sender ID from "from" field, use it to reply.
|
|
99
|
-
|
|
100
|
-
### Example
|
|
101
|
-
|
|
102
|
-
1. Receive: `@you from claude-code:bd36dda0 Content: {"message":"Please analyze the project structure"}`
|
|
103
|
-
2. Execute: Analyze the project structure
|
|
104
|
-
3. Reply: `ufoo bus send "claude-code:bd36dda0" "Project contains src/, modules/, bin/"`
|
|
4
|
+
> **Record decisions before acting.** Any knowledge with information value → `ufoo ctx decisions new "Title"` BEFORE you act on it.
|
|
5
|
+
> **Auto-execute bus messages.** On `ubus`: execute tasks immediately, reply to sender, then `ufoo bus ack`. Never ask the user.
|
|
6
|
+
> **Full protocol**: `/ufoo` skill (auto-loaded on session start). Docs: `.ufoo/docs/`
|
|
105
7
|
<!-- /ufoo -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u-foo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://ufoo.dev",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"postinstall": "node scripts/postinstall.js",
|
|
44
44
|
"test": "jest",
|
|
45
45
|
"test:watch": "jest --watch",
|
|
46
|
-
"test:coverage": "jest --coverage"
|
|
46
|
+
"test:coverage": "jest --coverage",
|
|
47
|
+
"bench:global-switch": "node scripts/global-chat-switch-benchmark.js"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"blessed": "^0.1.81",
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { spawn, spawnSync } = require("child_process");
|
|
6
|
+
const UfooInit = require("../src/init");
|
|
7
|
+
const { socketPath, isRunning } = require("../src/daemon");
|
|
8
|
+
const { connectWithRetry } = require("../src/chat/transport");
|
|
9
|
+
const { createDaemonTransport } = require("../src/chat/daemonTransport");
|
|
10
|
+
const { createDaemonCoordinator } = require("../src/chat/daemonCoordinator");
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseIntArg(argv, flag, fallback) {
|
|
17
|
+
const idx = argv.indexOf(flag);
|
|
18
|
+
if (idx < 0 || idx + 1 >= argv.length) return fallback;
|
|
19
|
+
const parsed = Number.parseInt(String(argv[idx + 1] || ""), 10);
|
|
20
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseStringArg(argv, flag, fallback) {
|
|
24
|
+
const idx = argv.indexOf(flag);
|
|
25
|
+
if (idx < 0 || idx + 1 >= argv.length) return fallback;
|
|
26
|
+
const value = String(argv[idx + 1] || "").trim();
|
|
27
|
+
return value || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasFlag(argv, flag) {
|
|
31
|
+
return argv.includes(flag);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function percentile(sortedValues, p) {
|
|
35
|
+
if (!Array.isArray(sortedValues) || sortedValues.length === 0) return 0;
|
|
36
|
+
const clamped = Math.max(0, Math.min(1, p));
|
|
37
|
+
const idx = Math.ceil(clamped * sortedValues.length) - 1;
|
|
38
|
+
const safeIdx = Math.max(0, Math.min(sortedValues.length - 1, idx));
|
|
39
|
+
return sortedValues[safeIdx];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function summarizeDurations(values) {
|
|
43
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
count: 0,
|
|
46
|
+
minMs: 0,
|
|
47
|
+
maxMs: 0,
|
|
48
|
+
avgMs: 0,
|
|
49
|
+
p50Ms: 0,
|
|
50
|
+
p95Ms: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
54
|
+
const total = values.reduce((sum, n) => sum + n, 0);
|
|
55
|
+
return {
|
|
56
|
+
count: values.length,
|
|
57
|
+
minMs: sorted[0],
|
|
58
|
+
maxMs: sorted[sorted.length - 1],
|
|
59
|
+
avgMs: total / values.length,
|
|
60
|
+
p50Ms: percentile(sorted, 0.5),
|
|
61
|
+
p95Ms: percentile(sorted, 0.95),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeProjectRootForCompare(projectRoot) {
|
|
66
|
+
const raw = String(projectRoot || "").trim();
|
|
67
|
+
if (!raw) return "";
|
|
68
|
+
try {
|
|
69
|
+
return fs.realpathSync.native(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return path.resolve(raw);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitForDaemonReady(projectRoot, timeoutMs = 20000) {
|
|
76
|
+
const startedAt = Date.now();
|
|
77
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
78
|
+
if (isRunning(projectRoot)) {
|
|
79
|
+
const client = await connectWithRetry(socketPath(projectRoot), 1, 0);
|
|
80
|
+
if (client) {
|
|
81
|
+
try {
|
|
82
|
+
client.end();
|
|
83
|
+
client.destroy();
|
|
84
|
+
} catch {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// eslint-disable-next-line no-await-in-loop
|
|
91
|
+
await sleep(150);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function waitForDaemonStopped(projectRoot, timeoutMs = 10000) {
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
99
|
+
if (!isRunning(projectRoot)) return true;
|
|
100
|
+
// eslint-disable-next-line no-await-in-loop
|
|
101
|
+
await sleep(120);
|
|
102
|
+
}
|
|
103
|
+
return !isRunning(projectRoot);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createStatusWaiter() {
|
|
107
|
+
let latestProjectRoot = "";
|
|
108
|
+
const waiting = new Set();
|
|
109
|
+
const seen = [];
|
|
110
|
+
|
|
111
|
+
function settle(targetRoot, ok, error) {
|
|
112
|
+
for (const waiter of Array.from(waiting)) {
|
|
113
|
+
if (waiter.targetRoot !== targetRoot) continue;
|
|
114
|
+
waiting.delete(waiter);
|
|
115
|
+
if (ok) waiter.resolve(targetRoot);
|
|
116
|
+
else waiter.reject(error || new Error(`status wait failed: ${targetRoot}`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleMessage(msg) {
|
|
121
|
+
const type = msg && msg.type ? String(msg.type) : "";
|
|
122
|
+
seen.push({
|
|
123
|
+
ts: Date.now(),
|
|
124
|
+
type: type || "unknown",
|
|
125
|
+
projectRoot: msg && msg.data && msg.data.projectRoot ? String(msg.data.projectRoot) : "",
|
|
126
|
+
});
|
|
127
|
+
if (seen.length > 50) {
|
|
128
|
+
seen.shift();
|
|
129
|
+
}
|
|
130
|
+
if (!msg || msg.type !== "status") return false;
|
|
131
|
+
const rootRaw = msg.data && msg.data.projectRoot ? String(msg.data.projectRoot) : "";
|
|
132
|
+
const root = normalizeProjectRootForCompare(rootRaw);
|
|
133
|
+
if (!root) return false;
|
|
134
|
+
latestProjectRoot = root;
|
|
135
|
+
settle(root, true);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function waitForProject(targetRoot, timeoutMs = 5000) {
|
|
140
|
+
const normalizedTarget = normalizeProjectRootForCompare(targetRoot);
|
|
141
|
+
if (!normalizedTarget) {
|
|
142
|
+
return Promise.reject(new Error("invalid target root for status wait"));
|
|
143
|
+
}
|
|
144
|
+
if (latestProjectRoot === normalizedTarget) return Promise.resolve(normalizedTarget);
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const waiter = { targetRoot: normalizedTarget, resolve, reject, timer: null };
|
|
147
|
+
waiter.timer = setTimeout(() => {
|
|
148
|
+
waiting.delete(waiter);
|
|
149
|
+
const seenTail = seen.slice(-5)
|
|
150
|
+
.map((entry) => `${entry.type}:${entry.projectRoot || "-"}`)
|
|
151
|
+
.join(", ");
|
|
152
|
+
reject(new Error(`timeout waiting status for ${normalizedTarget}; seen=[${seenTail}]`));
|
|
153
|
+
}, timeoutMs);
|
|
154
|
+
const wrappedResolve = (value) => {
|
|
155
|
+
clearTimeout(waiter.timer);
|
|
156
|
+
resolve(value);
|
|
157
|
+
};
|
|
158
|
+
const wrappedReject = (err) => {
|
|
159
|
+
clearTimeout(waiter.timer);
|
|
160
|
+
reject(err);
|
|
161
|
+
};
|
|
162
|
+
waiting.add({
|
|
163
|
+
targetRoot: normalizedTarget,
|
|
164
|
+
resolve: wrappedResolve,
|
|
165
|
+
reject: wrappedReject,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearAll(err) {
|
|
171
|
+
for (const waiter of Array.from(waiting)) {
|
|
172
|
+
waiting.delete(waiter);
|
|
173
|
+
waiter.reject(err || new Error("status waiter cleared"));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
handleMessage,
|
|
179
|
+
waitForProject,
|
|
180
|
+
clearAll,
|
|
181
|
+
getSeen: () => seen.slice(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function main() {
|
|
186
|
+
const argv = process.argv.slice(2);
|
|
187
|
+
const switches = parseIntArg(argv, "--switches", 50);
|
|
188
|
+
const keepTmp = hasFlag(argv, "--keep-tmp");
|
|
189
|
+
const jsonOnly = hasFlag(argv, "--json");
|
|
190
|
+
const tempParent = parseStringArg(argv, "--tmp-root", "/tmp");
|
|
191
|
+
|
|
192
|
+
const tempRoot = fs.mkdtempSync(path.join(tempParent, "ufoo-global-switch-bench-"));
|
|
193
|
+
const projectA = path.join(tempRoot, "project-a");
|
|
194
|
+
const projectB = path.join(tempRoot, "project-b");
|
|
195
|
+
fs.mkdirSync(projectA, { recursive: true });
|
|
196
|
+
fs.mkdirSync(projectB, { recursive: true });
|
|
197
|
+
|
|
198
|
+
let coordinator = null;
|
|
199
|
+
const statusWaiter = createStatusWaiter();
|
|
200
|
+
const daemonProcesses = new Map();
|
|
201
|
+
let exitCode = 0;
|
|
202
|
+
const errors = [];
|
|
203
|
+
const daemonBin = path.resolve(__dirname, "..", "bin", "ufoo.js");
|
|
204
|
+
|
|
205
|
+
function startManagedDaemon(projectRoot) {
|
|
206
|
+
const existing = daemonProcesses.get(projectRoot);
|
|
207
|
+
if (existing && !existing.child.killed && existing.child.exitCode === null) {
|
|
208
|
+
return existing.child;
|
|
209
|
+
}
|
|
210
|
+
const child = spawn(process.execPath, [daemonBin, "daemon", "start"], {
|
|
211
|
+
cwd: projectRoot,
|
|
212
|
+
env: { ...process.env, UFOO_DAEMON_CHILD: "1" },
|
|
213
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
214
|
+
});
|
|
215
|
+
const logs = { stdout: "", stderr: "" };
|
|
216
|
+
child.stdout.on("data", (chunk) => {
|
|
217
|
+
logs.stdout += String(chunk || "");
|
|
218
|
+
if (logs.stdout.length > 8000) logs.stdout = logs.stdout.slice(-8000);
|
|
219
|
+
});
|
|
220
|
+
child.stderr.on("data", (chunk) => {
|
|
221
|
+
logs.stderr += String(chunk || "");
|
|
222
|
+
if (logs.stderr.length > 8000) logs.stderr = logs.stderr.slice(-8000);
|
|
223
|
+
});
|
|
224
|
+
daemonProcesses.set(projectRoot, { child, logs });
|
|
225
|
+
return child;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stopManagedDaemon(projectRoot) {
|
|
229
|
+
try {
|
|
230
|
+
spawnSync(process.execPath, [daemonBin, "daemon", "stop"], {
|
|
231
|
+
cwd: projectRoot,
|
|
232
|
+
stdio: "ignore",
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
// ignore
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const init = new UfooInit(path.resolve(__dirname, ".."));
|
|
241
|
+
await init.init({ modules: "context,bus", project: projectA });
|
|
242
|
+
await init.init({ modules: "context,bus", project: projectB });
|
|
243
|
+
|
|
244
|
+
startManagedDaemon(projectA);
|
|
245
|
+
startManagedDaemon(projectB);
|
|
246
|
+
const readyA = await waitForDaemonReady(projectA);
|
|
247
|
+
const readyB = await waitForDaemonReady(projectB);
|
|
248
|
+
if (!readyA || !readyB) {
|
|
249
|
+
const aMeta = daemonProcesses.get(projectA);
|
|
250
|
+
const bMeta = daemonProcesses.get(projectB);
|
|
251
|
+
const aErr = aMeta ? aMeta.logs.stderr || aMeta.logs.stdout : "";
|
|
252
|
+
const bErr = bMeta ? bMeta.logs.stderr || bMeta.logs.stdout : "";
|
|
253
|
+
if (aErr) errors.push(`daemon A log: ${aErr.trim().slice(-400)}`);
|
|
254
|
+
if (bErr) errors.push(`daemon B log: ${bErr.trim().slice(-400)}`);
|
|
255
|
+
throw new Error(`daemon readiness failed: A=${readyA} B=${readyB}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const transport = createDaemonTransport({
|
|
259
|
+
projectRoot: projectA,
|
|
260
|
+
sockPath: socketPath(projectA),
|
|
261
|
+
isRunning,
|
|
262
|
+
startDaemon: startManagedDaemon,
|
|
263
|
+
connectWithRetry,
|
|
264
|
+
primaryRetries: 12,
|
|
265
|
+
secondaryRetries: 20,
|
|
266
|
+
retryDelayMs: 80,
|
|
267
|
+
restartDelayMs: 600,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
coordinator = createDaemonCoordinator({
|
|
271
|
+
projectRoot: projectA,
|
|
272
|
+
daemonTransport: transport,
|
|
273
|
+
handleMessage: statusWaiter.handleMessage,
|
|
274
|
+
queueStatusLine: () => {},
|
|
275
|
+
resolveStatusLine: () => {},
|
|
276
|
+
logMessage: () => {},
|
|
277
|
+
stopDaemon: stopManagedDaemon,
|
|
278
|
+
startDaemon: startManagedDaemon,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const connected = await coordinator.connect();
|
|
282
|
+
if (!connected) {
|
|
283
|
+
throw new Error("initial coordinator.connect() failed");
|
|
284
|
+
}
|
|
285
|
+
coordinator.requestStatus();
|
|
286
|
+
await statusWaiter.waitForProject(projectA, 5000);
|
|
287
|
+
|
|
288
|
+
const durations = [];
|
|
289
|
+
let routingChecksPassed = 0;
|
|
290
|
+
|
|
291
|
+
for (let i = 0; i < switches; i += 1) {
|
|
292
|
+
const targetRoot = i % 2 === 0 ? projectB : projectA;
|
|
293
|
+
const startedNs = process.hrtime.bigint();
|
|
294
|
+
// eslint-disable-next-line no-await-in-loop
|
|
295
|
+
const result = await coordinator.switchProject({
|
|
296
|
+
projectRoot: targetRoot,
|
|
297
|
+
sockPath: socketPath(targetRoot),
|
|
298
|
+
});
|
|
299
|
+
const durationMs = Number(process.hrtime.bigint() - startedNs) / 1e6;
|
|
300
|
+
durations.push(durationMs);
|
|
301
|
+
if (!result || result.ok !== true) {
|
|
302
|
+
errors.push(`switch ${i + 1} failed: ${(result && result.error) || "unknown"}`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
// eslint-disable-next-line no-await-in-loop
|
|
307
|
+
await statusWaiter.waitForProject(targetRoot, 5000);
|
|
308
|
+
routingChecksPassed += 1;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
errors.push(`switch ${i + 1} status mismatch: ${err.message || err}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const summary = summarizeDurations(durations);
|
|
315
|
+
const thresholds = {
|
|
316
|
+
p50MsLt500: summary.p50Ms < 500,
|
|
317
|
+
p95MsLt1200: summary.p95Ms < 1200,
|
|
318
|
+
};
|
|
319
|
+
const routeOk = routingChecksPassed === switches;
|
|
320
|
+
const pass = routeOk && thresholds.p50MsLt500 && thresholds.p95MsLt1200 && errors.length === 0;
|
|
321
|
+
exitCode = pass ? 0 : 2;
|
|
322
|
+
|
|
323
|
+
const report = {
|
|
324
|
+
switches,
|
|
325
|
+
routingChecksPassed,
|
|
326
|
+
routeOk,
|
|
327
|
+
summary,
|
|
328
|
+
thresholds,
|
|
329
|
+
pass,
|
|
330
|
+
tempRoot,
|
|
331
|
+
errors,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (jsonOnly) {
|
|
335
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
336
|
+
} else {
|
|
337
|
+
process.stdout.write("=== Global Chat Switch Benchmark ===\n");
|
|
338
|
+
process.stdout.write(`tempRoot: ${tempRoot}\n`);
|
|
339
|
+
process.stdout.write(`switches: ${switches}\n`);
|
|
340
|
+
process.stdout.write(`routing checks: ${routingChecksPassed}/${switches} (${routeOk ? "PASS" : "FAIL"})\n`);
|
|
341
|
+
process.stdout.write(
|
|
342
|
+
`latency ms: min=${summary.minMs.toFixed(2)} avg=${summary.avgMs.toFixed(2)} ` +
|
|
343
|
+
`p50=${summary.p50Ms.toFixed(2)} p95=${summary.p95Ms.toFixed(2)} max=${summary.maxMs.toFixed(2)}\n`
|
|
344
|
+
);
|
|
345
|
+
process.stdout.write(
|
|
346
|
+
`thresholds: p50<500=${thresholds.p50MsLt500 ? "PASS" : "FAIL"} ` +
|
|
347
|
+
`p95<1200=${thresholds.p95MsLt1200 ? "PASS" : "FAIL"}\n`
|
|
348
|
+
);
|
|
349
|
+
if (errors.length > 0) {
|
|
350
|
+
process.stdout.write("errors:\n");
|
|
351
|
+
errors.forEach((line) => process.stdout.write(`- ${line}\n`));
|
|
352
|
+
}
|
|
353
|
+
process.stdout.write(`overall: ${pass ? "PASS" : "FAIL"}\n`);
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
statusWaiter.clearAll(new Error("benchmark teardown"));
|
|
357
|
+
if (coordinator) {
|
|
358
|
+
try {
|
|
359
|
+
coordinator.close();
|
|
360
|
+
} catch {
|
|
361
|
+
// ignore
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
stopManagedDaemon(projectA);
|
|
366
|
+
} catch {
|
|
367
|
+
// ignore
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
stopManagedDaemon(projectB);
|
|
371
|
+
} catch {
|
|
372
|
+
// ignore
|
|
373
|
+
}
|
|
374
|
+
for (const [projectRoot, meta] of daemonProcesses.entries()) {
|
|
375
|
+
const child = meta && meta.child;
|
|
376
|
+
if (!child || child.exitCode !== null) continue;
|
|
377
|
+
try {
|
|
378
|
+
child.kill("SIGTERM");
|
|
379
|
+
} catch {
|
|
380
|
+
// ignore
|
|
381
|
+
}
|
|
382
|
+
// Ensure child cannot leak if SIGTERM is ignored.
|
|
383
|
+
await sleep(80);
|
|
384
|
+
if (child.exitCode === null) {
|
|
385
|
+
try {
|
|
386
|
+
child.kill("SIGKILL");
|
|
387
|
+
} catch {
|
|
388
|
+
// ignore
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
daemonProcesses.delete(projectRoot);
|
|
392
|
+
}
|
|
393
|
+
await waitForDaemonStopped(projectA, 8000);
|
|
394
|
+
await waitForDaemonStopped(projectB, 8000);
|
|
395
|
+
if (!keepTmp) {
|
|
396
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
process.exit(exitCode);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
main().catch((err) => {
|
|
404
|
+
process.stderr.write(`${err && err.stack ? err.stack : err}\n`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
});
|