@zzclub/pipeline 0.10.2 → 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.
Files changed (52) hide show
  1. package/README.md +8 -72
  2. package/package.json +1 -1
  3. package/src/adapter-types.ts +5 -0
  4. package/src/adapters/builtin-image-renderer.ts +7 -6
  5. package/src/adapters/builtin-markdown-renderer.ts +3 -0
  6. package/src/cli.ts +10 -4
  7. package/src/command-outcome.ts +50 -0
  8. package/src/commands/abandon.ts +13 -2
  9. package/src/commands/checkpoint.ts +3 -2
  10. package/src/commands/config.ts +47 -20
  11. package/src/commands/monitor.ts +21 -0
  12. package/src/commands/prepare-finalize.ts +2 -0
  13. package/src/commands/prepare.ts +3 -0
  14. package/src/commands/publish.ts +5 -2
  15. package/src/commands/render.ts +7 -3
  16. package/src/commands/republish.ts +4 -1
  17. package/src/commands/sync-blog.ts +4 -2
  18. package/src/commands/wechat-export.ts +6 -3
  19. package/src/config.ts +21 -2
  20. package/src/imgx/render-article.ts +8 -1
  21. package/src/imgx/render-card.ts +6 -1
  22. package/src/imgx/runtime.ts +1 -1
  23. package/src/logger.test.ts +28 -1
  24. package/src/logger.ts +39 -26
  25. package/src/monitor/client.ts +60 -0
  26. package/src/monitor/recorder.ts +142 -0
  27. package/src/monitor/runtime.ts +58 -0
  28. package/src/monitor/server.ts +170 -0
  29. package/src/monitor/store.ts +235 -0
  30. package/src/monitor/types.ts +77 -0
  31. package/src/monitor.test.ts +241 -0
  32. package/src/plugins.ts +4 -5
  33. package/src/providers/blog.ts +4 -1
  34. package/src/providers/cos.ts +3 -0
  35. package/src/providers/index.ts +7 -3
  36. package/src/providers/publish-core.ts +7 -1
  37. package/src/providers/wechat.ts +7 -2
  38. package/src/schema/config.account-name.test.ts +79 -0
  39. package/src/schema/config.ts +9 -3
  40. package/src/spawn.ts +4 -2
  41. package/src/state.ts +4 -0
  42. package/src/workflow.test.ts +3 -2
  43. package/src/commands/analytics.test.ts +0 -156
  44. package/src/commands/analytics.ts +0 -240
  45. package/src/commands/topic.test.ts +0 -146
  46. package/src/commands/topic.ts +0 -717
  47. package/src/db.test.ts +0 -47
  48. package/src/db.ts +0 -95
  49. package/src/schema/analytics.test.ts +0 -54
  50. package/src/schema/analytics.ts +0 -19
  51. package/src/schema/topic.test.ts +0 -69
  52. package/src/schema/topic.ts +0 -55
package/README.md CHANGED
@@ -410,7 +410,7 @@ src/
410
410
  | `review` | Update content review status |
411
411
  | `abandon` | Mark one or more tasks as abandoned |
412
412
 
413
- `ops` 组,12 个命令:
413
+ `ops` 组,11 个命令:
414
414
 
415
415
  | 命令 | 说明 |
416
416
  | --- | --- |
@@ -425,8 +425,6 @@ src/
425
425
  | `hermes-metrics` | Show Hermes execution metrics per task |
426
426
  | `wx-drafts` | List or get drafts from WeChat draft box |
427
427
  | `wx-draft-delete` | Delete a draft from WeChat draft box |
428
- | `topic` | 选题管理(添加、列表、更新、排期、复盘、放弃) |
429
- | `analytics` | 发布数据录入和分析 |
430
428
 
431
429
  ## 常用命令
432
430
 
@@ -521,75 +519,6 @@ bun run src/cli.ts reset --state {state_path} --mode full # 完全放弃
521
519
 
522
520
  每种模式均会设置 `redo_hint`,确保 Agent 恢复时知道从哪个子步骤开始。
523
521
 
524
- ### 选题管理
525
-
526
- 使用 `topic` 命令管理内容选题的完整生命周期:
527
-
528
- ```bash
529
- # 添加选题
530
- bun run src/cli.ts topic add \
531
- --workspace {workspace} \
532
- --title "AI 工具推荐" \
533
- --priority high \
534
- --tags "AI,工具"
535
-
536
- # 列出选题
537
- bun run src/cli.ts topic list --workspace {workspace}
538
- bun run src/cli.ts topic list --workspace {workspace} --status backlog --priority high
539
-
540
- # 更新选题(AI 评估)
541
- bun run src/cli.ts topic update \
542
- --workspace {workspace} \
543
- --topic {topic_id} \
544
- --ai-score 85 \
545
- --ai-reason "基于热点趋势和受众匹配度"
546
-
547
- # 排期
548
- bun run src/cli.ts topic schedule \
549
- --workspace {workspace} \
550
- --topic {topic_id} \
551
- --scheduled-date 2026-06-15 \
552
- --target-account default
553
-
554
- # 复盘(发布后)
555
- bun run src/cli.ts topic retro \
556
- --workspace {workspace} \
557
- --topic {topic_id} \
558
- --performance good \
559
- --lessons "标题悬念效果好" \
560
- --metrics-snapshot '{"reads": 1500, "likes": 45}'
561
-
562
- # 放弃选题
563
- bun run src/cli.ts topic abandon \
564
- --workspace {workspace} \
565
- --topic {topic_id} \
566
- --reason "话题过时"
567
- ```
568
-
569
- 选题状态流转:`backlog` → `evaluating` → `scheduled` → `in_progress` → `published`(或 `abandoned`)
570
-
571
- ### 数据分析
572
-
573
- 使用 `analytics` 命令录入和分析发布后的数据:
574
-
575
- ```bash
576
- # 录入发布数据
577
- bun run src/cli.ts analytics record \
578
- --state {workspace}/posts/{date-slug}/workflow-state.json \
579
- --reads 1500 \
580
- --likes 45 \
581
- --favorites 23 \
582
- --shares 12 \
583
- --comments 8 \
584
- --notes "标题效果好,转化率高"
585
-
586
- # 列出历史数据
587
- bun run src/cli.ts analytics list --workspace {workspace}
588
- bun run src/cli.ts analytics list --workspace {workspace} --days 30 --sort reads
589
- ```
590
-
591
- 数据存储在 `{workspace}/zzhub.db`(SQLite 数据库),支持复杂查询和统计分析。
592
-
593
522
  ### Blog 同步
594
523
 
595
524
  ```bash
@@ -937,6 +866,13 @@ bun install --global .
937
866
 
938
867
  Markdown → WeChat HTML 转换通过插件系统完成(默认使用内置 `builtin-wechat-preview` 适配器)。
939
868
 
869
+ ## 本机多任务监控
870
+
871
+ `zzp monitor start` 按需启动共享 HTTP/SSE 服务,`monitor status` 查看状态,`monitor stop` 只停止监控。
872
+ CLI 独立记录执行和错误,GUI 可同时订阅多个任务。新版业务失败会退出 1,调用方仍应读取失败响应和最新任务状态。
873
+
874
+ 详见 [监控接口与退出码迁移](docs/monitor.md)。
875
+
940
876
  ## 测试与验证
941
877
 
942
878
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.10.2",
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
  }
@@ -5,13 +5,17 @@ import {
5
5
  configSummary,
6
6
  getConfigValue,
7
7
  loadConfig,
8
- PipelineConfigSchema,
8
+ normalizeConfig,
9
9
  redactConfig,
10
10
  redactConfigValue,
11
11
  saveConfig,
12
12
  setConfigValue,
13
13
  } from "../config";
14
14
 
15
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+
15
19
  export async function configCommand(args: string[]): Promise<void> {
16
20
  const parsed = parseArgs(args);
17
21
 
@@ -26,6 +30,11 @@ Options:
26
30
  --export Print full config as JSON (secrets redacted; use --raw to show all)
27
31
  --import Path to a JSON file to merge into current config
28
32
  --raw Show secrets unredacted (use with --export)
33
+
34
+ Examples:
35
+ zzhub-pipeline config --key wx.defaultAccount
36
+ zzhub-pipeline config --key wx.accounts.default.name --value "大号(早早集市)"
37
+ zzhub-pipeline config --export
29
38
  `.trim());
30
39
  return;
31
40
  }
@@ -50,27 +59,45 @@ Options:
50
59
  throw new Error(`Failed to read import file: ${err instanceof Error ? err.message : String(err)}`);
51
60
  }
52
61
 
53
- // Merge: existing values win, imported fills gaps
54
- const importedObj = (typeof imported === "object" && imported !== null ? imported : {}) as Record<string, unknown>;
55
- const mergedWx = {
56
- ...config.wx,
57
- ...(importedObj.wx as Record<string, unknown> ?? {}),
58
- accounts: {
59
- ...config.wx.accounts,
60
- ...((importedObj.wx as Record<string, unknown>)?.accounts as Record<string, unknown> ?? {}),
61
- },
62
- };
62
+ // Merge: top-level shallow; wx.accounts deep-merge per key so partial imports
63
+ // keep existing fields (e.g. display `name`) unless overridden.
64
+ const importedObj = isPlainObject(imported) ? imported : {};
65
+ const importedWx = isPlainObject(importedObj.wx) ? importedObj.wx : {};
66
+ const importedAccounts = isPlainObject(importedWx.accounts) ? importedWx.accounts : {};
67
+ const mergedAccounts: Record<string, unknown> = { ...config.wx.accounts };
68
+ for (const [key, value] of Object.entries(importedAccounts)) {
69
+ const accountKey = key.trim();
70
+ if (!accountKey) continue;
71
+ const existing = isPlainObject(mergedAccounts[accountKey])
72
+ ? mergedAccounts[accountKey]
73
+ : {};
74
+ const incoming = isPlainObject(value) ? value : {};
75
+ mergedAccounts[accountKey] = { ...existing, ...incoming };
76
+ }
63
77
  const raw = {
64
- paths: { ...config.paths, ...(importedObj.paths ?? {}) },
65
- services: { ...config.services, ...(importedObj.services ?? {}) },
66
- commands: { ...config.commands, ...(importedObj.commands ?? {}) },
67
- wx: mergedWx,
68
- cos: { ...config.cos, ...(importedObj.cos ?? {}) },
69
- plugins: { ...config.plugins, ...(importedObj.plugins ?? {}) },
70
- imgx: { ...config.imgx, ...(importedObj.imgx ?? {}) },
78
+ paths: { ...config.paths, ...(isPlainObject(importedObj.paths) ? importedObj.paths : {}) },
79
+ services: {
80
+ ...config.services,
81
+ ...(isPlainObject(importedObj.services) ? importedObj.services : {}),
82
+ },
83
+ commands: {
84
+ ...config.commands,
85
+ ...(isPlainObject(importedObj.commands) ? importedObj.commands : {}),
86
+ },
87
+ wx: {
88
+ ...config.wx,
89
+ ...importedWx,
90
+ accounts: mergedAccounts,
91
+ },
92
+ cos: { ...config.cos, ...(isPlainObject(importedObj.cos) ? importedObj.cos : {}) },
93
+ plugins: {
94
+ ...config.plugins,
95
+ ...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
96
+ },
97
+ imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
71
98
  };
72
- // Validate through Zod schema strips unknown fields, applies defaults
73
- const merged = PipelineConfigSchema.parse(raw);
99
+ // Soft-fill known account display names + Zod defaults / strip unknowns
100
+ const merged = normalizeConfig(raw);
74
101
 
75
102
  saveConfig(merged);
76
103
  printResult(configSummary(merged), renderConfig);
@@ -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
  }