@weiping/wingline 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/audit-table.d.ts +7 -0
- package/dist/audit-table.js +68 -0
- package/dist/cli.d.ts +61 -0
- package/dist/cli.js +323 -0
- package/dist/console.d.ts +33 -0
- package/dist/console.js +136 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +11 -0
- package/dist/output.d.ts +80 -0
- package/dist/output.js +335 -0
- package/dist/tui/app.d.ts +28 -0
- package/dist/tui/app.js +259 -0
- package/dist/tui/command.d.ts +16 -0
- package/dist/tui/command.js +35 -0
- package/dist/tui/input.d.ts +14 -0
- package/dist/tui/input.js +67 -0
- package/dist/tui/items.d.ts +15 -0
- package/dist/tui/items.js +140 -0
- package/dist/tui/keys.d.ts +6 -0
- package/dist/tui/keys.js +41 -0
- package/dist/tui/reduce.d.ts +10 -0
- package/dist/tui/reduce.js +127 -0
- package/dist/tui/render.d.ts +7 -0
- package/dist/tui/render.js +70 -0
- package/dist/tui/screen.d.ts +25 -0
- package/dist/tui/screen.js +52 -0
- package/dist/tui/text.d.ts +8 -0
- package/dist/tui/text.js +28 -0
- package/dist/tui/types.d.ts +86 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/views/inbox.d.ts +4 -0
- package/dist/tui/views/inbox.js +23 -0
- package/dist/tui/views/overview.d.ts +6 -0
- package/dist/tui/views/overview.js +13 -0
- package/dist/tui/views/picker.d.ts +4 -0
- package/dist/tui/views/picker.js +44 -0
- package/dist/tui/views/timeline.d.ts +4 -0
- package/dist/tui/views/timeline.js +11 -0
- package/dist/tui/width.d.ts +4 -0
- package/dist/tui/width.js +45 -0
- package/dist/verified.json +1 -0
- package/dist/web/assets/index-DLO1-OIY.css +1 -0
- package/dist/web/assets/index-DyOl7NwF.js +2 -0
- package/dist/web/index.html +14 -0
- package/package.json +34 -3
- package/README.md +0 -3
package/dist/console.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { WinglineError, commandLine, consoleWorktree, createConsoleServer, nodeOnPath, openTab, pidAlive, readConsoleFile, removeConsoleFileIfMine, requestLink, selfCommand, writeConsoleFile, } from '@weiping/wingline-core';
|
|
5
|
+
/** 构建后前端位于 dist/web,与本文件编译出的 dist/console.js 同级。 */
|
|
6
|
+
const WEB_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'web');
|
|
7
|
+
/** 与 cli.ts 读的是同一份:/api/session 回给前端的版本号。 */
|
|
8
|
+
const PACKAGE_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
9
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
10
|
+
/** 新起服务的就绪确认:每 200ms 轮询一次 console.json,最多 10 秒。 */
|
|
11
|
+
const READY_POLL_MS = 200;
|
|
12
|
+
const READY_CONFIRM_MS = 10_000;
|
|
13
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
/** 已在运行的服务:pid 存活且能拿到新登录链接;否则视为没有服务(spec 4.1)。 */
|
|
15
|
+
async function liveServer(home) {
|
|
16
|
+
const f = readConsoleFile(home);
|
|
17
|
+
if (!f || !pidAlive(f.pid))
|
|
18
|
+
return null;
|
|
19
|
+
const link = await requestLink(f);
|
|
20
|
+
return link ? { port: f.port, link } : null;
|
|
21
|
+
}
|
|
22
|
+
/** 在 Orca 里起的控制台终端要能重新执行本 CLI:POSIX 用单引号,Windows 用双引号 + PATH 上的 node。 */
|
|
23
|
+
function launchLine(deps) {
|
|
24
|
+
return commandLine(selfCommand({
|
|
25
|
+
platform: process.platform,
|
|
26
|
+
node: process.execPath,
|
|
27
|
+
script: process.argv[1] ?? '',
|
|
28
|
+
args: ['console'],
|
|
29
|
+
home: deps.home,
|
|
30
|
+
orca: deps.orcaPath,
|
|
31
|
+
nodeOnPath: process.platform === 'win32' ? nodeOnPath(deps) : undefined,
|
|
32
|
+
}), process.platform);
|
|
33
|
+
}
|
|
34
|
+
/** --open 且没有服务:在 Orca 里新开 console@wingline 终端,轮询 console.json 直到服务可连(默认最多 10 秒)。 */
|
|
35
|
+
async function startInOrca(deps, a) {
|
|
36
|
+
const worktree = consoleWorktree(deps, a.worktree);
|
|
37
|
+
await deps.orca.terminalCreate({ worktree, title: 'console@wingline', command: launchLine(deps) });
|
|
38
|
+
const confirmMs = a.confirmMs ?? READY_CONFIRM_MS;
|
|
39
|
+
const nap = a.sleep ?? sleep;
|
|
40
|
+
// 轮次计数给测试一条快路,墙钟兜住真实上界:某次 poll+sleep 变慢(慢盘、忙机器)时不会把 10 秒拖成 50 秒。
|
|
41
|
+
const deadline = Date.now() + confirmMs;
|
|
42
|
+
for (let waited = 0; waited < confirmMs && Date.now() < deadline; waited += READY_POLL_MS) {
|
|
43
|
+
const live = await liveServer(deps.home);
|
|
44
|
+
if (live)
|
|
45
|
+
return live.link;
|
|
46
|
+
await nap(READY_POLL_MS);
|
|
47
|
+
}
|
|
48
|
+
throw new WinglineError('uncertain', 'console_start_unconfirmed', `已在 Orca 中启动控制台,但 ${confirmMs / 1000} 秒内没有就绪`, {
|
|
49
|
+
hint: '到 console@wingline 终端里查看输出',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 先关服务、再删 console.json(关闭失败也删):另一个 wl 进程读到文件时服务一定还能连上,
|
|
54
|
+
* 不会读到指向已关闭服务的文件(复审 I-3 的残余窗口)。
|
|
55
|
+
*/
|
|
56
|
+
export async function stopServer(srv, cleanup) {
|
|
57
|
+
try {
|
|
58
|
+
await srv.close();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
cleanup();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** 前台运行:起服务、写 console.json,阻塞到收到 SIGINT/SIGTERM/SIGHUP 后关闭并删除自己的 console.json。 */
|
|
65
|
+
async function foreground(deps, a) {
|
|
66
|
+
const srv = await createConsoleServer(deps, {
|
|
67
|
+
port: a.port, webRoot: a.webRoot ?? WEB_ROOT, version: PACKAGE_VERSION,
|
|
68
|
+
}).catch((err) => {
|
|
69
|
+
if (err.code === 'EADDRINUSE') {
|
|
70
|
+
throw new WinglineError('input', 'console_port_in_use', `端口 ${a.port} 已被占用`, { hint: '用 --port 换一个端口' });
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
writeConsoleFile(deps.home, { pid: process.pid, port: srv.port, startedAt: new Date().toISOString(), controlKey: srv.controlKey });
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// 写不进去只影响“别的 wl 进程找到这个服务”(--link / --open 复用),服务本身照常运行
|
|
79
|
+
deps.warn(`写 console.json 失败,其他 wl 进程将找不到这个控制台:${err instanceof Error ? err.message : String(err)}`);
|
|
80
|
+
}
|
|
81
|
+
const ready = { outcome: 'started', url: srv.loginUrl(), port: srv.port };
|
|
82
|
+
a.onReady?.(ready);
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
const stop = () => {
|
|
85
|
+
for (const s of SIGNALS)
|
|
86
|
+
process.off(s, stop);
|
|
87
|
+
void stopServer(srv, () => removeConsoleFileIfMine(deps.home, process.pid)).then(resolve, resolve);
|
|
88
|
+
};
|
|
89
|
+
for (const s of SIGNALS)
|
|
90
|
+
process.on(s, stop);
|
|
91
|
+
});
|
|
92
|
+
return ready;
|
|
93
|
+
}
|
|
94
|
+
export async function consoleCmd(deps, a) {
|
|
95
|
+
const live = await liveServer(deps.home);
|
|
96
|
+
if (a.link) {
|
|
97
|
+
if (!live)
|
|
98
|
+
throw new WinglineError('input', 'console_not_running', '控制台没有在运行', { hint: '运行 wl console --open 启动并打开' });
|
|
99
|
+
return { outcome: 'link', url: live.link, port: live.port };
|
|
100
|
+
}
|
|
101
|
+
if (a.open) {
|
|
102
|
+
const url = live?.link ?? (await startInOrca(deps, a));
|
|
103
|
+
let worktree;
|
|
104
|
+
try {
|
|
105
|
+
worktree = consoleWorktree(deps, a.worktree);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err instanceof WinglineError && err.code === 'console_no_worktree') {
|
|
109
|
+
return { outcome: 'opened', url, tab: 'unsupported', tabReason: 'worktree' };
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const tab = await openTab(deps, url, worktree);
|
|
114
|
+
return { outcome: 'opened', url, tab };
|
|
115
|
+
}
|
|
116
|
+
if (live)
|
|
117
|
+
return { outcome: 'link', url: live.link, port: live.port };
|
|
118
|
+
return foreground(deps, a);
|
|
119
|
+
}
|
|
120
|
+
/** 启动就绪时打印(前台服务还在跑)。 */
|
|
121
|
+
export function renderReady(r) {
|
|
122
|
+
return `控制台服务已启动(端口 ${r.port})\n登录链接:${r.url}\n`;
|
|
123
|
+
}
|
|
124
|
+
/** 最终结果(服务停止后,或其他三种结果)。 */
|
|
125
|
+
export function renderConsole(r) {
|
|
126
|
+
if (r.outcome === 'started')
|
|
127
|
+
return '控制台服务已停止\n';
|
|
128
|
+
if (r.outcome === 'link')
|
|
129
|
+
return `控制台登录链接:${r.url}\n`;
|
|
130
|
+
if (r.tab === 'opened')
|
|
131
|
+
return `已在 Orca 中打开控制台:${r.url}\n`;
|
|
132
|
+
if (r.tabReason === 'worktree') {
|
|
133
|
+
return `控制台链接:${r.url}\n提示:没有运行中的团队,无法确定工作区(console_no_worktree),请运行 wl up 或用 --worktree 指定,也可直接在浏览器打开链接\n`;
|
|
134
|
+
}
|
|
135
|
+
return `控制台链接:${r.url}\n提示:当前 Orca 不支持在内置浏览器中打开标签页(console_tab_unsupported),请在浏览器中打开上面的链接\n`;
|
|
136
|
+
}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 警告过滤必须先于 node:sqlite 的加载,所以 cli.js 用动态导入。
|
|
3
|
+
process.removeAllListeners('warning');
|
|
4
|
+
process.on('warning', (w) => {
|
|
5
|
+
if (w.name === 'ExperimentalWarning' && w.message.includes('SQLite'))
|
|
6
|
+
return;
|
|
7
|
+
process.stderr.write(`(${w.name}) ${w.message}\n`);
|
|
8
|
+
});
|
|
9
|
+
const { run } = await import('./cli.js');
|
|
10
|
+
process.exitCode = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, env: process.env, stdin: process.stdin });
|
|
11
|
+
export {};
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type DoneResult, type DownResult, type PsRow, type RestoreAllResult, type RestoreResult, type SendResultRow, type ShownTask, type StaleItem, type TaskRow, type TimelineEvent, type UpResult, type AttentionReport, type DispatcherHealth, type DispatcherStatus, type createTask, type escalateTask, type reassignTask, type recoverTask, type resolveGate, type startTask } from '@weiping/wingline-core';
|
|
2
|
+
/** 调度员状态的一行文字;从未启动过(never)返回 null,调用方据此不显示。 */
|
|
3
|
+
export declare function dispatcherLine(h: DispatcherHealth): string | null;
|
|
4
|
+
export declare function renderPs(rows: PsRow[]): string;
|
|
5
|
+
export declare function renderUp(r: UpResult, opts?: {
|
|
6
|
+
newRun?: boolean;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function renderSend(r: SendResultRow): string;
|
|
9
|
+
export declare function renderDown(r: DownResult): string;
|
|
10
|
+
export interface ErrorDoc {
|
|
11
|
+
kind: string;
|
|
12
|
+
code: string;
|
|
13
|
+
message: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
detail?: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare function errorDoc(err: unknown): ErrorDoc;
|
|
18
|
+
export declare function renderError(doc: ErrorDoc): string;
|
|
19
|
+
type Awaited2<F extends (...a: never[]) => Promise<unknown>> = Awaited<ReturnType<F>>;
|
|
20
|
+
export declare function renderCreate(r: Awaited2<typeof createTask>, post: string | undefined): string;
|
|
21
|
+
export declare function renderList(rows: TaskRow[]): string;
|
|
22
|
+
export declare function renderStale(items: StaleItem[]): string;
|
|
23
|
+
export declare function closureLine(c: NonNullable<DoneResult['closure']>): string;
|
|
24
|
+
export declare function renderShow(t: ShownTask): string;
|
|
25
|
+
export declare function renderReassign(r: Awaited2<typeof reassignTask>, id: string, to: string): string;
|
|
26
|
+
export declare function renderStart(r: Awaited2<typeof startTask>, id: string, as: string | undefined): string;
|
|
27
|
+
export declare function renderDone(r: DoneResult): string;
|
|
28
|
+
export declare function renderEscalate(r: Awaited2<typeof escalateTask>): string;
|
|
29
|
+
export declare function renderResolve(r: Awaited2<typeof resolveGate>): string;
|
|
30
|
+
export declare function renderRecover(r: Awaited2<typeof recoverTask>, id: string): string;
|
|
31
|
+
export declare function renderDispatcherUp(r: {
|
|
32
|
+
address: string;
|
|
33
|
+
outcome: 'already_running' | 'started';
|
|
34
|
+
handle?: string;
|
|
35
|
+
}): string;
|
|
36
|
+
export declare function renderDispatcherDown(r: {
|
|
37
|
+
address: string;
|
|
38
|
+
outcome: 'noop' | 'closed' | 'already_gone';
|
|
39
|
+
}): string;
|
|
40
|
+
export declare function renderDispatcherStatus(s: DispatcherStatus): string;
|
|
41
|
+
export declare function renderUnfreeze(r: {
|
|
42
|
+
skipped: Array<{
|
|
43
|
+
taskId: string;
|
|
44
|
+
command: string;
|
|
45
|
+
}>;
|
|
46
|
+
}, address: string): string;
|
|
47
|
+
/** spec 第 6.5 节的分节顺序;调度员失联时放最前。 */
|
|
48
|
+
export declare function renderAttention(r: AttentionReport): string;
|
|
49
|
+
export declare function renderRestore(r: RestoreResult): string;
|
|
50
|
+
export declare function renderRestoreAll(r: RestoreAllResult): string;
|
|
51
|
+
export declare function renderAutostartInstall(r: {
|
|
52
|
+
platform: string;
|
|
53
|
+
file: string;
|
|
54
|
+
command: string[];
|
|
55
|
+
logPath: string;
|
|
56
|
+
waitOrca: string;
|
|
57
|
+
}): string;
|
|
58
|
+
export declare function renderAutostartUninstall(r: {
|
|
59
|
+
file: string;
|
|
60
|
+
removed: boolean;
|
|
61
|
+
}): string;
|
|
62
|
+
export declare function renderAutostartStatus(r: {
|
|
63
|
+
installed: boolean;
|
|
64
|
+
platform: string;
|
|
65
|
+
user?: string;
|
|
66
|
+
location: string;
|
|
67
|
+
command: string[];
|
|
68
|
+
commandMissing: string[];
|
|
69
|
+
logTail: string[];
|
|
70
|
+
linger?: boolean;
|
|
71
|
+
logPath: string;
|
|
72
|
+
}): string;
|
|
73
|
+
export declare function renderTimelineEvent(e: TimelineEvent): string;
|
|
74
|
+
/**
|
|
75
|
+
* 跨天插一行日期;返回逐条打印的函数,`--follow` 与一次性输出共用同一套格式,
|
|
76
|
+
* 这样 follow 出来的样子和 `wl log` 一样。
|
|
77
|
+
*/
|
|
78
|
+
export declare function logPrinter(): (e: TimelineEvent) => string;
|
|
79
|
+
export declare const renderLog: (events: TimelineEvent[]) => string;
|
|
80
|
+
export {};
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { WinglineError, } from '@weiping/wingline-core';
|
|
2
|
+
const UP_LABEL = { already_live: '已在岗', launched: '已拉起', failed: '失败', uncertain: '不确定' };
|
|
3
|
+
const RUN_LABEL = {
|
|
4
|
+
created: '已新建', adopted: '已找回', kept: '协调者保持不变', reclaimed: '已重新认领', deferred: '本轮跳过(lead 未就绪)',
|
|
5
|
+
failed: '失败', uncertain: '不确定', ambiguous: '候选不唯一', lost: '原 Run 已丢失',
|
|
6
|
+
};
|
|
7
|
+
const DOWN_LABEL = { stopped: '已停止', closed: '已关闭', uncertain: '不确定', already_gone: '终端已不存在' };
|
|
8
|
+
function table(head, rows) {
|
|
9
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
10
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
11
|
+
return [line(head), ...rows.map(line)].join('\n') + '\n';
|
|
12
|
+
}
|
|
13
|
+
const teamOfAddress = (address) => address.slice(address.indexOf('@') + 1);
|
|
14
|
+
const minutesOrSeconds = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)} 秒前` : `${Math.floor(ms / 60_000)} 分钟前`);
|
|
15
|
+
/** 调度员状态的一行文字;从未启动过(never)返回 null,调用方据此不显示。 */
|
|
16
|
+
export function dispatcherLine(h) {
|
|
17
|
+
const age = h.ageMs === undefined ? '' : `(心跳 ${minutesOrSeconds(h.ageMs)})`;
|
|
18
|
+
if (h.state === 'running')
|
|
19
|
+
return `调度员:运行中${age}`;
|
|
20
|
+
if (h.state === 'stale')
|
|
21
|
+
return `调度员:失联${age}`;
|
|
22
|
+
return h.state === 'stopped' ? '调度员:已停止' : null;
|
|
23
|
+
}
|
|
24
|
+
function dispatcherTitles(rows) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const lines = [];
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
const team = teamOfAddress(r.address);
|
|
29
|
+
const line = r.dispatcher && dispatcherLine(r.dispatcher);
|
|
30
|
+
if (!line || seen.has(team))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(team);
|
|
33
|
+
lines.push(`团队 ${team} ${line}`);
|
|
34
|
+
}
|
|
35
|
+
return lines.length ? lines.join('\n') + '\n' : '';
|
|
36
|
+
}
|
|
37
|
+
export function renderPs(rows) {
|
|
38
|
+
if (rows.length === 0)
|
|
39
|
+
return '没有已登记的岗位。用 wl up <team.yaml> 拉起团队。\n';
|
|
40
|
+
return dispatcherTitles(rows) + table(['ADDRESS', 'AGENT', 'STATUS', 'HANDLE', 'WORKTREE', 'LEAD'], rows.map((r) => [r.address, r.agent, r.status, r.handle ?? '-', r.worktree, r.isLead ? 'yes' : '']));
|
|
41
|
+
}
|
|
42
|
+
export function renderUp(r, opts = {}) {
|
|
43
|
+
const lines = r.posts.map((p) => {
|
|
44
|
+
const detail = [p.reason, p.retry && `重试:${p.retry}`].filter(Boolean).join(';');
|
|
45
|
+
return `${p.address} ${UP_LABEL[p.outcome]}${detail ? ` ${detail}` : ''}`;
|
|
46
|
+
});
|
|
47
|
+
const reset = opts.newRun && r.run?.outcome === 'created' ? '旧 Run 里的任务不会再出现在 wl task list 中(它们仍在 Orca 里)\n' : '';
|
|
48
|
+
return `团队 ${r.team}\n${lines.join('\n')}\n${renderRun(r)}${reset}${renderDead(r)}`;
|
|
49
|
+
}
|
|
50
|
+
function renderRun(r) {
|
|
51
|
+
if (!r.run)
|
|
52
|
+
return '';
|
|
53
|
+
const run = r.run;
|
|
54
|
+
const head = `Run ${RUN_LABEL[run.outcome]}${run.runId ? ` ${run.runId}` : ''}`;
|
|
55
|
+
const detail = [run.reason, run.retry && `重试:${run.retry}`].filter(Boolean).join(';');
|
|
56
|
+
const candidates = (run.candidates ?? []).map((c) => `\n 候选 ${c.id}(${c.createdAt})`).join('');
|
|
57
|
+
return `${head}${detail ? ` ${detail}` : ''}${candidates}\n`;
|
|
58
|
+
}
|
|
59
|
+
function renderDead(r) {
|
|
60
|
+
if (!r.deadAssignees?.length)
|
|
61
|
+
return '';
|
|
62
|
+
const lines = r.deadAssignees.map((d) => ` ${d.taskId} ${d.title} 归属 ${d.owner ?? '-'} 恢复:${d.command}`);
|
|
63
|
+
return `派给已消失终端的任务:\n${lines.join('\n')}\n`;
|
|
64
|
+
}
|
|
65
|
+
export function renderSend(r) {
|
|
66
|
+
return `${r.address} 已送达\n`;
|
|
67
|
+
}
|
|
68
|
+
export function renderDown(r) {
|
|
69
|
+
const lines = r.posts.map((p) => `${p.address} ${DOWN_LABEL[p.outcome]}${p.reason ? ` ${p.reason}` : ''}`);
|
|
70
|
+
return `团队 ${r.team}\n${lines.join('\n')}\n`;
|
|
71
|
+
}
|
|
72
|
+
export function errorDoc(err) {
|
|
73
|
+
if (err instanceof WinglineError) {
|
|
74
|
+
return {
|
|
75
|
+
kind: err.kind, code: err.code, message: err.message,
|
|
76
|
+
...(err.hint ? { hint: err.hint } : {}),
|
|
77
|
+
...(err.detail !== undefined && err.code === 'close_unconfirmed' ? { detail: err.detail } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { kind: 'internal', code: 'internal', message: err instanceof Error ? err.message : String(err) };
|
|
81
|
+
}
|
|
82
|
+
export function renderError(doc) {
|
|
83
|
+
const detail = doc.code === 'close_unconfirmed' ? renderDown(doc.detail) : '';
|
|
84
|
+
return `${detail}错误:${doc.message}\n${doc.hint ? `提示:${doc.hint}\n` : ''}`;
|
|
85
|
+
}
|
|
86
|
+
/** 派发之后通知结果的人读说明。 */
|
|
87
|
+
function deliveryLine(address, taskId, d) {
|
|
88
|
+
const who = address ?? '<岗位地址>';
|
|
89
|
+
if (d.delivery === 'not_notified') {
|
|
90
|
+
return `${who} 此刻在提示/无法读取画面上,没有打字通知;任务已派发。等对方空闲后可以 wl send ${who} "你有新任务 ${taskId},见 wl task show ${taskId}"\n`;
|
|
91
|
+
}
|
|
92
|
+
if (d.delivery === 'queued')
|
|
93
|
+
return `${who} 手上有任务 ${d.busyWith ?? ''},新任务已排队\n`;
|
|
94
|
+
if (d.delivery === 'notified_unconfirmed')
|
|
95
|
+
return `${who} 已通知,但没有确认对方开始处理\n`;
|
|
96
|
+
if (d.delivery === 'notified')
|
|
97
|
+
return `${who} 已通知\n`;
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
export function renderCreate(r, post) {
|
|
101
|
+
return `任务 ${r.id} 已创建\n${deliveryLine(post, r.id, r)}`;
|
|
102
|
+
}
|
|
103
|
+
export function renderList(rows) {
|
|
104
|
+
if (rows.length === 0)
|
|
105
|
+
return '没有任务。\n';
|
|
106
|
+
return table(['ID', 'STATUS', 'OWNER', 'TAGS', 'TITLE'], rows.map((t) => [t.id, t.status, t.claimedBy ?? '-', t.tags.join(',') || '-', t.title]));
|
|
107
|
+
}
|
|
108
|
+
export function renderStale(items) {
|
|
109
|
+
if (items.length === 0)
|
|
110
|
+
return '没有卡住的任务。\n';
|
|
111
|
+
const reasons = [...new Set(items.map((i) => i.reason))];
|
|
112
|
+
return reasons
|
|
113
|
+
.map((reason) => `${reason}\n${items.filter((i) => i.reason === reason).map((i) => ` ${i.taskId} ${i.title} ${i.reason} → ${i.command}`).join('\n')}\n`)
|
|
114
|
+
.join('');
|
|
115
|
+
}
|
|
116
|
+
const SOURCE_LABEL = { explicit: '显式', implicit: '隐式', human: '人' };
|
|
117
|
+
export function closureLine(c) {
|
|
118
|
+
const targets = (c.targets ?? []).join('、');
|
|
119
|
+
if (c.kind === 'handed_to')
|
|
120
|
+
return `已转交给 ${targets}\n`;
|
|
121
|
+
if (c.kind === 'sent_for_review')
|
|
122
|
+
return `已送审给 ${targets}\n`;
|
|
123
|
+
return c.kind === 'escalated' ? '已转人工决策\n' : '已直接完成\n';
|
|
124
|
+
}
|
|
125
|
+
export function renderShow(t) {
|
|
126
|
+
const head = [`${t.id} ${t.title}`, `状态 ${t.status} 主人 ${t.claimedBy ?? '-'} 标签 ${t.tags.join(',') || '-'}`];
|
|
127
|
+
if (t.plan)
|
|
128
|
+
head.push(`计划 ${t.plan}`);
|
|
129
|
+
if (t.outcome)
|
|
130
|
+
head.push(`上报 ${t.outcome}(${t.reportedBy ?? '-'})`);
|
|
131
|
+
const closure = t.closure ? closureLine(t.closure) : '';
|
|
132
|
+
const history = (t.history ?? []).map((h) => `${h.at} ${h.actor}(${SOURCE_LABEL[h.actorSource]})${h.action}${h.detail ? ` ${h.detail}` : ''}`);
|
|
133
|
+
return `${head.join('\n')}\n${closure}${history.length ? history.join('\n') + '\n' : ''}`;
|
|
134
|
+
}
|
|
135
|
+
export function renderReassign(r, id, to) {
|
|
136
|
+
const n = r.notify;
|
|
137
|
+
const tail = n.outcome === 'not_notified' ? `;没有打字通知(${n.reason}),可以 wl send ${to} "任务 ${id} 转给了你,见 wl task show ${id}"` : '';
|
|
138
|
+
return `任务 ${id} 已改派给 ${to}${tail}\n`;
|
|
139
|
+
}
|
|
140
|
+
export function renderStart(r, id, as) {
|
|
141
|
+
const head = r.already ? `任务 ${id} 已经在进行中\n` : `任务 ${id} 已开始\n`;
|
|
142
|
+
return head + deliveryLine(as, id, r);
|
|
143
|
+
}
|
|
144
|
+
const LINK_LABEL = {
|
|
145
|
+
notified: '已通知', notified_unconfirmed: '已通知(未确认)', not_notified: '没有打字通知', self: '就是你自己', queued: '已排队',
|
|
146
|
+
skipped_offline: '岗位不在线,未派发', skipped_frozen: '岗位已冻结,未派发',
|
|
147
|
+
};
|
|
148
|
+
function linkReason(l) {
|
|
149
|
+
if (l.delivery !== 'not_notified')
|
|
150
|
+
return '';
|
|
151
|
+
return `(${l.reason ?? '未知'});可以 wl send ${l.target} "你有新任务 ${l.taskId},见 wl task show ${l.taskId}"`;
|
|
152
|
+
}
|
|
153
|
+
export function renderDone(r) {
|
|
154
|
+
if (r.outcome === 'gate_failed')
|
|
155
|
+
return `gate 未通过,输出见 ${r.gateOutputPath}\n`;
|
|
156
|
+
if (r.outcome === 'gate_failed_after_bypass') {
|
|
157
|
+
return `任务已被直接上报,补跑 gate 未通过,输出见 ${r.gateOutputPath};已通知牵头岗\n`;
|
|
158
|
+
}
|
|
159
|
+
if (r.outcome === 'escalated') {
|
|
160
|
+
return `连续 gate 失败,已转人工决策:gate ${r.gateId};wl task resolve ${r.gateId} --resolution <决定>\n`;
|
|
161
|
+
}
|
|
162
|
+
const links = (r.linkedTasks ?? []).map((l) => ` ${l.target} 子任务 ${l.taskId} ${LINK_LABEL[l.delivery]}${linkReason(l)}\n`).join('');
|
|
163
|
+
return `${r.replayed ? '(这一轮早已完成,以下是存下的结果)\n' : ''}已完成\n${r.closure ? closureLine(r.closure) : ''}${links}`;
|
|
164
|
+
}
|
|
165
|
+
export function renderEscalate(r) {
|
|
166
|
+
const back = r.tookBackDispatch ? '任务已从岗位手里收回,resolve 后回到 ready\n' : '';
|
|
167
|
+
return `已转人工决策:gate ${r.gateId}(任务状态 ${r.status})\n${back}处理:wl task resolve ${r.gateId} --resolution <决定>\n`;
|
|
168
|
+
}
|
|
169
|
+
export function renderResolve(r) {
|
|
170
|
+
return `任务 ${r.taskId} 现在 ${r.status},主人 ${r.claimedBy ?? '-'}\n`;
|
|
171
|
+
}
|
|
172
|
+
export function renderRecover(r, id) {
|
|
173
|
+
const head = `任务 ${id}:${r.status}(第 ${r.epoch} 轮)${r.reason ? `,${r.reason}` : ''}\n`;
|
|
174
|
+
return head + deliveryLine(r.owner ?? undefined, id, r);
|
|
175
|
+
}
|
|
176
|
+
export function renderDispatcherUp(r) {
|
|
177
|
+
return r.outcome === 'started' ? `${r.address} 已启动${r.handle ? `(终端 ${r.handle})` : ''}\n` : `${r.address} 已在运行\n`;
|
|
178
|
+
}
|
|
179
|
+
export function renderDispatcherDown(r) {
|
|
180
|
+
const label = { noop: '未改动(加 --close 才会关闭终端)', closed: '终端已关闭', already_gone: '终端已不存在' }[r.outcome];
|
|
181
|
+
return `${r.address} ${label}\n`;
|
|
182
|
+
}
|
|
183
|
+
export function renderDispatcherStatus(s) {
|
|
184
|
+
const at = (x) => (x ? `${x.at}(${x.outcome})` : '无');
|
|
185
|
+
const head = `团队 ${s.team} ${dispatcherLine(s.health) ?? '调度员:从未启动'}\n`;
|
|
186
|
+
const body = `上次收件箱检查:${at(s.lastCheck)}\n上次巡查:${at(s.lastScan)}\n冻结岗位 ${s.frozen} 静音岗位 ${s.hushed} 待处理事项 ${s.attentionItems}\n`;
|
|
187
|
+
return head + body + s.warnings.map((w) => `警告:${w}\n`).join('');
|
|
188
|
+
}
|
|
189
|
+
export function renderUnfreeze(r, address) {
|
|
190
|
+
const skipped = r.skipped.map((s) => ` ${s.taskId} ${s.command}\n`).join('');
|
|
191
|
+
return `${address} 已解冻\n` + (skipped ? `冻结期间被跳过的派发(按需手动处理):\n${skipped}` : '');
|
|
192
|
+
}
|
|
193
|
+
/** spec 第 6.5 节的分节顺序;调度员失联时放最前。 */
|
|
194
|
+
export function renderAttention(r) {
|
|
195
|
+
const sections = [];
|
|
196
|
+
const dispatcher = `调度员:${dispatcherLine(r.dispatcher)?.replace('调度员:', '') ?? '从未启动'}`;
|
|
197
|
+
if (r.warnings.length)
|
|
198
|
+
sections.push(['告警:', ...r.warnings.map((w) => ` ${w}`)].join('\n'));
|
|
199
|
+
if (r.dispatcher.state === 'stale')
|
|
200
|
+
sections.push(dispatcher + `\n 重新启动:wl dispatcher up ${r.team}`);
|
|
201
|
+
if (r.frozen.length) {
|
|
202
|
+
sections.push(['冻结的岗位:', ...r.frozen.map((f) => ` ${f.post} ${f.reason}(${f.frozenAt},跳过 ${f.skippedChildren} 个子任务) wl dispatcher unfreeze ${f.post}`)].join('\n'));
|
|
203
|
+
}
|
|
204
|
+
if (r.signals.length) {
|
|
205
|
+
sections.push(['处理中的信号:', ...r.signals.map((s) => ` L${s.level} ${s.signal}${s.summary ? `(${s.summary})` : ''} ${s.post}${s.taskId ? ` ${s.taskId}` : ''}${s.command ? ` ${s.command}` : ''}`)].join('\n'));
|
|
206
|
+
}
|
|
207
|
+
if (r.questions.length) {
|
|
208
|
+
sections.push(['待回答的问题:', ...r.questions.map((q) => ` ${q.messageId} ${q.asker}:${q.body}${q.options.length ? `(选项:${q.options.join(' / ')})` : ''} wl dispatcher reply ${r.team} ${q.messageId} --body <回答>`)].join('\n'));
|
|
209
|
+
}
|
|
210
|
+
sections.push(...staleSections(r));
|
|
211
|
+
if (r.dispatcher.state !== 'stale')
|
|
212
|
+
sections.push(dispatcher);
|
|
213
|
+
return sections.join('\n') + '\n';
|
|
214
|
+
}
|
|
215
|
+
function staleSections(r) {
|
|
216
|
+
const gates = r.stale.filter((i) => i.reason === 'blocked_waiting');
|
|
217
|
+
const rest = r.stale.filter((i) => i.reason !== 'blocked_waiting');
|
|
218
|
+
const line = (i) => ` ${i.taskId} ${i.title} ${i.reason} 归属 ${i.owner ?? '-'} ${i.command}`;
|
|
219
|
+
const out = [];
|
|
220
|
+
const unavailable = r.unavailable.length ? `不可用(${r.unavailable.join(';')})` : null;
|
|
221
|
+
for (const [title, items] of [['待决的 gate', gates], ['其余卡住的任务', rest]]) {
|
|
222
|
+
if (unavailable)
|
|
223
|
+
out.push(`${title}:${unavailable}`);
|
|
224
|
+
else if (items.length)
|
|
225
|
+
out.push([`${title}:`, ...items.map(line)].join('\n'));
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
// —— wl restore / wl log ——
|
|
230
|
+
const RESTORE_LABEL = {
|
|
231
|
+
kept: '仍在运行', resumed: '已接回原会话', fresh: '新起(上下文已丢失)',
|
|
232
|
+
ambiguous: '候选不唯一', uncertain: '不确定', failed: '失败',
|
|
233
|
+
};
|
|
234
|
+
/** 一行岗位结果;候选不唯一时把候选逐条列出来,并给出重试命令。 */
|
|
235
|
+
function restorePostLines(p, team) {
|
|
236
|
+
const out = [` ${p.address} ${RESTORE_LABEL[p.outcome]}${p.reason ? ` ${p.reason}` : ''}${p.sessionId ? ` 会话 ${p.sessionId}` : ''}`];
|
|
237
|
+
for (const c of p.candidates ?? []) {
|
|
238
|
+
out.push(` 候选 ${c.sessionId} 最后活动 ${c.updatedAt ?? '-'} ${c.messageCount ?? 0} 条消息`);
|
|
239
|
+
}
|
|
240
|
+
if (p.note)
|
|
241
|
+
out.push(` 注:${p.note}`);
|
|
242
|
+
const retry = p.retry ?? (p.outcome === 'ambiguous' && p.candidates?.length ? `wl restore ${team} --session ${p.address.split('@')[0]}=<id>` : undefined);
|
|
243
|
+
if (retry)
|
|
244
|
+
out.push(` 试:${retry}`);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
export function renderRestore(r) {
|
|
248
|
+
const head = `团队 ${r.team}${r.dryRun ? '(dry-run:只看不改)' : ''}`;
|
|
249
|
+
const run = r.run ? [` ${r.run.runId ?? '-'} ${RUN_LABEL[r.run.outcome]}`] : [];
|
|
250
|
+
const dispatcher = [` ${r.dispatcher.action === 'restarted' ? '已重启' : r.dispatcher.action === 'left' ? '保持不动' : '未拉起'} ${r.dispatcher.state}${r.dispatcher.reason ? ` ${r.dispatcher.reason}` : ''}`];
|
|
251
|
+
const tasks = r.tasks.length
|
|
252
|
+
? r.tasks.map((t) => ` ${t.taskId || '-'} ${t.owner || '-'} ${TASK_OUTCOME[t.outcome]}${t.reason ? ` ${t.reason}` : ''}${t.retry ? `\n 重试:${t.retry}` : ''}`)
|
|
253
|
+
: [' 无'];
|
|
254
|
+
return [
|
|
255
|
+
head,
|
|
256
|
+
'岗位:', ...r.posts.flatMap((p) => restorePostLines(p, r.team)),
|
|
257
|
+
'Run:', ...run,
|
|
258
|
+
'调度员:', ...dispatcher,
|
|
259
|
+
'任务:', ...tasks,
|
|
260
|
+
].join('\n') + '\n';
|
|
261
|
+
}
|
|
262
|
+
export function renderRestoreAll(r) {
|
|
263
|
+
if (r.teams.length === 0)
|
|
264
|
+
return '没有处于运行状态的团队,无需恢复\n';
|
|
265
|
+
return r.teams.map((t) => t.result
|
|
266
|
+
? renderRestore(t.result)
|
|
267
|
+
: `团队 ${t.team}\n 失败:${t.error.code} ${t.error.message}${t.error.hint ? `\n 提示:${t.error.hint}` : ''}\n`).join('\n');
|
|
268
|
+
}
|
|
269
|
+
const quoteWord = (w) => (/[\s"']/.test(w) ? JSON.stringify(w) : w);
|
|
270
|
+
export function renderAutostartInstall(r) {
|
|
271
|
+
return [
|
|
272
|
+
`已安装自启动(${r.platform === 'darwin' ? 'macOS launchd' : 'Linux systemd 用户服务'})`,
|
|
273
|
+
` 位置:${r.file}`,
|
|
274
|
+
` 命令:${r.command.map(quoteWord).join(' ')}`,
|
|
275
|
+
` 登录后最多等 Orca 就绪 ${r.waitOrca};日志:${r.logPath}`,
|
|
276
|
+
...(r.platform === 'darwin' ? ['macOS 上安装即执行一次 wl restore --all,结果见上面的日志。'] : []),
|
|
277
|
+
].join('\n') + '\n';
|
|
278
|
+
}
|
|
279
|
+
export function renderAutostartUninstall(r) {
|
|
280
|
+
return r.removed ? `已卸载自启动:${r.file}(日志保留)\n` : '没有安装过自启动,无需卸载\n';
|
|
281
|
+
}
|
|
282
|
+
export function renderAutostartStatus(r) {
|
|
283
|
+
const lines = [r.installed ? '自启动:已安装' : '自启动:未安装', ` 位置:${r.location}`];
|
|
284
|
+
if (r.installed)
|
|
285
|
+
lines.push(` 命令:${r.command.map(quoteWord).join(' ')}`);
|
|
286
|
+
if (r.commandMissing.length > 0)
|
|
287
|
+
lines.push(` ⚠ 命令里的 ${r.commandMissing.join('、')} 已经不存在(Node 路径已变化),请重新执行 wl autostart install`);
|
|
288
|
+
if (r.platform === 'linux' && r.installed && r.linger === false) {
|
|
289
|
+
lines.push(` ⚠ 用户 ${r.user ?? '<user>'} 没有开启 linger:无界面或 SSH 场景需要 loginctl enable-linger ${r.user ?? '<user>'}`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(r.logTail.length > 0 ? `日志 ${r.logPath}(最后 ${r.logTail.length} 行):` : `日志 ${r.logPath}:暂无`, ...r.logTail.map((l) => ` ${l}`));
|
|
292
|
+
return lines.join('\n') + '\n';
|
|
293
|
+
}
|
|
294
|
+
const TASK_OUTCOME = { recovered: '已恢复', queued: '已排队', ready: '本就不该重派', skipped: '未恢复', failed: '恢复失败' };
|
|
295
|
+
const TIMELINE_SOURCE = { task: '任务', patrol: '巡查', command: '命令' };
|
|
296
|
+
const SUMMARY_MAX = 120;
|
|
297
|
+
/** 复审 I-1b:actor/action/target 来自用户可控的 --as 与正文列,控制字符会伪造出额外的日志行。 */
|
|
298
|
+
const CONTROL = /[\x00-\x1f\x7f]/g;
|
|
299
|
+
const flat = (s) => s.replace(CONTROL, ' ');
|
|
300
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
301
|
+
/** 摘要压成一行,最长 SUMMARY_MAX 字;末尾的空列不留。 */
|
|
302
|
+
function eventSummary(e) {
|
|
303
|
+
const raw = typeof e.detail === 'string' ? e.detail : e.detail === null || e.detail === undefined ? '' : JSON.stringify(e.detail);
|
|
304
|
+
const flat = raw.replace(/[\r\n\t]+/g, ' ').trim();
|
|
305
|
+
const body = [e.outcome ?? '', flat].filter(Boolean).join(' ');
|
|
306
|
+
return body.length <= SUMMARY_MAX ? body : `${body.slice(0, SUMMARY_MAX)}…`;
|
|
307
|
+
}
|
|
308
|
+
export function renderTimelineEvent(e) {
|
|
309
|
+
const d = new Date(e.at);
|
|
310
|
+
const cols = [
|
|
311
|
+
`${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`,
|
|
312
|
+
TIMELINE_SOURCE[e.source], flat(e.actor) || '-', flat(e.action), e.target === null ? '' : flat(e.target), eventSummary(e),
|
|
313
|
+
];
|
|
314
|
+
while (cols.length && cols.at(-1) === '')
|
|
315
|
+
cols.pop();
|
|
316
|
+
return cols.join(' ');
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* 跨天插一行日期;返回逐条打印的函数,`--follow` 与一次性输出共用同一套格式,
|
|
320
|
+
* 这样 follow 出来的样子和 `wl log` 一样。
|
|
321
|
+
*/
|
|
322
|
+
export function logPrinter() {
|
|
323
|
+
let day = '';
|
|
324
|
+
return (e) => {
|
|
325
|
+
const d = new Date(e.at);
|
|
326
|
+
const today = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
327
|
+
const head = today === day ? '' : day ? `—— ${today} ——\n` : '';
|
|
328
|
+
day = today;
|
|
329
|
+
return `${head}${renderTimelineEvent(e)}\n`;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
export const renderLog = (events) => {
|
|
333
|
+
const print = logPrinter();
|
|
334
|
+
return events.map(print).join('');
|
|
335
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { attention, listTasks, ps, readTimeline, readTimelineAfter, type CommandDeps, type WriteCommands } from '@weiping/wingline-core';
|
|
2
|
+
import { reduce, initialTuiState } from './reduce.js';
|
|
3
|
+
import { renderView } from './render.js';
|
|
4
|
+
import type { Effect, TuiData, TuiIo, TuiState } from './types.js';
|
|
5
|
+
export { initialTuiState, reduce, renderView };
|
|
6
|
+
export type { Effect, TuiData, TuiState };
|
|
7
|
+
/** 读取数据用的函数;测试里可以替换其中任意一个。 */
|
|
8
|
+
export interface TuiReader {
|
|
9
|
+
ps: typeof ps;
|
|
10
|
+
attention: typeof attention;
|
|
11
|
+
listTasks: typeof listTasks;
|
|
12
|
+
readTimeline: typeof readTimeline;
|
|
13
|
+
readTimelineAfter: typeof readTimelineAfter;
|
|
14
|
+
}
|
|
15
|
+
export interface RunTuiOptions {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
/** 写命令表;缺省是带 `tui:` 审计前缀的那一份。 */
|
|
18
|
+
commands?: WriteCommands;
|
|
19
|
+
read?: Partial<TuiReader>;
|
|
20
|
+
/** 岗位与待办的刷新间隔,缺省 5 秒。 */
|
|
21
|
+
dataMs?: number;
|
|
22
|
+
/** 时间线的刷新间隔,缺省 2 秒。 */
|
|
23
|
+
timelineMs?: number;
|
|
24
|
+
/** 每画一帧调用一次(测试观察屏幕内容用)。 */
|
|
25
|
+
onFrame?: (lines: string[]) => void;
|
|
26
|
+
}
|
|
27
|
+
/** 全屏终端视图。结束(q / Ctrl-C / 信号 / abort)时 resolve;出错时先恢复终端再 reject。 */
|
|
28
|
+
export declare function runTui(deps: CommandDeps, team: string, io: TuiIo, o?: RunTuiOptions): Promise<void>;
|