@akairos/pipeline 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -7,10 +7,13 @@ import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
7
7
  describe("pipeline daily logger", () => {
8
8
  const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
9
9
  const previousMonitor = process.env.ZZHUB_PIPELINE_MONITOR;
10
+ const previousBudget = process.env.ZZHUB_PIPELINE_WORKER_BUDGET;
10
11
  beforeEach(() => { process.env.ZZHUB_PIPELINE_MONITOR = "0"; });
11
12
  let tempDir: string | null = null;
12
13
 
13
14
  afterEach(() => {
15
+ if (previousBudget === undefined) delete process.env.ZZHUB_PIPELINE_WORKER_BUDGET;
16
+ else process.env.ZZHUB_PIPELINE_WORKER_BUDGET = previousBudget;
14
17
  if (previousMonitor === undefined) delete process.env.ZZHUB_PIPELINE_MONITOR;
15
18
  else process.env.ZZHUB_PIPELINE_MONITOR = previousMonitor;
16
19
  if (previousLogDir === undefined) {
@@ -80,4 +83,16 @@ describe("pipeline daily logger", () => {
80
83
  expect(body).toContain("Error: boom-upload");
81
84
  expect(body).toContain("boom-upload");
82
85
  });
86
+
87
+ test("trusted Worker truncates diagnostic lines without changing the original exception", async () => {
88
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
89
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
90
+ process.env.ZZHUB_PIPELINE_WORKER_BUDGET = "1";
91
+ const message = `original-error-${"x".repeat(20_000)}`;
92
+ await expect(runWithCommandLog("publish", [], async () => { throw new Error(message); })).rejects.toThrow(message);
93
+ const body = readFileSync(getDailyLogPath(), "utf8");
94
+ expect(body).toContain("[truncated]");
95
+ expect(body).toContain("=== FAIL command=publish");
96
+ expect(Buffer.byteLength(body)).toBeLessThan(6000);
97
+ });
83
98
  });
package/src/logger.ts CHANGED
@@ -14,6 +14,7 @@ import { homedir, platform } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { outcomeExitCode, type CommandOutcome } from "./command-outcome";
16
16
  import { observeCommand, reportLog } from "./monitor/recorder";
17
+ import { LOG_LIMIT, redact } from "./monitor/runtime";
17
18
 
18
19
  const APP_DIR = "zzhub-pipeline";
19
20
  const LOG_DIR_NAME = "logs";
@@ -158,7 +159,20 @@ export async function runWithCommandLog(
158
159
  const startedAt = new Date();
159
160
  const startedMs = Date.now();
160
161
  const logPath = getDailyLogPath(startedAt);
161
- const write = (message: string) => {
162
+ let logBytes = 0;
163
+ let truncated = false;
164
+ const write = (message: string, terminal = false) => {
165
+ if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1") {
166
+ if (truncated && !terminal) return;
167
+ const oversized = message.length > 4096;
168
+ message = redact(message);
169
+ if (oversized) message = `${message} [truncated]`;
170
+ if (logBytes + Buffer.byteLength(message) > LOG_LIMIT && !terminal) {
171
+ truncated = true;
172
+ message = "[truncated] Worker diagnostic log limit reached; command outcome remains available.";
173
+ }
174
+ logBytes += Buffer.byteLength(message) + 30;
175
+ }
162
176
  appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
163
177
  };
164
178
 
@@ -177,12 +191,14 @@ export async function runWithCommandLog(
177
191
  outcome = { status: "failed", errors: [{ code: "PROCESS_EXIT", message: `进程退出:${process.exitCode}` }] };
178
192
  }
179
193
  const label = outcomeExitCode(outcome) ? "FAIL" : outcome.status === "success" ? "OK" : outcome.status.toUpperCase();
180
- write(`=== ${label} command=${command} duration_ms=${Date.now() - startedMs} ===`);
181
- for (const error of outcome.errors ?? []) write(`${error.code}: ${error.message}${error.cause ? `\n${error.cause}` : ""}`);
194
+ write(`=== ${label} command=${command} duration_ms=${Date.now() - startedMs} ===`, true);
195
+ const errors = process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1" ? outcome.errors?.slice(0, 3) : outcome.errors;
196
+ for (const error of errors ?? []) write(`${error.code}: ${error.message}${error.cause ? `\n${error.cause}` : ""}`, true);
197
+ if (errors && outcome.errors!.length > errors.length) write("[truncated] Additional errors remain in the command result.", true);
182
198
  return outcome;
183
199
  } catch (err) {
184
- write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`);
185
- write(formatError(err));
200
+ write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`, true);
201
+ write(formatError(err), true);
186
202
  throw err;
187
203
  } finally {
188
204
  restoreConsole();
@@ -19,6 +19,8 @@ interface Recording {
19
19
  progressAccount?: string;
20
20
  ended?: boolean;
21
21
  tasks: Set<string>;
22
+ eventBytes: number;
23
+ eventTruncated: boolean;
22
24
  }
23
25
 
24
26
  /** 调用上下文自动穿过内置异步流程,不要求业务函数传监控对象。 */
@@ -29,8 +31,25 @@ function emit(type: MonitorEvent["type"], data: Record<string, unknown>): void {
29
31
  const current = recording.getStore();
30
32
  if (!current || current.disabled || current.ended) return;
31
33
  try {
32
- const event: MonitorEvent = { version: 1, execution_id: current.id, seq: ++current.seq, at: new Date().toISOString(), type, data };
33
- appendFileSync(current.file, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
34
+ let event: MonitorEvent = { version: 1, execution_id: current.id, seq: ++current.seq, at: new Date().toISOString(), type, data };
35
+ if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1") {
36
+ // 仅截断观测内容,终态与调用方原异常、退出码和业务 receipt 不受影响。
37
+ event = JSON.parse(JSON.stringify(event, (_key, value) => typeof value === "string" && value.length > 4096 ? `${redact(value.slice(0, 4096))} [truncated]` : value)) as MonitorEvent;
38
+ if (Buffer.byteLength(JSON.stringify(event)) > 64 * 1024) {
39
+ const outcome = event.data.outcome as CommandOutcome | undefined;
40
+ event.data = type === "finished" ? { exit_code: event.data.exit_code, outcome: { status: outcome?.status, errors: outcome?.errors?.slice(0, 3).map(error => ({ code: error.code, message: error.message })) }, truncated: true }
41
+ : { level: "warn", message: "Worker 观测事件超过单条上限,内容已截断。", truncated: true };
42
+ if (type !== "finished") event.type = "log";
43
+ }
44
+ if (current.eventTruncated && type !== "finished") return;
45
+ if (current.eventBytes + Buffer.byteLength(JSON.stringify(event)) > LOG_LIMIT && type !== "finished") {
46
+ current.eventTruncated = true;
47
+ event.type = "log"; event.data = { level: "warn", message: "Worker 观测事件达到上限,后续事件截断;业务错误和终态保留。", truncated: true };
48
+ }
49
+ }
50
+ const line = `${JSON.stringify(event)}\n`;
51
+ appendFileSync(current.file, line, { encoding: "utf8", mode: 0o600 });
52
+ current.eventBytes += Buffer.byteLength(line);
34
53
  } catch {
35
54
  // 只停用本次记录,不重试、不递归写日志,也不影响业务结果。
36
55
  current.disabled = true;
@@ -115,7 +134,7 @@ export async function observeCommand(command: string, handler: () => Promise<Com
115
134
  const dir = join(monitorDir(), "events");
116
135
  mkdirSync(dir, { recursive: true, mode: 0o700 });
117
136
  const id = crypto.randomUUID();
118
- context = { id, file: join(dir, `${id}.jsonl`), seq: 0, disabled: false, logBytes: 0, truncated: false, lastProgressAt: 0, progressKey: "", tasks: new Set() };
137
+ context = { id, file: join(dir, `${id}.jsonl`), seq: 0, disabled: false, logBytes: 0, truncated: false, lastProgressAt: 0, progressKey: "", tasks: new Set(), eventBytes: 0, eventTruncated: false };
119
138
  } catch { return handler(); }
120
139
 
121
140
  return recording.run(context, async () => {
@@ -28,7 +28,7 @@ beforeEach(async () => {
28
28
  afterEach(async () => {
29
29
  stop?.();
30
30
  stop = undefined;
31
- for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "NO_COLOR"]) {
31
+ for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "ZZHUB_PIPELINE_WORKER_BUDGET", "NO_COLOR"]) {
32
32
  if (previousEnv[key] === undefined) delete process.env[key];
33
33
  else process.env[key] = previousEnv[key];
34
34
  }
@@ -178,6 +178,22 @@ test("incomplete final records resume on the next read and state aliases keep on
178
178
  expect(store.snapshot().tasks[0]?.state_path).toContain("posts/test/workflow-state.json");
179
179
  });
180
180
 
181
+ test("trusted Worker caps individual and cumulative events while returning the original failure", async () => {
182
+ process.env.ZZHUB_PIPELINE_WORKER_BUDGET = "1";
183
+ const message = "字".repeat(5000);
184
+ const outcome = await runWithCommandLog("render", [], async () => {
185
+ for (let index = 0; index < 1100; index++) reportProgress({ stage: `stage-${index}`, message });
186
+ return { status: "failed", errors: [{ code: "RENDER_FAILED", message: "原始最终错误" }] };
187
+ });
188
+ expect(outcome.errors?.[0]?.message).toBe("原始最终错误");
189
+ const directory = join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events");
190
+ const raw = await readFile(join(directory, (await readdir(directory))[0]!), "utf8");
191
+ const events = raw.trim().split("\n").map(line => JSON.parse(line));
192
+ expect(raw).toContain("truncated");
193
+ expect(Buffer.byteLength(raw)).toBeLessThan(LOG_LIMIT + 64 * 1024);
194
+ expect(events.at(-1)).toMatchObject({ type: "finished", data: { exit_code: 1, outcome: { errors: [{ message: "原始最终错误" }] } } });
195
+ });
196
+
181
197
  test("HTTP/SSE support auth, shared subscriptions, cursor replay, resync and server restart", async () => {
182
198
  const server = await serveMonitor();
183
199
  expect(server).not.toBeNull();
@@ -548,6 +548,9 @@ export async function exportMarkdownToWechatHtml(
548
548
  }
549
549
 
550
550
  const html = result.html;
551
+ if (process.env.ZZHUB_PIPELINE_WORKER_BUDGET === "1" && Buffer.byteLength(html, "utf8") > 50 * 1024 * 1024) {
552
+ throw makeError("render_error", "Worker HTML export exceeds 50 MiB");
553
+ }
551
554
  const semanticHtml =
552
555
  "semanticHtml" in result && typeof result.semanticHtml === "string"
553
556
  ? result.semanticHtml