@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 +7 -0
- package/package.json +1 -1
- package/src/adapter-types.ts +5 -0
- package/src/adapters/builtin-image-renderer.ts +7 -6
- package/src/adapters/builtin-markdown-renderer.ts +3 -0
- package/src/cli.ts +10 -4
- package/src/command-outcome.ts +50 -0
- package/src/commands/abandon.ts +13 -2
- package/src/commands/checkpoint.ts +3 -2
- package/src/commands/monitor.ts +21 -0
- package/src/commands/prepare-finalize.ts +2 -0
- package/src/commands/prepare.ts +3 -0
- package/src/commands/publish.ts +5 -2
- package/src/commands/render.ts +7 -3
- package/src/commands/republish.ts +4 -1
- package/src/commands/sync-blog.ts +4 -2
- package/src/commands/wechat-export.ts +6 -3
- package/src/imgx/render-article.ts +8 -1
- package/src/imgx/render-card.ts +6 -1
- package/src/imgx/runtime.ts +1 -1
- package/src/logger.test.ts +28 -1
- package/src/logger.ts +39 -26
- package/src/monitor/client.ts +60 -0
- package/src/monitor/recorder.ts +142 -0
- package/src/monitor/runtime.ts +58 -0
- package/src/monitor/server.ts +170 -0
- package/src/monitor/store.ts +235 -0
- package/src/monitor/types.ts +77 -0
- package/src/monitor.test.ts +241 -0
- package/src/plugins.ts +4 -1
- package/src/providers/blog.ts +4 -1
- package/src/providers/cos.ts +3 -0
- package/src/providers/index.ts +7 -3
- package/src/providers/publish-core.ts +7 -1
- package/src/providers/wechat.ts +4 -1
- package/src/spawn.ts +4 -2
- package/src/state.ts +4 -0
- package/src/workflow.test.ts +2 -2
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
package/src/adapter-types.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/commands/abandon.ts
CHANGED
|
@@ -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
|
-
|
|
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`;
|
package/src/commands/prepare.ts
CHANGED
|
@@ -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
|
package/src/commands/publish.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/imgx/render-card.ts
CHANGED
|
@@ -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) {
|
package/src/imgx/runtime.ts
CHANGED
|
@@ -407,7 +407,7 @@ export function resolveInputPath(path: string): string {
|
|
|
407
407
|
}
|
|
408
408
|
|
|
409
409
|
export function printSaved(outPath: string): void {
|
|
410
|
-
console.
|
|
410
|
+
console.error(`✅ Saved to ${outPath}`);
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
export function readImageSize(path: string): { width: number; height: number } | null {
|
package/src/logger.test.ts
CHANGED
|
@@ -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;
|