@akairos/pipeline 0.21.1 → 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.
@@ -0,0 +1,2 @@
1
+ export { createArticleSnapshot, validateArticleSnapshot, createArtifactManifest, hashArtifactManifest, validateArtifactManifest, rewriteArticleImages } from "./src/article-snapshot";
2
+ export type { ArticleSnapshot, ArticleSnapshotInput, ArticleSnapshotContent, BodyAssetIdentity, ArtifactManifest } from "./src/article-snapshot";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/pipeline",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "src/",
15
15
  "article-style.ts",
16
16
  "cover-snapshot.ts",
17
+ "article-snapshot.ts",
17
18
  "ARTICLE-THEMES.md",
18
19
  "THIRD-PARTY-NOTICES.md"
19
20
  ],
@@ -30,11 +31,12 @@
30
31
  },
31
32
  "dependencies": {
32
33
  "@akairos/milkdown-article-plugins": "^0.1.6",
33
- "@akairos/myno": "0.3.2",
34
- "@akairos/typeset-studio": "0.3.1",
34
+ "@akairos/myno": "0.4.1",
35
+ "@akairos/typeset-studio": "0.4.0",
35
36
  "@chenglou/pretext": "0.0.8",
36
37
  "image-size": "^2.0.2",
37
38
  "pngjs": "^7.0.0",
39
+ "remark": "^15.0.1",
38
40
  "zod": "^4.4.2"
39
41
  },
40
42
  "optionalDependencies": {
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createArticleSnapshot, validateArticleSnapshot, rewriteArticleImages, createArtifactManifest, hashArtifactManifest, validateArtifactManifest } from "../article-snapshot";
3
+
4
+ const asset = { id: "31161ca7-af98-4a84-adc7-c60885a3bd0f", sha256: "a".repeat(64), contentType: "image/png" as const, byteLength: 100, width: 10, height: 10 };
5
+ const input = { documentId: "521b9c37-1d74-4f23-a0ad-1801ad2f3669", documentRevision: 1, title: "预览", account: "fixed-account", body: `![描述](asset:${asset.id} "图注")`, bodyAssets: [asset] };
6
+
7
+ test("完整文章输入 hash 绑定修订、正文/账号及素材,拒绝未托管/过量图片", () => {
8
+ const snapshot = createArticleSnapshot(input);
9
+ expect(validateArticleSnapshot(snapshot, input)).toEqual(snapshot);
10
+ expect(() => validateArticleSnapshot(snapshot, { ...input, title: "变化" })).toThrow("input hash");
11
+ expect(createArticleSnapshot({ ...input, documentRevision: 2 }).inputHash).not.toBe(snapshot.inputHash);
12
+ for (const body of ["![外图](https://example.test/a.png)", "<img src='asset:bad'>", Array(21).fill(input.body).join("\n")]) expect(() => createArticleSnapshot({ ...input, body })).toThrow();
13
+ expect(() => createArticleSnapshot({ ...input, bodyAssets: [asset, asset] })).toThrow("Duplicate");
14
+ expect(() => validateArticleSnapshot({ ...snapshot, schemaVersion: 2 }, input)).toThrow();
15
+ });
16
+
17
+ test("图片引用转换保定义图注、描述与代码/普通链接/共享definition", () => {
18
+ const source = '[普通][shared]\n\n![描述][shared]\n\n[shared]: /api/assets/body/image "定义图注"\n\n```md\n![代码](/api/assets/body/image)\n```';
19
+ const rewritten = rewriteArticleImages(source, () => `asset:${asset.id}`);
20
+ expect(rewritten).toContain(`![描述](asset:${asset.id} "定义图注")`);
21
+ expect(rewritten).toContain('[shared]: /api/assets/body/image "定义图注"');
22
+ expect(rewritten).toContain('[普通][shared]');
23
+ expect(rewritten).toContain('![代码](/api/assets/body/image)');
24
+ const brackets = rewriteArticleImages('![左\\[右\\]尾][shared]\n\n[shared]: /image "图注"', () => `asset:${asset.id}`);
25
+ expect(brackets).toContain(`![左\\[右\\]尾](asset:${asset.id} "图注")`);
26
+ expect(createArticleSnapshot({ ...input, body: brackets }).inputHash).toBeTruthy();
27
+ });
28
+
29
+ test("产物 manifest 严格固定key/type和双hash身份", () => {
30
+ const manifest = createArtifactManifest({ snapshotHash: "b".repeat(64), runtime: { pipeline: "0.22.0", myno: "0.4.1", typeset: "0.4.0" }, artifacts: [
31
+ { key: "cover", kind: "cover", contentType: "image/png", byteLength: 1, sha256: "c".repeat(64) },
32
+ { key: "article", kind: "html", contentType: "text/html", byteLength: 2, sha256: "d".repeat(64) },
33
+ { key: `media_${asset.id}`, kind: "media", contentType: "image/png", byteLength: 100, sha256: asset.sha256 },
34
+ ] });
35
+ expect(validateArtifactManifest(manifest, hashArtifactManifest(manifest))).toEqual(manifest);
36
+ expect(() => validateArtifactManifest(manifest, "0".repeat(64))).toThrow();
37
+ expect(() => validateArtifactManifest({ ...manifest, path: "/private/html" })).toThrow();
38
+ expect(() => createArtifactManifest({ ...manifest, artifacts: [...manifest.artifacts, manifest.artifacts[0]!] })).toThrow();
39
+ });
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { remark } from "remark";
3
+ import { z } from "zod";
4
+ import { validateArticleStyleSnapshot, type ArticleStyleSnapshot } from "./article-style";
5
+ import { validateCoverSnapshot, type CoverSnapshot } from "./cover-snapshot";
6
+
7
+ const hash = z.string().regex(/^[a-f\d]{64}$/);
8
+ const uuid = z.string().uuid().transform(value => value.toLowerCase());
9
+ export const BodyAssetIdentitySchema = z.object({
10
+ id: uuid, sha256: hash, contentType: z.enum(["image/png", "image/jpeg", "image/webp"]),
11
+ byteLength: z.number().int().positive().max(5 * 1024 * 1024),
12
+ width: z.number().int().positive(), height: z.number().int().positive(),
13
+ }).strict().refine(value => value.width * value.height <= 32_000_000, "Body image exceeds 32 megapixels");
14
+ export type BodyAssetIdentity = z.infer<typeof BodyAssetIdentitySchema>;
15
+
16
+ export interface ArticleSnapshotContent {
17
+ title: string; body: string; account: string;
18
+ articleStyle?: ArticleStyleSnapshot; coverSnapshot?: CoverSnapshot;
19
+ }
20
+ export interface ArticleSnapshotInput extends ArticleSnapshotContent {
21
+ documentId: string; documentRevision: number; bodyAssets: BodyAssetIdentity[];
22
+ }
23
+ const ArticleSnapshotSchema = z.object({
24
+ schemaVersion: z.literal(1), documentId: uuid, documentRevision: z.number().int().positive(),
25
+ bodyAssets: z.array(BodyAssetIdentitySchema).max(20), inputHash: hash,
26
+ }).strict();
27
+ export type ArticleSnapshot = z.infer<typeof ArticleSnapshotSchema>;
28
+
29
+ interface ImageNode { type: string; url?: string; identifier?: string; alt?: string; title?: string | null; value?: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: ImageNode[] }
30
+
31
+ /** 只遍历 Markdown 图片节点,代码和普通链接不参与资产绑定。 */
32
+ export function rewriteArticleImages(body: string, resolveImage: (source: string) => string): string {
33
+ const tree = remark().parse(body) as ImageNode;
34
+ const definitions = new Map<string, ImageNode>();
35
+ const collect = (node: ImageNode) => { if (node.type === "definition") definitions.set(node.identifier!, node); node.children?.forEach(collect); };
36
+ collect(tree);
37
+ const edits: Array<{ start: number; end: number; text: string }> = [];
38
+ let count = 0;
39
+ const visit = (node: ImageNode) => {
40
+ if (node.type === "html" && /<img\b/i.test(node.value || "")) throw new Error("Raw HTML images must be converted to managed Markdown images");
41
+ if (node.type === "image" || node.type === "imageReference") {
42
+ if (++count > 20) throw new Error("Article supports at most 20 visible images");
43
+ const definition = definitions.get(node.identifier!);
44
+ const source = node.url ?? definition?.url;
45
+ if (!source) throw new Error("Unresolved image reference");
46
+ const mapped = resolveImage(source);
47
+ if (mapped !== source) {
48
+ const start = node.position?.start.offset, end = node.position?.end.offset;
49
+ if (start === undefined || end === undefined) throw new Error("Image source position is unavailable");
50
+ const escape = (text: string) => text.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
51
+ const originalTitle = node.title ?? definition?.title;
52
+ const title = originalTitle ? ` "${originalTitle.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : "";
53
+ edits.push({ start, end, text: `![${escape(node.alt || "")}](${mapped}${title})` });
54
+ }
55
+ }
56
+ node.children?.forEach(visit);
57
+ };
58
+ visit(tree);
59
+ for (const edit of edits.sort((a, b) => b.start - a.start)) body = body.slice(0, edit.start) + edit.text + body.slice(edit.end);
60
+ return body;
61
+ }
62
+
63
+ export function canonicalHash(value: unknown): string {
64
+ const normalize = (item: any): any => Array.isArray(item) ? item.map(normalize) : item && typeof item === "object"
65
+ ? Object.fromEntries(Object.keys(item).sort().filter(key => item[key] !== undefined).map(key => [key, normalize(item[key])])) : item;
66
+ return createHash("sha256").update(JSON.stringify(normalize(value))).digest("hex");
67
+ }
68
+
69
+ /** 输入 hash 包含文章、账号固定身份、配置内容与素材清单,不重复保存大字段。 */
70
+ export function createArticleSnapshot(value: ArticleSnapshotInput): ArticleSnapshot {
71
+ const content = z.object({ title: z.string().trim().min(1).max(255), body: z.string().min(1), account: z.string().regex(/^[a-zA-Z0-9_.-]+$/) }).parse(value);
72
+ if (Buffer.byteLength(content.body) > 2_097_152) throw new Error("Article body exceeds 2 MiB");
73
+ const header = ArticleSnapshotSchema.omit({ inputHash: true }).parse({ schemaVersion: 1, documentId: value.documentId, documentRevision: value.documentRevision, bodyAssets: value.bodyAssets });
74
+ header.bodyAssets.sort((a, b) => a.id.localeCompare(b.id));
75
+ const identities = new Map(header.bodyAssets.map(asset => [asset.id, asset]));
76
+ if (identities.size !== header.bodyAssets.length) throw new Error("Duplicate body asset id");
77
+ const uniqueBytes = new Map(header.bodyAssets.map(asset => [asset.sha256, asset.byteLength]));
78
+ if ([...uniqueBytes.values()].reduce((sum, bytes) => sum + bytes, 0) > 40 * 1024 * 1024) throw new Error("Body assets exceed 40 MiB");
79
+ const used = new Set<string>();
80
+ rewriteArticleImages(content.body, source => {
81
+ const id = /^asset:([a-f\d-]+)$/.exec(source)?.[1];
82
+ if (!id || !identities.has(id)) throw new Error("Article contains an unmanaged image");
83
+ used.add(id); return source;
84
+ });
85
+ if (used.size !== identities.size) throw new Error("Body asset is not referenced by the article");
86
+ const articleStyle = value.articleStyle ? validateArticleStyleSnapshot(value.articleStyle) : undefined;
87
+ const coverSnapshot = value.coverSnapshot ? validateCoverSnapshot(value.coverSnapshot) : undefined;
88
+ return { ...header, inputHash: canonicalHash({ ...header, ...content, articleStyle, coverSnapshot }) };
89
+ }
90
+
91
+ export function validateArticleSnapshot(value: unknown, content: ArticleSnapshotContent): ArticleSnapshot {
92
+ const input = ArticleSnapshotSchema.parse(value);
93
+ const snapshot = createArticleSnapshot({ ...content, ...input });
94
+ if (snapshot.inputHash !== input.inputHash) throw new Error("Article input hash mismatch");
95
+ return snapshot;
96
+ }
97
+
98
+ const ArtifactSchema = z.object({
99
+ key: z.string().regex(/^(article|cover|media_[a-f\d-]{36})$/), kind: z.enum(["html", "cover", "media"]),
100
+ contentType: z.enum(["text/html", "image/png", "image/jpeg", "image/webp", "image/gif"]), byteLength: z.number().int().positive().max(50 * 1024 * 1024), sha256: hash,
101
+ }).strict();
102
+ const ArtifactManifestSchema = z.object({
103
+ schemaVersion: z.literal(1), snapshotHash: hash, artifacts: z.array(ArtifactSchema).min(2).max(42),
104
+ runtime: z.object({ pipeline: z.string().min(1), myno: z.string().min(1), typeset: z.string().min(1) }).strict(),
105
+ }).strict().superRefine((value, ctx) => {
106
+ const keys = value.artifacts.map(item => item.key);
107
+ if (new Set(keys).size !== keys.length || !keys.includes("article") || !keys.includes("cover")) ctx.addIssue({ code: "custom", message: "Artifact keys must be unique and contain article/cover" });
108
+ for (const item of value.artifacts) if (item.key === "article" ? item.kind !== "html" || item.contentType !== "text/html" : item.key === "cover" ? item.kind !== "cover" || item.contentType !== "image/png" : item.kind !== "media" || item.contentType === "text/html") ctx.addIssue({ code: "custom", message: "Artifact key and type mismatch" });
109
+ });
110
+ export type ArtifactManifest = z.infer<typeof ArtifactManifestSchema>;
111
+ export function createArtifactManifest(value: Omit<ArtifactManifest, "schemaVersion">): ArtifactManifest {
112
+ const manifest = ArtifactManifestSchema.parse({ schemaVersion: 1, ...value });
113
+ manifest.artifacts.sort((a, b) => a.key.localeCompare(b.key));
114
+ return manifest;
115
+ }
116
+ export function hashArtifactManifest(value: ArtifactManifest): string { return canonicalHash(ArtifactManifestSchema.parse(value)); }
117
+ export function validateArtifactManifest(value: unknown, expectedHash?: string): ArtifactManifest {
118
+ const manifest = ArtifactManifestSchema.parse(value);
119
+ if (expectedHash && hashArtifactManifest(manifest) !== expectedHash) throw new Error("Artifact manifest hash mismatch");
120
+ return manifest;
121
+ }
@@ -0,0 +1,17 @@
1
+ import { parseArgs, requireArg } from "../args";
2
+ import { loadTaskState } from "../task-manager";
3
+ import { loadConfig, resolveWorkspacePaths } from "../config";
4
+ import { readArticleTheme } from "../article-theme";
5
+ import { exportWechatArticle } from "../providers/article-export";
6
+ import { printResult } from "../output";
7
+
8
+ /** 在发布检查点只导出本地 HTML,预览绝不调用发布 provider。 */
9
+ export async function articleExport(args: string[]): Promise<void> {
10
+ const parsed = parseArgs(args);
11
+ const { state } = await loadTaskState(requireArg(parsed, "state", "state JSON path"));
12
+ if (state.route.primary !== "wechat-article" || state.phase.render.status !== "done" || !state.asset_path) throw new Error("Article preview requires a rendered article run");
13
+ const config = loadConfig();
14
+ const articleTheme = state.intent.article_theme ? await readArticleTheme(state.intent.article_theme) : undefined;
15
+ const result = await exportWechatArticle({ state, config, workspacePaths: resolveWorkspacePaths(state.workspace_root, config), account: state.route.account, articleTheme });
16
+ printResult({ htmlPath: result.rendered.htmlPath, coverPath: result.photos[0], assetPath: state.asset_path });
17
+ }
@@ -40,6 +40,7 @@ import {
40
40
  stripLeadingTitleHeading,
41
41
  removePageMarkers,
42
42
  } from "../text";
43
+ import { rewriteArticleImages } from "../article-snapshot";
43
44
 
44
45
  function isExternalAssetRef(value: string): boolean {
45
46
  return /^(https?:|data:|blob:|\/\/)/i.test(value);
@@ -133,7 +134,12 @@ async function materializeInlineAssets(options: {
133
134
  return targetRelativePath;
134
135
  };
135
136
 
136
- let rewritten = options.body.replace(
137
+ let rewritten = process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT === "1"
138
+ ? rewriteArticleImages(options.body, source => {
139
+ const target = registerAsset(source);
140
+ return target ? formatRelativeAssetRef(target) : source;
141
+ })
142
+ : options.body.replace(
137
143
  /!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(\s+(?:"[^"]*"|'[^']*'))?\)/g,
138
144
  (match, alt, angleSrc, bareSrc, titleSuffix) => {
139
145
  const targetRelativePath = registerAsset(String(angleSrc ?? bareSrc ?? ""));
@@ -147,7 +153,7 @@ async function materializeInlineAssets(options: {
147
153
  },
148
154
  );
149
155
 
150
- rewritten = rewritten.replace(
156
+ if (process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT !== "1") rewritten = rewritten.replace(
151
157
  /(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
152
158
  (match, prefix, src, suffix) => {
153
159
  const targetRelativePath = registerAsset(String(src ?? ""));
@@ -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();
package/src/plugins.ts CHANGED
@@ -30,6 +30,7 @@ import { wxDraftDelete } from "./commands/wx-draft-delete";
30
30
  import type { CommandOutcome } from "./command-outcome";
31
31
  import { monitor } from "./commands/monitor";
32
32
  import { typesetAssetsSync } from "./commands/typeset-assets-sync";
33
+ import { articleExport } from "./commands/article-export";
33
34
 
34
35
  export interface CommandDefinition {
35
36
  name: string;
@@ -76,6 +77,7 @@ export function getCommandPlugins(): CommandPlugin[] {
76
77
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
77
78
  { name: "article-theme", summary: "Inspect or install a local article theme", plugin: "ops", handler: articleTheme },
78
79
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
80
+ { name: "article-export", summary: "Export a rendered article locally without publishing", plugin: "ops", handler: articleExport },
79
81
  { name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
80
82
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
81
83
  { name: "typeset-assets-sync", summary: "Upload Studio template assets and build a runtime config package", plugin: "ops", handler: typesetAssetsSync },
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { resolveMarkdownRenderer } from "../adapter-loader";
5
+ import { resolveConfigRelativePath, type PipelineConfig, type ResolvedWorkspacePaths } from "../config";
6
+ import type { ArticleThemePackage } from "../article-theme";
7
+ import type { WorkflowState } from "../state";
8
+ import { injectIllustrationImages } from "../text";
9
+ import { reportProgress } from "../monitor/recorder";
10
+ import { validateArtifactManifest, type ArtifactManifest } from "../article-snapshot";
11
+ import type { MarkdownRenderOutput } from "../adapter-types";
12
+
13
+ /** 文章导出只做本地排版,预览与直接发布复用;没有微信上传操作。 */
14
+ export async function exportWechatArticle({ state, config, workspacePaths, account, articleTheme }: {
15
+ state: WorkflowState; config: PipelineConfig; workspacePaths: ResolvedWorkspacePaths; account: string; articleTheme?: ArticleThemePackage;
16
+ }): Promise<{ html: string; photos: string[]; rendered: MarkdownRenderOutput; exportPostPath: string }> {
17
+ let exportPostPath = join(state.asset_path, "post.md");
18
+ const cover = state.images.render_assets.find(asset => asset.route === "wechat-article" && asset.kind === "cover");
19
+ const photos = cover ? [cover.path] : [];
20
+ if (state.images.body_inputs.scope === "article" && state.images.body_inputs.status === "ready") {
21
+ const body = injectIllustrationImages(await readFile(exportPostPath, "utf8"), state.images.body_inputs.received);
22
+ exportPostPath = join(state.asset_path, "post-wechat-draft.md");
23
+ await writeFile(exportPostPath, body);
24
+ photos.push(...state.images.body_inputs.received.map(image => image.path));
25
+ }
26
+ const renderer = await resolveMarkdownRenderer(config);
27
+ const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
28
+ const standalone = articleTheme?.manifest.css !== undefined;
29
+ const rendered = await renderer.render({ onProgress: reportProgress, markdownPath: exportPostPath,
30
+ outPath: workspacePaths.zotepadExportHtml, account, title: state.metadata.title,
31
+ customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
32
+ articleThemePath: articleTheme?.path, themeOverrides: standalone ? undefined : wxAccount?.theme });
33
+ return { html: rendered.html, photos, rendered, exportPostPath };
34
+ }
35
+
36
+ export interface FrozenArticleArtifacts {
37
+ manifest: ArtifactManifest;
38
+ manifestHash: string;
39
+ files: Record<string, string>;
40
+ }
41
+
42
+ /** 私有 manifest 是唯一文件白名单;确认发布只读已验证产物,不再导出或渲染。 */
43
+ export async function readFrozenArticleArtifacts(manifestPath: string): Promise<{ html: string; photos: string[]; rendered?: undefined; exportPostPath: string }> {
44
+ const stored = JSON.parse(await readFile(manifestPath, "utf8")) as FrozenArticleArtifacts;
45
+ if (stored.manifestHash !== process.env.ZZHUB_PIPELINE_ARTIFACT_MANIFEST_HASH || stored.manifest.snapshotHash !== process.env.ZZHUB_PIPELINE_ARTICLE_SNAPSHOT_HASH) throw new Error("Frozen artifact identity does not match the confirmed job");
46
+ const manifest = validateArtifactManifest(stored.manifest, stored.manifestHash);
47
+ const root = dirname(resolve(manifestPath));
48
+ const bytes = new Map<string, Buffer>();
49
+ const paths = new Map<string, string>();
50
+ for (const artifact of manifest.artifacts) {
51
+ const name = stored.files[artifact.key];
52
+ if (!name || name.includes("/") || name.includes("\\") || name === "." || name === "..") throw new Error("Invalid private artifact file");
53
+ const path = join(root, name);
54
+ const data = await readFile(path);
55
+ if (data.byteLength !== artifact.byteLength || createHash("sha256").update(data).digest("hex") !== artifact.sha256) throw new Error("Frozen article artifact hash mismatch");
56
+ bytes.set(artifact.key, data); paths.set(artifact.key, path);
57
+ }
58
+ let html = bytes.get("article")!.toString("utf8");
59
+ html = await new HTMLRewriter().on("img", { element(element) {
60
+ const source = element.getAttribute("src") || "";
61
+ const id = /^asset:([a-f\d-]{36})$/.exec(source)?.[1];
62
+ const path = id ? paths.get(`media_${id}`) : undefined;
63
+ if (!path) throw new Error("Frozen HTML contains an unmanaged image");
64
+ element.setAttribute("src", path);
65
+ if (element.hasAttribute("data-src")) element.setAttribute("data-src", path);
66
+ } }).transform(new Response(html)).text();
67
+ return { html, photos: [paths.get("cover")!, ...manifest.artifacts.filter(artifact => artifact.kind === "media").map(artifact => paths.get(artifact.key)!)], exportPostPath: paths.get("article")! };
68
+ }
@@ -3,17 +3,14 @@ import { createHash } from "node:crypto";
3
3
  import { readFile, writeFile } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import {
6
- resolveConfigRelativePath,
7
6
  type PipelineConfig,
8
7
  type ResolvedWorkspacePaths,
9
8
  } from "../config";
10
9
  import { PublishResult, RenderAsset, RoutePrimary, WorkflowState } from "../state";
11
10
  import {
12
- injectIllustrationImages,
13
11
  prepareBodyForNewspic,
14
12
  } from "../text";
15
- import { resolveMarkdownRenderer } from "../adapter-loader";
16
- import { reportProgress } from "../monitor/recorder";
13
+ import { exportWechatArticle, readFrozenArticleArtifacts } from "./article-export";
17
14
  import { publishBlogRoute } from "./blog";
18
15
  import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
19
16
 
@@ -36,70 +33,22 @@ async function publishWechatArticleRoute({
36
33
  accountOverride,
37
34
  articleTheme,
38
35
  }: PublishRouteContext): Promise<PublishResult> {
39
- const postPath = join(state.asset_path, "post.md");
40
- let exportPostPath = postPath;
41
- const cover = (state.images.render_assets as RenderAsset[]).find(
42
- (asset) => asset.route === "wechat-article" && asset.kind === "cover",
43
- );
44
-
45
- const photos: string[] = [];
46
- if (cover) {
47
- photos.push(cover.path);
48
- }
49
- if (
50
- state.images.body_inputs.scope === "article" &&
51
- state.images.body_inputs.status === "ready"
52
- ) {
53
- const postContent = await readFile(postPath, "utf-8");
54
- const draftContent = injectIllustrationImages(
55
- postContent,
56
- state.images.body_inputs.received,
57
- );
58
- exportPostPath = join(state.asset_path, "post-wechat-draft.md");
59
- if (!dryRun) {
60
- await writeFile(exportPostPath, draftContent, "utf-8");
61
- }
62
-
63
- for (const img of state.images.body_inputs.received) {
64
- photos.push(img.path);
65
- }
66
- }
67
-
68
36
  const account = accountOverride || state.route.account;
69
-
70
37
  if (dryRun) {
71
- console.error(`[dry-run] Zotepad export: ${exportPostPath}`);
72
- console.error(`[dry-run] Wechat draft: ${state.metadata.title}`);
73
- return {
74
- route: "wechat-article",
75
- account,
76
- status: "skipped",
77
- detail: "dry-run",
78
- published_at: null,
79
- content_version: state.artifacts.content_version,
80
- render_version: state.artifacts.render_version,
81
- };
38
+ return { route: "wechat-article", account, status: "skipped", detail: "dry-run", published_at: null,
39
+ content_version: state.artifacts.content_version, render_version: state.artifacts.render_version };
82
40
  }
83
41
 
84
42
  try {
85
- const markdownRenderer = await resolveMarkdownRenderer(config);
86
- const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
87
- // 独立正文配置与发布账号的凭据、默认 CSS 分离。
88
- const standalone = articleTheme?.manifest.css !== undefined;
89
- const rendered = await markdownRenderer.render({
90
- onProgress: reportProgress,
91
- markdownPath: exportPostPath,
92
- outPath: workspacePaths.zotepadExportHtml,
93
- account,
94
- title: state.metadata.title,
95
- customCss: standalone ? null : resolveConfigRelativePath(wxAccount?.customCss),
96
- articleThemePath: articleTheme?.path,
97
- themeOverrides: standalone ? undefined : wxAccount?.theme,
98
- });
99
- const { html } = rendered;
43
+ if (process.env.ZZHUB_PIPELINE_PREVIEW_ONLY === "1") throw new Error("Preview operation cannot publish or upload WeChat images");
44
+ const frozenPath = process.env.ZZHUB_PIPELINE_ARTICLE_ARTIFACTS;
45
+ const prepared = frozenPath
46
+ ? await readFrozenArticleArtifacts(frozenPath)
47
+ : await exportWechatArticle({ state, config, workspacePaths, account, articleTheme });
48
+ const { html, photos, rendered, exportPostPath } = prepared;
100
49
 
101
50
  // Register with local preview server unless explicitly disabled.
102
- if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
51
+ if (rendered && process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
103
52
  try {
104
53
  const { registerPreviewEntry } = await import("../wechat-preview/server");
105
54
  const registration = await registerPreviewEntry(
@@ -131,6 +80,7 @@ async function publishWechatArticleRoute({
131
80
  title: state.metadata.title,
132
81
  html,
133
82
  photos,
83
+ ...(frozenPath ? { explicitPhotos: true } : {}),
134
84
  idempotencyKey: createHash("sha256").update(JSON.stringify([state.run_id, state.publish.attempt ?? 0, account, state.artifacts.content_version, state.artifacts.render_version, articleTheme?.hash, state.intent.existing_draft_media_id])).digest("hex"),
135
85
  config,
136
86
  existingDraftMediaId: state.intent.existing_draft_media_id,
@@ -139,6 +139,8 @@ interface BaseWechatPublishInput {
139
139
  }
140
140
 
141
141
  export interface WechatDraftInput extends BaseWechatPublishInput {
142
+ /** 冻结产物已给出真实图片清单,禁止从代码文本再猜图片来源。 */
143
+ explicitPhotos?: boolean;
142
144
  html: string;
143
145
  existingDraftMediaId?: string | null;
144
146
  }
@@ -642,7 +644,7 @@ export function calculatePicCrop(width: number, height: number): { pic_crop_235_
642
644
 
643
645
  export async function createWechatDraft(input: WechatDraftInput): Promise<Record<string, unknown>> {
644
646
  const runtime = getWxRuntimeConfig(input.config, input.account);
645
- const fallbackPhotos = extractImageUrls(input.html);
647
+ const fallbackPhotos = input.explicitPhotos ? [] : extractImageUrls(input.html);
646
648
  const photos = parsePhotos(input.photos);
647
649
  const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
648
650
  if (finalPhotos.length === 0) {
@@ -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