@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
4
|
+
import { MonitorStore, type MonitorFilter } from "./store";
|
|
5
|
+
import { monitorDir, processIdentity, processStatus } from "./runtime";
|
|
6
|
+
import { monitorHealth, readMonitorDescriptor } from "./client";
|
|
7
|
+
import type { MonitorDescriptor, MonitorUpdate } from "./types";
|
|
8
|
+
|
|
9
|
+
function json(data: unknown, status = 200): Response {
|
|
10
|
+
return Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 原子锁只属于监控进程,与任何工作流锁互不关联。 */
|
|
14
|
+
async function claimServer(root: string, instanceId: string): Promise<(() => void) | null> {
|
|
15
|
+
const lockPath = join(root, "server.lock");
|
|
16
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
19
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, identity: processIdentity(process.pid), instance_id: instanceId })); }
|
|
20
|
+
finally { closeSync(fd); }
|
|
21
|
+
return () => {
|
|
22
|
+
try {
|
|
23
|
+
if (JSON.parse(readFileSync(lockPath, "utf8")).instance_id === instanceId) unlinkSync(lockPath);
|
|
24
|
+
} catch { /* 已被本实例清理。 */ }
|
|
25
|
+
};
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
28
|
+
const descriptor = readMonitorDescriptor(root);
|
|
29
|
+
if (descriptor && await monitorHealth(descriptor)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { pid: number; identity: string | null };
|
|
32
|
+
if (Number.isSafeInteger(lock.pid) && lock.pid > 0 && processStatus(lock.pid, lock.identity) === "interrupted") {
|
|
33
|
+
unlinkSync(lockPath);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
} catch { /* 写锁过程中短暂空文件不能被抢占。 */ }
|
|
37
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error("监控启动锁仍被占用;请检查已有 monitor serve 进程。");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 一个本机 HTTP 服务承载快照、分页日志和多任务 SSE。 */
|
|
44
|
+
export async function serveMonitor(): Promise<{ descriptor: MonitorDescriptor; stop: () => void } | null> {
|
|
45
|
+
const root = monitorDir();
|
|
46
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
47
|
+
const instanceId = crypto.randomUUID();
|
|
48
|
+
const release = await claimServer(root, instanceId);
|
|
49
|
+
if (!release) return null;
|
|
50
|
+
const token = randomBytes(32).toString("hex");
|
|
51
|
+
const store = new MonitorStore(instanceId, root);
|
|
52
|
+
let lastAccess = Date.now();
|
|
53
|
+
let ready = false;
|
|
54
|
+
let stopped = false;
|
|
55
|
+
const connections = new Set<() => void>();
|
|
56
|
+
let stop = () => {};
|
|
57
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
58
|
+
try {
|
|
59
|
+
server = Bun.serve({
|
|
60
|
+
hostname: "127.0.0.1", port: 0,
|
|
61
|
+
async fetch(request, runtime) {
|
|
62
|
+
const authorization = request.headers.get("authorization") || "";
|
|
63
|
+
const expected = `Bearer ${token}`;
|
|
64
|
+
if (Buffer.byteLength(authorization) !== Buffer.byteLength(expected) || !timingSafeEqual(Buffer.from(authorization), Buffer.from(expected))) return json({ error: "unauthorized" }, 401);
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
// 首版只接收同机原生客户端,不向任意网页开放本地凭据与日志。
|
|
67
|
+
if (request.headers.has("origin") && request.headers.get("origin") !== url.origin) return json({ error: "origin not allowed" }, 403);
|
|
68
|
+
lastAccess = Date.now();
|
|
69
|
+
if (url.pathname === "/v1/health" && request.method === "GET") return json({ service: "zzhub-pipeline-monitor", version: 1, instance_id: instanceId, ready, issues: [...store.issues] });
|
|
70
|
+
if (url.pathname === "/v1/stop" && request.method === "POST") { setTimeout(() => stop(), 25); return json({ stopped: true }); }
|
|
71
|
+
if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
|
|
72
|
+
if (!ready) return json({ error: "monitor starting" }, 503);
|
|
73
|
+
const filter: MonitorFilter = {
|
|
74
|
+
workspace: url.searchParams.get("workspace") || undefined,
|
|
75
|
+
task_id: url.searchParams.get("task_id") || undefined,
|
|
76
|
+
execution_id: url.searchParams.get("execution_id") || undefined,
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
if (url.pathname === "/v1/snapshot") { await store.refresh(); return json(store.snapshot(filter)); }
|
|
80
|
+
const match = url.pathname.match(/^\/v1\/executions\/([0-9a-f-]+)(\/logs)?$/);
|
|
81
|
+
if (match) {
|
|
82
|
+
const execution = store.executions.get(match[1]!);
|
|
83
|
+
if (!execution) return json({ error: "execution not found" }, 404);
|
|
84
|
+
if (!match[2]) return json(execution);
|
|
85
|
+
const after = Number(url.searchParams.get("after_seq") || "0");
|
|
86
|
+
const limit = Number(url.searchParams.get("limit") || "200");
|
|
87
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 500) return json({ error: "invalid log cursor or limit" }, 400);
|
|
88
|
+
return json(await store.logs(execution.id, after, limit));
|
|
89
|
+
}
|
|
90
|
+
if (url.pathname !== "/v1/events") return json({ error: "not found" }, 404);
|
|
91
|
+
runtime.timeout(request, 0);
|
|
92
|
+
const cursor = request.headers.get("last-event-id") || url.searchParams.get("after") || "";
|
|
93
|
+
const events = cursor ? store.eventsAfter(cursor) : null;
|
|
94
|
+
let cleanup = () => {};
|
|
95
|
+
const encoder = new TextEncoder();
|
|
96
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
97
|
+
start(controller) {
|
|
98
|
+
let closed = false;
|
|
99
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
100
|
+
const close = () => {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
closed = true;
|
|
103
|
+
clearInterval(heartbeat);
|
|
104
|
+
store.listeners.delete(send);
|
|
105
|
+
connections.delete(close);
|
|
106
|
+
request.signal.removeEventListener("abort", close);
|
|
107
|
+
try { controller.close(); } catch { /* 客户端可能已取消。 */ }
|
|
108
|
+
};
|
|
109
|
+
const enqueue = (text: string) => {
|
|
110
|
+
if (closed) return;
|
|
111
|
+
if ((controller.desiredSize ?? 0) < 0) { close(); return; }
|
|
112
|
+
try { controller.enqueue(encoder.encode(text)); } catch { close(); }
|
|
113
|
+
};
|
|
114
|
+
const send = (event: MonitorUpdate) => {
|
|
115
|
+
if (store.matchesUpdate(event, filter)) enqueue(`id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
116
|
+
};
|
|
117
|
+
cleanup = close;
|
|
118
|
+
if (events === null) {
|
|
119
|
+
enqueue(`event: resync\ndata: ${JSON.stringify({ reason: "snapshot_required", instance_id: instanceId })}\n\n`);
|
|
120
|
+
close();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// 注册与重放之间不 await,避免丢失快照之后的事件。
|
|
124
|
+
connections.add(close);
|
|
125
|
+
store.listeners.add(send);
|
|
126
|
+
enqueue(": connected\n\n");
|
|
127
|
+
for (const event of events) send(event);
|
|
128
|
+
if (closed) return;
|
|
129
|
+
heartbeat = setInterval(() => enqueue(": keepalive\n\n"), 15_000);
|
|
130
|
+
request.signal.addEventListener("abort", close, { once: true });
|
|
131
|
+
if (request.signal.aborted) close();
|
|
132
|
+
},
|
|
133
|
+
cancel() { cleanup(); },
|
|
134
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark: 256 * 1024 }));
|
|
135
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no" } });
|
|
136
|
+
} catch { return json({ error: "monitor data unavailable" }, 500); }
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
} catch (error) { release(); throw error; }
|
|
140
|
+
|
|
141
|
+
const descriptor: MonitorDescriptor = { version: 1, instance_id: instanceId, pid: process.pid, url: `http://127.0.0.1:${server.port}`, token };
|
|
142
|
+
const timer = setInterval(() => {
|
|
143
|
+
void store.refresh();
|
|
144
|
+
if (!connections.size && Date.now() - lastAccess > 5 * 60_000) stop();
|
|
145
|
+
}, 500);
|
|
146
|
+
const onSignal = () => stop();
|
|
147
|
+
stop = () => {
|
|
148
|
+
if (stopped) return;
|
|
149
|
+
stopped = true;
|
|
150
|
+
clearInterval(timer);
|
|
151
|
+
connections.forEach((close) => close());
|
|
152
|
+
server.stop(true);
|
|
153
|
+
process.removeListener("SIGINT", onSignal);
|
|
154
|
+
process.removeListener("SIGTERM", onSignal);
|
|
155
|
+
process.removeListener("exit", stop);
|
|
156
|
+
try { if (readMonitorDescriptor(root)?.instance_id === instanceId) unlinkSync(join(root, "server.json")); } catch { /* 描述文件已清理。 */ }
|
|
157
|
+
release();
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
const temporary = join(root, `server.${instanceId}.tmp`);
|
|
161
|
+
writeFileSync(temporary, JSON.stringify(descriptor), { mode: 0o600 });
|
|
162
|
+
renameSync(temporary, join(root, "server.json"));
|
|
163
|
+
process.once("SIGINT", onSignal);
|
|
164
|
+
process.once("SIGTERM", onSignal);
|
|
165
|
+
process.once("exit", stop);
|
|
166
|
+
await store.refresh();
|
|
167
|
+
ready = true;
|
|
168
|
+
return { descriptor, stop };
|
|
169
|
+
} catch (error) { stop(); throw error; }
|
|
170
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { open, readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { getTaskByStatePath } from "../task-manager";
|
|
5
|
+
import { canonicalPath, monitorDir, processStatus, redact, RETENTION_MS, STORAGE_LIMIT } from "./runtime";
|
|
6
|
+
import type { MonitorEvent, MonitorExecution, MonitorProgress, MonitorSnapshot, MonitorTask, MonitorUpdate } from "./types";
|
|
7
|
+
import type { CommandOutcome } from "../command-outcome";
|
|
8
|
+
|
|
9
|
+
const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f-]{27}$/;
|
|
10
|
+
const OUTCOMES = new Set(["success", "skipped", "waiting", "partial_failure", "failed"]);
|
|
11
|
+
export interface MonitorFilter { workspace?: string; task_id?: string; execution_id?: string }
|
|
12
|
+
interface FileCursor { offset: number; tail: string; seq: number; decoder?: StringDecoder }
|
|
13
|
+
|
|
14
|
+
/** 内存投影可重建;事件文件是执行历史,工作流文件是业务状态真相源。 */
|
|
15
|
+
export class MonitorStore {
|
|
16
|
+
readonly executions = new Map<string, MonitorExecution>();
|
|
17
|
+
readonly tasks = new Map<string, MonitorTask>();
|
|
18
|
+
readonly issues: string[] = [];
|
|
19
|
+
readonly listeners = new Set<(event: MonitorUpdate) => void>();
|
|
20
|
+
private files = new Map<string, FileCursor>();
|
|
21
|
+
private dirtyTasks = new Set<string>();
|
|
22
|
+
private sequence = 0;
|
|
23
|
+
private replay: MonitorUpdate[] = [];
|
|
24
|
+
private replayBytes = 0;
|
|
25
|
+
private pending: Promise<void> | null = null;
|
|
26
|
+
private lastProbe = 0;
|
|
27
|
+
private lastPrune = 0;
|
|
28
|
+
|
|
29
|
+
constructor(readonly instanceId: string, readonly root = monitorDir()) {}
|
|
30
|
+
|
|
31
|
+
get cursor(): string { return `${this.instanceId}:${this.sequence}`; }
|
|
32
|
+
|
|
33
|
+
private issue(error: unknown): void {
|
|
34
|
+
this.issues.push(redact(error instanceof Error ? error.message : String(error)));
|
|
35
|
+
if (this.issues.length > 20) this.issues.shift();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private broadcast(type: MonitorUpdate["type"], data: Record<string, unknown>, executionId?: string, taskId?: string): void {
|
|
39
|
+
this.sequence++;
|
|
40
|
+
const update: MonitorUpdate = { version: 1, cursor: this.cursor, type, data: structuredClone(data), execution_id: executionId, task_id: taskId };
|
|
41
|
+
this.replay.push(update);
|
|
42
|
+
this.replayBytes += Buffer.byteLength(JSON.stringify(update));
|
|
43
|
+
while (this.replay.length > 1000 || this.replayBytes > 2 * 1024 * 1024) {
|
|
44
|
+
this.replayBytes -= Buffer.byteLength(JSON.stringify(this.replay.shift()));
|
|
45
|
+
}
|
|
46
|
+
for (const listener of this.listeners) {
|
|
47
|
+
try { listener(update); } catch { this.listeners.delete(listener); }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
matches(execution: MonitorExecution, filter: MonitorFilter): boolean {
|
|
52
|
+
return (!filter.execution_id || filter.execution_id === execution.id)
|
|
53
|
+
&& (!filter.task_id || execution.task_ids.includes(filter.task_id))
|
|
54
|
+
&& (!filter.workspace || execution.task_ids.some((id) => this.tasks.get(id)?.workspace === canonicalPath(filter.workspace!)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
matchesUpdate(update: MonitorUpdate, filter: MonitorFilter): boolean {
|
|
58
|
+
if (update.execution_id) {
|
|
59
|
+
const execution = this.executions.get(update.execution_id);
|
|
60
|
+
return !!execution && this.matches(execution, filter);
|
|
61
|
+
}
|
|
62
|
+
const task = update.task_id ? this.tasks.get(update.task_id) : null;
|
|
63
|
+
return !!task && (!filter.task_id || task.id === filter.task_id)
|
|
64
|
+
&& (!filter.workspace || task.workspace === canonicalPath(filter.workspace))
|
|
65
|
+
&& (!filter.execution_id || this.executions.get(filter.execution_id)?.task_ids.includes(task.id) === true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
snapshot(filter: MonitorFilter = {}): MonitorSnapshot {
|
|
69
|
+
const executions = [...this.executions.values()].filter((item) => this.matches(item, filter))
|
|
70
|
+
.sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
71
|
+
const tasks = [...this.tasks.values()].filter((task) => (!filter.workspace || task.workspace === canonicalPath(filter.workspace))
|
|
72
|
+
&& (!filter.task_id || task.id === filter.task_id)
|
|
73
|
+
&& (!filter.execution_id || this.executions.get(filter.execution_id)?.task_ids.includes(task.id) === true))
|
|
74
|
+
.map((task) => ({ ...task, active_execution_ids: executions.filter((execution) => !execution.is_query
|
|
75
|
+
&& (execution.status === "running" || execution.status === "unknown") && execution.task_ids.includes(task.id)).map((execution) => execution.id) }));
|
|
76
|
+
return structuredClone({ version: 1, instance_id: this.instanceId, cursor: this.cursor, tasks,
|
|
77
|
+
executions: executions.filter((execution, index) => index < 100 || execution.status === "running" || execution.status === "unknown") });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 返回 null 表示游标已失效,客户端必须重新读取快照。 */
|
|
81
|
+
eventsAfter(cursor: string): MonitorUpdate[] | null {
|
|
82
|
+
if (cursor === this.cursor) return [];
|
|
83
|
+
const prefix = `${this.instanceId}:`;
|
|
84
|
+
if (!cursor.startsWith(prefix) || !/^\d+$/.test(cursor.slice(prefix.length))) return null;
|
|
85
|
+
const sequence = Number(cursor.slice(prefix.length));
|
|
86
|
+
const oldest = this.sequence - this.replay.length;
|
|
87
|
+
return sequence < oldest || sequence > this.sequence ? null : this.replay.slice(sequence - oldest);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
refresh(): Promise<void> {
|
|
91
|
+
if (!this.pending) this.pending = this.scan().catch((error) => this.issue(error)).finally(() => { this.pending = null; });
|
|
92
|
+
return this.pending;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private apply(event: MonitorEvent): void {
|
|
96
|
+
const data = event.data;
|
|
97
|
+
if (event.type === "started") {
|
|
98
|
+
if (typeof data.command !== "string" || !Number.isSafeInteger(data.pid) || Number(data.pid) <= 0) return;
|
|
99
|
+
this.executions.set(event.execution_id, {
|
|
100
|
+
id: event.execution_id, command: data.command, pid: Number(data.pid),
|
|
101
|
+
process_identity: typeof data.process_identity === "string" ? data.process_identity : null,
|
|
102
|
+
is_query: data.is_query === true, started_at: event.at, ended_at: null, last_event_at: event.at,
|
|
103
|
+
status: "running", outcome: null, exit_code: null, task_ids: [],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const execution = this.executions.get(event.execution_id);
|
|
107
|
+
if (!execution) return;
|
|
108
|
+
execution.last_event_at = event.at;
|
|
109
|
+
if (event.type === "task") {
|
|
110
|
+
if (![data.id, data.workspace, data.run_id, data.state_path].every((value) => typeof value === "string" && value.length > 0)) return;
|
|
111
|
+
const task = data as unknown as MonitorTask;
|
|
112
|
+
this.tasks.set(task.id, { ...this.tasks.get(task.id), ...task });
|
|
113
|
+
if (!execution.task_ids.includes(task.id)) execution.task_ids.push(task.id);
|
|
114
|
+
this.dirtyTasks.add(task.id);
|
|
115
|
+
} else if (event.type === "progress") {
|
|
116
|
+
execution.progress = data as unknown as MonitorProgress;
|
|
117
|
+
} else if (event.type === "finished") {
|
|
118
|
+
const outcome = data.outcome as CommandOutcome | undefined;
|
|
119
|
+
if (!outcome || !OUTCOMES.has(outcome.status)) return;
|
|
120
|
+
execution.outcome = outcome;
|
|
121
|
+
execution.exit_code = typeof data.exit_code === "number" ? data.exit_code : null;
|
|
122
|
+
execution.status = "exited";
|
|
123
|
+
execution.ended_at = event.at;
|
|
124
|
+
execution.task_ids.forEach((id) => this.dirtyTasks.add(id));
|
|
125
|
+
} else if (event.type === "log") {
|
|
126
|
+
if (data.truncated) execution.logs_truncated = true;
|
|
127
|
+
this.broadcast("log", { ...data, seq: event.seq, at: event.at }, execution.id);
|
|
128
|
+
if (!data.truncated) return;
|
|
129
|
+
}
|
|
130
|
+
this.broadcast("execution.updated", execution as unknown as Record<string, unknown>, execution.id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async readEvents(id: string, cursor: FileCursor, consume: (event: MonitorEvent) => boolean | void): Promise<void> {
|
|
134
|
+
const handle = await open(join(this.root, "events", `${id}.jsonl`), "r");
|
|
135
|
+
try {
|
|
136
|
+
const size = (await handle.stat()).size;
|
|
137
|
+
if (size < cursor.offset) throw new Error(`监控事件文件被截短:${id}`);
|
|
138
|
+
// 分块扫描,不把所有任务的日志装入内存。
|
|
139
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
140
|
+
cursor.decoder ??= new StringDecoder("utf8");
|
|
141
|
+
while (cursor.offset < size) {
|
|
142
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, size - cursor.offset), cursor.offset);
|
|
143
|
+
if (!bytesRead) break;
|
|
144
|
+
cursor.offset += bytesRead;
|
|
145
|
+
const text = cursor.tail + cursor.decoder.write(buffer.subarray(0, bytesRead));
|
|
146
|
+
const lines = text.split("\n");
|
|
147
|
+
cursor.tail = lines.pop() || "";
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
try {
|
|
150
|
+
const event = JSON.parse(line) as MonitorEvent;
|
|
151
|
+
if (event.version !== 1 || event.execution_id !== id || !Number.isSafeInteger(event.seq)
|
|
152
|
+
|| event.seq <= cursor.seq || !event.data || typeof event.data !== "object" || typeof event.at !== "string") continue;
|
|
153
|
+
cursor.seq = event.seq;
|
|
154
|
+
if (consume(event) === false) return;
|
|
155
|
+
} catch { this.issue(`忽略损坏的监控事件:${id}`); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} finally { await handle.close(); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async scan(): Promise<void> {
|
|
162
|
+
const names = await readdir(join(this.root, "events")).catch(() => [] as string[]);
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
const id = name.replace(/\.jsonl$/, "");
|
|
165
|
+
if (!name.endsWith(".jsonl") || !ID_PATTERN.test(id)) continue;
|
|
166
|
+
// 已完整读到终态的日志不可再追加,历史越多也不会反复打开所有文件。
|
|
167
|
+
if (this.executions.get(id)?.status === "exited") continue;
|
|
168
|
+
const cursor = this.files.get(id) || { offset: 0, tail: "", seq: 0 };
|
|
169
|
+
this.files.set(id, cursor);
|
|
170
|
+
await this.readEvents(id, cursor, (event) => this.apply(event)).catch((error) => this.issue(error));
|
|
171
|
+
}
|
|
172
|
+
if (Date.now() - this.lastProbe > 2000) {
|
|
173
|
+
this.lastProbe = Date.now();
|
|
174
|
+
for (const execution of this.executions.values()) {
|
|
175
|
+
if (execution.status !== "running" && execution.status !== "unknown") continue;
|
|
176
|
+
const status = processStatus(execution.pid, execution.process_identity);
|
|
177
|
+
if (status === execution.status) continue;
|
|
178
|
+
execution.status = status;
|
|
179
|
+
if (status === "interrupted") {
|
|
180
|
+
execution.ended_at = new Date().toISOString();
|
|
181
|
+
execution.task_ids.forEach((id) => this.dirtyTasks.add(id));
|
|
182
|
+
}
|
|
183
|
+
this.broadcast("execution.updated", execution as unknown as Record<string, unknown>, execution.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const id of this.dirtyTasks) {
|
|
187
|
+
const task = this.tasks.get(id)!;
|
|
188
|
+
try {
|
|
189
|
+
const report = await getTaskByStatePath(task.state_path);
|
|
190
|
+
if (report.summary.run_id !== task.run_id) throw new Error(`监控状态文件身份不匹配:${id}`);
|
|
191
|
+
const updated: MonitorTask = { ...task, state_path: report.summary.state_path,
|
|
192
|
+
title: redact(report.summary.metadata.title || "") || null, mode: report.summary.mode, phase: report.summary.phase.current,
|
|
193
|
+
next_action: { action: report.next_action.action, reason: redact(report.next_action.reason), executor: report.next_action.executor },
|
|
194
|
+
publish_results: report.summary.publish.results.map((result) => ({ route: result.route, account: result.account,
|
|
195
|
+
status: result.status, detail: result.detail ? redact(result.detail) : null })),
|
|
196
|
+
};
|
|
197
|
+
this.tasks.set(id, updated);
|
|
198
|
+
this.broadcast("task.updated", updated as unknown as Record<string, unknown>, undefined, id);
|
|
199
|
+
} catch (error) { this.issue(error); }
|
|
200
|
+
}
|
|
201
|
+
this.dirtyTasks.clear();
|
|
202
|
+
if (Date.now() - this.lastPrune > 60_000) { this.lastPrune = Date.now(); await this.prune(); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async logs(id: string, afterSeq: number, limit: number): Promise<{ logs: MonitorEvent[]; next_seq: number; truncated: boolean }> {
|
|
206
|
+
if (!ID_PATTERN.test(id) || !this.executions.has(id)) throw new Error("execution not found");
|
|
207
|
+
const logs: MonitorEvent[] = [];
|
|
208
|
+
const cursor = { offset: 0, tail: "", seq: 0 };
|
|
209
|
+
await this.readEvents(id, cursor, (event) => {
|
|
210
|
+
if (event.type === "log" && event.seq > afterSeq) logs.push(event);
|
|
211
|
+
return logs.length < limit;
|
|
212
|
+
});
|
|
213
|
+
return { logs, next_seq: logs.at(-1)?.seq ?? Math.max(afterSeq, cursor.seq), truncated: this.executions.get(id)?.logs_truncated === true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async prune(): Promise<void> {
|
|
217
|
+
const files = await Promise.all([...this.executions.values()].map(async (execution) => {
|
|
218
|
+
const path = join(this.root, "events", `${execution.id}.jsonl`);
|
|
219
|
+
const info = await stat(path).catch(() => null);
|
|
220
|
+
return { execution, path, size: info?.size ?? 0 };
|
|
221
|
+
}));
|
|
222
|
+
let total = files.reduce((sum, file) => sum + file.size, 0);
|
|
223
|
+
for (const file of files.sort((a, b) => a.execution.started_at.localeCompare(b.execution.started_at))) {
|
|
224
|
+
if (file.execution.status !== "exited" && file.execution.status !== "interrupted") continue;
|
|
225
|
+
if (Date.now() - Date.parse(file.execution.ended_at || file.execution.started_at) < RETENTION_MS && total <= STORAGE_LIMIT) continue;
|
|
226
|
+
try { await unlink(file.path); } catch (error) { this.issue(error); continue; }
|
|
227
|
+
total -= file.size;
|
|
228
|
+
this.executions.delete(file.execution.id);
|
|
229
|
+
this.files.delete(file.execution.id);
|
|
230
|
+
}
|
|
231
|
+
// ponytail: 历史元数据随文件重建;本机几十个并行任务先用内存投影。
|
|
232
|
+
const referenced = new Set([...this.executions.values()].flatMap((execution) => execution.task_ids));
|
|
233
|
+
for (const id of this.tasks.keys()) if (!referenced.has(id)) this.tasks.delete(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CommandOutcome } from "../command-outcome";
|
|
2
|
+
|
|
3
|
+
/** 只报告能测量的进度;未知总量不生成百分比。 */
|
|
4
|
+
export interface MonitorProgress {
|
|
5
|
+
stage: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
current?: number;
|
|
8
|
+
total?: number;
|
|
9
|
+
unit?: "pages" | "files" | "bytes" | "targets";
|
|
10
|
+
route?: string;
|
|
11
|
+
account?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MonitorEvent {
|
|
15
|
+
version: 1;
|
|
16
|
+
execution_id: string;
|
|
17
|
+
seq: number;
|
|
18
|
+
at: string;
|
|
19
|
+
type: "started" | "task" | "progress" | "log" | "finished";
|
|
20
|
+
data: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MonitorExecution {
|
|
24
|
+
id: string;
|
|
25
|
+
command: string;
|
|
26
|
+
pid: number;
|
|
27
|
+
process_identity: string | null;
|
|
28
|
+
is_query: boolean;
|
|
29
|
+
started_at: string;
|
|
30
|
+
ended_at: string | null;
|
|
31
|
+
last_event_at: string;
|
|
32
|
+
status: "running" | "exited" | "interrupted" | "unknown";
|
|
33
|
+
outcome: CommandOutcome | null;
|
|
34
|
+
exit_code: number | null;
|
|
35
|
+
task_ids: string[];
|
|
36
|
+
progress?: MonitorProgress;
|
|
37
|
+
logs_truncated?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MonitorTask {
|
|
41
|
+
id: string;
|
|
42
|
+
workspace: string;
|
|
43
|
+
run_id: string;
|
|
44
|
+
state_path: string;
|
|
45
|
+
title: string | null;
|
|
46
|
+
mode?: string;
|
|
47
|
+
phase?: string;
|
|
48
|
+
next_action?: { action: string; reason: string; executor: string };
|
|
49
|
+
publish_results?: Array<{ route: string; account: string; status: string; detail: string | null }>;
|
|
50
|
+
active_execution_ids?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MonitorUpdate {
|
|
54
|
+
version: 1;
|
|
55
|
+
cursor: string;
|
|
56
|
+
type: "execution.updated" | "task.updated" | "log";
|
|
57
|
+
execution_id?: string;
|
|
58
|
+
task_id?: string;
|
|
59
|
+
data: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MonitorSnapshot {
|
|
63
|
+
version: 1;
|
|
64
|
+
instance_id: string;
|
|
65
|
+
cursor: string;
|
|
66
|
+
tasks: MonitorTask[];
|
|
67
|
+
executions: MonitorExecution[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 描述文件只能由当前用户读取,令牌不得发送到渲染进程。 */
|
|
71
|
+
export interface MonitorDescriptor {
|
|
72
|
+
version: 1;
|
|
73
|
+
instance_id: string;
|
|
74
|
+
pid: number;
|
|
75
|
+
url: string;
|
|
76
|
+
token: string;
|
|
77
|
+
}
|