@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 +1 -1
- package/src/logger.test.ts +15 -0
- package/src/logger.ts +21 -5
- package/src/monitor/recorder.ts +22 -3
- package/src/monitor.test.ts +17 -1
- package/src/wechat-preview/index.ts +3 -0
package/package.json
CHANGED
package/src/logger.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/src/monitor/recorder.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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 () => {
|
package/src/monitor.test.ts
CHANGED
|
@@ -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
|