@zzclub/pipeline 0.11.0 → 0.12.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/README.md CHANGED
@@ -866,6 +866,13 @@ bun install --global .
866
866
 
867
867
  Markdown → WeChat HTML 转换通过插件系统完成(默认使用内置 `builtin-wechat-preview` 适配器)。
868
868
 
869
+ ## 本机多任务监控
870
+
871
+ `zzp monitor start` 按需启动共享 HTTP/SSE 服务,`monitor status` 查看状态,`monitor stop` 只停止监控。
872
+ CLI 独立记录执行和错误,GUI 可同时订阅多个任务。新版业务失败会退出 1,调用方仍应读取失败响应和最新任务状态。
873
+
874
+ 详见 [监控接口与退出码迁移](docs/monitor.md)。
875
+
869
876
  ## 测试与验证
870
877
 
871
878
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
12
+ import type { MonitorProgress } from "./monitor/types";
12
13
 
13
14
  // ── Doctor check ──────────────────────────────────────────────────
14
15
 
@@ -26,6 +27,8 @@ export interface PipelinePluginDoctorCheck {
26
27
  * to runRenderArticleCli and runRenderCardCli via CLI argv.
27
28
  */
28
29
  export interface ImageRenderInput {
30
+ /** 可选进度通知,旧插件可忽略。 */
31
+ onProgress?: (progress: MonitorProgress) => void;
29
32
  /** Workflow state providing context (route, metadata, etc.) */
30
33
  state: WorkflowState;
31
34
 
@@ -117,6 +120,8 @@ export interface ImageRenderPlugin {
117
120
  * Maps to ExportMarkdownToWechatHtmlInput from wechat-preview.
118
121
  */
119
122
  export interface MarkdownRenderInput {
123
+ /** 可选进度通知,旧插件可忽略。 */
124
+ onProgress?: (progress: MonitorProgress) => void;
120
125
  /** Path to the markdown source file */
121
126
  markdownPath: string;
122
127
 
@@ -138,6 +138,7 @@ async function renderCover(
138
138
  outputDir: string,
139
139
  visualParams: AccountVisualParams | null,
140
140
  highlightWords: string[],
141
+ onProgress?: ImageRenderInput["onProgress"],
141
142
  ): Promise<RenderAsset> {
142
143
  const coverOut = join(outputDir, "cover.png");
143
144
  const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
@@ -152,12 +153,12 @@ async function renderCover(
152
153
  }
153
154
  cmdParts.push("--highlight-words", highlightWords.join(","));
154
155
  cmdParts.push("--out", coverOut);
155
- runRenderCardCli(cmdParts);
156
+ runRenderCardCli(cmdParts, onProgress);
156
157
  } else {
157
158
  const cmdParts = ["--template", "poster-3-4", "--text", title];
158
159
  appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
159
160
  cmdParts.push("--out", coverOut);
160
- runRenderCardCli(cmdParts);
161
+ runRenderCardCli(cmdParts, onProgress);
161
162
  }
162
163
 
163
164
  return { kind: "cover", route: route as RenderAsset["route"], path: coverOut };
@@ -231,7 +232,7 @@ async function renderLongformPages(
231
232
 
232
233
  let result: RenderArticleResult;
233
234
  try {
234
- result = runRenderArticleCli(pageParts);
235
+ result = runRenderArticleCli(pageParts, input.onProgress);
235
236
  } finally {
236
237
  if (!keepTempFiles) {
237
238
  await rm(tempDir, { recursive: true, force: true });
@@ -309,7 +310,7 @@ export const builtinImageRenderer: ImageRenderPlugin = {
309
310
 
310
311
  if (route === "wechat-article") {
311
312
  // Article: cover only (wechat-cover-split)
312
- const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords);
313
+ const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress);
313
314
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
314
315
  }
315
316
 
@@ -339,13 +340,13 @@ export const builtinImageRenderer: ImageRenderPlugin = {
339
340
  if (!isLong) {
340
341
  // Short: single poster cover
341
342
  const coverTitle = generateCoverTitle(title);
342
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
343
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
343
344
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
344
345
  }
345
346
 
346
347
  // Long: cover + article pages
347
348
  const coverTitle = generateCoverTitle(title);
348
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
349
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
349
350
 
350
351
  const bodyImages = input.bodyImages ?? [];
351
352
  const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { exportMarkdownToWechatHtml } from "../wechat-preview";
9
9
  import { findChrome } from "../imgx/runtime";
10
+ import { notifyProgress } from "../monitor/recorder";
10
11
  import type {
11
12
  MarkdownRenderPlugin,
12
13
  MarkdownRenderInput,
@@ -43,6 +44,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
43
44
  },
44
45
 
45
46
  async render(input: MarkdownRenderInput): Promise<MarkdownRenderOutput> {
47
+ notifyProgress(input.onProgress, { stage: "render.html", message: "正在导出微信 HTML" });
46
48
  const result = await exportMarkdownToWechatHtml({
47
49
  markdownPath: input.markdownPath,
48
50
  outPath: input.outPath,
@@ -54,6 +56,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
54
56
  timeoutMs: input.timeoutMs,
55
57
  debugDir: input.debugDir ?? undefined,
56
58
  });
59
+ notifyProgress(input.onProgress, { stage: "render.html", message: "微信 HTML 导出完成", current: 1, total: 1, unit: "files" });
57
60
 
58
61
  return {
59
62
  html: result.html,
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { getDailyLogPath, runWithCommandLog } from "./logger";
7
7
  import { formatUsage, getCommandRegistry } from "./plugins";
8
+ import { outcomeExitCode } from "./command-outcome";
8
9
 
9
10
  const COMMANDS = getCommandRegistry();
10
11
 
@@ -37,14 +38,19 @@ async function main() {
37
38
  }
38
39
 
39
40
  try {
40
- await runWithCommandLog(cmd, args.slice(1), async () => {
41
- await command.handler(args.slice(1));
42
- });
41
+ // monitor 输出包含本机认证令牌,不能经过日志复制或监控自身。
42
+ const outcome = cmd === "monitor"
43
+ ? await command.handler(args.slice(1))
44
+ : await runWithCommandLog(cmd, args.slice(1), () => command.handler(args.slice(1)));
45
+ if (outcome) {
46
+ process.exitCode = outcomeExitCode(outcome);
47
+ for (const error of outcome.errors ?? []) console.error(`[zzhub-pipeline ${cmd}] ${error.code}: ${error.message}`);
48
+ }
43
49
  } catch (err) {
44
50
  const msg = err instanceof Error ? err.message : String(err);
45
51
  console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
46
52
  console.error(`[zzhub-pipeline ${cmd}] Full log: ${getDailyLogPath()}`);
47
- process.exit(1);
53
+ process.exitCode = 1;
48
54
  }
49
55
  }
50
56
 
@@ -0,0 +1,50 @@
1
+ import type { PublishResult } from "./state";
2
+
3
+ /** 同一次执行共用的业务结果,独立于工作流是否完成。 */
4
+ export interface CommandOutcome {
5
+ status: "success" | "skipped" | "waiting" | "partial_failure" | "failed";
6
+ errors?: CommandError[];
7
+ }
8
+
9
+ /** 可供终端和监控展示的错误,不推测可重试性。 */
10
+ export interface CommandError {
11
+ code: string;
12
+ message: string;
13
+ stage?: string;
14
+ route?: string;
15
+ account?: string;
16
+ cause?: string;
17
+ }
18
+
19
+ /** 保留已有错误码和堆栈,未知异常使用统一兜底。 */
20
+ export function commandError(error: unknown, code = "COMMAND_FAILED"): CommandError {
21
+ return {
22
+ code: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : code,
23
+ message: error instanceof Error ? error.message : String(error),
24
+ ...(error instanceof Error && error.stack ? { cause: error.stack } : {}),
25
+ };
26
+ }
27
+
28
+ /** 出口只使用 0/1;等待输入和幂等跳过都是正常完成命令。 */
29
+ export function outcomeExitCode(outcome: CommandOutcome): number {
30
+ return outcome.status === "failed" || outcome.status === "partial_failure" ? 1 : 0;
31
+ }
32
+
33
+ /** 仅汇总本次请求目标,避免历史失败污染新的追加发布。 */
34
+ export function publishOutcome(results: PublishResult[], skipped = false): CommandOutcome {
35
+ const failures = results.filter((result) => result.status === "failed");
36
+ if (failures.length) {
37
+ return {
38
+ status: results.some((result) => result.status === "success") ? "partial_failure" : "failed",
39
+ errors: failures.map((result) => ({
40
+ code: "PUBLISH_TARGET_FAILED",
41
+ message: result.detail || "发布失败,provider 未提供原因",
42
+ stage: "publish",
43
+ route: result.route,
44
+ account: result.account,
45
+ })),
46
+ };
47
+ }
48
+ if (results.some((result) => result.status === "handoff")) return { status: "waiting" };
49
+ return { status: skipped || !results.length || results.every((result) => result.status === "skipped") ? "skipped" : "success" };
50
+ }
@@ -17,6 +17,7 @@ import { parseArgs, optionalArg } from "../args";
17
17
  import { printResult, renderAbandon } from "../output";
18
18
  import { filterActiveTasks, getTaskByStatePath, listTasks, type ListedTask } from "../task-manager";
19
19
  import { updateState } from "../state";
20
+ import type { CommandOutcome } from "../command-outcome";
20
21
 
21
22
  // ── Result type ───────────────────────────────────────────────────────────────
22
23
 
@@ -146,7 +147,7 @@ async function runInteractive(items: ListedTask[]): Promise<ListedTask[]> {
146
147
 
147
148
  // ── Command entry point ───────────────────────────────────────────────────────
148
149
 
149
- export async function abandon(args: string[]): Promise<void> {
150
+ export async function abandon(args: string[]): Promise<void | CommandOutcome> {
150
151
  const parsed = parseArgs(args);
151
152
 
152
153
  if (parsed.help) {
@@ -184,7 +185,7 @@ Options:
184
185
  }
185
186
  const result = await abandonTask(task);
186
187
  printResult([result], renderAbandon);
187
- return;
188
+ return abandonOutcome([result]);
188
189
  }
189
190
 
190
191
  // ── Interactive: TTY checkbox ─────────────────────────────────────────────
@@ -199,4 +200,14 @@ Options:
199
200
 
200
201
  const results = await Promise.all(chosen.map(abandonTask));
201
202
  printResult(results, renderAbandon);
203
+ return abandonOutcome(results);
204
+ }
205
+
206
+ /** 批量操作保留成功项,失败项决定本次执行结果。 */
207
+ function abandonOutcome(results: AbandonResult[]): CommandOutcome {
208
+ const failed = results.filter((result) => !result.ok);
209
+ return {
210
+ status: failed.length ? (failed.length < results.length ? "partial_failure" : "failed") : "success",
211
+ errors: failed.map((result) => ({ code: "ABANDON_FAILED", message: result.error || "放弃任务失败" })),
212
+ };
202
213
  }
@@ -15,8 +15,9 @@ import { getTaskByStatePath } from "../task-manager";
15
15
  import { parseTaskViewMode, renderTaskStatusMarkdown } from "../task-views";
16
16
  import type { PhaseName } from "../state";
17
17
  import { validateForPhase } from "../state";
18
+ import type { CommandOutcome } from "../command-outcome";
18
19
 
19
- export async function checkpoint(args: string[]): Promise<void> {
20
+ export async function checkpoint(args: string[]): Promise<void | CommandOutcome> {
20
21
  const parsed = parseArgs(args);
21
22
 
22
23
  if (parsed.help) {
@@ -61,6 +62,6 @@ Options:
61
62
  }
62
63
 
63
64
  if (errors.length > 0) {
64
- process.exit(1);
65
+ return { status: "failed", errors: errors.map((error) => ({ code: "CHECKPOINT_FAILED", message: `${error.field}: ${error.message}`, stage: phase })) };
65
66
  }
66
67
  }
@@ -0,0 +1,21 @@
1
+ import { parseArgs } from "../args";
2
+ import { printHelp, printResult } from "../output";
3
+ import { monitorHealth, readMonitorDescriptor, startMonitor, stopMonitor } from "../monitor/client";
4
+ import { serveMonitor } from "../monitor/server";
5
+
6
+ /** 服务管理不进入业务日志,防止描述文件令牌被日志复制。 */
7
+ export async function monitor(args: string[]): Promise<void> {
8
+ const parsed = parseArgs(args);
9
+ const action = (JSON.parse(String(parsed._)) as string[])[0] || "status";
10
+ if (parsed.help) { printHelp("Usage: zzp monitor start | serve | status | stop\nLocal HTTP/SSE monitoring; business commands run independently."); return; }
11
+ if (action === "start") { printResult(await startMonitor()); return; }
12
+ if (action === "serve") { const server = await serveMonitor(); printResult(server?.descriptor || await startMonitor()); return; }
13
+ if (action === "stop") { printResult({ stopped: await stopMonitor() }); return; }
14
+ if (action === "status") {
15
+ const descriptor = readMonitorDescriptor();
16
+ const running = !!descriptor && await monitorHealth(descriptor);
17
+ printResult({ running, ...(running ? { url: descriptor.url, instance_id: descriptor.instance_id, version: 1 } : {}) });
18
+ return;
19
+ }
20
+ throw new Error(`Unknown monitor action: ${action}`);
21
+ }
@@ -32,6 +32,7 @@ import {
32
32
  getCanonicalStatePath,
33
33
  } from "../state";
34
34
  import { loadTaskState } from "../task-manager";
35
+ import { reportProgress } from "../monitor/recorder";
35
36
  import {
36
37
  extractHighlightWords,
37
38
  buildFrontmatter,
@@ -330,6 +331,7 @@ Options:
330
331
  });
331
332
 
332
333
  // Write post.md
334
+ reportProgress({ stage: "prepare.save", message: "保存文章和工作流状态" });
333
335
  const postContent = `${frontmatter}\n\n${removePageMarkers(body)}`;
334
336
  const postPath = join(assetPath, "post.md");
335
337
  const postTempPath = `${postPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
@@ -35,6 +35,7 @@ import {
35
35
  writeState,
36
36
  } from "../state";
37
37
  import { loadTaskState } from "../task-manager";
38
+ import { reportProgress } from "../monitor/recorder";
38
39
  import { resolveFullRoute } from "../routes";
39
40
  import { resolveAuthoring, hasStyleRequest } from "../profiles";
40
41
  import { parseAccountName, parseRoutePrimary } from "../publish-targets";
@@ -200,6 +201,7 @@ Options:
200
201
  state.intent.requires.publish = state.intent.task_kind === "publish";
201
202
 
202
203
  // ── Step 2: Author select ──
204
+ reportProgress({ stage: "prepare.author", message: "确定写作规则" });
203
205
  const styleRequest =
204
206
  isStyleRequest || hasStyleRequest(intentText);
205
207
 
@@ -219,6 +221,7 @@ Options:
219
221
  // In the current workflow, Writer/Style already own the LLM rewrite.
220
222
  // Prepare records the authoring snapshot, then always formats the final body.
221
223
  const bodyFormatted = formatArticle(cleanBody);
224
+ reportProgress({ stage: "prepare.metadata", message: "整理文章元信息" });
222
225
 
223
226
  // ── Step 4: Asset meta ──
224
227
  // Determine title
@@ -34,8 +34,9 @@ import {
34
34
  upsertPublishResult,
35
35
  } from "../providers/publish-core";
36
36
  import { loadTaskState } from "../task-manager";
37
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
37
38
 
38
- export async function publish(args: string[]): Promise<void> {
39
+ export async function publish(args: string[]): Promise<void | CommandOutcome> {
39
40
  const parsed = parseArgs(args);
40
41
 
41
42
  if (parsed.help) {
@@ -119,7 +120,7 @@ Options:
119
120
  dry_run: true,
120
121
  ...(errors.length > 0 ? { errors } : {}),
121
122
  }, renderPublish);
122
- return;
123
+ return publishOutcome(results, true);
123
124
  }
124
125
 
125
126
  // Check if all targets are done
@@ -151,6 +152,8 @@ Options:
151
152
  output.errors = errors;
152
153
  }
153
154
  printResult(output, renderPublish);
155
+ return publishOutcome(state.publish.results.filter((result) => targets.some((target) =>
156
+ target.route === result.route && target.account === result.account)), results.length === 0);
154
157
  } finally {
155
158
  await releaseOperationLock();
156
159
  }
@@ -38,6 +38,8 @@ import {
38
38
  } from "../text";
39
39
  import { getLongformTheme } from "../routes";
40
40
  import { collectNewspicRequiredMarkers } from "../workflow-materials";
41
+ import type { CommandOutcome } from "../command-outcome";
42
+ import { reportProgress } from "../monitor/recorder";
41
43
 
42
44
  function getNewspicRenderSpec(state: WorkflowState): NewspicRenderSpec {
43
45
  const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
@@ -47,7 +49,7 @@ function getNewspicRenderSpec(state: WorkflowState): NewspicRenderSpec {
47
49
  };
48
50
  }
49
51
 
50
- export async function render(args: string[]): Promise<void> {
52
+ export async function render(args: string[]): Promise<void | CommandOutcome> {
51
53
  const parsed = parseArgs(args);
52
54
 
53
55
  if (parsed.help) {
@@ -182,7 +184,7 @@ Options:
182
184
  message: `Waiting for ${markers.length} body image(s)`,
183
185
  phase: state.phase.current,
184
186
  }, renderRender);
185
- return;
187
+ return { status: "waiting" };
186
188
  }
187
189
  } else if (state.images.body_inputs.scope === "newspic-longform") {
188
190
  state.images.body_inputs = defaultBodyInputs();
@@ -196,13 +198,14 @@ Options:
196
198
  skip_render: true,
197
199
  phase: state.phase.current,
198
200
  }, renderRender);
199
- return;
201
+ return { status: "skipped" };
200
202
  }
201
203
 
202
204
  // ── Invoke image renderer adapter ───────────────────────────────
203
205
 
204
206
  const config = loadConfig();
205
207
  const imageRenderer = await resolveImageRenderer(config);
208
+ reportProgress({ stage: "render.adapter", message: "开始生成图片" });
206
209
 
207
210
  const bodyImages = (
208
211
  state.images.body_inputs.scope === "newspic-longform" &&
@@ -212,6 +215,7 @@ Options:
212
215
  : [];
213
216
 
214
217
  const renderResult = await imageRenderer.render({
218
+ onProgress: reportProgress,
215
219
  state,
216
220
  bodyText: cleanBody,
217
221
  outputDir,
@@ -36,8 +36,9 @@ import {
36
36
  upsertPublishResult,
37
37
  } from "../providers/publish-core";
38
38
  import { loadTaskState } from "../task-manager";
39
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
39
40
 
40
- export async function republish(args: string[]): Promise<void> {
41
+ export async function republish(args: string[]): Promise<void | CommandOutcome> {
41
42
  const parsed = parseArgs(args);
42
43
 
43
44
  if (parsed.help) {
@@ -182,6 +183,8 @@ Examples:
182
183
  output.errors = errors;
183
184
  }
184
185
  printResult(output);
186
+ return publishOutcome(dryRun ? results : state.publish.results.filter((result) => newTargets.some((target) =>
187
+ target.route === result.route && target.account === result.account)), dryRun || results.length === 0);
185
188
  } finally {
186
189
  await releaseOperationLock();
187
190
  }
@@ -8,8 +8,9 @@ import {
8
8
  } from "../state";
9
9
  import { loadTaskState } from "../task-manager";
10
10
  import { publishBlogRoute } from "../providers/blog";
11
+ import { publishOutcome, type CommandOutcome } from "../command-outcome";
11
12
 
12
- export async function syncBlog(args: string[]): Promise<void> {
13
+ export async function syncBlog(args: string[]): Promise<void | CommandOutcome> {
13
14
  const parsed = parseArgs(args);
14
15
 
15
16
  if (parsed.help) {
@@ -50,7 +51,7 @@ Options:
50
51
 
51
52
  if (dryRun) {
52
53
  printResult({ ...result, mode: state.mode }, renderSyncBlog);
53
- return;
54
+ return publishOutcome([result], true);
54
55
  }
55
56
 
56
57
  // Write result back to state
@@ -67,6 +68,7 @@ Options:
67
68
  ...result,
68
69
  mode: finalState.mode,
69
70
  }, renderSyncBlog);
71
+ return publishOutcome([result]);
70
72
  } finally {
71
73
  await releaseOperationLock();
72
74
  }
@@ -2,6 +2,8 @@ import { spawn } from "child_process";
2
2
  import { resolve } from "path";
3
3
  import { flagArg, optionalArg, parseArgs, requireArg } from "../args";
4
4
  import { printResult, renderWechatExport } from "../output";
5
+ import { commandError, type CommandOutcome } from "../command-outcome";
6
+ import { reportProgress } from "../monitor/recorder";
5
7
  import { loadConfig, resolveConfigRelativePath } from "../config";
6
8
  import { resolveMarkdownRenderer } from "../adapter-loader";
7
9
  import { WechatExportError } from "../wechat-preview";
@@ -54,7 +56,7 @@ async function openUrl(url: string): Promise<void> {
54
56
  }
55
57
  }
56
58
 
57
- export async function wechatExport(args: string[]): Promise<void> {
59
+ export async function wechatExport(args: string[]): Promise<void | CommandOutcome> {
58
60
  const parsed = parseArgs(args);
59
61
 
60
62
  if (parsed.help) {
@@ -114,6 +116,7 @@ ${CSS_DEMO}
114
116
 
115
117
  try {
116
118
  const result = await markdownRenderer.render({
119
+ onProgress: reportProgress,
117
120
  markdownPath,
118
121
  outPath,
119
122
  account,
@@ -154,6 +157,7 @@ ${CSS_DEMO}
154
157
  if (open && preview_url) await openUrl(preview_url);
155
158
  } else {
156
159
  preview_register_error = reg.error;
160
+ console.warn(`微信预览登记失败:${reg.error || "未知原因"}`);
157
161
  }
158
162
  }
159
163
 
@@ -197,8 +201,7 @@ ${CSS_DEMO}
197
201
  renderWechatExport,
198
202
  );
199
203
  if (open && reg.preview_url) await openUrl(reg.preview_url);
200
- process.exitCode = 1;
201
- return;
204
+ return { status: "failed", errors: [commandError(error, error.kind)] };
202
205
  }
203
206
  }
204
207
  throw error;
@@ -29,6 +29,8 @@ import {
29
29
  } from "./runtime";
30
30
  import { layoutNextLineRange, prepareWithSegments } from "./pretext-adapter";
31
31
  import { ensurePretextRuntime } from "./pretext-runtime";
32
+ import { notifyProgress } from "../monitor/recorder";
33
+ import type { MonitorProgress } from "../monitor/types";
32
34
 
33
35
  const DEFAULT_CONTENT_WIDTH = getLongformGeometry(getLongformTheme("paper-sage")).contentWidth;
34
36
 
@@ -1170,7 +1172,8 @@ function renderPage(params: {
1170
1172
  });
1171
1173
  }
1172
1174
 
1173
- export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1175
+ export function runRenderArticleCli(argv: string[], onProgress?: (progress: MonitorProgress) => void): RenderArticleResult {
1176
+ notifyProgress(onProgress, { stage: "render.layout", message: "正在计算分页" });
1174
1177
  const parsed = parseArgs(argv);
1175
1178
  const title = getArg(parsed, "title");
1176
1179
  if (title.length === 0) throw new Error("需要 --title");
@@ -1312,6 +1315,7 @@ export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1312
1315
  };
1313
1316
 
1314
1317
  if (outPath.length > 0) {
1318
+ notifyProgress(onProgress, { stage: "render.pages", current: 0, total: 1, unit: "pages" });
1315
1319
  const requestedPage = pageNum > 0 ? pageNum : 1;
1316
1320
  const page = pages[Math.max(0, Math.min(requestedPage - 1, pages.length - 1))]!;
1317
1321
  const total = pageTotal > 0 ? pageTotal : pages.length;
@@ -1328,6 +1332,7 @@ export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1328
1332
  highlightWords,
1329
1333
  });
1330
1334
  printSaved(outPath);
1335
+ notifyProgress(onProgress, { stage: "render.pages", current: 1, total: 1, unit: "pages" });
1331
1336
  return result;
1332
1337
  }
1333
1338
 
@@ -1336,6 +1341,7 @@ export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1336
1341
  throw new Error("需要 --out (单页模式) 或 --out-dir (批量模式)");
1337
1342
  }
1338
1343
  for (let index = 0; index < pages.length; index++) {
1344
+ notifyProgress(onProgress, { stage: "render.pages", message: `正在渲染第 ${index + 1} 页`, current: index, total: pages.length, unit: "pages" });
1339
1345
  const pageOut = join(outDir, `article-${String(index + 1).padStart(2, "0")}.png`);
1340
1346
  // DEBUG: dump layout coordinates
1341
1347
  const dbgPage = pages[index]!;
@@ -1355,6 +1361,7 @@ export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1355
1361
  highlightWords,
1356
1362
  });
1357
1363
  printSaved(pageOut);
1364
+ notifyProgress(onProgress, { stage: "render.pages", current: index + 1, total: pages.length, unit: "pages" });
1358
1365
  }
1359
1366
  return result;
1360
1367
  }
@@ -19,6 +19,8 @@ import {
19
19
  screenshotHtml,
20
20
  TEMPLATES_DIR,
21
21
  } from "./runtime";
22
+ import { notifyProgress } from "../monitor/recorder";
23
+ import type { MonitorProgress } from "../monitor/types";
22
24
 
23
25
  const SIZE_MAP: Record<string, { width: number; height: number }> = {
24
26
  "poster-3-4": { width: 900, height: 1200 },
@@ -100,7 +102,8 @@ function splitWechatTitle(text: string): { line1: string; line2: string } {
100
102
  };
101
103
  }
102
104
 
103
- export function runRenderCardCli(argv: string[]): void {
105
+ export function runRenderCardCli(argv: string[], onProgress?: (progress: MonitorProgress) => void): void {
106
+ notifyProgress(onProgress, { stage: "render.cover", message: "正在生成封面", current: 0, total: 1, unit: "pages" });
104
107
  const parsed = parseArgs(argv);
105
108
  const template = getArg(parsed, "template", "poster-3-4");
106
109
  const outPath = requireArg(parsed, "out");
@@ -209,6 +212,7 @@ export function runRenderCardCli(argv: string[]): void {
209
212
  });
210
213
  rmSync(rawPath, { force: true });
211
214
  printSaved(outPath);
215
+ notifyProgress(onProgress, { stage: "render.cover", current: 1, total: 1, unit: "pages" });
212
216
  return;
213
217
  }
214
218
 
@@ -221,6 +225,7 @@ export function runRenderCardCli(argv: string[]): void {
221
225
  virtualTimeBudgetMs: usesPretext ? PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS : undefined,
222
226
  });
223
227
  printSaved(outPath);
228
+ notifyProgress(onProgress, { stage: "render.cover", current: 1, total: 1, unit: "pages" });
224
229
  }
225
230
 
226
231
  if (import.meta.main) {
@@ -407,7 +407,7 @@ export function resolveInputPath(path: string): string {
407
407
  }
408
408
 
409
409
  export function printSaved(outPath: string): void {
410
- console.log(`✅ Saved to ${outPath}`);
410
+ console.error(`✅ Saved to ${outPath}`);
411
411
  }
412
412
 
413
413
  export function readImageSize(path: string): { width: number; height: number } | null {
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -6,9 +6,13 @@ import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
6
6
 
7
7
  describe("pipeline daily logger", () => {
8
8
  const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
9
+ const previousMonitor = process.env.ZZHUB_PIPELINE_MONITOR;
10
+ beforeEach(() => { process.env.ZZHUB_PIPELINE_MONITOR = "0"; });
9
11
  let tempDir: string | null = null;
10
12
 
11
13
  afterEach(() => {
14
+ if (previousMonitor === undefined) delete process.env.ZZHUB_PIPELINE_MONITOR;
15
+ else process.env.ZZHUB_PIPELINE_MONITOR = previousMonitor;
12
16
  if (previousLogDir === undefined) {
13
17
  delete process.env.ZZHUB_PIPELINE_LOG_DIR;
14
18
  } else {
@@ -20,6 +24,29 @@ describe("pipeline daily logger", () => {
20
24
  }
21
25
  });
22
26
 
27
+ test("logs returned business failures as FAIL without throwing or exiting the host", async () => {
28
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
29
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
30
+ const outcome = await runWithCommandLog("publish", [], async () => ({ status: "failed", errors: [{ code: "PUBLISH_FAILED", message: "upload failed" }] }));
31
+ expect(outcome.status).toBe("failed");
32
+ expect(readFileSync(getDailyLogPath(), "utf8")).toContain("=== FAIL command=publish");
33
+ });
34
+
35
+ test("an explicit nonzero exit code cannot be logged as success", async () => {
36
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
37
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
38
+ // Bun 需要显式恢复 0;写回 undefined 不会清除已经设置的退出码。
39
+ const previousCode = process.exitCode ?? 0;
40
+ try {
41
+ const outcome = await runWithCommandLog("legacy", [], async () => {
42
+ process.exitCode = 1;
43
+ return { status: "success" };
44
+ });
45
+ expect(outcome.status).toBe("failed");
46
+ expect(readFileSync(getDailyLogPath(), "utf8")).toContain("=== FAIL command=legacy");
47
+ } finally { process.exitCode = previousCode; }
48
+ });
49
+
23
50
  test("writes command start/ok markers and console output to the daily log", async () => {
24
51
  tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
25
52
  process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;