agent-gear 2.1.0 → 2.2.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.
@@ -0,0 +1,357 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+
4
+ /**
5
+ * 单条脚本的尾部输出上限(256 KiB),与 cloud-controller 的 `WORKLOAD_LOG_MAX_BYTES` 对齐。
6
+ */
7
+ export const MAX_OUTPUT_BYTES = 256 * 1024;
8
+
9
+ /** 未确认执行结果在内存中的保留上限(重连重放用)。 */
10
+ export const MAX_PENDING_RESULTS = 50;
11
+
12
+ /** 空 script(含纯空白)的失败文案。 */
13
+ export const EMPTY_SCRIPT_MESSAGE = '工作负载脚本为空,跳过执行';
14
+
15
+ export interface WorkloadItem {
16
+ /** 服务端预创建的执行记录 ID,回报时原样回填(防串号)。 */
17
+ logId: string;
18
+ /** 工作负载名称,仅用于日志/展示。 */
19
+ workloadName: string;
20
+ /** 待执行脚本内容;空字符串表示该条为已删除工作负载的占位记录。 */
21
+ script: string;
22
+ /** 单条脚本超时(毫秒)。 */
23
+ timeoutMs: number;
24
+ }
25
+
26
+ export interface WorkloadRunPayload {
27
+ /** `init`:回连注册后的初始化批次;`post`:销毁流程中的清理批次。 */
28
+ phase: 'init' | 'post';
29
+ /** 按执行顺序排列的脚本条目。 */
30
+ items: WorkloadItem[];
31
+ /** `post` 批次共享总预算(毫秒);`init` 不携带。 */
32
+ budgetMs?: number;
33
+ }
34
+
35
+ export type WorkloadStatus = 'success' | 'failed' | 'timeout';
36
+
37
+ export interface WorkloadResult {
38
+ logId: string;
39
+ status: WorkloadStatus;
40
+ /** 进程退出码;`timeout` 时为 `null`。 */
41
+ exitCode: number | null;
42
+ /** 合并捕获的 stdout+stderr(尾部 ≤ 256 KiB)。 */
43
+ log: string;
44
+ /** 客户端记录的开始时间(ISO;服务端时间戳为准)。 */
45
+ startedAt: string;
46
+ /** 客户端记录的结束时间(ISO)。 */
47
+ finishedAt: string;
48
+ }
49
+
50
+ export type WorkloadResultSink = (result: WorkloadResult) => void;
51
+
52
+ interface ActiveRun {
53
+ cancelled: boolean;
54
+ child: ChildProcess | null;
55
+ }
56
+
57
+ interface ItemOutcome {
58
+ cancelled: boolean;
59
+ result?: WorkloadResult;
60
+ }
61
+
62
+ /**
63
+ * 单条脚本输出的尾部环形截断缓冲:始终保留最近 `limit` 字节。
64
+ *
65
+ * 大输出脚本(如构建日志、循环 echo)若不截断会打爆内存与 Mongo,
66
+ * 契约要求保留尾部 ≤ 256 KiB(尾部通常包含真正的错误信息)。
67
+ */
68
+ class TailBuffer {
69
+ private chunks: Buffer[] = [];
70
+ private size = 0;
71
+
72
+ constructor(private readonly limit: number) {}
73
+
74
+ push(chunk: Buffer): void {
75
+ if (chunk.length === 0) return;
76
+ this.chunks.push(chunk);
77
+ this.size += chunk.length;
78
+ while (this.size > this.limit && this.chunks.length > 0) {
79
+ const first = this.chunks[0];
80
+ const excess = this.size - this.limit;
81
+ if (first.length <= excess) {
82
+ this.chunks.shift();
83
+ this.size -= first.length;
84
+ } else {
85
+ this.chunks[0] = first.subarray(excess);
86
+ this.size -= excess;
87
+ }
88
+ }
89
+ }
90
+
91
+ toString(): string {
92
+ const buffer = Buffer.concat(this.chunks);
93
+ // 截断保留尾部时,起始处可能落在多字节 UTF-8 字符的续字节上(0b10xxxxxx),
94
+ // 直接解码会在日志首字符产生乱码(U+FFFD);跳过续字节到字符边界再解码
95
+ // (与服务端 `truncateLogTail` 同一口径)。
96
+ let start = 0;
97
+ while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1;
98
+ return buffer.subarray(start).toString('utf8');
99
+ }
100
+ }
101
+
102
+ function nowIso(): string {
103
+ return new Date().toISOString();
104
+ }
105
+
106
+ /**
107
+ * 工作负载脚本执行器(agent-gear 侧)。
108
+ *
109
+ * - 按 `items` 顺序**串行**执行 `bash -c <script>`(`cwd=os.homedir()`,继承进程环境,stdout/stderr 合并捕获);
110
+ * - 单条超时或收到新批次时终止整个进程组(`detached: true` + `kill(-pid)`);
111
+ * - 非零退出回报 `failed` + `exitCode`,不影响后续条目;
112
+ * - `post` 批次共享 `budgetMs`:剩余预算耗尽时后续条目直接回报 `timeout`(不启动进程);
113
+ * - 未被服务端确认的结果保存在内存(上限 50 条)供重连重放。
114
+ *
115
+ * 进程内全局单实例,保证同一时间只跑一个批次(后到的批次打断前一个,用于 post 打断 init)。
116
+ */
117
+ export class WorkloadExecutor {
118
+ private active: ActiveRun | null = null;
119
+ private activePromise: Promise<void> | null = null;
120
+ private pendingResults: WorkloadResult[] = [];
121
+
122
+ /** 当前是否有批次正在执行。 */
123
+ isRunning(): boolean {
124
+ return this.active !== null;
125
+ }
126
+
127
+ /** 未确认的执行结果快照(供重连重放读取)。 */
128
+ getPendingResults(): WorkloadResult[] {
129
+ return [...this.pendingResults];
130
+ }
131
+
132
+ /** 清空未确认的执行结果。 */
133
+ clearPendingResults(): void {
134
+ this.pendingResults = [];
135
+ }
136
+
137
+ /**
138
+ * 重连后重放未确认结果:逐条 emit 后清空缓冲。
139
+ * 服务端对已进入终态的记录按幂等忽略(契约 §2.9/§2.10)。
140
+ */
141
+ replayPendingResults(emit: WorkloadResultSink): void {
142
+ const pending = this.getPendingResults();
143
+ this.clearPendingResults();
144
+ for (const result of pending) {
145
+ emit(result);
146
+ }
147
+ }
148
+
149
+ /** 等待当前批次结束;无批次时立即返回,超时也返回(不阻塞退出流程)。 */
150
+ async waitForIdle(timeoutMs: number): Promise<void> {
151
+ const promise = this.activePromise;
152
+ if (!this.active || !promise) return;
153
+
154
+ let timer: ReturnType<typeof setTimeout> | undefined;
155
+ const timeout = new Promise<void>((resolve) => {
156
+ timer = setTimeout(resolve, timeoutMs);
157
+ });
158
+ try {
159
+ await Promise.race([promise.then(() => undefined, () => undefined), timeout]);
160
+ } finally {
161
+ if (timer) clearTimeout(timer);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 执行一个批次。若已有批次在跑,先终止其进程组并停止推进,再执行新批次。
167
+ */
168
+ runBatch(payload: WorkloadRunPayload, onResult: WorkloadResultSink): Promise<void> {
169
+ this.interruptActive();
170
+
171
+ const run: ActiveRun = { cancelled: false, child: null };
172
+ this.active = run;
173
+ const promise = this.executeBatch(payload, onResult, run).finally(() => {
174
+ // 仅当自己仍是当前批次时才清空(被打断时 active 已指向新批次)
175
+ if (this.active === run) {
176
+ this.active = null;
177
+ this.activePromise = null;
178
+ }
179
+ });
180
+ this.activePromise = promise;
181
+ return promise;
182
+ }
183
+
184
+ private async executeBatch(
185
+ payload: WorkloadRunPayload,
186
+ onResult: WorkloadResultSink,
187
+ run: ActiveRun,
188
+ ): Promise<void> {
189
+ const items = Array.isArray(payload?.items) ? payload.items : [];
190
+ if (items.length === 0) return;
191
+
192
+ const budgetMs = typeof payload.budgetMs === 'number' && payload.budgetMs > 0 ? payload.budgetMs : null;
193
+ const batchStart = Date.now();
194
+
195
+ for (const item of items) {
196
+ if (run.cancelled) return;
197
+
198
+ let effectiveTimeoutMs = item.timeoutMs;
199
+ if (budgetMs !== null) {
200
+ const remaining = budgetMs - (Date.now() - batchStart);
201
+ if (remaining <= 0) {
202
+ // 预算耗尽:后续条目不再启动进程,直接回报 timeout
203
+ this.report(onResult, this.emptyResult(item.logId, 'timeout', ''));
204
+ continue;
205
+ }
206
+ effectiveTimeoutMs = Math.min(item.timeoutMs, remaining);
207
+ }
208
+
209
+ if (!item.script || item.script.trim().length === 0) {
210
+ this.report(onResult, this.emptyResult(item.logId, 'failed', EMPTY_SCRIPT_MESSAGE));
211
+ continue;
212
+ }
213
+
214
+ const outcome = await this.executeItem(item, effectiveTimeoutMs, run);
215
+ if (outcome.cancelled) return;
216
+ if (outcome.result) this.report(onResult, outcome.result);
217
+ }
218
+ }
219
+
220
+ private report(onResult: WorkloadResultSink, result: WorkloadResult): void {
221
+ this.pendingResults.push(result);
222
+ while (this.pendingResults.length > MAX_PENDING_RESULTS) {
223
+ this.pendingResults.shift();
224
+ }
225
+ onResult(result);
226
+ }
227
+
228
+ private emptyResult(logId: string, status: WorkloadStatus, log: string): WorkloadResult {
229
+ const now = nowIso();
230
+ return { logId, status, exitCode: null, log, startedAt: now, finishedAt: now };
231
+ }
232
+
233
+ private executeItem(item: WorkloadItem, timeoutMs: number, run: ActiveRun): Promise<ItemOutcome> {
234
+ return new Promise<ItemOutcome>((resolve) => {
235
+ if (run.cancelled) {
236
+ resolve({ cancelled: true });
237
+ return;
238
+ }
239
+
240
+ const startedAt = nowIso();
241
+ let child: ChildProcess;
242
+ try {
243
+ child = spawn('bash', ['-c', item.script], {
244
+ cwd: homedir(),
245
+ detached: true,
246
+ env: process.env,
247
+ });
248
+ } catch (error) {
249
+ resolve({
250
+ cancelled: false,
251
+ result: {
252
+ logId: item.logId,
253
+ status: 'failed',
254
+ exitCode: null,
255
+ log: `启动脚本失败: ${(error as Error).message}`,
256
+ startedAt,
257
+ finishedAt: nowIso(),
258
+ },
259
+ });
260
+ return;
261
+ }
262
+
263
+ run.child = child;
264
+ const tail = new TailBuffer(MAX_OUTPUT_BYTES);
265
+ let settled = false;
266
+ let timedOut = false;
267
+
268
+ const finish = (outcome: ItemOutcome): void => {
269
+ if (settled) return;
270
+ settled = true;
271
+ clearTimeout(timer);
272
+ if (run.child === child) run.child = null;
273
+ resolve(outcome);
274
+ };
275
+
276
+ const timer = setTimeout(() => {
277
+ timedOut = true;
278
+ this.killGroup(child);
279
+ }, Math.max(1, timeoutMs));
280
+
281
+ child.stdout?.on('data', (chunk: Buffer) => tail.push(chunk));
282
+ child.stderr?.on('data', (chunk: Buffer) => tail.push(chunk));
283
+
284
+ child.on('error', (error: Error) => {
285
+ tail.push(Buffer.from(`\n[执行脚本失败] ${error.message}`));
286
+ if (run.cancelled) {
287
+ finish({ cancelled: true });
288
+ return;
289
+ }
290
+ finish({
291
+ cancelled: false,
292
+ result: {
293
+ logId: item.logId,
294
+ status: 'failed',
295
+ exitCode: null,
296
+ log: tail.toString(),
297
+ startedAt,
298
+ finishedAt: nowIso(),
299
+ },
300
+ });
301
+ });
302
+
303
+ child.on('close', (code, signal) => {
304
+ if (run.cancelled) {
305
+ finish({ cancelled: true });
306
+ return;
307
+ }
308
+ if (timedOut) {
309
+ finish({
310
+ cancelled: false,
311
+ result: {
312
+ logId: item.logId,
313
+ status: 'timeout',
314
+ exitCode: null,
315
+ log: tail.toString(),
316
+ startedAt,
317
+ finishedAt: nowIso(),
318
+ },
319
+ });
320
+ return;
321
+ }
322
+ const exitCode = code ?? (signal ? 1 : 0);
323
+ finish({
324
+ cancelled: false,
325
+ result: {
326
+ logId: item.logId,
327
+ status: exitCode === 0 ? 'success' : 'failed',
328
+ exitCode,
329
+ log: tail.toString(),
330
+ startedAt,
331
+ finishedAt: nowIso(),
332
+ },
333
+ });
334
+ });
335
+ });
336
+ }
337
+
338
+ private interruptActive(): void {
339
+ const run = this.active;
340
+ if (!run) return;
341
+ run.cancelled = true;
342
+ this.killGroup(run.child);
343
+ }
344
+
345
+ private killGroup(child: ChildProcess | null): void {
346
+ if (!child || child.pid == null) return;
347
+ try {
348
+ process.kill(-child.pid, 'SIGKILL');
349
+ } catch {
350
+ try {
351
+ child.kill('SIGKILL');
352
+ } catch {
353
+ /* process already exited */
354
+ }
355
+ }
356
+ }
357
+ }
@@ -229,7 +229,7 @@ describe('SocketIO Client', () => {
229
229
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
230
230
  key: 'zag_k_1_abc123',
231
231
  hostname: 'my-machine',
232
- version: '2.1.0',
232
+ version: '2.2.0',
233
233
  resumeToken: undefined,
234
234
  homeDir: '/home/user',
235
235
  platform: 'linux',
@@ -247,7 +247,7 @@ describe('SocketIO Client', () => {
247
247
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
248
248
  key: 'zag_k_1_abc123',
249
249
  hostname: 'test-machine',
250
- version: '2.1.0',
250
+ version: '2.2.0',
251
251
  resumeToken: undefined,
252
252
  homeDir: '/home/user',
253
253
  platform: 'linux',