magic-agent 2.4.2 → 2.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/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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/package.json +8 -2
|
@@ -28,14 +28,17 @@ import (
|
|
|
28
28
|
"fmt"
|
|
29
29
|
"io"
|
|
30
30
|
"net/http"
|
|
31
|
+
"net/url"
|
|
31
32
|
"os"
|
|
32
33
|
"os/exec"
|
|
33
34
|
"path/filepath"
|
|
35
|
+
"strconv"
|
|
34
36
|
"strings"
|
|
35
37
|
"sync"
|
|
36
38
|
"time"
|
|
37
39
|
|
|
38
40
|
"github.com/darren/magic-agent/internal/agent"
|
|
41
|
+
"github.com/darren/magic-agent/internal/requirements"
|
|
39
42
|
)
|
|
40
43
|
|
|
41
44
|
// FeatureRequirements core 侧的能力位(与 internal/agent 的同名常量一致)。
|
|
@@ -45,6 +48,12 @@ import (
|
|
|
45
48
|
// 「插件依赖哪些能力位」在 grep 时只有一处 —— 与 FeatureSessionTeam 的处理方式一致。
|
|
46
49
|
const FeatureRequirements = "feature.requirements"
|
|
47
50
|
|
|
51
|
+
// FeatureRequirementShots core 侧的能力位(与 internal/agent 的同名常量一致)。
|
|
52
|
+
//
|
|
53
|
+
// 与 FeatureRequirements 分开:core 可能支持需求聚合但还不支持截图 ——
|
|
54
|
+
// 界面要能分别判断,别把「需求」页签整个藏掉。
|
|
55
|
+
const FeatureRequirementShots = "feature.requirement.shots"
|
|
56
|
+
|
|
48
57
|
// reqAutoDebounce 一轮收尾后等多久再归纳(期间再来一轮就顺延)。
|
|
49
58
|
const reqAutoDebounce = 12 * time.Second
|
|
50
59
|
|
|
@@ -54,6 +63,12 @@ const reqAutoDebounce = 12 * time.Second
|
|
|
54
63
|
// core 侧单次调用默认 4 分钟。给到 10 分钟覆盖「首次回填一大段历史」这种最慢的情况。
|
|
55
64
|
const reqConsolidateTimeout = 10 * time.Minute
|
|
56
65
|
|
|
66
|
+
// reqShotTimeout 追加截图的上限。
|
|
67
|
+
//
|
|
68
|
+
// 落盘本身是毫秒级的,但要先抢 core 的归纳锁(另一轮归纳在跑时要等)——
|
|
69
|
+
// 给 30 秒足够,又不至于让界面一直转圈。
|
|
70
|
+
const reqShotTimeout = 30 * time.Second
|
|
71
|
+
|
|
57
72
|
// reqAutoOffEnv 关掉自动归纳的环境变量(排查 / 省额度用)。
|
|
58
73
|
const reqAutoOffEnv = "MAGIC_CLIENT_NO_REQ_AUTO"
|
|
59
74
|
|
|
@@ -80,6 +95,25 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
|
|
|
80
95
|
s.proxyCore(w, r, "--requirements", ws)
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
// handleRequirementsGraph GET /desk/requirements/graph?ws=<ws_id|目录>:
|
|
99
|
+
// 会话↔需求关系图谱(nodes=会话/功能点,edges=关系)。纯读转发,同族范式。
|
|
100
|
+
func (s *Service) handleRequirementsGraph(w http.ResponseWriter, r *http.Request) {
|
|
101
|
+
if r.Method != http.MethodGet {
|
|
102
|
+
writeErr(w, http.StatusMethodNotAllowed, "GET only")
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if !hasStr(s.contract.Features, FeatureRequirements) {
|
|
106
|
+
writeErr(w, http.StatusNotImplemented,
|
|
107
|
+
"当前 core 不支持需求聚合(缺少 "+FeatureRequirements+" 能力位,请升级 magic-agent)")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
ws := strings.TrimSpace(r.URL.Query().Get("ws"))
|
|
111
|
+
if ws == "" {
|
|
112
|
+
ws = "all"
|
|
113
|
+
}
|
|
114
|
+
s.proxyCore(w, r, "--requirements-graph", ws)
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
// handleRequirementsConsolidate POST /desk/requirements/consolidate:
|
|
84
118
|
// 立即归纳一轮(用户点「整理」)。
|
|
85
119
|
//
|
|
@@ -149,6 +183,156 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
149
183
|
writeCoreJSON(w, out)
|
|
150
184
|
}
|
|
151
185
|
|
|
186
|
+
// handleRequirementsShots POST /desk/requirements/shots:
|
|
187
|
+
// 给一个功能点追加一张「页面完成情况」截图。
|
|
188
|
+
//
|
|
189
|
+
// 请求体 = **PNG 原始字节**(不是 multipart):这条通道只收图,形状越简单越不容易出错。
|
|
190
|
+
// 参数走查询串:ws(工作区 id 或目录,空 = 当前目录)、module、name(功能点名)、caption。
|
|
191
|
+
//
|
|
192
|
+
// 为什么图片不直接落到需求目录、而要过 core:
|
|
193
|
+
//
|
|
194
|
+
// 需求文档是 core 的资产(~/.magic-agent/requirements/<wsid>/),它有自己的一套
|
|
195
|
+
// 锁(与归纳共用)、路径归一(Windows 反斜杠)与文档段落格式。让 core 来写,
|
|
196
|
+
// 客户端就只负责「拿到字节、传过去」—— 两边不会各写一套格式。
|
|
197
|
+
func (s *Service) handleRequirementsShots(w http.ResponseWriter, r *http.Request) {
|
|
198
|
+
if r.Method != http.MethodPost {
|
|
199
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
/* 能力位 gate:老 core 没有 --requirement-shot(exit 2)。先查能力位并**说清楚为什么** ——
|
|
203
|
+
让用户看到「core 太旧,请升级」,比让它报一句 usage error 强得多。 */
|
|
204
|
+
if !hasStr(s.contract.Features, FeatureRequirementShots) {
|
|
205
|
+
writeErr(w, http.StatusNotImplemented,
|
|
206
|
+
"当前 core 不支持需求截图(缺少 "+FeatureRequirementShots+" 能力位,请升级 magic-agent)")
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
q := r.URL.Query()
|
|
210
|
+
name := strings.TrimSpace(q.Get("name"))
|
|
211
|
+
if name == "" {
|
|
212
|
+
writeErr(w, http.StatusBadRequest, "缺少 name(功能点名)")
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
module := strings.TrimSpace(q.Get("module"))
|
|
216
|
+
caption := strings.TrimSpace(q.Get("caption"))
|
|
217
|
+
ws := strings.TrimSpace(q.Get("ws"))
|
|
218
|
+
if ws == "" {
|
|
219
|
+
if wd, err := os.Getwd(); err == nil {
|
|
220
|
+
ws = wd
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if ws == "" {
|
|
224
|
+
writeErr(w, http.StatusBadRequest, "ws 不能为空")
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
// 先收进内存再判大小。
|
|
228
|
+
//
|
|
229
|
+
// ⚠️ 不用 http.MaxBytesReader:它一超限就直接写 400("request body too large"),
|
|
230
|
+
// 分不清「图片太大」和「请求体坏了」—— 用户看到的是一句看不懂的错。
|
|
231
|
+
// 这里自己读,超限的单独回 413。
|
|
232
|
+
body, err := io.ReadAll(io.LimitReader(r.Body, requirements.MaxShotBytes+1))
|
|
233
|
+
if err != nil {
|
|
234
|
+
writeErr(w, http.StatusBadRequest, "读取截图失败:"+err.Error())
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if len(body) == 0 {
|
|
238
|
+
writeErr(w, http.StatusBadRequest, "截图内容为空")
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if len(body) > requirements.MaxShotBytes {
|
|
242
|
+
writeErr(w, http.StatusRequestEntityTooLarge,
|
|
243
|
+
fmt.Sprintf("截图过大(上限 %d 字节)", requirements.MaxShotBytes))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
args := []string{"--requirement-shot", ws, "--requirement-name", name}
|
|
247
|
+
if module != "" {
|
|
248
|
+
args = append(args, "--requirement-module", module)
|
|
249
|
+
}
|
|
250
|
+
if caption != "" {
|
|
251
|
+
args = append(args, "--requirement-caption", caption)
|
|
252
|
+
}
|
|
253
|
+
out, err := s.runCoreTimedStdin(r.Context(), reqShotTimeout, bytes.NewReader(body), args...)
|
|
254
|
+
if err != nil {
|
|
255
|
+
code := http.StatusBadGateway
|
|
256
|
+
if isCoreTimeout(err) {
|
|
257
|
+
code = http.StatusGatewayTimeout
|
|
258
|
+
}
|
|
259
|
+
writeErr(w, code, fmt.Sprintf("%s --requirement-shot 失败:%v", s.opts.MagicAgent, err))
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
writeCoreJSON(w, out)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// handleRequirementShotRemote POST /desk/requirements/shot-remote:
|
|
266
|
+
// 服务端 agent-browser 打开 url 截一张全页图,落到 /desk/uploads/ 并回图片地址。
|
|
267
|
+
//
|
|
268
|
+
// 2026-10-25 内置浏览器 MCP 改基 agent-browser 后,手机端的「截图」不再依赖
|
|
269
|
+
// 页面内 relay 的 html2canvas(跨域污染 / iframe 抓不到 / 慢页超时),改由
|
|
270
|
+
// 服务端真实 Chromium 截图。请求体 JSON:{"url":"…"}(必须 http/https)。
|
|
271
|
+
// 响应:{ok, data:"/desk/uploads/…png"}(data 直接喂给标注浮层的 <img>)。
|
|
272
|
+
//
|
|
273
|
+
// 能力位与 /desk/requirements/shots 同一杆秤:缺 feature.requirement.shots 回 501;
|
|
274
|
+
// 服务端没装 agent-browser 也回 501 + 安装提示。
|
|
275
|
+
func (s *Service) handleRequirementShotRemote(w http.ResponseWriter, r *http.Request) {
|
|
276
|
+
if r.Method != http.MethodPost {
|
|
277
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if !hasStr(s.contract.Features, FeatureRequirementShots) {
|
|
281
|
+
writeErr(w, http.StatusNotImplemented,
|
|
282
|
+
"当前 core 不支持需求截图(缺少 "+FeatureRequirementShots+" 能力位,请升级 magic-agent)")
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
var req struct {
|
|
286
|
+
URL string `json:"url"`
|
|
287
|
+
FullPage bool `json:"full_page,omitempty"`
|
|
288
|
+
}
|
|
289
|
+
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
|
|
290
|
+
if err != nil {
|
|
291
|
+
writeErr(w, http.StatusBadRequest, "读取请求失败:"+err.Error())
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if err := json.Unmarshal(body, &req); err != nil {
|
|
295
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON")
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
u := strings.TrimSpace(req.URL)
|
|
299
|
+
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
|
300
|
+
writeErr(w, http.StatusBadRequest, "url 必须是 http/https 地址")
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if abBin() == "" {
|
|
304
|
+
writeErr(w, http.StatusNotImplemented,
|
|
305
|
+
"服务端未安装 agent-browser(npm install -g agent-browser && agent-browser install,或设 "+EnvAgentBrowserBin+")")
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
// 截图落 uploads 目录(与 /desk/upload 同一个家,/desk/uploads/<name> 可回读)。
|
|
309
|
+
dir := filepath.Join(os.TempDir(), "magic-client-uploads")
|
|
310
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
311
|
+
writeErr(w, http.StatusInternalServerError, "创建上传目录失败:"+err.Error())
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
out := filepath.Join(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-reqshot.png")
|
|
315
|
+
if err := abOpen(r.Context(), u); err != nil {
|
|
316
|
+
writeErr(w, http.StatusBadGateway, "agent-browser 打开页面失败:"+err.Error())
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if err := abShot(r.Context(), out, req.FullPage); err != nil {
|
|
320
|
+
_ = os.Remove(out)
|
|
321
|
+
writeErr(w, http.StatusBadGateway, "agent-browser 截图失败:"+err.Error())
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
st, err := os.Stat(out)
|
|
325
|
+
if err != nil || st.Size() == 0 {
|
|
326
|
+
writeErr(w, http.StatusBadGateway, "截图文件没有生成")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
330
|
+
"ok": true,
|
|
331
|
+
"data": "/desk/uploads/" + url.PathEscape(filepath.Base(out)),
|
|
332
|
+
"size": st.Size(),
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
152
336
|
// consolidateArgs 组归纳命令。
|
|
153
337
|
//
|
|
154
338
|
// engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
|
|
@@ -180,9 +364,19 @@ func consolidateArgs(ws, module string, force, all, auto bool, engine, model str
|
|
|
180
364
|
|
|
181
365
|
// runCoreTimed 与 runCore 同形,但超时可调(归纳不是 45s 能跑完的)。
|
|
182
366
|
func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args ...string) ([]byte, error) {
|
|
367
|
+
return s.runCoreTimedStdin(ctx, timeout, nil, args...)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// runCoreTimedStdin 与 runCoreTimed 同形,但把 stdin 接到子进程。
|
|
371
|
+
//
|
|
372
|
+
// 截图走这条路:PNG 几百 KB,放进 argv 会撞命令行长度上限、也会把二进制搅进进程列表。
|
|
373
|
+
func (s *Service) runCoreTimedStdin(ctx context.Context, timeout time.Duration, stdin io.Reader, args ...string) ([]byte, error) {
|
|
183
374
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
184
375
|
defer cancel()
|
|
185
|
-
cmd :=
|
|
376
|
+
cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
|
|
377
|
+
if stdin != nil {
|
|
378
|
+
cmd.Stdin = stdin
|
|
379
|
+
}
|
|
186
380
|
// 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
|
|
187
381
|
cmd.WaitDelay = 5 * time.Second
|
|
188
382
|
var stdout, stderr bytes.Buffer
|
|
@@ -327,3 +521,6 @@ func (s *Service) runAutoConsolidate(ws string) {
|
|
|
327
521
|
|
|
328
522
|
// 编译期断言:能力位常量与 core 侧同值(改错一个字这里就编译不过)。
|
|
329
523
|
var _ = func() bool { return agent.FeatureRequirements == FeatureRequirements }
|
|
524
|
+
|
|
525
|
+
// 同上,截图能力位。
|
|
526
|
+
var _ = func() bool { return agent.FeatureRequirementShots == FeatureRequirementShots }
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// requirements_shots_test.go - 「页面完成情况」截图端点的三条硬约束:
|
|
4
|
+
// · 老 core 缺能力位 → 501 且点名缺哪个位(不是 400/502 让人猜);
|
|
5
|
+
// · PNG 走 **stdin** 传给 core(不进 argv:几百 KB 会撞命令行长度上限);
|
|
6
|
+
// · 参数(ws / module / name / caption)原样透传。
|
|
7
|
+
//
|
|
8
|
+
// 假 core 用 .bat(Windows 上 exec 不了 shebang 脚本 —— 既有基线,见 jobs_test.go)。
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"bytes"
|
|
12
|
+
"net/http"
|
|
13
|
+
"net/http/httptest"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"runtime"
|
|
17
|
+
"strings"
|
|
18
|
+
"testing"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// shotCore 假 core:回契约、记下参数、并把从 stdin 收到的字节长度写进日志。
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ Windows 上的两个坑(都只影响**测试桩**,真实 core 是 .exe,直接 exec 时
|
|
24
|
+
// 中文参数与二进制 stdin 都是完好的 —— 实测 arg[2]="需求页" 字节 e9 9c 80…):
|
|
25
|
+
// ① cmd.exe 按**控制台代码页**(936=GBK)传参,桩里要按同一编码解读;
|
|
26
|
+
// ② PNG 头里有 0x1A(EOF 字符),文本模式读 stdin 会**截断**(12 字节只读到 11)——
|
|
27
|
+
// 必须用二进制模式读。
|
|
28
|
+
//
|
|
29
|
+
// 二进制读 stdin 的写法踩过坑:[Console]::OpenStandardInput() 返回的是 Stream,
|
|
30
|
+
// **没有 ReadToEnd**(PowerShell 5.1 会报 method not found,$b 保持 null → 记成 0 字节)。
|
|
31
|
+
// 正确写法是 CopyTo 到 MemoryStream 再看 Length。
|
|
32
|
+
func shotCore(t *testing.T, features, logPath string) string {
|
|
33
|
+
t.Helper()
|
|
34
|
+
dir := t.TempDir()
|
|
35
|
+
if runtime.GOOS == "windows" {
|
|
36
|
+
// 读 stdin 的小脚本单独放一个 .ps1:批处理里的 -Command 引号嵌套太容易走样。
|
|
37
|
+
ps1 := filepath.Join(dir, "readstdin.ps1")
|
|
38
|
+
psBody := "$stdin = [Console]::OpenStandardInput()\n" +
|
|
39
|
+
"$ms = New-Object System.IO.MemoryStream\n" +
|
|
40
|
+
"$stdin.CopyTo($ms)\n" +
|
|
41
|
+
"[Console]::Out.Write('STDIN_BYTES=' + $ms.Length + \"`n\")\n"
|
|
42
|
+
if err := os.WriteFile(ps1, []byte(psBody), 0o755); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
path := filepath.Join(dir, "magic-agent.bat")
|
|
46
|
+
lines := []string{
|
|
47
|
+
`@echo off`,
|
|
48
|
+
`set "A=%*"`,
|
|
49
|
+
`echo %A%>>"` + logPath + `"`,
|
|
50
|
+
`if "%A:--contract=%" == "%A%" goto other`,
|
|
51
|
+
`echo {"contractVersion":2,"features":[` + features + `],"engines":[]}`,
|
|
52
|
+
`exit /b 0`,
|
|
53
|
+
`:other`,
|
|
54
|
+
// 二进制读 stdin,记下字节数(追加进同一个日志)。
|
|
55
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -File "` + ps1 + `">>"` + logPath + `"`,
|
|
56
|
+
`echo {"ok":true,"workspace":"ws-1","dir":"/x","file":"/x/f.md","path":"/x/1.png","shots":1}`,
|
|
57
|
+
`exit /b 0`,
|
|
58
|
+
}
|
|
59
|
+
// 批处理按代码页存(cmd.exe 按它解释脚本里的字节)。
|
|
60
|
+
body := strings.Join(lines, "\r\n") + "\r\n"
|
|
61
|
+
if err := os.WriteFile(path, encodeWindowsScript(body), 0o755); err != nil {
|
|
62
|
+
t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
return path
|
|
65
|
+
}
|
|
66
|
+
path := filepath.Join(dir, "magic-agent")
|
|
67
|
+
script := `#!/bin/sh
|
|
68
|
+
echo "$*" >> ` + logPath + `
|
|
69
|
+
case "$*" in
|
|
70
|
+
*--contract*)
|
|
71
|
+
echo '{"contractVersion":2,"features":[` + features + `],"engines":[]}'
|
|
72
|
+
exit 0
|
|
73
|
+
;;
|
|
74
|
+
esac
|
|
75
|
+
# stdin 字节数(证明 PNG 走了 stdin)
|
|
76
|
+
n=$(wc -c)
|
|
77
|
+
echo "STDIN_BYTES=$n" >> ` + logPath + `
|
|
78
|
+
echo '{"ok":true,"workspace":"ws-1","dir":"/x","file":"/x/f.md","path":"/x/1.png","shots":1}'
|
|
79
|
+
exit 0
|
|
80
|
+
`
|
|
81
|
+
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
return path
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// shotsService 造一个带截图能力位的测试服务。
|
|
88
|
+
func shotsService(t *testing.T, logPath string) *Service {
|
|
89
|
+
t.Helper()
|
|
90
|
+
core := shotCore(t, `"feature.session.events","feature.session.control",`+
|
|
91
|
+
`"`+FeatureRequirements+`","`+FeatureRequirementShots+`"`, logPath)
|
|
92
|
+
return newTestService(t, core)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestRequirementShotsRouteNeedsFeature(t *testing.T) {
|
|
96
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
97
|
+
core := shotCore(t, `"feature.session.events","feature.session.control"`, logPath)
|
|
98
|
+
svc := newTestService(t, core)
|
|
99
|
+
|
|
100
|
+
req := httptest.NewRequest(http.MethodPost, "/desk/requirements/shots?token=tok&name=需求页", bytes.NewReader([]byte("x")))
|
|
101
|
+
rec := httptest.NewRecorder()
|
|
102
|
+
svc.Handler().ServeHTTP(rec, req)
|
|
103
|
+
if rec.Code != http.StatusNotImplemented {
|
|
104
|
+
t.Fatalf("缺能力位应回 501,实际 %d: %s", rec.Code, rec.Body.String())
|
|
105
|
+
}
|
|
106
|
+
if !strings.Contains(rec.Body.String(), FeatureRequirementShots) {
|
|
107
|
+
t.Errorf("报错应点名缺哪个能力位,实际 %s", rec.Body.String())
|
|
108
|
+
}
|
|
109
|
+
for _, c := range readCalls(t, logPath) {
|
|
110
|
+
if strings.Contains(c, "--requirement-shot") {
|
|
111
|
+
t.Errorf("缺能力位时不该去调 core:%s", c)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestRequirementShotsRouteForwardsArgsAndStdin(t *testing.T) {
|
|
117
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
118
|
+
svc := shotsService(t, logPath)
|
|
119
|
+
|
|
120
|
+
png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03, 0x04}
|
|
121
|
+
url := "/desk/requirements/shots?token=tok&ws=ws-1&module=%E4%BB%BB%E5%8A%A1%E5%88%97%E8%A1%A8&name=%E9%9C%80%E6%B1%82%E9%A1%B5&caption=%E6%94%B9%E6%88%90%E5%8F%8C%E5%88%97"
|
|
122
|
+
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(png))
|
|
123
|
+
rec := httptest.NewRecorder()
|
|
124
|
+
svc.Handler().ServeHTTP(rec, req)
|
|
125
|
+
if rec.Code != http.StatusOK {
|
|
126
|
+
t.Fatalf("截图应 200,实际 %d: %s", rec.Code, rec.Body.String())
|
|
127
|
+
}
|
|
128
|
+
// 响应是 core 的结构化结果。
|
|
129
|
+
if !strings.Contains(rec.Body.String(), `"ok":true`) {
|
|
130
|
+
t.Errorf("应原样回传 core 的结果,实际 %s", rec.Body.String())
|
|
131
|
+
}
|
|
132
|
+
var sawArgs bool
|
|
133
|
+
var sawStdin bool
|
|
134
|
+
for _, c := range readCalls(t, logPath) {
|
|
135
|
+
// ⚠️ Windows 上 cmd.exe 按控制台代码页传参 → 日志里是 GBK 字节。
|
|
136
|
+
// 按同一编码解读(真实 core 是 .exe,直接 exec 时参数是 UTF-8 —— 那是另一条路)。
|
|
137
|
+
decoded := decodeScriptBytes([]byte(c))
|
|
138
|
+
if strings.Contains(decoded, "--requirement-shot ws-1") &&
|
|
139
|
+
strings.Contains(decoded, "--requirement-name 需求页") &&
|
|
140
|
+
strings.Contains(decoded, "--requirement-module 任务列表") &&
|
|
141
|
+
strings.Contains(decoded, "--requirement-caption 改成双列") {
|
|
142
|
+
sawArgs = true
|
|
143
|
+
}
|
|
144
|
+
if strings.Contains(c, "STDIN_BYTES=") {
|
|
145
|
+
// PNG 字节数(12)应原样过 stdin。
|
|
146
|
+
if !strings.Contains(c, "STDIN_BYTES=12") {
|
|
147
|
+
t.Errorf("PNG 应原样走 stdin(12 字节),实际 %s", c)
|
|
148
|
+
}
|
|
149
|
+
sawStdin = true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if !sawArgs {
|
|
153
|
+
t.Errorf("参数应原样透传,实际 %v", readCalls(t, logPath))
|
|
154
|
+
}
|
|
155
|
+
if !sawStdin {
|
|
156
|
+
t.Errorf("PNG 应走 stdin 而不是 argv,实际 %v", readCalls(t, logPath))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestRequirementShotsRejectsBadInput(t *testing.T) {
|
|
161
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
162
|
+
svc := shotsService(t, logPath)
|
|
163
|
+
cases := []struct {
|
|
164
|
+
name string
|
|
165
|
+
url string
|
|
166
|
+
body []byte
|
|
167
|
+
code int
|
|
168
|
+
}{
|
|
169
|
+
{"缺 name", "/desk/requirements/shots?token=tok", []byte("x"), http.StatusBadRequest},
|
|
170
|
+
{"空 body", "/desk/requirements/shots?token=tok&name=x", nil, http.StatusBadRequest},
|
|
171
|
+
{"GET 不行", "/desk/requirements/shots?token=tok&name=x", nil, http.StatusMethodNotAllowed},
|
|
172
|
+
}
|
|
173
|
+
for _, c := range cases {
|
|
174
|
+
t.Run(c.name, func(t *testing.T) {
|
|
175
|
+
var req *http.Request
|
|
176
|
+
if c.code == http.StatusMethodNotAllowed {
|
|
177
|
+
req = httptest.NewRequest(http.MethodGet, c.url, nil)
|
|
178
|
+
} else {
|
|
179
|
+
req = httptest.NewRequest(http.MethodPost, c.url, bytes.NewReader(c.body))
|
|
180
|
+
}
|
|
181
|
+
rec := httptest.NewRecorder()
|
|
182
|
+
svc.Handler().ServeHTTP(rec, req)
|
|
183
|
+
if rec.Code != c.code {
|
|
184
|
+
t.Errorf("应回 %d,实际 %d: %s", c.code, rec.Code, rec.Body.String())
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func TestRequirementShotsRejectsOversize(t *testing.T) {
|
|
191
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
192
|
+
svc := shotsService(t, logPath)
|
|
193
|
+
big := make([]byte, 9<<20) // > 8MB 上限
|
|
194
|
+
req := httptest.NewRequest(http.MethodPost, "/desk/requirements/shots?token=tok&name=x", bytes.NewReader(big))
|
|
195
|
+
rec := httptest.NewRecorder()
|
|
196
|
+
svc.Handler().ServeHTTP(rec, req)
|
|
197
|
+
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
198
|
+
t.Errorf("超限应回 413,实际 %d: %s", rec.Code, rec.Body.String())
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
/* restart.go - `POST /desk/restart`:让手机上「我的 → 重启服务」能重启桌面端客户端。
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么不能在本进程里直接做 ──
|
|
6
|
+
* 重启 = 停掉自己再拉起自己。StopDaemon 会 TerminatePID 本进程,写在同一个进程里
|
|
7
|
+
* 等于「自杀到一半」:响应发不出去、后面的 start 也轮不到。
|
|
8
|
+
* 所以这里只做三件事:**先立标志、先答 200、再由进程内定时器拉起**一个脱离终端的
|
|
9
|
+
* `magic-client restart` 助手;助手停旧实例、按状态文件里的参数拉起新实例
|
|
10
|
+
* (见 daemon.go 的 RestartDaemon)。
|
|
11
|
+
*
|
|
12
|
+
* ── 为什么要「先标志、定时器后触发」(2026-10-25 用户定调)──
|
|
13
|
+
* 旧链路在 handler 里当场 spawn 带 `--delay 1s` 的助手,两个毛病:
|
|
14
|
+
* ① 重复点击(双击 / 手机重试 / 两台设备同时点)各起一个助手,两个助手互相把对方
|
|
15
|
+
* 刚拉起的实例停掉 —— 端口抢占,手机 30 秒以上还「重启后连不上」;
|
|
16
|
+
* ② 助手在 handler 里就起身,与「响应 flush 回手机」只隔一个 --delay,先后靠运气。
|
|
17
|
+
* 新链路:标志让重复点击变成一句「已在重启中」(幂等,不再排第二个定时器);
|
|
18
|
+
* 定时器到点才拉助手,严格晚于 handler 返回 —— 响应一定先落地,服务才可能被杀。
|
|
19
|
+
* 标志同时经 /desk/health.restarting 带出去:手机探活看到它 = 旧实例认账、即将下线。
|
|
20
|
+
*
|
|
21
|
+
* ── 为什么先校验状态文件 ──
|
|
22
|
+
* 重启参数全来自 `~/.magic-client/client.json`。如果那个文件不存在(前台 serve 起的)
|
|
23
|
+
* 或指向的不是本进程(同机跑着两个实例),照着它重启会重启**别人**。两种都当场拒绝,
|
|
24
|
+
* 让手机看到能落地的一句话,而不是「点了没反应」。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import (
|
|
28
|
+
"log"
|
|
29
|
+
"net/http"
|
|
30
|
+
"os"
|
|
31
|
+
"os/exec"
|
|
32
|
+
"sync"
|
|
33
|
+
"time"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// restartDelay 定时器等多久再拉助手:给这次 200 响应留落地时间。
|
|
37
|
+
// 是 var 不是 const:测试里改小,不用真等一秒。
|
|
38
|
+
var restartDelay = time.Second
|
|
39
|
+
|
|
40
|
+
// restartPending 本进程是否已受理过一次重启(「标志」)。
|
|
41
|
+
//
|
|
42
|
+
// 为什么必须在服务端记:重复点击的对手不只是这一台手机 —— 手机重试、另一台设备
|
|
43
|
+
// 同时点,都会各带一个 POST 过来。没有标志就会各排一个定时器、各起一个助手,
|
|
44
|
+
// 两个助手互相把对方刚拉起的实例停掉(端口抢占,输家要等 ready 超时)。
|
|
45
|
+
var (
|
|
46
|
+
restartMu sync.Mutex
|
|
47
|
+
restartPending bool
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// restartSpawner 定时器到点时真正干活的人:脱离终端拉一个 `magic-client restart`
|
|
51
|
+
// 助手停旧起新。包级变量是为了测试能换成不碰真进程的假动作。
|
|
52
|
+
var restartSpawner = spawnRestartHelper
|
|
53
|
+
|
|
54
|
+
// restartRequested 本进程是否已受理过重启。/desk/health 带这个字段,
|
|
55
|
+
// 手机据此把「旧实例认账」当成「它马上要下线」,不必等连接真的断。
|
|
56
|
+
func restartRequested() bool {
|
|
57
|
+
restartMu.Lock()
|
|
58
|
+
defer restartMu.Unlock()
|
|
59
|
+
return restartPending
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// handleRestart 重启后台实例(手机「我的」页那枚按钮)。
|
|
63
|
+
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
64
|
+
if r.Method != http.MethodPost {
|
|
65
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
st, err := ReadDaemonState(DefaultStatePath())
|
|
69
|
+
if err != nil {
|
|
70
|
+
writeErr(w, http.StatusInternalServerError, "读实例状态失败:"+err.Error())
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if st == nil || st.PID != os.Getpid() {
|
|
74
|
+
writeErr(w, http.StatusConflict,
|
|
75
|
+
"当前实例不是后台启动的(没有指向本进程的状态文件),不能自重启:"+
|
|
76
|
+
"请到桌面终端执行 magic-client restart")
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
exe, err := os.Executable()
|
|
80
|
+
if err != nil {
|
|
81
|
+
writeErr(w, http.StatusInternalServerError, "定位自身可执行文件失败:"+err.Error())
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* 标志:本进程已受理过一次重启。重复点击(双击 / 手机重试 / 另一台设备同时点)
|
|
86
|
+
直接答「已在重启中」—— 不再排第二个定时器、不再起第二个助手。 */
|
|
87
|
+
restartMu.Lock()
|
|
88
|
+
if restartPending {
|
|
89
|
+
restartMu.Unlock()
|
|
90
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
91
|
+
"ok": true, "restarting": true, "already_pending": true, "pid": os.Getpid(),
|
|
92
|
+
})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
restartPending = true
|
|
96
|
+
restartMu.Unlock()
|
|
97
|
+
|
|
98
|
+
// 先答,再让定时器触发:handler 返回后响应才真正落回手机,AfterFunc 在这一刻
|
|
99
|
+
// 之后才跑 —— 助手起身严格晚于响应落地,不存在「先把服务杀了、响应卡在缓冲区」。
|
|
100
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
101
|
+
"ok": true,
|
|
102
|
+
"restarting": true,
|
|
103
|
+
"pid": os.Getpid(),
|
|
104
|
+
})
|
|
105
|
+
time.AfterFunc(restartDelay, func() { restartSpawner(exe, s.log) })
|
|
106
|
+
s.log.Printf("收到重启请求:%s 后由定时器拉起助手停旧起新(旧实例即将停止)", restartDelay)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// spawnRestartHelper 脱离终端拉一个 `magic-client restart` 助手:它停旧实例、按状态
|
|
110
|
+
// 文件里的参数起新实例。不带 --delay —— 定时器已经等过 restartDelay 了。
|
|
111
|
+
func spawnRestartHelper(exe string, lg *log.Logger) {
|
|
112
|
+
logPath := DefaultLogPath()
|
|
113
|
+
var logf *os.File
|
|
114
|
+
if logPath != "" {
|
|
115
|
+
logf, _ = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
116
|
+
}
|
|
117
|
+
cmd := exec.Command(exe, "restart")
|
|
118
|
+
if logf != nil {
|
|
119
|
+
cmd.Stdout, cmd.Stderr = logf, logf
|
|
120
|
+
}
|
|
121
|
+
detach(cmd)
|
|
122
|
+
if err := cmd.Start(); err != nil {
|
|
123
|
+
if lg != nil {
|
|
124
|
+
lg.Printf("重启失败(spawn %s restart):%v(手机侧可去桌面执行 magic-client restart)", exe, err)
|
|
125
|
+
}
|
|
126
|
+
_ = logf.Close()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
// 子进程已拿到自己的句柄:父进程这份可以关了(它马上要被停)。
|
|
130
|
+
_ = logf.Close()
|
|
131
|
+
if lg != nil {
|
|
132
|
+
lg.Printf("重启助手已启动:%s restart(旧实例即将停止)", exe)
|
|
133
|
+
}
|
|
134
|
+
}
|