@zzclub/pipeline 0.10.1 → 0.10.2

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 (51) hide show
  1. package/README.md +43 -1
  2. package/package.json +2 -1
  3. package/src/adapter-types.ts +21 -0
  4. package/src/adapters/builtin-markdown-renderer.ts +7 -0
  5. package/src/cli.ts +5 -1
  6. package/src/commands/ingest-handoff.ts +1 -21
  7. package/src/commands/prepare-finalize.ts +2 -1
  8. package/src/commands/prepare.ts +22 -14
  9. package/src/commands/publish.ts +2 -3
  10. package/src/commands/reconcile.ts +3 -5
  11. package/src/commands/render.ts +2 -1
  12. package/src/commands/republish.ts +2 -3
  13. package/src/commands/reset.ts +9 -1
  14. package/src/commands/review.ts +3 -2
  15. package/src/commands/sync-blog.ts +2 -1
  16. package/src/commands/wechat-export.ts +134 -12
  17. package/src/commands/wechat-preview.ts +160 -0
  18. package/src/imgx/runtime.ts +31 -3
  19. package/src/logger.test.ts +56 -0
  20. package/src/logger.ts +178 -0
  21. package/src/plugins.ts +2 -0
  22. package/src/providers/blog.ts +1 -2
  23. package/src/providers/index.ts +29 -1
  24. package/src/schema/config.ts +2 -0
  25. package/src/state.ts +24 -0
  26. package/src/task-manager.test.ts +57 -0
  27. package/src/task-manager.ts +23 -5
  28. package/src/wechat-preview/assets/browser-dist/editor-export.js +129 -73
  29. package/src/wechat-preview/browser/editor-export.css +5 -366
  30. package/src/wechat-preview/browser/editor-export.ts +7 -3
  31. package/src/wechat-preview/bundle-stale.test.ts +52 -0
  32. package/src/wechat-preview/index.ts +390 -83
  33. package/src/wechat-preview/server/client.ts +164 -0
  34. package/src/wechat-preview/server/http.ts +229 -0
  35. package/src/wechat-preview/server/index.ts +33 -0
  36. package/src/wechat-preview/server/local-file.test.ts +52 -0
  37. package/src/wechat-preview/server/local-file.ts +116 -0
  38. package/src/wechat-preview/server/paths.ts +80 -0
  39. package/src/wechat-preview/server/registry.test.ts +60 -0
  40. package/src/wechat-preview/server/registry.ts +118 -0
  41. package/src/wechat-preview/server/singleton.ts +118 -0
  42. package/src/wechat-preview/server/types.ts +61 -0
  43. package/src/wechat-preview/server/ui.ts +324 -0
  44. package/src/wechat-preview/themes.ts +8 -0
  45. package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
  46. package/src/wechat-preview/wechat-renderer.test.ts +2 -0
  47. package/src/wechat-preview/wechat-renderer.ts +52 -6
  48. package/src/wechat-preview.test.ts +14 -7
  49. package/src/workflow-materials.test.ts +52 -1
  50. package/src/workflow-materials.ts +12 -0
  51. package/src/workflow.test.ts +61 -0
@@ -0,0 +1,160 @@
1
+ import { spawn } from "child_process";
2
+ import { flagArg, optionalArg, parseArgs } from "../args";
3
+ import { printResult } from "../output";
4
+ import {
5
+ clearPreviewEntries,
6
+ getPreviewServerStatus,
7
+ listPreviewEntries,
8
+ resolvePreviewHost,
9
+ resolvePreviewPort,
10
+ startPreviewServer,
11
+ } from "../wechat-preview/server";
12
+
13
+ async function openUrl(url: string): Promise<void> {
14
+ const opener =
15
+ process.platform === "darwin"
16
+ ? "open"
17
+ : process.platform === "win32"
18
+ ? "cmd"
19
+ : "xdg-open";
20
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
21
+ try {
22
+ spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
23
+ } catch {
24
+ // non-fatal
25
+ }
26
+ }
27
+
28
+ function printHelp(): void {
29
+ console.log(`
30
+ Usage: zzhub-pipeline wechat-preview <subcommand> [options]
31
+
32
+ Subcommands:
33
+ serve Start the singleton local preview server (or print existing URL)
34
+ status Show whether the preview server is running
35
+ open Open the dashboard in a browser
36
+ list List registered preview entries
37
+ clear Clear all preview entries
38
+
39
+ serve options:
40
+ --host Bind host (default: 127.0.0.1, env ZZHUB_WECHAT_PREVIEW_HOST)
41
+ --port Bind port (default: 18765, env ZZHUB_WECHAT_PREVIEW_PORT)
42
+ --open Open the dashboard after start
43
+ --force Fail if a server is already running (default: reuse existing)
44
+
45
+ Environment:
46
+ ZZHUB_WECHAT_PREVIEW_DIR Data/lock directory override
47
+ `.trim());
48
+ }
49
+
50
+ export async function wechatPreview(args: string[]): Promise<void> {
51
+ const sub = args[0];
52
+ const rest = args.slice(1);
53
+ const parsed = parseArgs(rest);
54
+
55
+ if (!sub || sub === "help" || sub === "--help" || parsed.help) {
56
+ printHelp();
57
+ return;
58
+ }
59
+
60
+ if (sub === "serve") {
61
+ const host = optionalArg(parsed, "host");
62
+ const portRaw = optionalArg(parsed, "port");
63
+ const port = portRaw ? Number(portRaw) : undefined;
64
+ const open = flagArg(parsed, "open");
65
+ const force = flagArg(parsed, "force");
66
+
67
+ const result = await startPreviewServer({
68
+ host,
69
+ port: port !== undefined && Number.isFinite(port) ? port : undefined,
70
+ open,
71
+ reuseExisting: !force,
72
+ });
73
+
74
+ if (open) {
75
+ await openUrl(result.url);
76
+ }
77
+
78
+ printResult({
79
+ action: "serve",
80
+ url: result.url,
81
+ host: result.host,
82
+ port: result.port,
83
+ reused: result.reused,
84
+ message: result.reused
85
+ ? `Already running at ${result.url}`
86
+ : `Listening on ${result.url}`,
87
+ });
88
+
89
+ // Keep process alive when we own the server
90
+ if (!result.reused) {
91
+ await new Promise<void>(() => {
92
+ // blocked until SIGINT/SIGTERM
93
+ });
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (sub === "status") {
99
+ const host = optionalArg(parsed, "host");
100
+ const portRaw = optionalArg(parsed, "port");
101
+ const port = portRaw ? Number(portRaw) : undefined;
102
+ const status = await getPreviewServerStatus({
103
+ host: host ?? resolvePreviewHost(),
104
+ port: port !== undefined && Number.isFinite(port) ? port : resolvePreviewPort(),
105
+ });
106
+ printResult({ action: "status", ...status });
107
+ return;
108
+ }
109
+
110
+ if (sub === "open") {
111
+ const status = await getPreviewServerStatus();
112
+ if (!status.running || !status.url) {
113
+ throw new Error(
114
+ "WeChat preview server is not running. Start with: wechat-preview serve",
115
+ );
116
+ }
117
+ await openUrl(status.url);
118
+ printResult({ action: "open", url: status.url });
119
+ return;
120
+ }
121
+
122
+ if (sub === "list") {
123
+ const status = await getPreviewServerStatus();
124
+ if (status.running) {
125
+ // Prefer live server list
126
+ try {
127
+ const res = await fetch(`${status.url}/api/entries`);
128
+ if (res.ok) {
129
+ printResult({ action: "list", entries: await res.json() });
130
+ return;
131
+ }
132
+ } catch {
133
+ // fall through to disk
134
+ }
135
+ }
136
+ printResult({ action: "list", entries: listPreviewEntries(), source: "disk" });
137
+ return;
138
+ }
139
+
140
+ if (sub === "clear") {
141
+ const status = await getPreviewServerStatus();
142
+ if (status.running && status.url) {
143
+ try {
144
+ const res = await fetch(`${status.url}/api/entries`, { method: "DELETE" });
145
+ if (res.ok) {
146
+ const body = (await res.json()) as { cleared?: number };
147
+ printResult({ action: "clear", cleared: body.cleared ?? 0, via: "server" });
148
+ return;
149
+ }
150
+ } catch {
151
+ // fall through
152
+ }
153
+ }
154
+ const cleared = clearPreviewEntries();
155
+ printResult({ action: "clear", cleared, via: "disk" });
156
+ return;
157
+ }
158
+
159
+ throw new Error(`Unknown wechat-preview subcommand: ${sub}`);
160
+ }
@@ -304,10 +304,30 @@ export function screenshotHtml(options: {
304
304
  rmSync(tempShotDir, { recursive: true, force: true });
305
305
  }
306
306
 
307
+ export class ChromeDumpError extends Error {
308
+ readonly stderr: string;
309
+ readonly tempHtmlPath?: string;
310
+ readonly exitCode: number | null;
311
+
312
+ constructor(message: string, options: {
313
+ stderr: string;
314
+ tempHtmlPath?: string;
315
+ exitCode: number | null;
316
+ }) {
317
+ super(message);
318
+ this.name = "ChromeDumpError";
319
+ this.stderr = options.stderr;
320
+ this.tempHtmlPath = options.tempHtmlPath;
321
+ this.exitCode = options.exitCode;
322
+ }
323
+ }
324
+
307
325
  export function dumpHtmlDom(options: {
308
326
  chromePath: string;
309
327
  html: string;
310
328
  virtualTimeBudgetMs?: number;
329
+ /** When true, leave the temp shell HTML on disk if Chrome fails (caller must clean up). */
330
+ keepTempOnError?: boolean;
311
331
  }): string {
312
332
  const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
313
333
  const fileUrl = pathToFileURL(tempHtml).href;
@@ -329,12 +349,20 @@ export function dumpHtmlDom(options: {
329
349
  stdout: "pipe",
330
350
  stderr: "pipe",
331
351
  });
332
- cleanupTempFile(tempHtml);
333
352
  if (result.exitCode !== 0) {
334
353
  const stderr = new TextDecoder().decode(result.stderr);
335
- throw new Error(`Chrome dump-dom failed:\n${stderr}`);
354
+ if (!options.keepTempOnError) {
355
+ cleanupTempFile(tempHtml);
356
+ }
357
+ throw new ChromeDumpError(`Chrome dump-dom failed (exit ${result.exitCode}):\n${stderr}`, {
358
+ stderr,
359
+ tempHtmlPath: options.keepTempOnError ? tempHtml : undefined,
360
+ exitCode: result.exitCode,
361
+ });
336
362
  }
337
- return new TextDecoder().decode(result.stdout);
363
+ const dom = new TextDecoder().decode(result.stdout);
364
+ cleanupTempFile(tempHtml);
365
+ return dom;
338
366
  }
339
367
 
340
368
  export function cropTop(options: {
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
6
+
7
+ describe("pipeline daily logger", () => {
8
+ const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
9
+ let tempDir: string | null = null;
10
+
11
+ afterEach(() => {
12
+ if (previousLogDir === undefined) {
13
+ delete process.env.ZZHUB_PIPELINE_LOG_DIR;
14
+ } else {
15
+ process.env.ZZHUB_PIPELINE_LOG_DIR = previousLogDir;
16
+ }
17
+ if (tempDir) {
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ tempDir = null;
20
+ }
21
+ });
22
+
23
+ test("writes command start/ok markers and console output to the daily log", async () => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
25
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
26
+
27
+ await runWithCommandLog("status", ["--state", "/tmp/x.json"], async () => {
28
+ console.log("hello-from-status");
29
+ });
30
+
31
+ const logPath = getDailyLogPath();
32
+ expect(getLogDir()).toBe(tempDir);
33
+ expect(existsSync(logPath)).toBe(true);
34
+ const body = readFileSync(logPath, "utf-8");
35
+ expect(body).toContain("=== START command=status ===");
36
+ expect(body).toContain("=== OK command=status");
37
+ expect(body).toContain("[console.log] hello-from-status");
38
+ expect(body).toContain(`"--state"`);
39
+ });
40
+
41
+ test("records failure stack and rethrows", async () => {
42
+ tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
43
+ process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
44
+
45
+ await expect(
46
+ runWithCommandLog("publish", [], async () => {
47
+ throw new Error("boom-upload");
48
+ }),
49
+ ).rejects.toThrow("boom-upload");
50
+
51
+ const body = readFileSync(getDailyLogPath(), "utf-8");
52
+ expect(body).toContain("=== FAIL command=publish");
53
+ expect(body).toContain("Error: boom-upload");
54
+ expect(body).toContain("boom-upload");
55
+ });
56
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Daily rolling file logger for zzhub-pipeline CLI commands.
3
+ *
4
+ * Layout (platform-aware, same base as wechat-preview config dir):
5
+ * macOS: ~/Library/Application Support/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
6
+ * Linux: ~/.config/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
7
+ * Windows: %APPDATA%/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
8
+ *
9
+ * Override with ZZHUB_PIPELINE_LOG_DIR.
10
+ */
11
+
12
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
13
+ import { homedir, platform } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const APP_DIR = "zzhub-pipeline";
17
+ const LOG_DIR_NAME = "logs";
18
+ const LOG_PREFIX = "pipeline-";
19
+ const LOG_SUFFIX = ".log";
20
+ /** Keep about two weeks of daily files. */
21
+ const LOG_RETENTION_DAYS = 14;
22
+
23
+ function getBaseConfigDir(): string {
24
+ const home = homedir();
25
+ const currentPlatform = platform();
26
+
27
+ if (currentPlatform === "win32") {
28
+ const appData = process.env.APPDATA;
29
+ if (appData) return join(appData, APP_DIR);
30
+ return join(home, "AppData", "Roaming", APP_DIR);
31
+ }
32
+
33
+ if (currentPlatform === "darwin") {
34
+ return join(home, "Library", "Application Support", APP_DIR);
35
+ }
36
+
37
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
38
+ if (xdgConfigHome && xdgConfigHome.trim().length > 0) {
39
+ return join(xdgConfigHome, APP_DIR);
40
+ }
41
+
42
+ return join(home, ".config", APP_DIR);
43
+ }
44
+
45
+ export function getLogDir(): string {
46
+ const override = process.env.ZZHUB_PIPELINE_LOG_DIR?.trim();
47
+ if (override) return override;
48
+ return join(getBaseConfigDir(), LOG_DIR_NAME);
49
+ }
50
+
51
+ function formatDateKey(date: Date = new Date()): string {
52
+ const y = date.getFullYear();
53
+ const m = String(date.getMonth() + 1).padStart(2, "0");
54
+ const d = String(date.getDate()).padStart(2, "0");
55
+ return `${y}-${m}-${d}`;
56
+ }
57
+
58
+ export function getDailyLogPath(date: Date = new Date()): string {
59
+ return join(getLogDir(), `${LOG_PREFIX}${formatDateKey(date)}${LOG_SUFFIX}`);
60
+ }
61
+
62
+ function ensureLogDir(): string {
63
+ const dir = getLogDir();
64
+ if (!existsSync(dir)) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+ return dir;
68
+ }
69
+
70
+ function pruneOldLogs(dir: string): void {
71
+ try {
72
+ const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
73
+ for (const name of readdirSync(dir)) {
74
+ if (!name.startsWith(LOG_PREFIX) || !name.endsWith(LOG_SUFFIX)) continue;
75
+ const key = name.slice(LOG_PREFIX.length, -LOG_SUFFIX.length);
76
+ // YYYY-MM-DD
77
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
78
+ const stamp = Date.parse(`${key}T00:00:00`);
79
+ if (!Number.isFinite(stamp) || stamp >= cutoff) continue;
80
+ unlinkSync(join(dir, name));
81
+ }
82
+ } catch {
83
+ // pruning is best-effort
84
+ }
85
+ }
86
+
87
+ export function appendLogLine(message: string, date: Date = new Date()): void {
88
+ try {
89
+ const dir = ensureLogDir();
90
+ pruneOldLogs(dir);
91
+ const path = getDailyLogPath(date);
92
+ const line = message.endsWith("\n") ? message : `${message}\n`;
93
+ appendFileSync(path, line, "utf-8");
94
+ } catch {
95
+ // never break CLI for logging failures
96
+ }
97
+ }
98
+
99
+ function formatError(err: unknown): string {
100
+ if (err instanceof Error) {
101
+ return `${err.name}: ${err.message}${err.stack ? `\n${err.stack}` : ""}`;
102
+ }
103
+ return String(err);
104
+ }
105
+
106
+ type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
107
+
108
+ function installConsoleTee(write: (chunk: string) => void): () => void {
109
+ const methods: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
110
+ const originals = new Map<ConsoleMethod, (...args: unknown[]) => void>();
111
+
112
+ for (const method of methods) {
113
+ const original = console[method].bind(console);
114
+ originals.set(method, original);
115
+ console[method] = (...args: unknown[]) => {
116
+ original(...args);
117
+ try {
118
+ const text = args
119
+ .map((arg) => {
120
+ if (typeof arg === "string") return arg;
121
+ try {
122
+ return JSON.stringify(arg);
123
+ } catch {
124
+ return String(arg);
125
+ }
126
+ })
127
+ .join(" ");
128
+ write(`[console.${method}] ${text}`);
129
+ } catch {
130
+ // ignore
131
+ }
132
+ };
133
+ }
134
+
135
+ return () => {
136
+ for (const method of methods) {
137
+ const original = originals.get(method);
138
+ if (original) console[method] = original as typeof console.log;
139
+ }
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Run a CLI command with daily-file logging.
145
+ * Captures console output, start/end markers, duration, and full error stacks.
146
+ */
147
+ export async function runWithCommandLog(
148
+ command: string,
149
+ args: string[],
150
+ handler: () => Promise<void>,
151
+ ): Promise<void> {
152
+ const startedAt = new Date();
153
+ const startedMs = Date.now();
154
+ const logPath = getDailyLogPath(startedAt);
155
+ const write = (message: string) => {
156
+ appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
157
+ };
158
+
159
+ write(`=== START command=${command} ===`);
160
+ write(`log_file=${logPath}`);
161
+ write(`cwd=${process.cwd()}`);
162
+ write(`argv=${JSON.stringify(args)}`);
163
+ if (process.env.ZZHUB_PIPELINE_ROOT) {
164
+ write(`ZZHUB_PIPELINE_ROOT=${process.env.ZZHUB_PIPELINE_ROOT}`);
165
+ }
166
+
167
+ const restoreConsole = installConsoleTee(write);
168
+ try {
169
+ await handler();
170
+ write(`=== OK command=${command} duration_ms=${Date.now() - startedMs} ===`);
171
+ } catch (err) {
172
+ write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`);
173
+ write(formatError(err));
174
+ throw err;
175
+ } finally {
176
+ restoreConsole();
177
+ }
178
+ }
package/src/plugins.ts CHANGED
@@ -25,6 +25,7 @@ import { syncBlog } from "./commands/sync-blog";
25
25
  import { tasks } from "./commands/tasks";
26
26
  import { topic } from "./commands/topic";
27
27
  import { wechatExport } from "./commands/wechat-export";
28
+ import { wechatPreview } from "./commands/wechat-preview";
28
29
  import { wxDrafts } from "./commands/wx-drafts";
29
30
  import { wxDraftDelete } from "./commands/wx-draft-delete";
30
31
 
@@ -71,6 +72,7 @@ export function getCommandPlugins(): CommandPlugin[] {
71
72
  { name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
72
73
  { name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
73
74
  { name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
75
+ { name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
74
76
  { name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
75
77
  { name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
76
78
  { name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },
@@ -141,8 +141,7 @@ export async function publishBlogRoute({
141
141
  lines.push(` - ${tag}`);
142
142
  }
143
143
  }
144
- const author = state.route.content_profile || "default";
145
- lines.push(`author: ${author}`);
144
+ lines.push("author: Kairos");
146
145
  lines.push("---");
147
146
 
148
147
  const bodyWithFrontmatter = `${lines.join("\n")}\n\n${stripFrontmatter(postContent)}`;
@@ -79,7 +79,7 @@ async function publishWechatArticleRoute({
79
79
  try {
80
80
  const markdownRenderer = await resolveMarkdownRenderer(config);
81
81
  const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
82
- const { html } = await markdownRenderer.render({
82
+ const rendered = await markdownRenderer.render({
83
83
  markdownPath: exportPostPath,
84
84
  outPath: workspacePaths.zotepadExportHtml,
85
85
  account,
@@ -87,6 +87,34 @@ async function publishWechatArticleRoute({
87
87
  customCss: resolveConfigRelativePath(wxAccount?.customCss),
88
88
  themeOverrides: wxAccount?.theme,
89
89
  });
90
+ const { html } = rendered;
91
+
92
+ // Register with local preview server unless explicitly disabled.
93
+ if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
94
+ try {
95
+ const { registerPreviewEntry } = await import("../wechat-preview/server");
96
+ await registerPreviewEntry(
97
+ {
98
+ title: `[publish] ${state.metadata.title || "untitled"}`,
99
+ account,
100
+ status: "success",
101
+ duration_ms: rendered.durationMs ?? 0,
102
+ markdown_path: exportPostPath,
103
+ html_path: workspacePaths.zotepadExportHtml,
104
+ preview_style: rendered.previewStyle,
105
+ html,
106
+ debug: {
107
+ bundle_rebuilt: rendered.bundleRebuilt,
108
+ bundle_stale: rendered.bundleStale,
109
+ },
110
+ },
111
+ { autoStart: process.env.ZZHUB_WECHAT_PREVIEW_AUTO_START !== "0" },
112
+ );
113
+ } catch {
114
+ // preview registration must never block publish
115
+ }
116
+ }
117
+
90
118
  const response = await createWechatDraft({
91
119
  account,
92
120
  title: state.metadata.title,
@@ -39,6 +39,8 @@ const WechatExportThemeOverridesSchema = z.object({
39
39
  fontFamily: z.string().optional(),
40
40
  bodyColor: z.string().optional(),
41
41
  mutedColor: z.string().optional(),
42
+ h2Color: z.string().optional(),
43
+ h3Color: z.string().optional(),
42
44
  primaryColor: z.string().optional(),
43
45
  dividerColor: z.string().optional(),
44
46
  blockquoteBorderColor: z.string().optional(),
package/src/state.ts CHANGED
@@ -370,6 +370,30 @@ export function reenterPublish(state: WorkflowState): void {
370
370
  state.redo_hint = null;
371
371
  }
372
372
 
373
+ /**
374
+ * Reset every derived artifact when the core inputs (body, title, route, intent,
375
+ * explicit constraints) have changed. The run identity (run_id/workspace_root/
376
+ * state_path) and the current title are preserved; everything produced by
377
+ * prepare/render/publish is wiped so the workflow re-runs from prepare.
378
+ *
379
+ * Used by ingest-handoff resume when it detects an upstream input change.
380
+ */
381
+ export function resetDerivedState(state: WorkflowState): void {
382
+ state.mode = "active";
383
+ state.phase = defaultPhase();
384
+ state.asset_path = "";
385
+ state.formatted_body_path = null;
386
+ state.metadata = {
387
+ ...defaultMetadata(),
388
+ title: state.metadata.title,
389
+ };
390
+ state.artifacts = defaultArtifacts();
391
+ state.images = defaultImages();
392
+ state.publish = defaultPublish();
393
+ state.content_review = defaultContentReview();
394
+ state.redo_hint = null;
395
+ }
396
+
373
397
  // ── Run ID ────────────────────────────────────────────────────────
374
398
 
375
399
  /**
@@ -0,0 +1,57 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ import { defaultState, writeState } from "./state";
7
+ import { loadTaskState } from "./task-manager";
8
+
9
+ const TEST_CONFIG_PATH = join(
10
+ tmpdir(),
11
+ `zzhub-pipeline-test-config-loadtask-${process.pid}.json`,
12
+ );
13
+ process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
14
+
15
+ async function makeTempDir(prefix: string): Promise<string> {
16
+ return mkdtemp(join(tmpdir(), prefix));
17
+ }
18
+
19
+ afterAll(async () => {
20
+ await writeFile(TEST_CONFIG_PATH, "{}", "utf-8").catch(() => undefined);
21
+ });
22
+
23
+ describe("loadTaskState", () => {
24
+ test("returns a reconciled view when body_inputs drift from the body on disk", async () => {
25
+ const workspace = await makeTempDir("zzhub-loadtask-");
26
+ const statePath = join(workspace, "workflow-state.json");
27
+ const bodyPath = join(workspace, "post.md");
28
+
29
+ const state = defaultState();
30
+ state.run_id = "run-loadtask";
31
+ state.workspace_root = workspace;
32
+ state.state_path = statePath;
33
+ state.source_body_path = bodyPath;
34
+ state.route.primary = "wechat-article";
35
+ state.intent.content_form = "article";
36
+ state.metadata.title = "T";
37
+ state.metadata.slug = "t";
38
+ state.metadata.date = "2026-04-10";
39
+ state.phase.prepare.status = "done";
40
+ // State claims one illustration input is pending, but the rewritten body
41
+ // no longer contains any markers.
42
+ state.images.body_inputs = {
43
+ scope: "article",
44
+ expected: 1,
45
+ received: [],
46
+ status: "pending",
47
+ layout: "staggered",
48
+ };
49
+ await writeFile(bodyPath, "# T\n\nBody without any markers", "utf-8");
50
+ await writeState(statePath, state);
51
+
52
+ const resolved = await loadTaskState(statePath);
53
+
54
+ expect(resolved.state.images.body_inputs.status).toBe("none");
55
+ expect(resolved.state.images.body_inputs.expected).toBe(0);
56
+ });
57
+ });
@@ -5,12 +5,26 @@ import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "./confi
5
5
  import {
6
6
  readResolvedState,
7
7
  type PhaseName,
8
+ type ResolvedState,
8
9
  type ValidationError,
9
10
  validateForPhase,
10
11
  type WorkflowState,
11
12
  } from "./state";
12
13
  import { reconcileStateArtifacts } from "./workflow-materials";
13
14
 
15
+ /**
16
+ * Load workflow state and apply in-memory artifact reconciliation so every
17
+ * command sees the same reconciled view of body_inputs/render_assets that
18
+ * `status` produces. Use this instead of `readResolvedState` in commands that
19
+ * read state and then make a decision based on it. `writeState`'s own
20
+ * optimistic re-read deliberately stays un-reconciled.
21
+ */
22
+ export async function loadTaskState(path: string): Promise<ResolvedState> {
23
+ const resolved = await readResolvedState(path);
24
+ await reconcileStateArtifacts(resolved.state);
25
+ return resolved;
26
+ }
27
+
14
28
  export type TaskFileSource = "run" | "canonical";
15
29
 
16
30
  export interface TaskGap {
@@ -491,14 +505,18 @@ function buildTaskStatus(summary: TaskSummary, state: WorkflowState): TaskStatus
491
505
  },
492
506
  };
493
507
  } else {
508
+ // Reached only when prepare/render/publish all report done (or aren't
509
+ // required) yet mode/phase hasn't advanced to done — an inconsistent state.
510
+ // Re-running prepare here would reenterPrepare and wipe render_assets,
511
+ // forcing a wasteful re-render. Route to a read-only checkpoint instead.
494
512
  nextAction = {
495
- action: "prepare",
496
- reason: "Task is active and should continue from the current phase.",
497
- executor: "cli",
498
- command: `zzhub-pipeline prepare --state "${summary.state_path}"`,
513
+ action: "checkpoint",
514
+ reason: "Phases report done but the task is not marked complete; inspect state before mutating it.",
515
+ executor: "repair",
516
+ command: `zzhub-pipeline checkpoint --state "${summary.state_path}"`,
499
517
  params: {
500
518
  state_path: summary.state_path,
501
- on_complete: "After the command succeeds, re-run status to determine the next step.",
519
+ on_complete: "Inspect the checkpoint output, then reset or repair the inconsistent phase before continuing.",
502
520
  },
503
521
  };
504
522
  }