@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.
- package/README.md +8 -72
- 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/config.ts +47 -20
- 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/config.ts +21 -2
- 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 -5
- 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 +7 -2
- package/src/schema/config.account-name.test.ts +79 -0
- package/src/schema/config.ts +9 -3
- package/src/spawn.ts +4 -2
- package/src/state.ts +4 -0
- package/src/workflow.test.ts +3 -2
- package/src/commands/analytics.test.ts +0 -156
- package/src/commands/analytics.ts +0 -240
- package/src/commands/topic.test.ts +0 -146
- package/src/commands/topic.ts +0 -717
- package/src/db.test.ts +0 -47
- package/src/db.ts +0 -95
- package/src/schema/analytics.test.ts +0 -54
- package/src/schema/analytics.ts +0 -19
- package/src/schema/topic.test.ts +0 -69
- package/src/schema/topic.ts +0 -55
|
@@ -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;
|
package/src/config.ts
CHANGED
|
@@ -43,6 +43,15 @@ export interface ResolvedWorkspacePaths {
|
|
|
43
43
|
|
|
44
44
|
const DEFAULT_WX_ACCOUNT_NAME = "default";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Soft display names for known account keys when `name` is missing/empty.
|
|
48
|
+
* Never overrides a user-set name.
|
|
49
|
+
*/
|
|
50
|
+
export const SUGGESTED_ACCOUNT_NAMES: Record<string, string> = {
|
|
51
|
+
default: "大号(早早集市)",
|
|
52
|
+
ancientone: "小号(古一)",
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
export const DEFAULT_CONFIG: PipelineConfig = PipelineConfigSchema.parse({});
|
|
47
56
|
|
|
48
57
|
const PIPELINE_CONFIG_DIR = "zzhub-pipeline";
|
|
@@ -104,8 +113,9 @@ export function getLegacyZCliConfigPath(): string {
|
|
|
104
113
|
/**
|
|
105
114
|
* Merge source config with legacy config, then normalize through Zod schema.
|
|
106
115
|
* Legacy values fill gaps where source has nothing.
|
|
116
|
+
* Soft-fills known account display `name` only when missing/empty.
|
|
107
117
|
*/
|
|
108
|
-
function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig {
|
|
118
|
+
export function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig {
|
|
109
119
|
const source = isPlainObject(value) ? value : {};
|
|
110
120
|
const legacy = isPlainObject(legacyValue) ? legacyValue : {};
|
|
111
121
|
|
|
@@ -122,7 +132,16 @@ function normalizeConfig(value: unknown, legacyValue?: unknown): PipelineConfig
|
|
|
122
132
|
if (!accountName) continue;
|
|
123
133
|
const legacyAccount = isPlainObject(legacyAccounts[accountName]) ? legacyAccounts[accountName] : {};
|
|
124
134
|
const sourceAccount = isPlainObject(sourceAccounts[accountName]) ? sourceAccounts[accountName] : {};
|
|
125
|
-
|
|
135
|
+
const merged = { ...legacyAccount, ...sourceAccount };
|
|
136
|
+
// Trim + soft-fill display name for known keys only when missing/empty (never override user-set name).
|
|
137
|
+
if (typeof merged.name === "string") {
|
|
138
|
+
merged.name = merged.name.trim();
|
|
139
|
+
}
|
|
140
|
+
const existingName = typeof merged.name === "string" ? merged.name : "";
|
|
141
|
+
if (!existingName && SUGGESTED_ACCOUNT_NAMES[accountName]) {
|
|
142
|
+
merged.name = SUGGESTED_ACCOUNT_NAMES[accountName];
|
|
143
|
+
}
|
|
144
|
+
mergedAccounts[accountName] = merged;
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
// Merge wx config: source wins, legacy fills gaps
|
|
@@ -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;
|
package/src/logger.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
13
13
|
import { homedir, platform } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { outcomeExitCode, type CommandOutcome } from "./command-outcome";
|
|
16
|
+
import { observeCommand, reportLog } from "./monitor/recorder";
|
|
15
17
|
|
|
16
18
|
const APP_DIR = "zzhub-pipeline";
|
|
17
19
|
const LOG_DIR_NAME = "logs";
|
|
@@ -118,6 +120,7 @@ function installConsoleTee(write: (chunk: string) => void): () => void {
|
|
|
118
120
|
const text = args
|
|
119
121
|
.map((arg) => {
|
|
120
122
|
if (typeof arg === "string") return arg;
|
|
123
|
+
if (arg instanceof Error) return formatError(arg);
|
|
121
124
|
try {
|
|
122
125
|
return JSON.stringify(arg);
|
|
123
126
|
} catch {
|
|
@@ -126,6 +129,8 @@ function installConsoleTee(write: (chunk: string) => void): () => void {
|
|
|
126
129
|
})
|
|
127
130
|
.join(" ");
|
|
128
131
|
write(`[console.${method}] ${text}`);
|
|
132
|
+
// stdout 包含完整业务 JSON;只采集诊断通道,避免正文或配置进入监控。
|
|
133
|
+
if (method !== "log") reportLog(method, text);
|
|
129
134
|
} catch {
|
|
130
135
|
// ignore
|
|
131
136
|
}
|
|
@@ -147,32 +152,40 @@ function installConsoleTee(write: (chunk: string) => void): () => void {
|
|
|
147
152
|
export async function runWithCommandLog(
|
|
148
153
|
command: string,
|
|
149
154
|
args: string[],
|
|
150
|
-
handler: () => Promise<void>,
|
|
151
|
-
): Promise<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
handler: () => Promise<void | CommandOutcome>,
|
|
156
|
+
): Promise<CommandOutcome> {
|
|
157
|
+
return observeCommand(command, async () => {
|
|
158
|
+
const startedAt = new Date();
|
|
159
|
+
const startedMs = Date.now();
|
|
160
|
+
const logPath = getDailyLogPath(startedAt);
|
|
161
|
+
const write = (message: string) => {
|
|
162
|
+
appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
|
|
163
|
+
};
|
|
158
164
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
write(`=== START command=${command} ===`);
|
|
166
|
+
write(`log_file=${logPath}`);
|
|
167
|
+
write(`cwd=${process.cwd()}`);
|
|
168
|
+
write(`argv=${JSON.stringify(args)}`);
|
|
169
|
+
if (process.env.ZZHUB_PIPELINE_ROOT) {
|
|
170
|
+
write(`ZZHUB_PIPELINE_ROOT=${process.env.ZZHUB_PIPELINE_ROOT}`);
|
|
171
|
+
}
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
const restoreConsole = installConsoleTee(write);
|
|
174
|
+
try {
|
|
175
|
+
let outcome: CommandOutcome = await handler() ?? { status: "success" };
|
|
176
|
+
if (Number(process.exitCode || 0) !== 0 && !outcomeExitCode(outcome)) {
|
|
177
|
+
outcome = { status: "failed", errors: [{ code: "PROCESS_EXIT", message: `进程退出:${process.exitCode}` }] };
|
|
178
|
+
}
|
|
179
|
+
const label = outcomeExitCode(outcome) ? "FAIL" : outcome.status === "success" ? "OK" : outcome.status.toUpperCase();
|
|
180
|
+
write(`=== ${label} command=${command} duration_ms=${Date.now() - startedMs} ===`);
|
|
181
|
+
for (const error of outcome.errors ?? []) write(`${error.code}: ${error.message}${error.cause ? `\n${error.cause}` : ""}`);
|
|
182
|
+
return outcome;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`);
|
|
185
|
+
write(formatError(err));
|
|
186
|
+
throw err;
|
|
187
|
+
} finally {
|
|
188
|
+
restoreConsole();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
178
191
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { PACKAGE_ROOT } from "../runtime-paths";
|
|
5
|
+
import { resolveBunBinary } from "../spawn";
|
|
6
|
+
import { monitorDir } from "./runtime";
|
|
7
|
+
import type { MonitorDescriptor } from "./types";
|
|
8
|
+
|
|
9
|
+
/** 本机描述文件严格限制协议和地址,不能借其访问远程服务。 */
|
|
10
|
+
export function readMonitorDescriptor(root = monitorDir()): MonitorDescriptor | null {
|
|
11
|
+
try {
|
|
12
|
+
const descriptor = JSON.parse(readFileSync(join(root, "server.json"), "utf8")) as MonitorDescriptor;
|
|
13
|
+
const url = new URL(descriptor.url);
|
|
14
|
+
if (descriptor.version !== 1 || url.protocol !== "http:" || url.hostname !== "127.0.0.1"
|
|
15
|
+
|| !url.port || url.username || url.password || !/^[0-9a-f]{64}$/.test(descriptor.token)
|
|
16
|
+
|| typeof descriptor.instance_id !== "string" || !Number.isSafeInteger(descriptor.pid)) return null;
|
|
17
|
+
return descriptor;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function monitorHealth(descriptor: MonitorDescriptor): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${descriptor.url}/v1/health`, {
|
|
24
|
+
headers: { Authorization: `Bearer ${descriptor.token}` }, signal: AbortSignal.timeout(500),
|
|
25
|
+
});
|
|
26
|
+
const health = await response.json() as { service?: string; instance_id?: string; ready?: boolean };
|
|
27
|
+
return response.ok && health.service === "zzhub-pipeline-monitor" && health.instance_id === descriptor.instance_id && health.ready === true;
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 只有显式 monitor start 才拉起进程,普通业务命令永不等待服务。 */
|
|
32
|
+
export async function startMonitor(): Promise<MonitorDescriptor> {
|
|
33
|
+
const existing = readMonitorDescriptor();
|
|
34
|
+
if (existing && await monitorHealth(existing)) return existing;
|
|
35
|
+
mkdirSync(monitorDir(), { recursive: true, mode: 0o700 });
|
|
36
|
+
const child = spawn(resolveBunBinary(), [resolve(PACKAGE_ROOT, "src/cli.ts"), "monitor", "serve"], {
|
|
37
|
+
detached: true, stdio: "ignore", env: { ...process.env, ZZHUB_PIPELINE_MONITOR: "0" },
|
|
38
|
+
});
|
|
39
|
+
let spawnError: Error | null = null;
|
|
40
|
+
child.on("error", (error) => { spawnError = error; });
|
|
41
|
+
child.unref();
|
|
42
|
+
const deadline = Date.now() + 10_000;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
if (spawnError) throw spawnError;
|
|
45
|
+
const descriptor = readMonitorDescriptor();
|
|
46
|
+
if (descriptor && await monitorHealth(descriptor)) return descriptor;
|
|
47
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
48
|
+
}
|
|
49
|
+
throw new Error("监控服务启动超时;可运行 zzp monitor serve 查看错误。");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function stopMonitor(): Promise<boolean> {
|
|
53
|
+
const descriptor = readMonitorDescriptor();
|
|
54
|
+
if (!descriptor || !await monitorHealth(descriptor)) return false;
|
|
55
|
+
const response = await fetch(`${descriptor.url}/v1/stop`, {
|
|
56
|
+
method: "POST", headers: { Authorization: `Bearer ${descriptor.token}` }, signal: AbortSignal.timeout(2000),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) throw new Error(`停止监控失败:HTTP ${response.status}`);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { commandError, outcomeExitCode, type CommandOutcome } from "../command-outcome";
|
|
5
|
+
import type { WorkflowState } from "../state";
|
|
6
|
+
import { canonicalPath, LOG_LIMIT, monitorDir, processIdentity, redact, taskId } from "./runtime";
|
|
7
|
+
import type { MonitorEvent, MonitorProgress } from "./types";
|
|
8
|
+
|
|
9
|
+
interface Recording {
|
|
10
|
+
id: string;
|
|
11
|
+
file: string;
|
|
12
|
+
seq: number;
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
logBytes: number;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
lastProgressAt: number;
|
|
17
|
+
progressKey: string;
|
|
18
|
+
progressRoute?: string;
|
|
19
|
+
progressAccount?: string;
|
|
20
|
+
ended?: boolean;
|
|
21
|
+
tasks: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 调用上下文自动穿过内置异步流程,不要求业务函数传监控对象。 */
|
|
25
|
+
const recording = new AsyncLocalStorage<Recording>();
|
|
26
|
+
const queryCommands = new Set(["status", "tasks", "find-run", "checkpoint", "doctor", "config", "wx-drafts", "hermes-metrics"]);
|
|
27
|
+
|
|
28
|
+
function emit(type: MonitorEvent["type"], data: Record<string, unknown>): void {
|
|
29
|
+
const current = recording.getStore();
|
|
30
|
+
if (!current || current.disabled || current.ended) return;
|
|
31
|
+
try {
|
|
32
|
+
const event: MonitorEvent = { version: 1, execution_id: current.id, seq: ++current.seq, at: new Date().toISOString(), type, data };
|
|
33
|
+
appendFileSync(current.file, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
34
|
+
} catch {
|
|
35
|
+
// 只停用本次记录,不重试、不递归写日志,也不影响业务结果。
|
|
36
|
+
current.disabled = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function reportLog(level: string, message: string): void {
|
|
41
|
+
const current = recording.getStore();
|
|
42
|
+
if (!current || current.disabled || current.truncated) return;
|
|
43
|
+
try {
|
|
44
|
+
const safe = redact(message);
|
|
45
|
+
current.logBytes += Buffer.byteLength(safe, "utf8");
|
|
46
|
+
if (current.logBytes > LOG_LIMIT) {
|
|
47
|
+
current.truncated = true;
|
|
48
|
+
emit("log", { level: "warn", message: "普通日志达到 10MB 上限,后续日志已截断;进度、错误和终态仍记录。", truncated: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
emit("log", { level, message: safe });
|
|
52
|
+
} catch { /* 日志不得影响调用。 */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function reportProgress(progress: MonitorProgress): void {
|
|
56
|
+
const current = recording.getStore();
|
|
57
|
+
if (!current || current.disabled) return;
|
|
58
|
+
try {
|
|
59
|
+
current.progressRoute = progress.route ?? current.progressRoute;
|
|
60
|
+
current.progressAccount = progress.account ?? current.progressAccount;
|
|
61
|
+
const key = `${progress.stage}:${current.progressRoute || ""}:${current.progressAccount || ""}`;
|
|
62
|
+
const complete = progress.total !== undefined && progress.current === progress.total;
|
|
63
|
+
if (key === current.progressKey && !complete && Date.now() - current.lastProgressAt < 200) return;
|
|
64
|
+
current.progressKey = key;
|
|
65
|
+
current.lastProgressAt = Date.now();
|
|
66
|
+
emit("progress", {
|
|
67
|
+
stage: redact(progress.stage),
|
|
68
|
+
...(progress.message ? { message: redact(progress.message) } : {}),
|
|
69
|
+
...(Number.isFinite(progress.current) && progress.current! >= 0 ? { current: progress.current } : {}),
|
|
70
|
+
...(Number.isFinite(progress.total) && progress.total! >= 0 ? { total: progress.total } : {}),
|
|
71
|
+
...(progress.unit ? { unit: progress.unit } : {}),
|
|
72
|
+
...(current.progressRoute ? { route: redact(current.progressRoute) } : {}),
|
|
73
|
+
...(current.progressAccount ? { account: redact(current.progressAccount) } : {}),
|
|
74
|
+
});
|
|
75
|
+
} catch { /* 进度采集不得改变业务路径。 */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 插件进度回调失败不能让已经执行的渲染失败。 */
|
|
79
|
+
export function notifyProgress(callback: ((progress: MonitorProgress) => void) | undefined, progress: MonitorProgress): void {
|
|
80
|
+
try { (callback || reportProgress)(progress); } catch { /* 可选观测回调失败时继续执行。 */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 只记录身份和状态引用,正文和业务配置不进入事件文件。 */
|
|
84
|
+
export function observeState(state: WorkflowState, path: string): void {
|
|
85
|
+
const current = recording.getStore();
|
|
86
|
+
if (!current || current.disabled || !state.run_id || !state.workspace_root) return;
|
|
87
|
+
try {
|
|
88
|
+
const workspace = canonicalPath(state.workspace_root);
|
|
89
|
+
const id = taskId(workspace, state.run_id);
|
|
90
|
+
const key = `${id}:${path}:${state.updated_at}`;
|
|
91
|
+
if (current.tasks.has(key)) return;
|
|
92
|
+
current.tasks.add(key);
|
|
93
|
+
emit("task", { id, workspace, run_id: state.run_id, state_path: canonicalPath(path), title: redact(state.metadata.title) || null });
|
|
94
|
+
} catch { /* 状态落盘不依赖观测成功。 */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function safeOutcome(outcome: CommandOutcome): CommandOutcome {
|
|
98
|
+
return {
|
|
99
|
+
status: outcome.status,
|
|
100
|
+
errors: outcome.errors?.map((error) => ({
|
|
101
|
+
code: redact(error.code), message: redact(error.message),
|
|
102
|
+
...(error.stage ? { stage: redact(error.stage) } : {}),
|
|
103
|
+
...(error.route ? { route: redact(error.route) } : {}),
|
|
104
|
+
...(error.account ? { account: redact(error.account) } : {}),
|
|
105
|
+
...(error.cause ? { cause: redact(error.cause) } : {}),
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 服务未运行时也独立记录;强制退出仅尽力补充终态。 */
|
|
111
|
+
export async function observeCommand(command: string, handler: () => Promise<CommandOutcome>): Promise<CommandOutcome> {
|
|
112
|
+
if (process.env.ZZHUB_PIPELINE_MONITOR === "0") return handler();
|
|
113
|
+
let context: Recording;
|
|
114
|
+
try {
|
|
115
|
+
const dir = join(monitorDir(), "events");
|
|
116
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
const id = crypto.randomUUID();
|
|
118
|
+
context = { id, file: join(dir, `${id}.jsonl`), seq: 0, disabled: false, logBytes: 0, truncated: false, lastProgressAt: 0, progressKey: "", tasks: new Set() };
|
|
119
|
+
} catch { return handler(); }
|
|
120
|
+
|
|
121
|
+
return recording.run(context, async () => {
|
|
122
|
+
emit("started", { command, pid: process.pid, process_identity: processIdentity(process.pid), is_query: queryCommands.has(command) });
|
|
123
|
+
const finish = (outcome: CommandOutcome, exitCode = outcomeExitCode(outcome)) => {
|
|
124
|
+
try { emit("finished", { outcome: safeOutcome(outcome), exit_code: exitCode }); } catch { /* 尽力记录终态。 */ }
|
|
125
|
+
context.ended = true;
|
|
126
|
+
};
|
|
127
|
+
const onExit = (code: number) => recording.run(context, () => finish(
|
|
128
|
+
code === 0 ? { status: "success" } : { status: "failed", errors: [{ code: "PROCESS_EXIT", message: `进程退出:${code}` }] }, code,
|
|
129
|
+
));
|
|
130
|
+
process.once("exit", onExit);
|
|
131
|
+
try {
|
|
132
|
+
const outcome = await handler();
|
|
133
|
+
finish(outcome);
|
|
134
|
+
return outcome;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
finish({ status: "failed", errors: [commandError(error)] });
|
|
137
|
+
throw error;
|
|
138
|
+
} finally {
|
|
139
|
+
process.removeListener("exit", onExit);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
export const LOG_LIMIT = 10 * 1024 * 1024;
|
|
8
|
+
export const RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
9
|
+
export const STORAGE_LIMIT = 512 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/** 独立于业务配置文件,保证同一用户的多个工作区共享监控。 */
|
|
12
|
+
export function monitorDir(): string {
|
|
13
|
+
if (process.env.ZZHUB_PIPELINE_MONITOR_DIR) return resolve(process.env.ZZHUB_PIPELINE_MONITOR_DIR);
|
|
14
|
+
const base = platform() === "darwin" ? join(homedir(), "Library", "Application Support")
|
|
15
|
+
: platform() === "win32" ? process.env.APPDATA || join(homedir(), "AppData", "Roaming")
|
|
16
|
+
: process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
17
|
+
return join(base, "zzhub-pipeline", "monitor");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function canonicalPath(path: string): string {
|
|
21
|
+
try { return realpathSync(path); } catch { return resolve(path); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function taskId(workspace: string, runId: string): string {
|
|
25
|
+
return createHash("sha256").update(`${canonicalPath(workspace)}\n${runId}`).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 过滤常见凭据和正文参数;监控从不采集完整 argv、环境或结果正文。 */
|
|
29
|
+
export function redact(value: string): string {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/Bearer\s+[^\s"',}]+/gi, "Bearer [redacted]")
|
|
32
|
+
.replace(/((?:[?&]|["']?)(?:access[_-]?token|refresh[_-]?token|token|pat|password|secret(?:key)?|appsecret|authorization|cookie)["']?\s*[:=]\s*)["']?[^\s"'&,}\r\n]+["']?/gi, "$1[redacted]")
|
|
33
|
+
.replace(/(--(?:pat|token|password|secret|body-text|text|intent-text)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, "$1[redacted]")
|
|
34
|
+
.slice(0, 4096);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PID 加系统启动指纹,避免 PID 复用造成错误的运行中判断。 */
|
|
38
|
+
export function processIdentity(pid: number): string | null {
|
|
39
|
+
try {
|
|
40
|
+
if (platform() === "linux") {
|
|
41
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
42
|
+
return `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]}`;
|
|
43
|
+
}
|
|
44
|
+
if (platform() === "darwin") {
|
|
45
|
+
const result = spawnSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 1000 });
|
|
46
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
47
|
+
}
|
|
48
|
+
} catch { /* 身份查询失败不影响 CLI。 */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function processStatus(pid: number, identity: string | null): "running" | "interrupted" | "unknown" {
|
|
53
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
54
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "interrupted" : "unknown";
|
|
55
|
+
}
|
|
56
|
+
const current = processIdentity(pid);
|
|
57
|
+
return identity && current ? (identity === current ? "running" : "interrupted") : "unknown";
|
|
58
|
+
}
|