magic-agent 2.6.0 → 2.6.3
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/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +8 -3
|
@@ -288,3 +288,106 @@ func TestAutoConsolidateMarksAuto(t *testing.T) {
|
|
|
288
288
|
t.Errorf("自动归纳应带 --requirement-auto,实际 %v", readCalls(t, logPath))
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
+
|
|
292
|
+
// TestAutoConsolidateUsesDefaultEngine 自动归纳要和手动「整理」同一个口径:
|
|
293
|
+
// 用「我的 → 默认引擎/模型」(/desk/prefs 存的那份),而不是固定 llm。
|
|
294
|
+
//
|
|
295
|
+
// 2026-10-26 用户:「手动自动都要默认设置的引擎」。改之前自动路径硬传空串,
|
|
296
|
+
// 界面上配了 dsh 也没用,后台永远走 llm 的默认模型。
|
|
297
|
+
func TestAutoConsolidateUsesDefaultEngine(t *testing.T) {
|
|
298
|
+
// 隔离家目录:mobile-prefs.json 落这里(Windows 读 USERPROFILE,两边都设)。
|
|
299
|
+
home := t.TempDir()
|
|
300
|
+
t.Setenv("HOME", home)
|
|
301
|
+
t.Setenv("USERPROFILE", home)
|
|
302
|
+
if err := os.MkdirAll(filepath.Join(home, ".magic-agent"), 0o755); err != nil {
|
|
303
|
+
t.Fatal(err)
|
|
304
|
+
}
|
|
305
|
+
prefs := `{"magic.engine":"dsh","magic.model":"modelverse/deepseek-v4.1-flash-1017",` +
|
|
306
|
+
`"magic.fallback.engine":"claude","magic.fallback.model":"opus"}`
|
|
307
|
+
if err := os.WriteFile(filepath.Join(home, ".magic-agent", "mobile-prefs.json"),
|
|
308
|
+
[]byte(prefs), 0o600); err != nil {
|
|
309
|
+
t.Fatal(err)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
313
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
314
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
315
|
+
svc := newTestService(t, core)
|
|
316
|
+
svc.runAutoConsolidate("/proj")
|
|
317
|
+
|
|
318
|
+
var line string
|
|
319
|
+
for _, c := range readCalls(t, logPath) {
|
|
320
|
+
if strings.Contains(c, "--consolidate-requirements /proj") {
|
|
321
|
+
line = c
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if line == "" {
|
|
325
|
+
t.Fatalf("自动归纳没调 core:%v", readCalls(t, logPath))
|
|
326
|
+
}
|
|
327
|
+
if !strings.Contains(line, "--requirement-engine dsh") {
|
|
328
|
+
t.Errorf("自动归纳应带默认引擎 dsh,实际 %q", line)
|
|
329
|
+
}
|
|
330
|
+
if !strings.Contains(line, "--requirement-model modelverse/deepseek-v4.1-flash-1017") {
|
|
331
|
+
t.Errorf("自动归纳应带默认模型,实际 %q", line)
|
|
332
|
+
}
|
|
333
|
+
if !strings.Contains(line, "--requirement-auto") {
|
|
334
|
+
t.Errorf("带上引擎也不能丢 --requirement-auto,实际 %q", line)
|
|
335
|
+
}
|
|
336
|
+
// 备用引擎是「主引擎失败才切」的,绝不能当成默认值顶上。
|
|
337
|
+
if strings.Contains(line, "claude") || strings.Contains(line, "opus") {
|
|
338
|
+
t.Errorf("自动归纳不该用备用引擎,实际 %q", line)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// TestAutoConsolidateNoPrefsFallsBack 没配过默认引擎 = 不传,交给 core 的默认(llm)。
|
|
343
|
+
// 既不能编一个引擎出来,也不能因为文件缺失让后台路径报错。
|
|
344
|
+
func TestAutoConsolidateNoPrefsFallsBack(t *testing.T) {
|
|
345
|
+
home := t.TempDir()
|
|
346
|
+
t.Setenv("HOME", home)
|
|
347
|
+
t.Setenv("USERPROFILE", home) // 没有 mobile-prefs.json
|
|
348
|
+
|
|
349
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
350
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
351
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
352
|
+
svc := newTestService(t, core)
|
|
353
|
+
svc.runAutoConsolidate("/proj")
|
|
354
|
+
|
|
355
|
+
var line string
|
|
356
|
+
for _, c := range readCalls(t, logPath) {
|
|
357
|
+
if strings.Contains(c, "--consolidate-requirements /proj") {
|
|
358
|
+
line = c
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if line == "" {
|
|
362
|
+
t.Fatalf("自动归纳没调 core:%v", readCalls(t, logPath))
|
|
363
|
+
}
|
|
364
|
+
if strings.Contains(line, "--requirement-engine") || strings.Contains(line, "--requirement-model") {
|
|
365
|
+
t.Errorf("没配默认引擎时不该带引擎/模型,实际 %q", line)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// TestAutoConsolidateRespectsEngineWhitelist profile 白名单外的默认引擎不能让自动归纳
|
|
370
|
+
// 每轮都失败(core 会 403):传空回落 core 默认,而不是硬传。
|
|
371
|
+
func TestAutoConsolidateRespectsEngineWhitelist(t *testing.T) {
|
|
372
|
+
home := t.TempDir()
|
|
373
|
+
t.Setenv("HOME", home)
|
|
374
|
+
t.Setenv("USERPROFILE", home)
|
|
375
|
+
if err := os.MkdirAll(filepath.Join(home, ".magic-agent"), 0o755); err != nil {
|
|
376
|
+
t.Fatal(err)
|
|
377
|
+
}
|
|
378
|
+
if err := os.WriteFile(filepath.Join(home, ".magic-agent", "mobile-prefs.json"),
|
|
379
|
+
[]byte(`{"magic.engine":"dsh","magic.model":"m"}`), 0o600); err != nil {
|
|
380
|
+
t.Fatal(err)
|
|
381
|
+
}
|
|
382
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
383
|
+
svc := newTestService(t, reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
384
|
+
`"`+FeatureRequirements+`"`, logPath))
|
|
385
|
+
svc.opts.Profile.Engines = []string{"claude"}
|
|
386
|
+
svc.runAutoConsolidate("/proj")
|
|
387
|
+
|
|
388
|
+
for _, c := range readCalls(t, logPath) {
|
|
389
|
+
if strings.Contains(c, "--requirement-engine") {
|
|
390
|
+
t.Errorf("白名单外的引擎不该透传,实际 %q", c)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -9,6 +9,12 @@ package client
|
|
|
9
9
|
* `magic-client restart` 助手;助手停旧实例、按状态文件里的参数拉起新实例
|
|
10
10
|
* (见 daemon.go 的 RestartDaemon)。
|
|
11
11
|
*
|
|
12
|
+
* ── 地址变了怎么办(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」)──
|
|
13
|
+
* 令牌跨重启稳定(token 文件),地址却未必:换网 / 换 Wi-Fi 后网卡 IP 变了、状态文件丢了
|
|
14
|
+
* 换了令牌、端口撞了。手机存的是「地址 + 令牌」,旧的就永远连不上。解法是让**旧实例**
|
|
15
|
+
* 在死之前把「新实例的手机入口 + 令牌」挂到自己的 /desk/health 上(restartHandoff):
|
|
16
|
+
* 手机探活时顺走,改完地址与令牌再连(见 client-ui-mobile 的 restartClient)。
|
|
17
|
+
*
|
|
12
18
|
* ── 为什么要「先标志、定时器后触发」(2026-10-25 用户定调)──
|
|
13
19
|
* 旧链路在 handler 里当场 spawn 带 `--delay 1s` 的助手,两个毛病:
|
|
14
20
|
* ① 重复点击(双击 / 手机重试 / 两台设备同时点)各起一个助手,两个助手互相把对方
|
|
@@ -26,9 +32,12 @@ package client
|
|
|
26
32
|
|
|
27
33
|
import (
|
|
28
34
|
"log"
|
|
35
|
+
"net"
|
|
29
36
|
"net/http"
|
|
30
37
|
"os"
|
|
31
38
|
"os/exec"
|
|
39
|
+
"strconv"
|
|
40
|
+
"strings"
|
|
32
41
|
"sync"
|
|
33
42
|
"time"
|
|
34
43
|
)
|
|
@@ -37,6 +46,109 @@ import (
|
|
|
37
46
|
// 是 var 不是 const:测试里改小,不用真等一秒。
|
|
38
47
|
var restartDelay = time.Second
|
|
39
48
|
|
|
49
|
+
// restartHandoffTTL 旧实例认账、把「新实例的地址 + 令牌」经 /desk/health 交代出去的时长。
|
|
50
|
+
//
|
|
51
|
+
// 为什么需要(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):
|
|
52
|
+
// 手机里存的是「地址 + 令牌」。令牌跨重启稳定,地址却会变 —— 换了 Wi-Fi / 热点、watchdog
|
|
53
|
+
// 起了另一个监听地址、状态文件丢了重发了一个令牌……新实例起来后旧地址就**永远**连不上了,
|
|
54
|
+
// 手机只能看到一片「连不上」,不知道新地址是什么。而旧实例恰恰在「刚答完 200、还没死」的那
|
|
55
|
+
// 一小段里能算出新实例的地址(按即将生效的参数),所以让它把结论挂在自己的 /desk/health 上,
|
|
56
|
+
// 手机在探活时顺手取走,改完地址与令牌再连。
|
|
57
|
+
//
|
|
58
|
+
// 2 分钟 ≈ 助手停旧起新的正常耗时(3–7 秒,抢端口时见过 30 秒以上)的余量:
|
|
59
|
+
// 新实例真起来后旧实例就没了,这段窗口只是兜住「新实例没起来时旧实例还活着」的情形。
|
|
60
|
+
const restartHandoffTTL = 2 * time.Minute
|
|
61
|
+
|
|
62
|
+
// restartHandoffMu 守着下面那组交接结果(定时器 goroutine 写、handler 读)。
|
|
63
|
+
var (
|
|
64
|
+
restartHandoffMu sync.Mutex
|
|
65
|
+
restartHandoffURLs []string // 新实例的手机可用入口(不带令牌,形如 http://10.0.0.5:9100/)
|
|
66
|
+
restartHandoffToken string // 新实例的令牌(同机局域网门锁,只回给已持令牌的 /desk/health)
|
|
67
|
+
restartHandoffUntil time.Time
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// setRestartHandoff 记下「新实例会是什么样」,供 /desk/health 交代出去。
|
|
71
|
+
func setRestartHandoff(urls []string, token string) {
|
|
72
|
+
restartHandoffMu.Lock()
|
|
73
|
+
defer restartHandoffMu.Unlock()
|
|
74
|
+
restartHandoffURLs = append([]string(nil), urls...)
|
|
75
|
+
restartHandoffToken = token
|
|
76
|
+
restartHandoffUntil = time.Now().Add(restartHandoffTTL)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// restartHandoff 当前该交代的新实例信息;现在不该交代 → nil。
|
|
80
|
+
//
|
|
81
|
+
// 正常情况下(旧实例已经出过交接)就按交接本身交代,不论重启标志:手机可能在
|
|
82
|
+
// restarting 变 true 之后就取走了、第二次探活发生在标志被复位/用例隔离之后。
|
|
83
|
+
// 什么都没交接(进程刚起来)→ 不交代。
|
|
84
|
+
func restartHandoff() map[string]any {
|
|
85
|
+
if !restartRequested() && !restartHandoffArmed() {
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
restartHandoffMu.Lock()
|
|
89
|
+
defer restartHandoffMu.Unlock()
|
|
90
|
+
if time.Now().After(restartHandoffUntil) {
|
|
91
|
+
return nil
|
|
92
|
+
}
|
|
93
|
+
if len(restartHandoffURLs) == 0 && restartHandoffToken == "" {
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
return map[string]any{
|
|
97
|
+
"urls": append([]string(nil), restartHandoffURLs...),
|
|
98
|
+
"token": restartHandoffToken,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// restartHandoffArmed 是否真的出过一次交接(与过期无关)。
|
|
103
|
+
func restartHandoffArmed() bool {
|
|
104
|
+
restartHandoffMu.Lock()
|
|
105
|
+
defer restartHandoffMu.Unlock()
|
|
106
|
+
return !restartHandoffUntil.IsZero()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// handoffOfPlan 按重启助手要用的启动参数,算出新实例的入口与令牌。
|
|
110
|
+
//
|
|
111
|
+
// 顺序有讲究:**公网域名排第一**。隧道是壳起的、magic-client 重启不碰它,所以域名是
|
|
112
|
+
// 「重启后最不容易失效」的入口(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名
|
|
113
|
+
// 就可以了」)。局域网地址排在后面,只在没开公网时才当主入口。
|
|
114
|
+
//
|
|
115
|
+
// 端口:新实例显式 `--addr` 时就是它;局域网实例没写 addr 时 serve 侧固定绑
|
|
116
|
+
// DefaultLanAddr(见 cmd/magic-client/main.go),RestartDaemon → StartDaemon 也显式带上。
|
|
117
|
+
// 两侧都拿不到端口 = 会退化成随机端口,算不准就不报局域网地址。
|
|
118
|
+
// 本地回环(127.0.0.1:端口)对手机没用,不放进 urls。
|
|
119
|
+
func handoffOfPlan(plan *DaemonState, publicURL string) ([]string, string) {
|
|
120
|
+
if plan == nil {
|
|
121
|
+
return nil, ""
|
|
122
|
+
}
|
|
123
|
+
token := strings.TrimSpace(plan.Token)
|
|
124
|
+
var out []string
|
|
125
|
+
if u := strings.TrimRight(strings.TrimSpace(publicURL), "/"); u != "" {
|
|
126
|
+
out = append(out, u+"/")
|
|
127
|
+
}
|
|
128
|
+
if !plan.Lan {
|
|
129
|
+
return out, token
|
|
130
|
+
}
|
|
131
|
+
listen := strings.TrimSpace(plan.Addr)
|
|
132
|
+
if listen == "" {
|
|
133
|
+
listen = DefaultLanAddr
|
|
134
|
+
}
|
|
135
|
+
host, portStr, err := net.SplitHostPort(listen)
|
|
136
|
+
if err != nil {
|
|
137
|
+
return out, token
|
|
138
|
+
}
|
|
139
|
+
port, err := strconv.Atoi(portStr)
|
|
140
|
+
if err != nil || port <= 0 {
|
|
141
|
+
return out, token
|
|
142
|
+
}
|
|
143
|
+
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
144
|
+
return out, token // 只绑了回环:手机够不着,别报一个连不上的地址
|
|
145
|
+
}
|
|
146
|
+
for _, a := range LanAddresses(port) {
|
|
147
|
+
out = append(out, "http://"+a+"/")
|
|
148
|
+
}
|
|
149
|
+
return out, token
|
|
150
|
+
}
|
|
151
|
+
|
|
40
152
|
// restartPending 本进程是否已受理过一次重启(「标志」)。
|
|
41
153
|
//
|
|
42
154
|
// 为什么必须在服务端记:重复点击的对手不只是这一台手机 —— 手机重试、另一台设备
|
|
@@ -51,6 +163,24 @@ var (
|
|
|
51
163
|
// 助手停旧起新。包级变量是为了测试能换成不碰真进程的假动作。
|
|
52
164
|
var restartSpawner = spawnRestartHelper
|
|
53
165
|
|
|
166
|
+
// restartOnTimer 定时器到点时做的事:先把「新实例的样子」挂到 /desk/health 上交代出去,
|
|
167
|
+
// 再拉助手。交接必须抢在停旧实例(spawn 之后秒级)之前,否则手机根本来不及取。
|
|
168
|
+
// 是 var 而不是直接调:用例要能观察「交接在助手起身之前就有了」。
|
|
169
|
+
var restartOnTimer = func(st *DaemonState, publicURL, exe string, lg *log.Logger) {
|
|
170
|
+
// 新实例的启动参数 = 旧实例状态文件里那一套(RestartDaemon → StartDaemon 原样用)。
|
|
171
|
+
urls, token := handoffOfPlan(st, publicURL)
|
|
172
|
+
setRestartHandoff(urls, token)
|
|
173
|
+
restartSpawner(exe, lg, statePathOf(st))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// statePathOf 这份状态记录自己所在的路径(重启助手要照着它去找回同一份参数)。
|
|
177
|
+
func statePathOf(st *DaemonState) string {
|
|
178
|
+
if st == nil {
|
|
179
|
+
return ""
|
|
180
|
+
}
|
|
181
|
+
return st.StatePath
|
|
182
|
+
}
|
|
183
|
+
|
|
54
184
|
// restartRequested 本进程是否已受理过重启。/desk/health 带这个字段,
|
|
55
185
|
// 手机据此把「旧实例认账」当成「它马上要下线」,不必等连接真的断。
|
|
56
186
|
func restartRequested() bool {
|
|
@@ -65,15 +195,26 @@ func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
|
65
195
|
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
66
196
|
return
|
|
67
197
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
198
|
+
/* 认领自己(2026-10-26 用户:「通过 magic-test 启动的无法在移动端点击重启服务重启」)。
|
|
199
|
+
*
|
|
200
|
+
* 这里原来只读 DefaultStatePath()(= `magic-client start` 的 client.json),
|
|
201
|
+
* 于是**前台 serve** 一律撞 409「当前实例不是后台启动的」。而 magic-test 的桥接正是
|
|
202
|
+
* `spawn(bin, ['serve', ...])` 起的实例 —— 手机上连的就是它,按钮永远点不动。
|
|
203
|
+
*
|
|
204
|
+
* selfDaemonState() 是「按本进程 pid 认领状态文件」的通用实现(先看 --state / 环境变量,
|
|
205
|
+
* 再扫目录里 client*.json),前台 serve 现在也登记了自己那份(serve.json),
|
|
206
|
+
* 两条启动路径因此共用同一条重启链路。
|
|
207
|
+
*
|
|
208
|
+
* 仍然拒绝「状态文件指向别人」:同机可能跑着好几个实例,重启错的等于把别人的服务踢掉。 */
|
|
209
|
+
st := s.selfDaemonState()
|
|
210
|
+
if st == nil {
|
|
211
|
+
/* 分两种说法,别让人对着同一句话反复试:
|
|
212
|
+
· 目录里**有**别人的登记 → 这台进程是手工 serve 且没登记(老版本 / 显式跳过),
|
|
213
|
+
只能去终端重启;
|
|
214
|
+
· 目录里一份都没有 → 从没 start 过,也没新版本的自登记。 */
|
|
74
215
|
writeErr(w, http.StatusConflict,
|
|
75
|
-
"
|
|
76
|
-
"
|
|
216
|
+
"这个实例没有可用的状态记录(不是 `magic-client start` 起的,也不是登记过自己的 serve):"+
|
|
217
|
+
"不能自重启。请到桌面终端执行 magic-client restart,或用 magic-client start 重启服务")
|
|
77
218
|
return
|
|
78
219
|
}
|
|
79
220
|
exe, err := os.Executable()
|
|
@@ -102,19 +243,32 @@ func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
|
102
243
|
"restarting": true,
|
|
103
244
|
"pid": os.Getpid(),
|
|
104
245
|
})
|
|
105
|
-
|
|
106
|
-
|
|
246
|
+
/* 公网域名要在**启动定时器之前**取:它是「重启后最不容易失效」的入口
|
|
247
|
+
(隧道由壳托管、客户端重启不碰它),必须进交接里告诉手机。 */
|
|
248
|
+
pub := s.publicURLOrEmpty()
|
|
249
|
+
time.AfterFunc(restartDelay, func() { restartOnTimer(st, pub, exe, s.log) })
|
|
250
|
+
s.log.Printf("收到重启请求:%s 后由定时器拉起助手停旧起新(旧实例即将停止);"+
|
|
251
|
+
"期间 /desk/health 会交代新实例的地址与令牌", restartDelay)
|
|
107
252
|
}
|
|
108
253
|
|
|
109
254
|
// spawnRestartHelper 脱离终端拉一个 `magic-client restart` 助手:它停旧实例、按状态
|
|
110
255
|
// 文件里的参数起新实例。不带 --delay —— 定时器已经等过 restartDelay 了。
|
|
111
|
-
|
|
256
|
+
//
|
|
257
|
+
// ⚠️ `--state` 必须显式传(2026-10-26):助手默认读 ~/.magic-client/client.json,
|
|
258
|
+
// 而本进程可能是**前台 serve**(magic-test 桥接那种),它的登记在 <数据目录>/serve.json。
|
|
259
|
+
// 不传的话助手会去重启**另一个**实例 —— 最坏情况是把同机 9100 那个 `start` 实例停掉,
|
|
260
|
+
// 而用户手机连着的这个照旧活着:界面上就是「点了重启,连的那个没动静,另一个却重启了」。
|
|
261
|
+
func spawnRestartHelper(exe string, lg *log.Logger, statePath string) {
|
|
112
262
|
logPath := DefaultLogPath()
|
|
113
263
|
var logf *os.File
|
|
114
264
|
if logPath != "" {
|
|
115
265
|
logf, _ = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
116
266
|
}
|
|
117
|
-
|
|
267
|
+
args := []string{"restart"}
|
|
268
|
+
if p := strings.TrimSpace(statePath); p != "" {
|
|
269
|
+
args = append(args, "--state", p)
|
|
270
|
+
}
|
|
271
|
+
cmd := exec.Command(exe, args...)
|
|
118
272
|
if logf != nil {
|
|
119
273
|
cmd.Stdout, cmd.Stderr = logf, logf
|
|
120
274
|
}
|
|
@@ -129,6 +283,6 @@ func spawnRestartHelper(exe string, lg *log.Logger) {
|
|
|
129
283
|
// 子进程已拿到自己的句柄:父进程这份可以关了(它马上要被停)。
|
|
130
284
|
_ = logf.Close()
|
|
131
285
|
if lg != nil {
|
|
132
|
-
lg.Printf("重启助手已启动:%s restart(旧实例即将停止)", exe)
|
|
286
|
+
lg.Printf("重启助手已启动:%s restart --state %s(旧实例即将停止)", exe, statePath)
|
|
133
287
|
}
|
|
134
288
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
"log"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
重启交接(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):
|
|
18
|
+
|
|
19
|
+
旧实例受理重启后,在定时器到点、停自己之前,把「新实例的手机入口 + 令牌」挂到
|
|
20
|
+
/desk/health.restartHandoff 上;手机探活时取走,改完原环境再连。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
func resetRestartHandoffForTest() {
|
|
24
|
+
restartHandoffMu.Lock()
|
|
25
|
+
restartHandoffURLs, restartHandoffToken, restartHandoffUntil = nil, "", time.Time{}
|
|
26
|
+
restartHandoffMu.Unlock()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// healthBody 打一次 /desk/health,返回原始 JSON。
|
|
30
|
+
func healthBody(t *testing.T, svc *Service) map[string]any {
|
|
31
|
+
t.Helper()
|
|
32
|
+
rec := httptest.NewRecorder()
|
|
33
|
+
svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/desk/health", nil))
|
|
34
|
+
if rec.Code != http.StatusOK {
|
|
35
|
+
t.Fatalf("health = %d,want 200(body: %s)", rec.Code, rec.Body.String())
|
|
36
|
+
}
|
|
37
|
+
var out map[string]any
|
|
38
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
39
|
+
t.Fatalf("解析 health 失败: %v(%s)", err, rec.Body.String())
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 没受理重启 / 已过期 → 不带 restartHandoff(老语义:只有 restarting 标志)。
|
|
45
|
+
// 受理后 → 带 urls + token;交接值在定时器到点(助手起身)之前就已有,不是等助手回来。
|
|
46
|
+
func TestHealthRestartHandoff(t *testing.T) {
|
|
47
|
+
prof := DefaultProfile()
|
|
48
|
+
prof.Storage.Dir = t.TempDir()
|
|
49
|
+
svc := &Service{log: log.New(io.Discard, "", 0), opts: Options{Profile: prof}}
|
|
50
|
+
|
|
51
|
+
resetRestartPendingForTest()
|
|
52
|
+
resetRestartHandoffForTest()
|
|
53
|
+
defer resetRestartPendingForTest()
|
|
54
|
+
defer resetRestartHandoffForTest()
|
|
55
|
+
|
|
56
|
+
if raw, ok := healthBody(t, svc)["restartHandoff"]; ok && raw != nil {
|
|
57
|
+
armed := restartHandoffArmed()
|
|
58
|
+
restartHandoffMu.Lock()
|
|
59
|
+
until := restartHandoffUntil
|
|
60
|
+
restartHandoffMu.Unlock()
|
|
61
|
+
t.Fatalf("没重启时 restartHandoff 应为 null(json 里键常在、值为 null),实际 %v(armed=%v until=%v pending=%v)",
|
|
62
|
+
raw, armed, until, restartRequested())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setRestartHandoff([]string{"http://10.1.2.3:9100/"}, "new-token-0123456789abcdef")
|
|
66
|
+
got, ok := healthBody(t, svc)["restartHandoff"].(map[string]any)
|
|
67
|
+
if !ok {
|
|
68
|
+
t.Fatal("受理重启后 health 应带 restartHandoff")
|
|
69
|
+
}
|
|
70
|
+
if got["token"] != "new-token-0123456789abcdef" {
|
|
71
|
+
t.Errorf("restartHandoff.token = %v,want 新实例令牌", got["token"])
|
|
72
|
+
}
|
|
73
|
+
urls, _ := got["urls"].([]any)
|
|
74
|
+
if len(urls) != 1 || urls[0] != "http://10.1.2.3:9100/" {
|
|
75
|
+
t.Errorf("restartHandoff.urls = %v,want [http://10.1.2.3:9100/]", got["urls"])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 过期(旧实例一直没被停掉)→ 不再交代,别让手机拿一份可能已过时的地址一直重连。
|
|
79
|
+
restartHandoffMu.Lock()
|
|
80
|
+
restartHandoffUntil = time.Now().Add(-time.Second)
|
|
81
|
+
restartHandoffMu.Unlock()
|
|
82
|
+
if raw := healthBody(t, svc)["restartHandoff"]; raw != nil {
|
|
83
|
+
t.Errorf("交接过期后 health 不该再带 restartHandoff,实际 %v", raw)
|
|
84
|
+
}
|
|
85
|
+
// 从没交接过的实例(刚起来)即便 restarting 被立着也不报空交接。
|
|
86
|
+
resetRestartHandoffForTest()
|
|
87
|
+
if raw := healthBody(t, svc)["restartHandoff"]; raw != nil {
|
|
88
|
+
t.Errorf("没算出交接内容时不该带空 restartHandoff,实际 %v", raw)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 只绑回环 / 关掉局域网:手机够不着,不是「地址变了」,不交代(仍交代令牌 —— 令牌换了
|
|
93
|
+
// 手机同样连不上,哪怕地址没变)。
|
|
94
|
+
func TestHandoffOfPlanSkipsLoopback(t *testing.T) {
|
|
95
|
+
urls, token := handoffOfPlan(&DaemonState{Lan: false, Addr: "127.0.0.1:9100", Token: "tk-0123456789abcdef0123"}, "")
|
|
96
|
+
if len(urls) != 0 {
|
|
97
|
+
t.Errorf("只绑回环时不该报 URL,实际 %v", urls)
|
|
98
|
+
}
|
|
99
|
+
if token != "tk-0123456789abcdef0123" {
|
|
100
|
+
t.Errorf("令牌应原样带出,实际 %q", token)
|
|
101
|
+
}
|
|
102
|
+
if urls, _ := handoffOfPlan(nil, ""); urls != nil {
|
|
103
|
+
t.Errorf("没有状态文件时不该报 URL,实际 %v", urls)
|
|
104
|
+
}
|
|
105
|
+
// 局域网实例没写 addr:serve 侧固定绑 DefaultLanAddr,端口必须算成 9100 而不是 0。
|
|
106
|
+
urls, _ = handoffOfPlan(&DaemonState{Lan: true, Port: 9100, Token: "tk"}, "")
|
|
107
|
+
for _, u := range urls {
|
|
108
|
+
if !strings.HasSuffix(u, ":9100/") {
|
|
109
|
+
t.Errorf("局域网实例的入口应带默认端口 9100,实际 %q", u)
|
|
110
|
+
}
|
|
111
|
+
if strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") {
|
|
112
|
+
t.Errorf("不该把回环地址报给手机:%q", u)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 公网域名排第一(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
|
|
118
|
+
// 隧道由壳托管、客户端重启不碰它,域名是重启后最不容易失效的入口。
|
|
119
|
+
func TestHandoffOfPlanPrefersPublicURL(t *testing.T) {
|
|
120
|
+
urls, _ := handoffOfPlan(&DaemonState{Lan: true, Port: 9100, Token: "tk"}, "https://abc-xyz.trycloudflare.com")
|
|
121
|
+
if len(urls) == 0 || urls[0] != "https://abc-xyz.trycloudflare.com/" {
|
|
122
|
+
t.Fatalf("公网域名应排在交接第一位,实际 %v", urls)
|
|
123
|
+
}
|
|
124
|
+
// 只绑回环(--local)也照样交公网域名:手机走隧道,域名是唯一入口。
|
|
125
|
+
urls, _ = handoffOfPlan(&DaemonState{Lan: false, Addr: "127.0.0.1:9100", Token: "tk"}, "https://abc.trycloudflare.com")
|
|
126
|
+
if len(urls) != 1 || urls[0] != "https://abc.trycloudflare.com/" {
|
|
127
|
+
t.Errorf("--local 实例应只交公网域名,实际 %v", urls)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 域名落盘后,重启(新进程)第一眼就能从 /desk/health 拿到它 —— 不必等探测。
|
|
132
|
+
func TestPublicURLSurvivesRestart(t *testing.T) {
|
|
133
|
+
prof := DefaultProfile()
|
|
134
|
+
prof.Storage.Dir = t.TempDir()
|
|
135
|
+
svc := &Service{opts: Options{Profile: prof}}
|
|
136
|
+
svc.savePublicURL("https://keep-me.trycloudflare.com")
|
|
137
|
+
|
|
138
|
+
// 模拟重启:另一个 Service(同一 profile)没有内存缓存。
|
|
139
|
+
fresh := &Service{opts: Options{Profile: prof}, log: log.New(io.Discard, "", 0)}
|
|
140
|
+
got := fresh.publicURL()
|
|
141
|
+
if got != "https://keep-me.trycloudflare.com" {
|
|
142
|
+
t.Fatalf("重启后 publicURL = %q,want 落盘域名(否则手机会退回局域网地址)", got)
|
|
143
|
+
}
|
|
144
|
+
// 落盘值读进内存后应立刻可用,且不因为「第一次探测还没回来」而丢。
|
|
145
|
+
if fresh.pubURL == "" {
|
|
146
|
+
t.Error("落盘域名应已进内存缓存")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 隧道真没了:连续两次探空、且过了宽限期才删落盘值(只探空一次就删太激进)。
|
|
151
|
+
func TestSavedPublicURLClearedOnlyAfterRepeatedMiss(t *testing.T) {
|
|
152
|
+
prof := DefaultProfile()
|
|
153
|
+
prof.Storage.Dir = t.TempDir()
|
|
154
|
+
svc := &Service{opts: Options{Profile: prof}}
|
|
155
|
+
|
|
156
|
+
svc.savePublicURL("https://gone.trycloudflare.com")
|
|
157
|
+
svc.pubLoaded = true
|
|
158
|
+
svc.pubURL = "https://gone.trycloudflare.com"
|
|
159
|
+
svc.pubMiss = 0
|
|
160
|
+
|
|
161
|
+
// 第一次探空:宽限期内 → 域名顶着,文件也不删。
|
|
162
|
+
svc.pubGraceTo = time.Now().Add(publicGrace)
|
|
163
|
+
svc.applyProbeResult("")
|
|
164
|
+
if svc.pubURL != "https://gone.trycloudflare.com" {
|
|
165
|
+
t.Errorf("宽限期内探空不该撤掉已知域名,实际 %q", svc.pubURL)
|
|
166
|
+
}
|
|
167
|
+
if svc.loadSavedPublicURL() == "" {
|
|
168
|
+
t.Error("第一次探空不该删落盘域名")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 宽限期结束 + 连续第二次探空 → 认定隧道没了:内存与落盘都清。
|
|
172
|
+
svc.pubGraceTo = time.Time{}
|
|
173
|
+
svc.applyProbeResult("")
|
|
174
|
+
if svc.pubURL != "" {
|
|
175
|
+
t.Errorf("两次探空后应清掉内存域名,实际 %q", svc.pubURL)
|
|
176
|
+
}
|
|
177
|
+
if v := svc.loadSavedPublicURL(); v != "" {
|
|
178
|
+
t.Errorf("两次探空后应删掉落盘域名,实际 %q", v)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 定时器到点:先落交接,再拉助手(手机必须在旧实例被停之前就能取到新地址)。
|
|
183
|
+
func TestRestartTimerHandsOffBeforeSpawn(t *testing.T) {
|
|
184
|
+
dir := t.TempDir()
|
|
185
|
+
statePath := filepath.Join(dir, "client.json")
|
|
186
|
+
t.Setenv(EnvState, statePath)
|
|
187
|
+
exe, err := os.Executable()
|
|
188
|
+
if err != nil {
|
|
189
|
+
t.Fatal(err)
|
|
190
|
+
}
|
|
191
|
+
st := &DaemonState{PID: os.Getpid(), Port: 9100, Token: "old-token", Exe: exe, Lan: true}
|
|
192
|
+
if err := writeDaemonState(statePath, st); err != nil {
|
|
193
|
+
t.Fatal(err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
resetRestartPendingForTest()
|
|
197
|
+
resetRestartHandoffForTest()
|
|
198
|
+
defer resetRestartPendingForTest()
|
|
199
|
+
defer resetRestartHandoffForTest()
|
|
200
|
+
|
|
201
|
+
spawnedToken := make(chan string, 1)
|
|
202
|
+
oldSpawner := restartSpawner
|
|
203
|
+
restartSpawner = func(string, *log.Logger, string) {
|
|
204
|
+
// 助手起身的那一刻,交接必须已经在 /desk/health 上(否则手机来不及取)。
|
|
205
|
+
h, ok := restartHandoff()["token"].(string)
|
|
206
|
+
if !ok {
|
|
207
|
+
h = ""
|
|
208
|
+
}
|
|
209
|
+
spawnedToken <- h
|
|
210
|
+
}
|
|
211
|
+
defer func() { restartSpawner = oldSpawner }()
|
|
212
|
+
oldDelay := restartDelay
|
|
213
|
+
restartDelay = 50 * time.Millisecond
|
|
214
|
+
defer func() { restartDelay = oldDelay }()
|
|
215
|
+
|
|
216
|
+
s := &Service{log: log.New(io.Discard, "", 0)}
|
|
217
|
+
rec := httptest.NewRecorder()
|
|
218
|
+
s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
|
|
219
|
+
if rec.Code != http.StatusOK {
|
|
220
|
+
t.Fatalf("POST /desk/restart = %d,want 200(body: %s)", rec.Code, rec.Body.String())
|
|
221
|
+
}
|
|
222
|
+
select {
|
|
223
|
+
case tok := <-spawnedToken:
|
|
224
|
+
if tok != "old-token" {
|
|
225
|
+
t.Errorf("助手起身时交接里的令牌 = %q,want 状态文件里的令牌 old-token", tok)
|
|
226
|
+
}
|
|
227
|
+
case <-time.After(2 * time.Second):
|
|
228
|
+
t.Fatal("定时器没有拉起助手")
|
|
229
|
+
}
|
|
230
|
+
}
|