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.
Files changed (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +7 -10
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -0,0 +1,1047 @@
1
+ package client
2
+
3
+ // scripts.go - 「发现 → 脚本」:用户登记脚本,服务端负责启停与日志。
4
+ //
5
+ // ## 这个功能是什么
6
+ //
7
+ // 手机端「发现」页多了一个「脚本」入口:用户登记若干脚本文件(路径 + 参数),
8
+ // 就能在手机上**启动 / 停止**它们,并**实时看日志**。相当于一个极简的
9
+ // 进程管理器,面向「这台桌面上要跑着的那些小服务 / 一次性任务」。
10
+ //
11
+ // ## 为什么放在插件(magic-client)这一侧而不是 core
12
+ //
13
+ // 与 plugins.go 的理由同源:脚本**不是引擎**。core 的 `--engines` 里没有它,
14
+ // 也不该有 —— core 是「跑一轮 agent」的进程,跑完就退出,没有「常驻管着某个
15
+ // 脚本」这个概念。只有常驻的插件知道「这个脚本现在还活着」。
16
+ //
17
+ // ## ── 纪律 ──
18
+ //
19
+ // 1) **客户端不能传任意命令行**。客户端只能登记「脚本文件路径 + 参数」,
20
+ // 服务端按登记的 id 执行。命令**不过 shell**(.cmd/.bat/.ps1 由
21
+ // agent.WrapLaunch 包成 PowerShell 单引号字面量调用)。
22
+ // 这条接口若接受客户端传来的命令串,就等于开了一个任意命令执行器。
23
+ // 2) **只认脚本文件**,扩展名走白名单(见 scriptExtOK)。不是脚本的东西
24
+ // (.txt / 任意 exe)一律拒绝 —— 白名单比黑名单稳。
25
+ // 3) **不假装成功**:脚本起不来就把 stderr 原样回给用户;日志区显示的是
26
+ // **真实输出**,不是「已启动」三个字。
27
+ // 4) **状态如实**:服务重启后靠 pid 探活判断脚本是不是还活着,不靠猜。
28
+ //
29
+ // ## 存储
30
+ //
31
+ // config.json 脚本定义(用户登记的清单)—— ~/.magic-agent/scripts/
32
+ // runs.json 运行态(pid / 状态 / 退出码 / 日志游标)—— 跨重启保留
33
+ // logs/<id>.log 每个脚本一份完整日志(环形缓冲之外的持久副本)
34
+ //
35
+ // 运行态落盘是必须的:服务重启后,脚本**进程还活着**(它是被 detach 出去的
36
+ // 独立进程组),但服务端已经忘了它 —— 不落盘就会出现「明明在跑,界面说已停止」。
37
+
38
+ import (
39
+ "bufio"
40
+ "context"
41
+ "encoding/json"
42
+ "errors"
43
+ "fmt"
44
+ "io"
45
+ "net/http"
46
+ "os"
47
+ "os/exec"
48
+ "path/filepath"
49
+ "sort"
50
+ "strconv"
51
+ "strings"
52
+ "sync"
53
+ "time"
54
+
55
+ "github.com/darren/magic-agent/internal/agent"
56
+ )
57
+
58
+ /* ─────────────── 数据形状 ─────────────── */
59
+
60
+ // ScriptDef 一个已登记的脚本(用户配置,持久化)。
61
+ type ScriptDef struct {
62
+ ID string `json:"id"`
63
+ Name string `json:"name"`
64
+ // Path 脚本文件绝对路径(服务端不做家目录限制 —— 令牌持有者本来就能让
65
+ // agent 跑任意命令,这里再限制只会让「脚本放在 D 盘」这种正当用法失败)。
66
+ Path string `json:"path"`
67
+ // Args 传给脚本的参数(不过 shell,逐个作为独立 argv 元素)。
68
+ Args []string `json:"args"`
69
+ // Cwd 工作目录;空 = 用脚本所在目录。
70
+ Cwd string `json:"cwd,omitempty"`
71
+ // Interpreter 解释器路径(空 = 直接跑脚本文件本身)。
72
+ //
73
+ // 为什么需要它:**白名单里没有 .exe**(可执行文件不是脚本),
74
+ // 所以 Python / Node 这类脚本在 Windows 上没法直接 exec ——
75
+ // 路径指向 .py 时 Windows 不会拿它当程序跑。
76
+ // 填了解释器就变成 `<解释器> <脚本路径> [参数…]`。
77
+ //
78
+ // ⚠️ 解释器本身**不受脚本白名单约束**(它是用户显式指定的可执行文件,
79
+ // 跟脚本路径是两层事)。但它必须是存在的文件。
80
+ Interpreter string `json:"interpreter,omitempty"`
81
+ // Env 追加的环境变量("KEY=VALUE" 逐条)。
82
+ //
83
+ // 为什么需要它:Python 脚本 stdout 非 tty 时改用**块缓冲**(4~8KB),
84
+ // 日志会「跑完一大半才刷出来」—— 实时日志就废了。
85
+ // 要治它得设 PYTHONUNBUFFERED=1(还要 PYTHONIOENCODING=utf-8 治乱码)。
86
+ // 这些是脚本自己的运行环境,不该由服务端替用户猜。
87
+ Env []string `json:"env,omitempty"`
88
+ // Note 备注(界面上给用户自己看的)。
89
+ Note string `json:"note,omitempty"`
90
+ }
91
+
92
+ // ScriptState 运行态。
93
+ type ScriptState struct {
94
+ // State: stopped | running | done | failed
95
+ State string `json:"state"`
96
+ PID int `json:"pid,omitempty"`
97
+ // ExitCode 脚本自己退出时的退出码(-1 = 还在跑 / 被强杀)。
98
+ ExitCode int `json:"exitCode"`
99
+ // StartedAt 起跑时刻(Unix 毫秒);0 = 没跑过。
100
+ StartedAt int64 `json:"startedAt,omitempty"`
101
+ // EndedAt 结束时刻(Unix 毫秒);0 = 还在跑。
102
+ EndedAt int64 `json:"endedAt,omitempty"`
103
+ // LogSeq 已经产生过多少行日志(客户端按它增量拉)。
104
+ LogSeq int `json:"logSeq"`
105
+ // Error 起跑失败时的原因(没起起来)。
106
+ Error string `json:"error,omitempty"`
107
+ }
108
+
109
+ // ScriptItem GET /desk/scripts 的一项:定义 + 运行态。
110
+ type ScriptItem struct {
111
+ ScriptDef
112
+ State ScriptState `json:"state"`
113
+ }
114
+
115
+ // scriptConfig 落盘的脚本清单。
116
+ type scriptConfig struct {
117
+ Scripts []ScriptDef `json:"scripts"`
118
+ }
119
+
120
+ /* ─────────────── 常量 ─────────────── */
121
+
122
+ const (
123
+ // scriptLogRing 内存里保留的日志行数(每脚本)。
124
+ //
125
+ // 为什么要有环:脚本可能狂打日志(每秒几十行),全留在内存里会把这台
126
+ // 桌面吃干。完整副本在 logs/<id>.log —— 要看全部可以用
127
+ // GET /desk/path-read 读那个文件(它就是普通文本)。
128
+ scriptLogRing = 500
129
+ // scriptMaxNameLen 名称长度上限(防超长名撑爆界面)。
130
+ scriptMaxNameLen = 80
131
+ // scriptMaxArgs 参数个数上限。
132
+ scriptMaxArgs = 32
133
+ // scriptMaxArgLen 单个参数长度上限。
134
+ scriptMaxArgLen = 512
135
+ // scriptMaxEnv 环境变量条数上限。
136
+ scriptMaxEnv = 32
137
+ )
138
+
139
+ // scriptExtOK 允许的脚本扩展名白名单。
140
+ //
141
+ // 为什么是白名单:黑名单("排除 .txt")永远漏 —— .pyc / .so / .msi 都能被
142
+ // 当脚本跑。白名单里这几类才是「人写的、拿来跑的」东西。
143
+ //
144
+ // .exe 特意**不在**白名单里:可执行文件不是「脚本」,而且允许它就等于允许
145
+ // 跑任意二进制。
146
+ var scriptExtOK = map[string]bool{
147
+ ".sh": true,
148
+ ".bash": true,
149
+ ".cmd": true,
150
+ ".bat": true,
151
+ ".ps1": true,
152
+ ".py": true,
153
+ ".js": true,
154
+ ".mjs": true,
155
+ }
156
+
157
+ /* ─────────────── 管理器 ─────────────── */
158
+
159
+ // scriptManager 脚本清单 + 运行态 + 日志的全部状态。
160
+ type scriptManager struct {
161
+ dir string // ~/.magic-agent/scripts
162
+
163
+ mu sync.Mutex
164
+ defs map[string]*ScriptDef // id → 定义
165
+ order []string // 定义顺序(登记先后)
166
+ runs map[string]*scriptRun // id → 运行态与日志
167
+ seq int // 生成 id 的序号
168
+ // starting 正在 spawn 的脚本 id(防并发重复启动,见 start)。
169
+ starting map[string]bool
170
+ }
171
+
172
+ // scriptRun 一个脚本的运行态 + 日志缓冲。
173
+ type scriptRun struct {
174
+ state ScriptState
175
+ // lines 环形缓冲里的日志行(seq, text)。
176
+ lines []scriptLine
177
+ // nextSeq 下一行日志要用的 seq。
178
+ nextSeq int
179
+ // cmd 当前持有的进程(用于停止);nil = 没在跑。
180
+ cmd *exec.Cmd
181
+ // cancel 当前这次运行的可取消 context。
182
+ cancel context.CancelFunc
183
+ // stopping 本次运行是**用户主动停的**(不是自己退的)。
184
+ //
185
+ // 为什么要有这个标记:被强杀的进程退出码非 0,若不区分就会显示成
186
+ // 「失败」—— 用户明明是自己点的停止,看到「失败」会以为脚本出错。
187
+ stopping bool
188
+ }
189
+
190
+ // scriptLine 一行日志。
191
+ type scriptLine struct {
192
+ Seq int `json:"seq"`
193
+ Text string `json:"text"`
194
+ }
195
+
196
+ // envScriptsDir 覆盖脚本数据目录(测试用;生产走 ~/.magic-agent/scripts)。
197
+ //
198
+ // 与 jobs.go 的 MAGIC_CLIENT_JOBS_DIR 同一套思路:单测要能造一个干净的
199
+ // 脚本目录,不许写进用户真实的 ~/.magic-agent/scripts。
200
+ const envScriptsDir = "MAGIC_CLIENT_SCRIPTS_DIR"
201
+
202
+ // newScriptManager 建管理器并读回落盘的清单与运行态。
203
+ func newScriptManager(s *Service) *scriptManager {
204
+ m := &scriptManager{
205
+ defs: map[string]*ScriptDef{},
206
+ runs: map[string]*scriptRun{},
207
+ starting: map[string]bool{},
208
+ }
209
+ dir := strings.TrimSpace(os.Getenv(envScriptsDir))
210
+ if dir == "" {
211
+ home, err := os.UserHomeDir()
212
+ if err != nil || home == "" {
213
+ m.dir = ""
214
+ return m
215
+ }
216
+ dir = filepath.Join(home, ".magic-agent", "scripts")
217
+ }
218
+ m.dir = dir
219
+ if err := os.MkdirAll(filepath.Join(m.dir, "logs"), 0o700); err != nil {
220
+ // s.log 可能是 nil(单测里 &Service{} 直接构造)→ 不能直接调。
221
+ if s.log != nil {
222
+ s.log.Printf("脚本目录创建失败 %s(%v),脚本清单将不落盘", m.dir, err)
223
+ }
224
+ m.dir = ""
225
+ return m
226
+ }
227
+ m.load()
228
+ return m
229
+ }
230
+
231
+ // scriptMgr 取脚本管理器(**惰性建**)。
232
+ //
233
+ // 为什么不用 s.scripts 直接取:单测里 Service 是 &Service{} 直接构造的,
234
+ // 没走 New() → 字段是 nil → handler 里解引用会 panic。
235
+ // 惰性建同时解决了「New 早于配置目录可用」的次序问题。
236
+ func (s *Service) scriptMgr() *scriptManager {
237
+ s.mu.Lock()
238
+ defer s.mu.Unlock()
239
+ if s.scripts == nil {
240
+ s.scripts = newScriptManager(s)
241
+ }
242
+ return s.scripts
243
+ }
244
+
245
+ /* ─────────────── 持久化 ─────────────── */
246
+
247
+ func (m *scriptManager) configPath() string {
248
+ if m.dir == "" {
249
+ return ""
250
+ }
251
+ return filepath.Join(m.dir, "config.json")
252
+ }
253
+
254
+ func (m *scriptManager) runsPath() string {
255
+ if m.dir == "" {
256
+ return ""
257
+ }
258
+ return filepath.Join(m.dir, "runs.json")
259
+ }
260
+
261
+ func (m *scriptManager) logPath(id string) string {
262
+ if m.dir == "" {
263
+ return ""
264
+ }
265
+ return filepath.Join(m.dir, "logs", id+".log")
266
+ }
267
+
268
+ // load 读回清单与运行态。
269
+ //
270
+ // 运行态里的 pid 要**探活**:服务重启后脚本进程可能还活着(它是独立进程组,
271
+ // 不随服务端退出),也可能早就被系统收了 —— 不探活就会出现两种胡话:
272
+ // 「明明在跑,界面说已停止」或「早就退了,界面还说在跑」。
273
+ func (m *scriptManager) load() {
274
+ if p := m.configPath(); p != "" {
275
+ if b, err := os.ReadFile(p); err == nil && json.Valid(b) {
276
+ var cfg scriptConfig
277
+ if json.Unmarshal(b, &cfg) == nil {
278
+ for i := range cfg.Scripts {
279
+ def := cfg.Scripts[i]
280
+ if def.ID == "" {
281
+ continue
282
+ }
283
+ m.defs[def.ID] = &def
284
+ m.order = append(m.order, def.ID)
285
+ }
286
+ }
287
+ }
288
+ }
289
+ if p := m.runsPath(); p != "" {
290
+ if b, err := os.ReadFile(p); err == nil && json.Valid(b) {
291
+ var states map[string]ScriptState
292
+ if json.Unmarshal(b, &states) == nil {
293
+ for id, st := range states {
294
+ if _, ok := m.defs[id]; !ok {
295
+ continue // 定义被删了 → 运行态也作废
296
+ }
297
+ if st.State == "running" {
298
+ if st.PID > 0 && agent.PIDAlive(st.PID) {
299
+ // 还活着:状态照旧,但服务端已经管不到它的输出了
300
+ // (管道随旧进程一起没了)。日志从落盘文件补。
301
+ st.Error = "服务已重启,这个脚本仍在运行;日志从落盘文件读"
302
+ } else {
303
+ st.State = "stopped"
304
+ st.PID = 0
305
+ st.ExitCode = -1
306
+ }
307
+ }
308
+ m.runs[id] = &scriptRun{state: st, nextSeq: st.LogSeq}
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ // saveConfig 落盘脚本清单(原子写:先写临时文件再 rename)。
316
+ func (m *scriptManager) saveConfig() {
317
+ p := m.configPath()
318
+ if p == "" {
319
+ return
320
+ }
321
+ cfg := scriptConfig{}
322
+ for _, id := range m.order {
323
+ if def, ok := m.defs[id]; ok {
324
+ cfg.Scripts = append(cfg.Scripts, *def)
325
+ }
326
+ }
327
+ b, err := json.MarshalIndent(cfg, "", " ")
328
+ if err != nil {
329
+ return
330
+ }
331
+ tmp := p + ".tmp"
332
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
333
+ return
334
+ }
335
+ _ = os.Rename(tmp, p)
336
+ }
337
+
338
+ // saveRuns 落盘运行态。
339
+ func (m *scriptManager) saveRuns() {
340
+ p := m.runsPath()
341
+ if p == "" {
342
+ return
343
+ }
344
+ states := map[string]ScriptState{}
345
+ for id, r := range m.runs {
346
+ states[id] = r.state
347
+ }
348
+ b, err := json.MarshalIndent(states, "", " ")
349
+ if err != nil {
350
+ return
351
+ }
352
+ tmp := p + ".tmp"
353
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
354
+ return
355
+ }
356
+ _ = os.Rename(tmp, p)
357
+ }
358
+
359
+ /* ─────────────── 清单操作 ─────────────── */
360
+
361
+ // list 返回清单(定义 + 运行态),按登记顺序。
362
+ func (m *scriptManager) list() []ScriptItem {
363
+ m.mu.Lock()
364
+ defer m.mu.Unlock()
365
+ items := make([]ScriptItem, 0, len(m.order))
366
+ for _, id := range m.order {
367
+ def, ok := m.defs[id]
368
+ if !ok {
369
+ continue
370
+ }
371
+ it := ScriptItem{ScriptDef: *def}
372
+ if r, ok := m.runs[id]; ok {
373
+ it.State = r.state
374
+ } else {
375
+ it.State = ScriptState{State: "stopped", ExitCode: -1}
376
+ }
377
+ items = append(items, it)
378
+ }
379
+ return items
380
+ }
381
+
382
+ // get 拿一个脚本条目。
383
+ func (m *scriptManager) get(id string) (ScriptItem, bool) {
384
+ m.mu.Lock()
385
+ defer m.mu.Unlock()
386
+ def, ok := m.defs[id]
387
+ if !ok {
388
+ return ScriptItem{}, false
389
+ }
390
+ it := ScriptItem{ScriptDef: *def}
391
+ if r, ok := m.runs[id]; ok {
392
+ it.State = r.state
393
+ } else {
394
+ it.State = ScriptState{State: "stopped", ExitCode: -1}
395
+ }
396
+ return it, true
397
+ }
398
+
399
+ // add 登记一个新脚本。
400
+ func (m *scriptManager) add(def ScriptDef) (ScriptItem, error) {
401
+ if err := validateScriptDef(&def); err != nil {
402
+ return ScriptItem{}, err
403
+ }
404
+ m.mu.Lock()
405
+ defer m.mu.Unlock()
406
+ m.seq++
407
+ def.ID = fmt.Sprintf("script-%d-%d", time.Now().UnixNano()/1e6, m.seq)
408
+ if def.Name == "" {
409
+ def.Name = scriptBaseName(def.Path)
410
+ }
411
+ m.defs[def.ID] = &def
412
+ m.order = append(m.order, def.ID)
413
+ m.runs[def.ID] = &scriptRun{state: ScriptState{State: "stopped", ExitCode: -1}}
414
+ m.saveConfig()
415
+ return ScriptItem{ScriptDef: def, State: ScriptState{State: "stopped", ExitCode: -1}}, nil
416
+ }
417
+
418
+ // update 改一个脚本的定义(**不**动运行态)。
419
+ func (m *scriptManager) update(id string, def ScriptDef) (ScriptItem, error) {
420
+ m.mu.Lock()
421
+ defer m.mu.Unlock()
422
+ old, ok := m.defs[id]
423
+ if !ok {
424
+ return ScriptItem{}, os.ErrNotExist
425
+ }
426
+ if err := validateScriptDef(&def); err != nil {
427
+ return ScriptItem{}, err
428
+ }
429
+ def.ID = id
430
+ if def.Name == "" {
431
+ def.Name = old.Name
432
+ }
433
+ m.defs[id] = &def
434
+ m.saveConfig()
435
+ return ScriptItem{ScriptDef: def, State: m.stateLocked(id)}, nil
436
+ }
437
+
438
+ // remove 删一个脚本。运行中不给删 —— 先让用户停掉。
439
+ func (m *scriptManager) remove(id string) error {
440
+ m.mu.Lock()
441
+ defer m.mu.Unlock()
442
+ if _, ok := m.defs[id]; !ok {
443
+ return os.ErrNotExist
444
+ }
445
+ if m.stateLocked(id).State == "running" {
446
+ return errScriptRunning
447
+ }
448
+ delete(m.defs, id)
449
+ delete(m.runs, id)
450
+ for i, x := range m.order {
451
+ if x == id {
452
+ m.order = append(m.order[:i], m.order[i+1:]...)
453
+ break
454
+ }
455
+ }
456
+ m.saveConfig()
457
+ m.saveRuns()
458
+ // 落盘日志也跟着删 —— 界面上写的是「配置与日志都会被删掉」,
459
+ // 留着文件就是话说了不算(用户删脚本是为了清掉不想留的东西)。
460
+ // 删不掉不算失败:脚本已经从清单里没了,日志只是残留文件。
461
+ if p := m.logPath(id); p != "" {
462
+ _ = os.Remove(p)
463
+ }
464
+ return nil
465
+ }
466
+
467
+ // errScriptRunning 运行中不能删/不能重复起。
468
+ var errScriptRunning = errors.New("这个脚本正在运行,先停止它")
469
+
470
+ // stateLocked 取运行态(调用方持锁)。
471
+ func (m *scriptManager) stateLocked(id string) ScriptState {
472
+ if r, ok := m.runs[id]; ok {
473
+ return r.state
474
+ }
475
+ return ScriptState{State: "stopped", ExitCode: -1}
476
+ }
477
+
478
+ /* ─────────────── 校验 ─────────────── */
479
+
480
+ // validateScriptDef 校验一个脚本定义。
481
+ //
482
+ // 关键:**路径必须是文件、扩展名在白名单内**。这两条是「客户端不能变成
483
+ // 任意命令执行器」的落地点。
484
+ func validateScriptDef(def *ScriptDef) error {
485
+ def.Name = strings.TrimSpace(def.Name)
486
+ def.Path = strings.TrimSpace(def.Path)
487
+ def.Cwd = strings.TrimSpace(def.Cwd)
488
+ def.Interpreter = strings.TrimSpace(def.Interpreter)
489
+ def.Note = strings.TrimSpace(def.Note)
490
+
491
+ if def.Name == "" {
492
+ return errors.New("脚本需要一个名字")
493
+ }
494
+ if len([]rune(def.Name)) > scriptMaxNameLen {
495
+ return fmt.Errorf("名字太长了(上限 %d 字)", scriptMaxNameLen)
496
+ }
497
+ if def.Path == "" {
498
+ return errors.New("脚本需要一个文件路径")
499
+ }
500
+ if !filepath.IsAbs(def.Path) {
501
+ return errors.New("脚本路径要用绝对路径")
502
+ }
503
+ ext := strings.ToLower(filepath.Ext(def.Path))
504
+ if !scriptExtOK[ext] {
505
+ return fmt.Errorf("不支持的脚本类型 %q(可用:%s)", ext, scriptExtStrings())
506
+ }
507
+ st, err := os.Stat(def.Path)
508
+ if err != nil {
509
+ return fmt.Errorf("找不到这个脚本文件:%s(%v)", def.Path, err)
510
+ }
511
+ if st.IsDir() {
512
+ return errors.New("这是一个目录,不是脚本文件")
513
+ }
514
+ if len(def.Args) > scriptMaxArgs {
515
+ return fmt.Errorf("参数太多(上限 %d 个)", scriptMaxArgs)
516
+ }
517
+ for i, a := range def.Args {
518
+ if len(a) > scriptMaxArgLen {
519
+ return fmt.Errorf("第 %d 个参数太长(上限 %d 字节)", i+1, scriptMaxArgLen)
520
+ }
521
+ }
522
+ // 解释器:必须存在且是文件(不校验白名单 —— 它是用户显式指定的可执行文件)。
523
+ if def.Interpreter != "" {
524
+ if !filepath.IsAbs(def.Interpreter) {
525
+ return errors.New("解释器路径要用绝对路径")
526
+ }
527
+ ist, err := os.Stat(def.Interpreter)
528
+ if err != nil {
529
+ return fmt.Errorf("找不到这个解释器:%s(%v)", def.Interpreter, err)
530
+ }
531
+ if ist.IsDir() {
532
+ return errors.New("解释器是一个目录,不是可执行文件")
533
+ }
534
+ }
535
+ // 环境变量:逐条 "KEY=VALUE",KEY 要像个环境变量名。
536
+ if len(def.Env) > scriptMaxEnv {
537
+ return fmt.Errorf("环境变量太多(上限 %d 条)", scriptMaxEnv)
538
+ }
539
+ for i, e := range def.Env {
540
+ e = strings.TrimSpace(e)
541
+ if e == "" {
542
+ continue
543
+ }
544
+ k, _, ok := strings.Cut(e, "=")
545
+ if !ok || strings.TrimSpace(k) == "" {
546
+ return fmt.Errorf("第 %d 个环境变量不是 KEY=VALUE 的形式:%q", i+1, e)
547
+ }
548
+ if strings.ContainsAny(k, " \t\r\n") {
549
+ return fmt.Errorf("第 %d 个环境变量的名字里有空格:%q", i+1, e)
550
+ }
551
+ }
552
+ return nil
553
+ }
554
+
555
+ func scriptExtStrings() string {
556
+ exts := make([]string, 0, len(scriptExtOK))
557
+ for e := range scriptExtOK {
558
+ exts = append(exts, e)
559
+ }
560
+ // 固定顺序,别让界面上的列表每次刷新都变。
561
+ sort.Strings(exts)
562
+ return strings.Join(exts, " ")
563
+ }
564
+
565
+ // scriptBaseName 取脚本文件名(显示用)。
566
+ //
567
+ // ⚠️ 同时按 '/' 与 '\\' 切:桌面可能是 Windows,路径是 D:\workspace\x.cmd,
568
+ // 只按 '/' 切会得到整条路径(记忆 project_client_path_handling)。
569
+ func scriptBaseName(p string) string {
570
+ s := strings.ReplaceAll(p, "\\", "/")
571
+ s = strings.TrimRight(s, "/")
572
+ if i := strings.LastIndex(s, "/"); i >= 0 {
573
+ return s[i+1:]
574
+ }
575
+ return s
576
+ }
577
+
578
+ /* ─────────────── 启停 ─────────────── */
579
+
580
+ // start 启动一个脚本。
581
+ //
582
+ // 流程:spawn 一个**独立进程组**的后台进程 → stdout/stderr 合流 → 逐行进
583
+ // 环形缓冲与落盘文件 → 进程退出时收尾状态。
584
+ //
585
+ // 用独立进程组是为了「停止」能整组带走(unix kill(-pid) / Windows taskkill /T),
586
+ // 不留孤儿 worker。
587
+ func (m *scriptManager) start(id string) (ScriptItem, error) {
588
+ m.mu.Lock()
589
+ def, ok := m.defs[id]
590
+ if !ok {
591
+ m.mu.Unlock()
592
+ return ScriptItem{}, os.ErrNotExist
593
+ }
594
+ if m.stateLocked(id).State == "running" {
595
+ m.mu.Unlock()
596
+ return ScriptItem{}, errScriptRunning
597
+ }
598
+ /* 「正在起」也占坑:否则两次并发的 start 会**都**通过上面的检查、
599
+ 都 spawn 一个进程 —— 界面上一个脚本跑着两份,日志也串在一起。
600
+ 标记在 spawn **之前**落,失败时撤销。 */
601
+ if m.starting[id] {
602
+ m.mu.Unlock()
603
+ return ScriptItem{}, errScriptRunning
604
+ }
605
+ if m.starting == nil {
606
+ m.starting = map[string]bool{}
607
+ }
608
+ m.starting[id] = true
609
+ // 复制一份定义出来用:解锁后 m.defs 可能被并发改动。
610
+ cp := *def
611
+ cp.Args = append([]string(nil), def.Args...)
612
+ cp.Env = append([]string(nil), def.Env...)
613
+ m.mu.Unlock()
614
+
615
+ defer func() {
616
+ m.mu.Lock()
617
+ delete(m.starting, id)
618
+ m.mu.Unlock()
619
+ }()
620
+
621
+ // 两条路:
622
+ // · 有解释器 → `<解释器> <脚本路径> [参数…]`(Python/Node 脚本走这条;
623
+ // 白名单里没有 .exe,Windows 上 .py 不能直接 exec)。
624
+ // · 没解释器 → 直接跑脚本文件(.cmd/.bat/.ps1 在 Windows 上要包
625
+ // PowerShell,否则参数会被 cmd.exe 拆坏)。
626
+ bin, args := cp.Path, cp.Args
627
+ if cp.Interpreter != "" {
628
+ bin = cp.Interpreter
629
+ args = append([]string{cp.Path}, cp.Args...)
630
+ } else {
631
+ bin, args = agent.WrapLaunch(cp.Path, cp.Args)
632
+ }
633
+
634
+ cwd := cp.Cwd
635
+ if cwd == "" {
636
+ cwd = filepath.Dir(cp.Path)
637
+ }
638
+ ctx, cancel := context.WithCancel(context.Background())
639
+ cmd := exec.CommandContext(ctx, bin, args...)
640
+ cmd.Dir = cwd
641
+ // 进程组:停止时整组杀。
642
+ agent.ConfigureProcGroup(cmd)
643
+ // 不分配控制台窗口:后台服务本身没有控制台,子进程每起一次都要新建
644
+ // conhost —— 实测多花 3.1 秒(见 internal/client/corecmd.go)。
645
+ noConsole(cmd)
646
+ // 环境:继承但剔除父会话专属项(SERVER__* / CODEBUDDY_* / CLAUDE_* 等),
647
+ // 再叠加脚本自己要的(PYTHONUNBUFFERED=1 之类)。
648
+ cmd.Env = agent.ChildEnvWith(cp.Env)
649
+
650
+ pr, pw, err := os.Pipe()
651
+ if err != nil {
652
+ cancel()
653
+ return ScriptItem{}, fmt.Errorf("建日志管道失败:%w", err)
654
+ }
655
+ cmd.Stdout = pw
656
+ cmd.Stderr = pw
657
+ if err := cmd.Start(); err != nil {
658
+ pw.Close()
659
+ pr.Close()
660
+ cancel()
661
+ return ScriptItem{}, fmt.Errorf("启动 %s 失败:%w", cp.Path, err)
662
+ }
663
+ pw.Close()
664
+ pid := cmd.Process.Pid
665
+
666
+ m.mu.Lock()
667
+ r, ok := m.runs[id]
668
+ if !ok {
669
+ m.mu.Unlock()
670
+ cancel()
671
+ _ = cmd.Process.Kill()
672
+ pr.Close()
673
+ return ScriptItem{}, os.ErrNotExist
674
+ }
675
+ r.cmd = cmd
676
+ r.cancel = cancel
677
+ r.stopping = false
678
+ /* 日志序号**跨重启单调递增**(不清零)。
679
+ 客户端拿着 after=<seq> 断线重连 —— 若序号清零,新行的 seq 会比上次
680
+ 拿到的 after 还小,客户端按 seq > after 过滤就会**一行都收不到**。
681
+ 同理不清空 lines:重启后往回翻还能看到上一轮的输出。 */
682
+ r.state = ScriptState{
683
+ State: "running",
684
+ PID: pid,
685
+ ExitCode: -1,
686
+ StartedAt: time.Now().UnixMilli(),
687
+ LogSeq: r.nextSeq,
688
+ }
689
+ m.saveRuns()
690
+ m.mu.Unlock()
691
+
692
+ // 逐行读日志(后台 goroutine,与 HTTP 请求无关 —— 脚本要能一直跑)。
693
+ go m.drain(id, pr, cmd)
694
+
695
+ item, _ := m.get(id)
696
+ return item, nil
697
+ }
698
+
699
+ // drain 逐行读脚本输出,喂给环形缓冲与落盘文件;进程退出时收尾。
700
+ func (m *scriptManager) drain(id string, pr *os.File, cmd *exec.Cmd) {
701
+ defer pr.Close()
702
+
703
+ // 落盘文件:完整副本(环形缓冲只留最近几百行)。
704
+ //
705
+ // ⚠️ **追加**而不是覆盖:多次运行同一个脚本时,历史输出连成一份,
706
+ // 用户翻日志能看到「上次跑到哪」。覆盖的话第二次运行就把上次的抹了。
707
+ var logFile *os.File
708
+ if p := m.logPath(id); p != "" {
709
+ if f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
710
+ logFile = f
711
+ defer logFile.Close()
712
+ }
713
+ }
714
+
715
+ sc := bufio.NewScanner(pr)
716
+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
717
+ for sc.Scan() {
718
+ /* ⚠️ 先按**原始字节**解,再入缓冲。
719
+ Windows 上脚本输出的是控制台代码页字节(中文系统 = GBK),
720
+ 直接当 UTF-8 解会得到一堆乱码(见 scripts_decode_windows.go)。 */
721
+ line := decodeScriptBytes(sc.Bytes())
722
+ if logFile != nil {
723
+ fmt.Fprintln(logFile, line)
724
+ }
725
+ m.appendLine(id, line)
726
+ }
727
+ if err := sc.Err(); err != nil {
728
+ m.appendLine(id, "⚠ 读日志出错:"+err.Error())
729
+ }
730
+
731
+ // 等进程真正退出,拿退出码。
732
+ //
733
+ // ⚠️ Wait 只能调一次 —— stop() 也会想等它退出,两处都 Wait 会 panic
734
+ //("exec: Wait was already called")。所以由**这里**独占地 Wait,
735
+ // stop() 只负责杀进程,然后等本 goroutine 通过 done 通知收尾。
736
+ err := cmd.Wait()
737
+ code := 0
738
+ if cmd.ProcessState != nil {
739
+ code = cmd.ProcessState.ExitCode()
740
+ }
741
+ if err != nil && code == 0 {
742
+ code = 1
743
+ }
744
+
745
+ m.mu.Lock()
746
+ r, ok := m.runs[id]
747
+ if !ok {
748
+ m.mu.Unlock()
749
+ return
750
+ }
751
+ // ⚠️ 只有**当前这次运行**退出才收尾 —— 用户可能已经停了它又重起了,
752
+ // 那时旧 goroutine 的 Wait 返回不该把新一次运行标成结束。
753
+ if r.cmd == cmd {
754
+ r.cmd = nil
755
+ r.cancel = nil
756
+ if r.state.State == "running" {
757
+ switch {
758
+ case r.stopping:
759
+ // 用户主动停的 → 不是失败。
760
+ r.state.State = "stopped"
761
+ r.state.ExitCode = -1
762
+ case code != 0:
763
+ r.state.State = "failed"
764
+ r.state.ExitCode = code
765
+ default:
766
+ r.state.State = "done"
767
+ r.state.ExitCode = code
768
+ }
769
+ r.state.EndedAt = time.Now().UnixMilli()
770
+ }
771
+ r.stopping = false
772
+ }
773
+ m.saveRuns()
774
+ m.mu.Unlock()
775
+ }
776
+
777
+ // appendLine 追加一行日志(进环形缓冲 + 更新游标)。
778
+ func (m *scriptManager) appendLine(id, text string) {
779
+ m.mu.Lock()
780
+ defer m.mu.Unlock()
781
+ r, ok := m.runs[id]
782
+ if !ok {
783
+ return
784
+ }
785
+ r.nextSeq++
786
+ r.lines = append(r.lines, scriptLine{Seq: r.nextSeq, Text: text})
787
+ if len(r.lines) > scriptLogRing {
788
+ r.lines = r.lines[len(r.lines)-scriptLogRing:]
789
+ }
790
+ r.state.LogSeq = r.nextSeq
791
+ }
792
+
793
+ // stop 停止一个脚本(杀整个进程组)。
794
+ func (m *scriptManager) stop(id string) (ScriptItem, error) {
795
+ m.mu.Lock()
796
+ r, ok := m.runs[id]
797
+ if !ok {
798
+ m.mu.Unlock()
799
+ return ScriptItem{}, os.ErrNotExist
800
+ }
801
+ if r.state.State != "running" || r.cmd == nil {
802
+ m.mu.Unlock()
803
+ item, _ := m.get(id) // 没在跑 → 不算错误(幂等)
804
+ return item, nil
805
+ }
806
+ pid := r.state.PID
807
+ cancel := r.cancel
808
+ // 标记「用户主动停的」—— drain 收尾时据此把状态记成 stopped 而不是 failed。
809
+ r.stopping = true
810
+ m.mu.Unlock()
811
+
812
+ // 杀整组:unix 上 kill(-pid, SIGKILL) 带走全部后代,Windows 上
813
+ // taskkill /T /F 带走整棵进程树。脚本 spawn 出来的 worker 不会变孤儿。
814
+ if err := agent.KillPID(pid, true); err != nil {
815
+ m.appendLine(id, "⚠ 停止失败:"+err.Error())
816
+ }
817
+ if cancel != nil {
818
+ cancel()
819
+ }
820
+ /* 不在这里 Wait —— drain goroutine 才是这次运行的唯一 Wait 者
821
+ (两处都 Wait 会 panic:"exec: Wait was already called")。
822
+ 等一小会儿让状态落定即可:杀完之后 drain 会很快收尾。
823
+ 状态落定前先返回也没关系 —— drain 会自己把 state 改掉。 */
824
+ for i := 0; i < 40; i++ {
825
+ item, _ := m.get(id)
826
+ if item.State.State != "running" {
827
+ return item, nil
828
+ }
829
+ time.Sleep(25 * time.Millisecond)
830
+ }
831
+ item, _ := m.get(id)
832
+ return item, nil
833
+ }
834
+
835
+ /* ─────────────── 日志读取 ─────────────── */
836
+
837
+ // logLines 取 after 之后的日志行。
838
+ //
839
+ // 客户端断线重连时带着 after=<seq> 回来 → 只补新行,不重发旧的。
840
+ func (m *scriptManager) logLines(id string, after int) ([]scriptLine, ScriptState, bool) {
841
+ m.mu.Lock()
842
+ defer m.mu.Unlock()
843
+ if _, ok := m.defs[id]; !ok {
844
+ return nil, ScriptState{}, false
845
+ }
846
+ r, ok := m.runs[id]
847
+ if !ok {
848
+ return nil, ScriptState{State: "stopped", ExitCode: -1}, true
849
+ }
850
+ out := make([]scriptLine, 0, len(r.lines))
851
+ for _, l := range r.lines {
852
+ if l.Seq > after {
853
+ out = append(out, l)
854
+ }
855
+ }
856
+ return out, r.state, true
857
+ }
858
+
859
+ /* ─────────────── HTTP ─────────────── */
860
+
861
+ // handleScripts GET /desk/scripts(清单)/ POST /desk/scripts(新增)。
862
+ func (s *Service) handleScripts(w http.ResponseWriter, r *http.Request) {
863
+ switch r.Method {
864
+ case http.MethodGet:
865
+ writeJSON(w, http.StatusOK, map[string]any{"scripts": s.scriptMgr().list()})
866
+
867
+ case http.MethodPost:
868
+ var def ScriptDef
869
+ if err := decodeJSONBody(r, &def); err != nil {
870
+ writeErr(w, http.StatusBadRequest, err.Error())
871
+ return
872
+ }
873
+ item, err := s.scriptMgr().add(def)
874
+ if err != nil {
875
+ writeErr(w, http.StatusBadRequest, err.Error())
876
+ return
877
+ }
878
+ writeJSON(w, http.StatusOK, item)
879
+
880
+ default:
881
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 GET / POST")
882
+ }
883
+ }
884
+
885
+ // handleScriptSub /desk/scripts/{id}[/{action}]。
886
+ //
887
+ // 子动作:PUT(改定义)/ DELETE(删)/ start / stop / log。
888
+ func (s *Service) handleScriptSub(w http.ResponseWriter, r *http.Request) {
889
+ rest := strings.TrimPrefix(r.URL.Path, "/desk/scripts/")
890
+ parts := strings.Split(strings.Trim(rest, "/"), "/")
891
+ if len(parts) == 0 || parts[0] == "" {
892
+ writeErr(w, http.StatusNotFound, "用法:/desk/scripts/{id}[/{start|stop|log}]")
893
+ return
894
+ }
895
+ id := parts[0]
896
+ action := ""
897
+ if len(parts) > 1 {
898
+ action = parts[1]
899
+ }
900
+
901
+ switch action {
902
+ case "":
903
+ switch r.Method {
904
+ case http.MethodPut:
905
+ var def ScriptDef
906
+ if err := decodeJSONBody(r, &def); err != nil {
907
+ writeErr(w, http.StatusBadRequest, err.Error())
908
+ return
909
+ }
910
+ item, err := s.scriptMgr().update(id, def)
911
+ if err != nil {
912
+ writeScriptErr(w, err)
913
+ return
914
+ }
915
+ writeJSON(w, http.StatusOK, item)
916
+ case http.MethodDelete:
917
+ if err := s.scriptMgr().remove(id); err != nil {
918
+ writeScriptErr(w, err)
919
+ return
920
+ }
921
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
922
+ default:
923
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 PUT / DELETE")
924
+ }
925
+
926
+ case "start":
927
+ if r.Method != http.MethodPost {
928
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
929
+ return
930
+ }
931
+ item, err := s.scriptMgr().start(id)
932
+ if err != nil {
933
+ writeScriptErr(w, err)
934
+ return
935
+ }
936
+ writeJSON(w, http.StatusOK, item)
937
+
938
+ case "stop":
939
+ if r.Method != http.MethodPost {
940
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
941
+ return
942
+ }
943
+ item, err := s.scriptMgr().stop(id)
944
+ if err != nil {
945
+ writeScriptErr(w, err)
946
+ return
947
+ }
948
+ writeJSON(w, http.StatusOK, item)
949
+
950
+ case "log":
951
+ if r.Method != http.MethodGet {
952
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
953
+ return
954
+ }
955
+ s.handleScriptLog(w, r, id)
956
+
957
+ default:
958
+ writeErr(w, http.StatusNotFound, "动作只有 start / stop / log")
959
+ }
960
+ }
961
+
962
+ // handleScriptLog GET /desk/scripts/{id}/log?after=<seq> → SSE。
963
+ //
964
+ // 事件:event: line({seq,text})逐行 / event: state({state,exitCode})收尾。
965
+ // after=<seq> 只补之后的行(断线重连用)。
966
+ func (s *Service) handleScriptLog(w http.ResponseWriter, r *http.Request, id string) {
967
+ after := 0
968
+ if v := strings.TrimSpace(r.URL.Query().Get("after")); v != "" {
969
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
970
+ after = n
971
+ }
972
+ }
973
+
974
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
975
+ w.Header().Set("Cache-Control", "no-cache")
976
+ w.Header().Set("X-Accel-Buffering", "no")
977
+ flusher, _ := w.(http.Flusher)
978
+ send := func(event string, payload any) {
979
+ b, _ := json.Marshal(payload)
980
+ if event != "" {
981
+ fmt.Fprintf(w, "event: %s\n", event)
982
+ }
983
+ fmt.Fprintf(w, "data: %s\n\n", b)
984
+ if flusher != nil {
985
+ flusher.Flush()
986
+ }
987
+ }
988
+
989
+ lines, state, ok := s.scriptMgr().logLines(id, after)
990
+ if !ok {
991
+ writeErr(w, http.StatusNotFound, "没有这个脚本:"+id)
992
+ return
993
+ }
994
+ for _, l := range lines {
995
+ send("line", l)
996
+ }
997
+ send("state", state)
998
+
999
+ // 脚本还在跑 → 挂着等新行;跑完了 → 直接收尾(客户端拿到 state 就知道结束了)。
1000
+ // 断连不是「停止脚本」:脚本归用户管,不归这条连接管。
1001
+ for {
1002
+ select {
1003
+ case <-r.Context().Done():
1004
+ return
1005
+ case <-time.After(700 * time.Millisecond):
1006
+ }
1007
+ lines, state, ok := s.scriptMgr().logLines(id, after)
1008
+ if !ok {
1009
+ return
1010
+ }
1011
+ for _, l := range lines {
1012
+ send("line", l)
1013
+ after = l.Seq
1014
+ }
1015
+ if state.State != "running" {
1016
+ send("state", state)
1017
+ return
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ // writeScriptErr 把内部错误映射成合适的 HTTP 码。
1023
+ func writeScriptErr(w http.ResponseWriter, err error) {
1024
+ switch {
1025
+ case errors.Is(err, os.ErrNotExist):
1026
+ writeErr(w, http.StatusNotFound, err.Error())
1027
+ case errors.Is(err, errScriptRunning):
1028
+ writeErr(w, http.StatusConflict, err.Error())
1029
+ default:
1030
+ writeErr(w, http.StatusBadRequest, err.Error())
1031
+ }
1032
+ }
1033
+
1034
+ // decodeJSONBody 读一个 JSON 请求体(上限 64KB)。
1035
+ func decodeJSONBody(r *http.Request, v any) error {
1036
+ b, err := io.ReadAll(io.LimitReader(r.Body, 1<<16))
1037
+ if err != nil {
1038
+ return fmt.Errorf("读请求体失败:%w", err)
1039
+ }
1040
+ if len(b) == 0 {
1041
+ return errors.New("请求体是空的")
1042
+ }
1043
+ if err := json.Unmarshal(b, v); err != nil {
1044
+ return fmt.Errorf("不是合法的 JSON:%w", err)
1045
+ }
1046
+ return nil
1047
+ }