@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
@@ -0,0 +1,241 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { appendFile, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { defaultState, writeState } from "./state";
6
+ import { runWithCommandLog } from "./logger";
7
+ import { notifyProgress, reportLog, reportProgress } from "./monitor/recorder";
8
+ import { MonitorStore } from "./monitor/store";
9
+ import { serveMonitor } from "./monitor/server";
10
+ import { LOG_LIMIT } from "./monitor/runtime";
11
+ import type { MonitorDescriptor, MonitorSnapshot } from "./monitor/types";
12
+
13
+ let root = "";
14
+ let stop: (() => void) | undefined;
15
+ const previousEnv = { ...process.env };
16
+ const cli = resolve(import.meta.dir, "cli.ts");
17
+
18
+ beforeEach(async () => {
19
+ root = await mkdtemp(join(tmpdir(), "zzp-monitor-test-"));
20
+ process.env.ZZHUB_PIPELINE_MONITOR_DIR = join(root, "monitor");
21
+ process.env.ZZHUB_PIPELINE_LOG_DIR = join(root, "logs");
22
+ process.env.ZZHUB_PIPELINE_CONFIG = join(root, "config.json");
23
+ process.env.ZZHUB_PIPELINE_MONITOR = "1";
24
+ process.env.NO_COLOR = "1";
25
+ await writeFile(process.env.ZZHUB_PIPELINE_CONFIG, "{}");
26
+ });
27
+
28
+ afterEach(async () => {
29
+ stop?.();
30
+ stop = undefined;
31
+ for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "NO_COLOR"]) {
32
+ if (previousEnv[key] === undefined) delete process.env[key];
33
+ else process.env[key] = previousEnv[key];
34
+ }
35
+ await rm(root, { recursive: true, force: true });
36
+ });
37
+
38
+ async function run(args: string[]) {
39
+ const child = Bun.spawn([process.execPath, cli, ...args], { env: { ...process.env }, stdout: "pipe", stderr: "pipe" });
40
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
41
+ return { stdout, stderr, exitCode };
42
+ }
43
+
44
+ async function seed(workspace = root, runId = "same-run") {
45
+ const asset = join(workspace, "posts", "test");
46
+ await mkdir(asset, { recursive: true });
47
+ await writeFile(join(asset, "post.md"), "# 测试\n\n正文");
48
+ const state = defaultState();
49
+ state.run_id = runId;
50
+ state.workspace_root = workspace;
51
+ state.state_path = join(asset, "workflow-state.json");
52
+ state.asset_path = asset;
53
+ state.metadata = { ...state.metadata, title: "测试", slug: "test", date: "2026-09-06" };
54
+ state.content_review.status = "passed";
55
+ state.intent.requires.render = false;
56
+ state.intent.requires.publish = true;
57
+ state.route.primary = "blog";
58
+ state.phase.current = "publish";
59
+ state.phase.prepare.status = "done";
60
+ state.phase.render.status = "done";
61
+ await writeState(state.state_path, state);
62
+ return state;
63
+ }
64
+
65
+ async function blogCommand(script: string) {
66
+ await writeFile(process.env.ZZHUB_PIPELINE_CONFIG!, JSON.stringify({ paths: { workspaceRoot: root, blogRoot: join(root, "blog") }, commands: { blogPublish: [process.execPath, "-e", script] } }));
67
+ }
68
+
69
+ function headers(descriptor: MonitorDescriptor) { return { Authorization: `Bearer ${descriptor.token}` }; }
70
+
71
+ test("CLI partial failures persist, exit 1, keep JSON stdout and retry only failed targets", async () => {
72
+ const state = await seed();
73
+ state.publish_targets = [{ route: "blog", account: "first" }, { route: "blog", account: "second" }];
74
+ await writeState(state.state_path!, state);
75
+ await blogCommand("const fs = require('fs'); console.log('child diagnostic'); if (fs.existsSync('attempt')) process.exit(7); fs.writeFileSync('attempt', 'ok');");
76
+ const failed = await run(["publish", "--state", state.state_path!]);
77
+ expect(failed.exitCode).toBe(1);
78
+ const result = JSON.parse(failed.stdout);
79
+ expect(result.publish_results.map((item: { status: string }) => item.status)).toEqual(["success", "failed"]);
80
+ expect(result.errors[0].error).toContain("exit=7");
81
+ expect(failed.stderr).toContain("child diagnostic");
82
+ expect(failed.stderr).toContain("PUBLISH_TARGET_FAILED");
83
+ const saved = JSON.parse(await readFile(state.state_path!, "utf8"));
84
+ expect(saved.mode).toBe("active");
85
+ expect(saved.phase.publish.status).toBe("pending");
86
+ const store = new MonitorStore("test");
87
+ await store.refresh();
88
+ expect(store.snapshot().executions[0]?.outcome?.status).toBe("partial_failure");
89
+ await blogCommand("require('fs').appendFileSync('retried', 'once');");
90
+ expect((await run(["publish", "--state", state.state_path!])).exitCode).toBe(0);
91
+ expect(await readFile(join(root, "blog", "retried"), "utf8")).toBe("once");
92
+ expect((await run(["publish", "--state", state.state_path!])).exitCode).toBe(0);
93
+ expect(await readFile(join(root, "blog", "retried"), "utf8")).toBe("once");
94
+ });
95
+
96
+ test("checkpoint failure exits through common cleanup; status and dry-run still exit 0", async () => {
97
+ const state = await seed();
98
+ state.content_review.status = "unchecked";
99
+ await writeState(state.state_path!, state);
100
+ const checkpoint = await run(["checkpoint", "--state", state.state_path!]);
101
+ expect(checkpoint.exitCode).toBe(1);
102
+ expect(JSON.parse(checkpoint.stdout).validation.valid).toBe(false);
103
+ expect((await run(["status", "--state", state.state_path!])).exitCode).toBe(0);
104
+ state.content_review.status = "passed";
105
+ await writeState(state.state_path!, state);
106
+ await blogCommand("process.exit(9)");
107
+ expect((await run(["publish", "--state", state.state_path!, "--dry-run"])).exitCode).toBe(0);
108
+ const store = new MonitorStore("test");
109
+ await store.refresh();
110
+ expect(store.snapshot().executions.find((execution) => execution.command === "checkpoint")?.status).toBe("exited");
111
+ });
112
+
113
+ test("20 parallel CLI calls preserve execution identities and separate identical run IDs across workspaces", async () => {
114
+ const a = await seed(join(root, "a"));
115
+ const b = await seed(join(root, "b"));
116
+ const results = await Promise.all(Array.from({ length: 20 }, (_, index) => run(["status", "--state", (index % 2 ? a : b).state_path!])));
117
+ expect(results.every((result) => result.exitCode === 0)).toBe(true);
118
+ const store = new MonitorStore("test");
119
+ await store.refresh();
120
+ expect(store.snapshot().executions).toHaveLength(20);
121
+ expect(store.snapshot().tasks).toHaveLength(2);
122
+ expect(store.snapshot({ workspace: a.workspace_root }).executions).toHaveLength(10);
123
+ expect(new Set(store.snapshot().executions.map((execution) => execution.id)).size).toBe(20);
124
+ }, 15_000);
125
+
126
+ test("recording failure and optional callback failure cannot change command results", async () => {
127
+ const file = join(root, "not-a-directory");
128
+ await writeFile(file, "x");
129
+ process.env.ZZHUB_PIPELINE_MONITOR_DIR = file;
130
+ const state = await seed();
131
+ expect((await run(["status", "--state", state.state_path!])).exitCode).toBe(0);
132
+ expect(() => notifyProgress(() => { throw new Error("observer unavailable"); }, { stage: "test" })).not.toThrow();
133
+ await expect(runWithCommandLog("render", [], async () => { throw new Error("original failure"); })).rejects.toThrow("original failure");
134
+ });
135
+
136
+ test("logs redact secrets, preserve UTF-8 and retain terminal errors after truncation", async () => {
137
+ const message = "中文".repeat(2000);
138
+ await runWithCommandLog("render", ["--pat", "private-pat"], async () => {
139
+ reportLog("info", "Authorization: Bearer private-token ?access_token=another-secret");
140
+ for (let index = 0; index < 1100; index++) reportLog("debug", message);
141
+ reportProgress({ stage: "render.pages", current: 3, total: 3, unit: "pages" });
142
+ return { status: "failed", errors: [{ code: "RENDER_FAILED", message: "最终错误" }] };
143
+ });
144
+ const store = new MonitorStore("test");
145
+ await store.refresh();
146
+ const execution = store.snapshot().executions[0]!;
147
+ expect(execution.logs_truncated).toBe(true);
148
+ expect(store.eventsAfter("test:0")).toBeNull();
149
+ expect(execution.outcome?.errors?.[0]?.message).toBe("最终错误");
150
+ const logs = await store.logs(execution.id, 0, 30);
151
+ expect(JSON.stringify(logs)).not.toContain("private-token");
152
+ expect(JSON.stringify(logs)).not.toContain("another-secret");
153
+ expect(JSON.stringify(logs)).not.toContain("�");
154
+ const raw = await readFile(join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events", `${execution.id}.jsonl`), "utf8");
155
+ expect(raw).not.toContain("private-pat");
156
+ expect(Buffer.byteLength(raw)).toBeLessThan(LOG_LIMIT + 300_000);
157
+ });
158
+
159
+ test("incomplete final records resume on the next read and state aliases keep one task", async () => {
160
+ await runWithCommandLog("prepare-finalize", [], async () => {
161
+ const state = await seed();
162
+ await writeState(join(root, "temporary-state.json"), state);
163
+ });
164
+ const eventDir = join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events");
165
+ const file = join(eventDir, (await readdir(eventDir))[0]!);
166
+ const raw = await readFile(file, "utf8");
167
+ const lines = raw.trimEnd().split("\n");
168
+ const final = lines.pop()!;
169
+ await writeFile(file, `${lines.join("\n")}\ninvalid-event\n${final.slice(0, -5)}`);
170
+ const store = new MonitorStore("test");
171
+ await store.refresh();
172
+ expect(store.snapshot().tasks).toHaveLength(1);
173
+ expect(store.snapshot().executions[0]?.outcome).toBeNull();
174
+ expect(store.snapshot().executions[0]?.status).not.toBe("interrupted");
175
+ await appendFile(file, `${final.slice(-5)}\n`);
176
+ await store.refresh();
177
+ expect(store.snapshot().executions[0]?.status).toBe("exited");
178
+ expect(store.snapshot().tasks[0]?.state_path).toContain("posts/test/workflow-state.json");
179
+ });
180
+
181
+ test("HTTP/SSE support auth, shared subscriptions, cursor replay, resync and server restart", async () => {
182
+ const server = await serveMonitor();
183
+ expect(server).not.toBeNull();
184
+ stop = server!.stop;
185
+ const descriptor = server!.descriptor;
186
+ expect((await fetch(`${descriptor.url}/v1/snapshot`)).status).toBe(401);
187
+ expect((await fetch(`${descriptor.url}/v1/snapshot`, { headers: { ...headers(descriptor), Origin: "https://untrusted.example" } })).status).toBe(403);
188
+ const initial = await (await fetch(`${descriptor.url}/v1/snapshot`, { headers: headers(descriptor) })).json() as MonitorSnapshot;
189
+ const controllers = [new AbortController(), new AbortController()];
190
+ const streams = await Promise.all(controllers.map((controller) => fetch(`${descriptor.url}/v1/events?after=${encodeURIComponent(initial.cursor)}`, { headers: headers(descriptor), signal: controller.signal })));
191
+ const readers = streams.map((response) => response.body!.getReader());
192
+ await Promise.all(readers.map((reader) => reader.read()));
193
+ try {
194
+ await runWithCommandLog("render", [], async () => { reportProgress({ stage: "render.pages", current: 1, total: 2, unit: "pages" }); });
195
+ await fetch(`${descriptor.url}/v1/snapshot`, { headers: headers(descriptor) });
196
+ const events = await Promise.all(readers.map(async (reader) => new TextDecoder().decode((await reader.read()).value)));
197
+ expect(events.every((event) => event.includes("execution.updated"))).toBe(true);
198
+ const replay = await fetch(`${descriptor.url}/v1/events?after=${encodeURIComponent(initial.cursor)}`, { headers: headers(descriptor) });
199
+ const replayReader = replay.body!.getReader();
200
+ expect(new TextDecoder().decode((await replayReader.read()).value)).toContain("execution.updated");
201
+ await replayReader.cancel();
202
+ const stale = await fetch(`${descriptor.url}/v1/events?after=old-instance:1`, { headers: headers(descriptor) });
203
+ expect(await stale.text()).toContain("resync");
204
+ } finally {
205
+ controllers.forEach((controller) => controller.abort());
206
+ await Promise.all(readers.map((reader) => reader.cancel().catch(() => {})));
207
+ }
208
+ stop();
209
+ const restarted = await serveMonitor();
210
+ stop = restarted!.stop;
211
+ expect(restarted!.descriptor.instance_id).not.toBe(descriptor.instance_id);
212
+ const restored = await (await fetch(`${restarted!.descriptor.url}/v1/snapshot`, { headers: headers(restarted!.descriptor) })).json() as MonitorSnapshot;
213
+ expect(restored.executions).toHaveLength(1);
214
+ });
215
+
216
+ test("parallel monitor start calls reuse a single service and stop only that service", async () => {
217
+ const results = await Promise.all(Array.from({ length: 4 }, () => run(["monitor", "start"])));
218
+ const descriptors = results.map((result) => JSON.parse(result.stdout) as MonitorDescriptor);
219
+ try {
220
+ expect(new Set(descriptors.map((descriptor) => descriptor.instance_id)).size).toBe(1);
221
+ expect(results.every((result) => result.exitCode === 0)).toBe(true);
222
+ expect(await readdir(join(root, "logs")).catch(() => [])).toHaveLength(0);
223
+ } finally { await run(["monitor", "stop"]); }
224
+ }, 20_000);
225
+
226
+ test("a killed execution becomes interrupted without a heartbeat timeout", async () => {
227
+ const script = `import { runWithCommandLog } from ${JSON.stringify(resolve(import.meta.dir, "logger.ts"))}; await runWithCommandLog('render', [], async () => { await Bun.sleep(60000); });`;
228
+ const child = Bun.spawn([process.execPath, "-e", script], { env: { ...process.env }, stdout: "ignore", stderr: "ignore" });
229
+ try {
230
+ for (let attempt = 0; attempt < 100; attempt++) {
231
+ if ((await readdir(join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events")).catch(() => [])).length) break;
232
+ await Bun.sleep(20);
233
+ }
234
+ child.kill("SIGKILL");
235
+ await child.exited;
236
+ const store = new MonitorStore("test");
237
+ await store.refresh();
238
+ expect(store.snapshot().executions[0]?.status).toBe("interrupted");
239
+ expect(store.snapshot().executions[0]?.exit_code).toBeNull();
240
+ } finally { if (child.exitCode === null) { child.kill(); await child.exited; } }
241
+ });
package/src/plugins.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { abandon } from "./commands/abandon";
2
- import { analytics } from "./commands/analytics";
3
2
  import { attachBody } from "./commands/attach-body";
4
3
  import { attachBodyImages } from "./commands/attach-body-images";
5
4
  import { attachNewspicSpec } from "./commands/attach-newspic-spec";
@@ -23,17 +22,18 @@ import { review } from "./commands/review";
23
22
  import { status } from "./commands/status";
24
23
  import { syncBlog } from "./commands/sync-blog";
25
24
  import { tasks } from "./commands/tasks";
26
- import { topic } from "./commands/topic";
27
25
  import { wechatExport } from "./commands/wechat-export";
28
26
  import { wechatPreview } from "./commands/wechat-preview";
29
27
  import { wxDrafts } from "./commands/wx-drafts";
30
28
  import { wxDraftDelete } from "./commands/wx-draft-delete";
29
+ import type { CommandOutcome } from "./command-outcome";
30
+ import { monitor } from "./commands/monitor";
31
31
 
32
32
  export interface CommandDefinition {
33
33
  name: string;
34
34
  summary: string;
35
35
  plugin: string;
36
- handler: (args: string[]) => Promise<void>;
36
+ handler: (args: string[]) => Promise<void | CommandOutcome>;
37
37
  }
38
38
 
39
39
  export interface CommandPlugin {
@@ -68,6 +68,7 @@ export function getCommandPlugins(): CommandPlugin[] {
68
68
  {
69
69
  name: "ops",
70
70
  commands: [
71
+ { name: "monitor", summary: "Manage local HTTP/SSE multi-task monitoring", plugin: "ops", handler: monitor },
71
72
  { name: "sync-blog", summary: "Copy post.md to blog repo, publish, and record result in state JSON", plugin: "ops", handler: syncBlog },
72
73
  { name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
73
74
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
@@ -79,8 +80,6 @@ export function getCommandPlugins(): CommandPlugin[] {
79
80
  { name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
80
81
  { name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
81
82
  { name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
82
- { name: "topic", summary: "Manage topics (add, list, update, schedule, retro, abandon)", plugin: "ops", handler: topic },
83
- { name: "analytics", summary: "Record and analyze post-publish metrics", plugin: "ops", handler: analytics },
84
83
  ],
85
84
  },
86
85
  ];
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "path";
3
3
  import { PipelineConfig, ResolvedWorkspacePaths } from "../config";
4
4
  import { spawnSync } from "../spawn";
5
+ import { reportProgress } from "../monitor/recorder";
5
6
  import { PublishResult, WorkflowState } from "../state";
6
7
  import { stripFrontmatter } from "../text";
7
8
  import { uploadFileToCos } from "./cos";
@@ -171,14 +172,16 @@ export async function publishBlogRoute({
171
172
 
172
173
  await mkdir(blogPostDir, { recursive: true });
173
174
  await writeFile(blogPostPath, blogContent, "utf-8");
175
+ reportProgress({ stage: "publish.script", message: "正在运行博客发布命令" });
174
176
  const result = spawnSync(config.commands.blogPublish, { cwd: blogRoot });
177
+ reportProgress({ stage: "publish.script", message: result.exitCode === 0 ? "博客发布命令完成" : "博客发布命令失败", current: 1, total: 1, unit: "targets" });
175
178
 
176
179
  if (result.exitCode !== 0) {
177
180
  return {
178
181
  route: "blog",
179
182
  account: "default",
180
183
  status: "failed",
181
- detail: `blog publish command failed: ${config.commands.blogPublish.join(" ")}`,
184
+ detail: `blog publish command failed: ${config.commands.blogPublish[0]} (exit=${result.exitCode ?? "unknown"}, signal=${result.signal ?? "none"}${result.error ? `, cause=${result.error.message}` : ""})`,
182
185
  published_at: null,
183
186
  content_version: state.artifacts.content_version,
184
187
  render_version: state.artifacts.render_version,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { basename } from "path";
3
3
  import COS from "cos-nodejs-sdk-v5";
4
+ import { reportProgress } from "../monitor/recorder";
4
5
 
5
6
  const STS_PATH = "/api/v1/upload/cos";
6
7
  const STS_TIMEOUT = 30000;
@@ -142,6 +143,7 @@ export async function uploadFileToCos(input: {
142
143
  }
143
144
 
144
145
  const sts = await requestSts(baseUrl, cosPat, filename, folder);
146
+ reportProgress({ stage: "upload.cos", message: "正在上传 COS 文件" });
145
147
  const buffer = readFileSync(localPath);
146
148
 
147
149
  const cos = new COS({
@@ -165,6 +167,7 @@ export async function uploadFileToCos(input: {
165
167
  Key: sts.Key,
166
168
  Body: buffer,
167
169
  ContentType: contentType,
170
+ onProgress: ({ loaded, total }) => reportProgress({ stage: "upload.cos", current: loaded, total, unit: "bytes" }),
168
171
  },
169
172
  (err) => {
170
173
  if (settled) return;
@@ -11,6 +11,7 @@ import {
11
11
  prepareBodyForNewspic,
12
12
  } from "../text";
13
13
  import { resolveMarkdownRenderer } from "../adapter-loader";
14
+ import { reportProgress } from "../monitor/recorder";
14
15
  import { publishBlogRoute } from "./blog";
15
16
  import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
16
17
 
@@ -80,6 +81,7 @@ async function publishWechatArticleRoute({
80
81
  const markdownRenderer = await resolveMarkdownRenderer(config);
81
82
  const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
82
83
  const rendered = await markdownRenderer.render({
84
+ onProgress: reportProgress,
83
85
  markdownPath: exportPostPath,
84
86
  outPath: workspacePaths.zotepadExportHtml,
85
87
  account,
@@ -93,7 +95,7 @@ async function publishWechatArticleRoute({
93
95
  if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
94
96
  try {
95
97
  const { registerPreviewEntry } = await import("../wechat-preview/server");
96
- await registerPreviewEntry(
98
+ const registration = await registerPreviewEntry(
97
99
  {
98
100
  title: `[publish] ${state.metadata.title || "untitled"}`,
99
101
  account,
@@ -110,8 +112,10 @@ async function publishWechatArticleRoute({
110
112
  },
111
113
  { autoStart: process.env.ZZHUB_WECHAT_PREVIEW_AUTO_START !== "0" },
112
114
  );
113
- } catch {
114
- // preview registration must never block publish
115
+ if (!registration.ok) console.warn(`微信预览登记失败:${registration.error || "未知原因"}`);
116
+ } catch (error) {
117
+ // 预览故障只记录警告,不改变发布结果。
118
+ console.warn("微信预览登记失败:", error);
115
119
  }
116
120
  }
117
121
 
@@ -7,6 +7,7 @@
7
7
  import type { PipelineConfig, ResolvedWorkspacePaths } from "../config";
8
8
  import type { PublishResult, PublishTarget, WorkflowState } from "../state";
9
9
  import { getPublishProvider, type PublishRouteContext } from "./index";
10
+ import { reportProgress } from "../monitor/recorder";
10
11
 
11
12
  export interface PublishTargetError {
12
13
  route: string;
@@ -102,6 +103,7 @@ export async function executePublishTargets(
102
103
  const errors: PublishTargetError[] = [];
103
104
 
104
105
  for (const target of filtered) {
106
+ reportProgress({ stage: "publish.targets", message: "正在发布", current: results.length, total: filtered.length, unit: "targets", route: target.route, account: target.account });
105
107
  let result: PublishResult;
106
108
  try {
107
109
  const provider = getPublishProvider(target.route);
@@ -135,10 +137,14 @@ export async function executePublishTargets(
135
137
  content_version: state.artifacts.content_version,
136
138
  render_version: state.artifacts.render_version,
137
139
  };
138
- errors.push(error);
140
+ }
141
+ // provider 返回 failed 与抛出异常使用同一条错误汇总路径。
142
+ if (result.status === "failed") {
143
+ errors.push({ route: target.route, account: target.account, error: result.detail || "发布失败,provider 未提供原因" });
139
144
  }
140
145
  results.push(result);
141
146
  await onResult?.(result);
147
+ reportProgress({ stage: "publish.targets", message: result.status === "failed" ? "目标发布失败" : "目标处理完成", current: results.length, total: filtered.length, unit: "targets", route: target.route, account: target.account });
142
148
  }
143
149
 
144
150
  return { results, errors };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { basename, extname } from "path";
3
3
  import { PipelineConfig, WxAccountConfig } from "../config";
4
+ import { reportProgress } from "../monitor/recorder";
4
5
 
5
6
  const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
6
7
  const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
@@ -161,8 +162,10 @@ export function normalizeAccountName(value: string): string {
161
162
  return account;
162
163
  }
163
164
 
164
- function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
165
+ /** Fill missing wx account fields with defaults. Preserves optional display `name`. */
166
+ export function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
165
167
  return {
168
+ name: source?.name ?? "",
166
169
  pat: source?.pat ?? "",
167
170
  appId: source?.appId ?? "",
168
171
  appSecret: source?.appSecret ?? "",
@@ -540,6 +543,7 @@ async function uploadPhotos(
540
543
  continue;
541
544
  }
542
545
 
546
+ reportProgress({ stage: "publish.upload", message: "正在上传微信图片", current: index, total: photos.length, unit: "files" });
543
547
  const payload = await resolvePhotoPayload(photoUrl, index);
544
548
  const formData = new FormData();
545
549
  formData.append("access_token", accessToken);
@@ -574,6 +578,7 @@ async function uploadPhotos(
574
578
  index,
575
579
  });
576
580
  imageUrlMap[photoUrl] = wxUrl || photoUrl;
581
+ reportProgress({ stage: "publish.upload", current: index + 1, total: photos.length, unit: "files" });
577
582
  }
578
583
 
579
584
  return {
@@ -781,7 +786,7 @@ async function notifyNezusPublishResult(params: {
781
786
  console.error(`[nezus] publish-result callback OK for note ${params.noteId}`);
782
787
  } catch (err) {
783
788
  // Non-fatal: the draft was created successfully even if the callback failed
784
- console.error(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
789
+ console.warn(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
785
790
  }
786
791
  }
787
792
 
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeConfig, SUGGESTED_ACCOUNT_NAMES } from "../config";
4
+ import { fillWxAccountConfig } from "../providers/wechat";
5
+ import { PipelineConfigSchema } from "./config";
6
+
7
+ describe("wx account display name", () => {
8
+ test("schema keeps name when provided", () => {
9
+ const parsed = PipelineConfigSchema.parse({
10
+ wx: {
11
+ accounts: {
12
+ default: {
13
+ name: "早早集市",
14
+ appId: "wx123",
15
+ },
16
+ },
17
+ },
18
+ });
19
+ expect(parsed.wx.accounts.default?.name).toBe("早早集市");
20
+ expect(parsed.wx.accounts.default?.appId).toBe("wx123");
21
+ });
22
+
23
+ test("schema defaults name to empty string when missing", () => {
24
+ const parsed = PipelineConfigSchema.parse({
25
+ wx: {
26
+ accounts: {
27
+ custom: {
28
+ appId: "wx999",
29
+ },
30
+ },
31
+ },
32
+ });
33
+ // custom key has no soft-suggest unless normalizeConfig is used
34
+ expect(parsed.wx.accounts.custom?.name).toBe("");
35
+ });
36
+
37
+ test("normalizeConfig soft-fills known keys only when name empty", () => {
38
+ const normalized = normalizeConfig({
39
+ wx: {
40
+ accounts: {
41
+ default: { appId: "wx1" },
42
+ ancientone: { name: "我自定义的小号", appId: "wx2" },
43
+ other: { appId: "wx3" },
44
+ },
45
+ },
46
+ });
47
+ expect(normalized.wx.accounts.default?.name).toBe(SUGGESTED_ACCOUNT_NAMES.default);
48
+ expect(normalized.wx.accounts.ancientone?.name).toBe("我自定义的小号");
49
+ expect(normalized.wx.accounts.other?.name).toBe("");
50
+ });
51
+
52
+ test("normalizeConfig does not override explicit empty-then-set name after trim", () => {
53
+ const normalized = normalizeConfig({
54
+ wx: {
55
+ accounts: {
56
+ default: { name: " 大号定制 ", appId: "wx1" },
57
+ },
58
+ },
59
+ });
60
+ expect(normalized.wx.accounts.default?.name).toBe("大号定制");
61
+ });
62
+
63
+ test("fillWxAccountConfig preserves name", () => {
64
+ const filled = fillWxAccountConfig({
65
+ name: "大号",
66
+ pat: "p",
67
+ appId: "id",
68
+ appSecret: "s",
69
+ customCss: null,
70
+ theme: { editorVars: {}, exportTheme: {} },
71
+ });
72
+ expect(filled.name).toBe("大号");
73
+ });
74
+
75
+ test("fillWxAccountConfig defaults name to empty", () => {
76
+ const filled = fillWxAccountConfig(undefined);
77
+ expect(filled.name).toBe("");
78
+ });
79
+ });
@@ -56,6 +56,12 @@ const WechatThemeOverridesSchema = withObjectDefault(
56
56
  );
57
57
 
58
58
  const WxAccountConfigSchema = z.object({
59
+ /**
60
+ * Optional Chinese display name for UIs (e.g. 大号(早早集市)).
61
+ * Config map key remains the stable account id (default / ancientone).
62
+ * Whitespace is trimmed in normalizeConfig when soft-filling / merging.
63
+ */
64
+ name: z.string().default(""),
59
65
  pat: z.string().default(""),
60
66
  appId: z.string().default(""),
61
67
  appSecret: z.string().default(""),
@@ -74,9 +80,9 @@ const WxConfigSchema = withObjectDefault(
74
80
  z.number().default(30000),
75
81
  ),
76
82
  defaultAccount: trimmedNonEmptyString("default"),
77
- accounts: z
78
- .record(z.string(), WxAccountConfigSchema)
79
- .default({ default: { pat: "", appId: "", appSecret: "", customCss: null, theme: { editorVars: {}, exportTheme: {} } } }),
83
+ accounts: z.record(z.string(), WxAccountConfigSchema).default(() => ({
84
+ default: WxAccountConfigSchema.parse({}),
85
+ })),
80
86
  }),
81
87
  );
82
88
 
package/src/spawn.ts CHANGED
@@ -42,6 +42,7 @@ export interface SpawnOptions {
42
42
 
43
43
  export interface SpawnResult {
44
44
  exitCode: number | null;
45
+ signal?: NodeJS.Signals | null;
45
46
  error?: Error;
46
47
  }
47
48
 
@@ -81,20 +82,21 @@ export function resolveBunBinary(): string {
81
82
  function normalizeResult(result: SpawnSyncReturns<Buffer>): SpawnResult {
82
83
  return {
83
84
  exitCode: result.status,
85
+ signal: result.signal,
84
86
  ...(result.error ? { error: result.error } : {}),
85
87
  };
86
88
  }
87
89
 
88
90
  /**
89
91
  * Run a command synchronously with enriched PATH.
90
- * stdout and stderr are inherited (printed to terminal).
92
+ * 子脚本诊断输出统一写入 stderr,保留 CLI stdout 的 JSON 契约。
91
93
  *
92
94
  * Returns a normalized spawn result.
93
95
  */
94
96
  export function spawnSync(cmd: string[], opts?: SpawnOptions): SpawnResult {
95
97
  const [binary, ...args] = cmd;
96
98
  const result = nodeSpawnSync(binary, args, {
97
- stdio: "inherit",
99
+ stdio: ["inherit", 2, 2],
98
100
  env: enrichedEnv(),
99
101
  ...(opts?.cwd ? { cwd: opts.cwd } : {}),
100
102
  });
package/src/state.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { access, mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
11
11
  import { dirname, isAbsolute, resolve } from "path";
12
+ import { observeState } from "./monitor/recorder";
12
13
  import {
13
14
  WorkflowStateSchema,
14
15
  NewspicRenderSpecSchema,
@@ -245,6 +246,8 @@ export async function readResolvedState(path: string): Promise<ResolvedState> {
245
246
  state.state_path = currentPath;
246
247
  }
247
248
 
249
+ observeState(state, currentPath);
250
+
248
251
  return {
249
252
  path: currentPath,
250
253
  requested_path: requestedPath,
@@ -310,6 +313,7 @@ export async function writeState(
310
313
  } finally {
311
314
  await releaseLock();
312
315
  }
316
+ observeState(validated, normalizedPath);
313
317
  }
314
318
 
315
319
  /**
@@ -50,7 +50,7 @@ async function makeTempDir(prefix: string): Promise<string> {
50
50
  return mkdtemp(join(tmpdir(), prefix));
51
51
  }
52
52
 
53
- async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
53
+ async function captureJsonOutput<T>(fn: () => Promise<unknown>): Promise<T> {
54
54
  const original = console.log;
55
55
  const lines: string[] = [];
56
56
  console.log = (...args: unknown[]) => {
@@ -66,7 +66,7 @@ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
66
66
  return JSON.parse(payload) as T;
67
67
  }
68
68
 
69
- async function captureTextOutput(fn: () => Promise<void>): Promise<string> {
69
+ async function captureTextOutput(fn: () => Promise<unknown>): Promise<string> {
70
70
  const original = console.log;
71
71
  const lines: string[] = [];
72
72
  console.log = (...args: unknown[]) => {
@@ -1437,6 +1437,7 @@ describe("config", () => {
1437
1437
  defaultAccount: "default",
1438
1438
  accounts: {
1439
1439
  default: {
1440
+ name: "",
1440
1441
  pat: "",
1441
1442
  appId: "",
1442
1443
  appSecret: "",