@zzclub/pipeline 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +8 -72
  2. package/package.json +1 -1
  3. package/src/adapter-types.ts +5 -0
  4. package/src/adapters/builtin-image-renderer.ts +7 -6
  5. package/src/adapters/builtin-markdown-renderer.ts +3 -0
  6. package/src/cli.ts +10 -4
  7. package/src/command-outcome.ts +50 -0
  8. package/src/commands/abandon.ts +13 -2
  9. package/src/commands/checkpoint.ts +3 -2
  10. package/src/commands/config.ts +47 -20
  11. package/src/commands/monitor.ts +21 -0
  12. package/src/commands/prepare-finalize.ts +2 -0
  13. package/src/commands/prepare.ts +3 -0
  14. package/src/commands/publish.ts +5 -2
  15. package/src/commands/render.ts +7 -3
  16. package/src/commands/republish.ts +4 -1
  17. package/src/commands/sync-blog.ts +4 -2
  18. package/src/commands/wechat-export.ts +6 -3
  19. package/src/config.ts +21 -2
  20. package/src/imgx/render-article.ts +8 -1
  21. package/src/imgx/render-card.ts +6 -1
  22. package/src/imgx/runtime.ts +1 -1
  23. package/src/logger.test.ts +28 -1
  24. package/src/logger.ts +39 -26
  25. package/src/monitor/client.ts +60 -0
  26. package/src/monitor/recorder.ts +142 -0
  27. package/src/monitor/runtime.ts +58 -0
  28. package/src/monitor/server.ts +170 -0
  29. package/src/monitor/store.ts +235 -0
  30. package/src/monitor/types.ts +77 -0
  31. package/src/monitor.test.ts +241 -0
  32. package/src/plugins.ts +4 -5
  33. package/src/providers/blog.ts +4 -1
  34. package/src/providers/cos.ts +3 -0
  35. package/src/providers/index.ts +7 -3
  36. package/src/providers/publish-core.ts +7 -1
  37. package/src/providers/wechat.ts +7 -2
  38. package/src/schema/config.account-name.test.ts +79 -0
  39. package/src/schema/config.ts +9 -3
  40. package/src/spawn.ts +4 -2
  41. package/src/state.ts +4 -0
  42. package/src/workflow.test.ts +3 -2
  43. package/src/commands/analytics.test.ts +0 -156
  44. package/src/commands/analytics.ts +0 -240
  45. package/src/commands/topic.test.ts +0 -146
  46. package/src/commands/topic.ts +0 -717
  47. package/src/db.test.ts +0 -47
  48. package/src/db.ts +0 -95
  49. package/src/schema/analytics.test.ts +0 -54
  50. package/src/schema/analytics.ts +0 -19
  51. package/src/schema/topic.test.ts +0 -69
  52. package/src/schema/topic.ts +0 -55
@@ -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
- process.exitCode = 1;
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
- mergedAccounts[accountName] = { ...legacyAccount, ...sourceAccount };
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
  }
@@ -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) {
@@ -407,7 +407,7 @@ export function resolveInputPath(path: string): string {
407
407
  }
408
408
 
409
409
  export function printSaved(outPath: string): void {
410
- console.log(`✅ Saved to ${outPath}`);
410
+ console.error(`✅ Saved to ${outPath}`);
411
411
  }
412
412
 
413
413
  export function readImageSize(path: string): { width: number; height: number } | null {
@@ -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<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
- };
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
- 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
- }
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
- 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
- }
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
+ }