// pipeline-jobs.mts —— 只读转储 `pipeline/jobs.sqlite`(查看器「流水线」页的取数口)
//
// ── 为什么单独一个脚本、而不是复用 `memory/pipeline/jobs-db.ts` 的 PipelineJobsDB ──
//
// 那个类的**构造函数本身就是写动作**:mkdir + `PRAGMA journal_mode = WAL` + 建表。
// 对一个"看一眼队列"的只读端点来说这些都越界了 —— 在全新 profile 上它会凭空造出一个
// 空 jobs.sqlite,把"从未跑过流水线"变成"有一个空库"(两种事实,不该被工具抹平)。
// 所以这里用 `node:sqlite` 的 `readOnly` 打开。
//
// 实测(Node 24.17 / 2026-10-08,本机):**主进程持写锁时只读打开仍能读** ——
// WAL 模式下读者不阻塞写者,只读连接的落点正是"pi 正在跑时看队列"这条主用途,
// 所以这条路可行;失败一律当失败报(不静默回空队列,那会把"读不到"说成"没有任务")。
//
// ── 队列口径(谁下一个跑)──────────────────────────────────────────────
//
// 复刻 `memory/pipeline/jobs-db.ts` 的 `listQueued()`:`ORDER BY CASE type WHEN 'l3' THEN 1
// ELSE 0 END ASC, id ASC` —— L3 永远排在最后,其余按入队序。这是**展示口径**,
// 它必须与真正的认领逻辑一致,否则页面会指着一条根本轮不到的任务说"下一个是它"。
// 那边改了这里要同步改(两处都写了这条注释,便于 grep)。
//
// 用法:
// node --experimental-strip-types scripts/l1-admin/pipeline-jobs.mts [--limit 40] [--profile
]
import { existsSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MEMORY_DB_DIR, PIPELINE_JOBS_FILE } from "../../memory/paths.ts";
import { finish, flagValue, profileArg, run, usageError } from "./viewer-cli.mts";
const USAGE = "node --experimental-strip-types scripts/l1-admin/pipeline-jobs.mts [--limit 40] [--profile ]";
/** 已结束任务回看的默认条数(队列本身不限量:queued 通常只有个位数)。 */
const DEFAULT_LIMIT = 40;
/** 上限:别让一个手滑的 --limit 把整张表拉进响应里。 */
const MAX_LIMIT = 200;
/** 五个状态必须是闭集:页面按状态上色,出现第六种就会渲染成"未知"。 */
const STATUSES = ["queued", "running", "done", "skipped", "failed"] as const;
/** jobs 表的一行(列名与 SCHEMA_SQL 一致;sqlite 给的是 snake_case)。 */
interface JobRow {
id: number;
type: string;
source: string;
status: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
error: string | null;
}
/** 行 → 界面口径的 job(camelCase;与 /api/subagent-runs 的字段风格一致)。 */
function mapJob(r: JobRow): Record {
return {
id: r.id,
type: r.type,
source: r.source,
status: r.status,
createdAt: r.created_at,
startedAt: r.started_at,
finishedAt: r.finished_at,
error: r.error,
};
}
run(() => {
const profilePath = profileArg();
if (!profilePath) usageError(USAGE);
const limitRaw = Number(flagValue("--limit"));
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(MAX_LIMIT, Math.round(limitRaw)) : DEFAULT_LIMIT;
const abs = join(profilePath, MEMORY_DB_DIR, PIPELINE_JOBS_FILE);
// 展示用相对路径统一正斜杠:Windows 的 join 会给出 `pipeline\jobs.sqlite`,混在界面里很难看
const relPath = `${MEMORY_DB_DIR}/${PIPELINE_JOBS_FILE.split(/[\\/]/).join("/")}`;
const zeroCounts: Record = { queued: 0, running: 0, done: 0, skipped: 0, failed: 0, total: 0 };
// 库还不存在 = 这台机器从未跑过流水线。这是**正常态**(不是错误),用 exists:false 表达。
if (!existsSync(abs)) {
finish({
success: true,
exists: false,
path: relPath,
limit,
counts: zeroCounts,
queued: [],
running: [],
recent: [],
note: "jobs.sqlite 还不存在:这个 profile 从未跑过流水线(首次提取后才会建库)。",
});
}
let db: DatabaseSync;
try {
db = new DatabaseSync(abs, { readOnly: true });
} catch (e) {
// 只读打开失败要说清楚:多半是权限或半关闭的 WAL(-shm 缺失且目录不可写)。
// **不能**退化成空队列 —— 那会把"读不到"说成"没有任务",正是本项目反复踩的静默失真。
finish({ success: false, error: `只读打开 ${relPath} 失败:${e instanceof Error ? e.message : String(e)}` });
}
try {
const counts: Record = { ...zeroCounts };
for (const row of db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all() as Array<{ status: string; n: number }>) {
if ((STATUSES as readonly string[]).includes(row.status)) counts[row.status] = row.n;
counts.total += row.n;
}
const queued = db
.prepare("SELECT * FROM jobs WHERE status = 'queued' ORDER BY CASE type WHEN 'l3' THEN 1 ELSE 0 END ASC, id ASC")
.all() as unknown as JobRow[];
const running = db.prepare("SELECT * FROM jobs WHERE status = 'running' ORDER BY id ASC").all() as unknown as JobRow[];
// 已结束的按 id 倒序回看:id 是自增的,等于"最近的在前",不必依赖时间字符串可比
const recent = db
.prepare("SELECT * FROM jobs WHERE status NOT IN ('queued', 'running') ORDER BY id DESC LIMIT ?")
.all(limit) as unknown as JobRow[];
finish({
success: true,
exists: true,
path: relPath,
limit,
counts,
queued: queued.map(mapJob),
running: running.map(mapJob),
recent: recent.map(mapJob),
// 口径说明一律纯文本(不带 markdown 记号):页面把它原样摆出来,出现字面 `**` 就是破相
note:
"jobs.sqlite 是跨进程的单一队列(主进程 worker 认领、子代理提交时标 done);" +
"queued 按认领顺序排列(L3 永远最后)。",
});
} finally {
try {
db.close();
} catch {
/* 关不掉不影响已读到的结论 */
}
}
});