@weiping/wingline-core 0.1.0 → 0.1.1
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/dist/commands/deps.d.ts +2 -0
- package/dist/commands/deps.js +2 -0
- package/dist/commands/dispatcher/attention.d.ts +2 -0
- package/dist/commands/dispatcher/attention.js +10 -9
- package/dist/commands/dispatcher/controls.d.ts +1 -1
- package/dist/commands/dispatcher/controls.js +5 -3
- package/dist/commands/dispatcher/loop.js +2 -0
- package/dist/commands/launch.d.ts +0 -1
- package/dist/commands/launch.js +1 -2
- package/dist/commands/restore/index.js +6 -0
- package/dist/commands/restore/posts.d.ts +1 -1
- package/dist/commands/restore/posts.js +17 -17
- package/dist/commands/restore/resume-command.d.ts +0 -1
- package/dist/commands/restore/resume-command.js +0 -3
- package/dist/commands/send.js +1 -2
- package/dist/commands/task/gate-exec.d.ts +5 -1
- package/dist/commands/task/gate-exec.js +29 -5
- package/dist/commands/task/notify.d.ts +1 -1
- package/dist/commands/task/notify.js +1 -1
- package/dist/commands/task/query.d.ts +2 -0
- package/dist/commands/task/query.js +11 -2
- package/dist/registry/registry.d.ts +13 -0
- package/dist/registry/registry.js +23 -2
- package/dist/server/api-read.js +12 -10
- package/dist/server/index.js +10 -2
- package/dist/server/sse.js +22 -10
- package/dist/spec/plan-change.js +4 -2
- package/package.json +1 -1
package/dist/commands/deps.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface CommandDeps {
|
|
|
17
17
|
monotonic: () => number;
|
|
18
18
|
}
|
|
19
19
|
export type Step = 'create' | 'wait' | 'brief' | 'send' | 'close';
|
|
20
|
+
/** terminalSend 的提交等待秒数:launch / restore / send / notify 共用一份,别再各写一个 10。 */
|
|
21
|
+
export declare const WAIT_SUBMIT_SEC = 10;
|
|
20
22
|
export declare const postAddress: (post: string, team: string) => string;
|
|
21
23
|
/**
|
|
22
24
|
* 本地请求账本的键 `<team>:<post>:<step>:<hash8>`(`terminal` 命令族的 --retry-request 只认 Orca 分配的 UUID;编排命令族见 commands/task/ledger.ts)。
|
package/dist/commands/deps.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { WinglineError } from '../errors.js';
|
|
2
2
|
import { inRange } from './version-range.js';
|
|
3
|
+
/** terminalSend 的提交等待秒数:launch / restore / send / notify 共用一份,别再各写一个 10。 */
|
|
4
|
+
export const WAIT_SUBMIT_SEC = 10;
|
|
3
5
|
export const postAddress = (post, team) => `${post}@${team}`;
|
|
4
6
|
/**
|
|
5
7
|
* 本地请求账本的键 `<team>:<post>:<step>:<hash8>`(`terminal` 命令族的 --retry-request 只认 Orca 分配的 UUID;编排命令族见 commands/task/ledger.ts)。
|
|
@@ -24,6 +24,8 @@ export interface AttentionReport {
|
|
|
24
24
|
/** 需要人知道但不属于某个条目的提示:未应用的规格草稿、Orca 同步不可用等。 */
|
|
25
25
|
warnings: string[];
|
|
26
26
|
}
|
|
27
|
+
/** 待办数的唯一口径:CLI `wl dispatcher status`、控制台顶栏与 TUI 都用它,别再各写一遍。 */
|
|
28
|
+
export declare const pendingCount: (r: Pick<AttentionReport, "frozen" | "signals" | "questions" | "stale" | "warnings">) => number;
|
|
27
29
|
/** spec 第 6.5 节:由规格文件、注册表与常量推导,条件成立期间一直显示。 */
|
|
28
30
|
export declare function warningsOf(deps: CommandDeps, team: string, spec: TeamSpec | null): string[];
|
|
29
31
|
/** spec 第 6.5 节:只读汇总;调度员没在跑也能用,取不到的来源显式列在 unavailable。 */
|
|
@@ -8,6 +8,8 @@ import { gatherStaleInput, staleReasons } from '../task/query.js';
|
|
|
8
8
|
import { dispatcherHealth } from './lifecycle.js';
|
|
9
9
|
import { signalSummary } from './signal-summary.js';
|
|
10
10
|
import { unreadGaveUp } from './unread.js';
|
|
11
|
+
/** 待办数的唯一口径:CLI `wl dispatcher status`、控制台顶栏与 TUI 都用它,别再各写一遍。 */
|
|
12
|
+
export const pendingCount = (r) => r.frozen.length + r.signals.length + r.questions.length + r.stale.length + r.warnings.length;
|
|
11
13
|
const DAY_MS = 24 * 3_600_000;
|
|
12
14
|
/** spec 第 6.5 节:由规格文件、注册表与常量推导,条件成立期间一直显示。 */
|
|
13
15
|
export function warningsOf(deps, team, spec) {
|
|
@@ -44,8 +46,7 @@ function specOf(deps, team) {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
function lastCommand(deps, team, s) {
|
|
47
|
-
const
|
|
48
|
-
const hit = events.findLast((e) => e.action === 'notify' && e.post === s.post && e.signal === s.signal && (e.taskId ?? '') === s.taskId);
|
|
49
|
+
const hit = deps.registry.lastNotifyEventOf(team, s.post, s.signal, s.taskId);
|
|
49
50
|
const command = hit?.detail?.command;
|
|
50
51
|
return command ?? null;
|
|
51
52
|
}
|
|
@@ -94,9 +95,9 @@ function groupWarnings(events) {
|
|
|
94
95
|
export async function dispatcherStatus(deps, team) {
|
|
95
96
|
const { registry } = deps;
|
|
96
97
|
const now = deps.now();
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const e =
|
|
98
|
+
// 定点查询而非读最新 500 条:巡查事件保留 30 天、每 5 分钟写若干条,500 条窗口会把更早的 check/scan 挤出去
|
|
99
|
+
const lastOf = (actions) => {
|
|
100
|
+
const e = registry.lastPatrolEventOf(team, actions);
|
|
100
101
|
return e ? { at: e.at, outcome: e.outcome ?? 'ok' } : null;
|
|
101
102
|
};
|
|
102
103
|
const report = await attention(deps, team);
|
|
@@ -104,11 +105,11 @@ export async function dispatcherStatus(deps, team) {
|
|
|
104
105
|
return {
|
|
105
106
|
team,
|
|
106
107
|
health: report.dispatcher,
|
|
107
|
-
lastCheck:
|
|
108
|
-
lastScan:
|
|
108
|
+
lastCheck: lastOf(['check_ok', 'check_failed']),
|
|
109
|
+
lastScan: lastOf(['scan', 'scan_skipped']),
|
|
109
110
|
frozen: report.frozen.length,
|
|
110
111
|
hushed: registry.listHushes(team).filter((h) => Date.parse(h.until) > now.getTime()).length,
|
|
111
|
-
attentionItems: report
|
|
112
|
-
warnings: groupWarnings(
|
|
112
|
+
attentionItems: pendingCount(report),
|
|
113
|
+
warnings: groupWarnings(registry.listPatrolEvents(team, { since, limit: 10_000 }).filter((e) => WARNING_ACTIONS.has(e.action))),
|
|
113
114
|
};
|
|
114
115
|
}
|
|
@@ -5,7 +5,7 @@ export declare function freezePostCmd(deps: CommandDeps, a: {
|
|
|
5
5
|
address: string;
|
|
6
6
|
reason: string;
|
|
7
7
|
}): Promise<void>;
|
|
8
|
-
/**
|
|
8
|
+
/** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。团队以冻结行为准——巡查可能冻结的是没登记的终端(任务派去了陌生终端),knownPost 会把它判成 post_unknown,永远解不开。 */
|
|
9
9
|
export declare function unfreezePostCmd(deps: CommandDeps, a: {
|
|
10
10
|
address: string;
|
|
11
11
|
}): Promise<{
|
|
@@ -34,12 +34,14 @@ async function stillReady(deps, team, ids) {
|
|
|
34
34
|
return new Set(ids);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
37
|
+
/** 解冻:先重置巡查行与游标,最后删冻结行(中途崩溃仍是冻结状态,可重跑)。团队以冻结行为准——巡查可能冻结的是没登记的终端(任务派去了陌生终端),knownPost 会把它判成 post_unknown,永远解不开。 */
|
|
38
38
|
export async function unfreezePostCmd(deps, a) {
|
|
39
|
-
const team = knownPost(deps, a.address);
|
|
40
39
|
const frozen = deps.registry.getFrozen(a.address);
|
|
41
|
-
if (!frozen)
|
|
40
|
+
if (!frozen) {
|
|
41
|
+
knownPost(deps, a.address); // 没冻结:按原口径报 team_unknown / post_unknown
|
|
42
42
|
throw new WinglineError('input', 'post_not_frozen', `岗位 ${a.address} 没有被冻结`);
|
|
43
|
+
}
|
|
44
|
+
const team = frozen.team;
|
|
43
45
|
const at = deps.now().toISOString();
|
|
44
46
|
deps.registry.resetPatrolForPost(a.address, at);
|
|
45
47
|
deps.registry.setCursor(team, a.address, at);
|
|
@@ -150,6 +150,8 @@ function maintain(deps, st) {
|
|
|
150
150
|
deps.registry.prunePatrolEvents(new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
|
|
151
151
|
// 审计事件同保留 30 天(3A spec §6.4);调度员循环 + wl log 各清理一次。
|
|
152
152
|
pruneAuditBestEffort(deps, st.team.name, new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
|
|
153
|
+
// 已结束状态同样只留 30 天:已清除的巡查行、已回答的提问、已定案的账本行,不清会一直涨
|
|
154
|
+
deps.registry.pruneFinishedState(new Date(now.getTime() - EVENT_KEEP_MS).toISOString());
|
|
153
155
|
st.logDay = dayOf(now);
|
|
154
156
|
}
|
|
155
157
|
const target = mustTeamReady(deps, st.team.name);
|
|
@@ -7,7 +7,6 @@ export interface Launch {
|
|
|
7
7
|
hash: string;
|
|
8
8
|
}
|
|
9
9
|
export declare const WAIT_TIMEOUT_MS = 60000;
|
|
10
|
-
export declare const WAIT_SUBMIT_SEC = 10;
|
|
11
10
|
/** 先记 pending,再调用,最后按结果记 done / uncertain / failed。ok 为 false 表示 Orca 回执未确认成功。 */
|
|
12
11
|
export declare function recorded<T>(reg: Registry, id: string, fn: () => Promise<T>, ok?: (v: T) => boolean): Promise<{
|
|
13
12
|
confirmed: boolean;
|
package/dist/commands/launch.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { WinglineError } from '../errors.js';
|
|
3
|
-
import { postAddress, requestId } from './deps.js';
|
|
3
|
+
import { postAddress, requestId, WAIT_SUBMIT_SEC } from './deps.js';
|
|
4
4
|
import { screenGuard } from './task/notify.js';
|
|
5
5
|
export const WAIT_TIMEOUT_MS = 60_000;
|
|
6
|
-
export const WAIT_SUBMIT_SEC = 10;
|
|
7
6
|
/** 先记 pending,再调用,最后按结果记 done / uncertain / failed。ok 为 false 表示 Orca 回执未确认成功。 */
|
|
8
7
|
export async function recorded(reg, id, fn, ok = () => true) {
|
|
9
8
|
reg.beginRequest(id);
|
|
@@ -33,10 +33,16 @@ export async function restore(deps, a) {
|
|
|
33
33
|
if (hash !== row.specHash) {
|
|
34
34
|
throw new WinglineError('input', 'team_spec_changed', `团队 ${a.team} 的规格已变化`, { hint: `不支持在线修改编队,请先执行 wl down ${a.team}` });
|
|
35
35
|
}
|
|
36
|
+
const seenSession = new Set();
|
|
36
37
|
for (const [post, id] of Object.entries(a.sessions ?? {})) {
|
|
37
38
|
if (!spec.posts.some((p) => p.id === post) || !SESSION_ID.test(id)) {
|
|
38
39
|
throw new WinglineError('input', 'session_override_invalid', `--session ${post}=${id} 无效:岗位不在团队里,或会话 ID 格式不符`);
|
|
39
40
|
}
|
|
41
|
+
// 同一会话指定给两个岗位:后者会在运行期撞上 reserved,这里提前拒绝
|
|
42
|
+
if (seenSession.has(id)) {
|
|
43
|
+
throw new WinglineError('input', 'session_override_invalid', `--session ${post}=${id} 无效:会话 ${id} 已指定给另一个岗位`);
|
|
44
|
+
}
|
|
45
|
+
seenSession.add(id);
|
|
40
46
|
}
|
|
41
47
|
// 第 2、3 步:按 team.yaml 顺序,牵头岗最先(lead 先就绪,Run 才有认领人)
|
|
42
48
|
const l = { deps, spec, hash };
|
|
@@ -21,7 +21,7 @@ export interface RestorePost {
|
|
|
21
21
|
/** restore 的请求键独立命名空间(复审 I-1):与 wl up 的键不冲突,代数各自计数。 */
|
|
22
22
|
export declare const restoreKey: (team: string, post: string, gen: number, step: "create" | "wait" | "notice") => string;
|
|
23
23
|
/** 已完成的 restore create 中最大代数加 1;本代 create 尚未完成(中断重跑沿用原代)时不前进。终端仍活着(located)时调用方走 kept 或补完上一代(pendingResume),不会拉起新代。 */
|
|
24
|
-
export declare function restoreGen(reg: Registry, team: string, post: string
|
|
24
|
+
export declare function restoreGen(reg: Registry, team: string, post: string): number;
|
|
25
25
|
/**
|
|
26
26
|
* 远端岗位的接回提示带一个每次接回唯一的标识(`WLR-` 加 8 位十六进制,由该次接回的请求键哈希得到,重试时不变)。
|
|
27
27
|
* 提示原文每次相同,原会话里可能早有旧的同样的提示;不带标识,丢了 `--resume` 的这一次会被旧提示误判为通过。
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { checkBriefingLimit } from '../../brief/render.js';
|
|
3
3
|
import { WinglineError } from '../../errors.js';
|
|
4
|
-
import { postAddress, requestId } from '../deps.js';
|
|
4
|
+
import { postAddress, requestId, WAIT_SUBMIT_SEC } from '../deps.js';
|
|
5
5
|
import { briefText } from '../up.js';
|
|
6
|
-
import { ensureMarker, lifetimeGen, recorded, startPost, stepDone,
|
|
6
|
+
import { ensureMarker, lifetimeGen, recorded, startPost, stepDone, WAIT_TIMEOUT_MS, withMarker, } from '../launch.js';
|
|
7
7
|
import { locatePost } from '../locate.js';
|
|
8
8
|
import { screenGuard } from '../task/notify.js';
|
|
9
9
|
import { findSession, markerHighlighted } from './find-session.js';
|
|
@@ -11,7 +11,7 @@ import { resumeCommand, SESSION_ID } from './resume-command.js';
|
|
|
11
11
|
/** restore 的请求键独立命名空间(复审 I-1):与 wl up 的键不冲突,代数各自计数。 */
|
|
12
12
|
export const restoreKey = (team, post, gen, step) => `restore:${team}:${post}:${gen}:${step}`;
|
|
13
13
|
/** 已完成的 restore create 中最大代数加 1;本代 create 尚未完成(中断重跑沿用原代)时不前进。终端仍活着(located)时调用方走 kept 或补完上一代(pendingResume),不会拉起新代。 */
|
|
14
|
-
export function restoreGen(reg, team, post
|
|
14
|
+
export function restoreGen(reg, team, post) {
|
|
15
15
|
let g = 0;
|
|
16
16
|
while (reg.getRequest(restoreKey(team, post, g, 'create'))?.state === 'done')
|
|
17
17
|
g++;
|
|
@@ -81,7 +81,7 @@ async function landingResult(deps, address, sessionId, token, remote, retry) {
|
|
|
81
81
|
* 返回要补完的会话 ID 与请求键;否则 null。会话 ID 没缓存、岗位已 live、句柄对不上都不算。
|
|
82
82
|
*/
|
|
83
83
|
function pendingResume(reg, team, post, handle) {
|
|
84
|
-
const gen = restoreGen(reg, team, post
|
|
84
|
+
const gen = restoreGen(reg, team, post) - 1;
|
|
85
85
|
if (gen < 0)
|
|
86
86
|
return null;
|
|
87
87
|
const rid = (step) => restoreKey(team, post, gen, step);
|
|
@@ -131,18 +131,20 @@ export async function restorePost(l, post, o) {
|
|
|
131
131
|
const retry = `wl restore ${l.spec.name}`;
|
|
132
132
|
try {
|
|
133
133
|
const located = await locatePost(orca, address);
|
|
134
|
-
if (located
|
|
135
|
-
// 上次接回没走完(终端是 restore
|
|
134
|
+
if (located) {
|
|
135
|
+
// 上次接回没走完(终端是 restore 拉起或复用的、空闲等待或接回提示未定案):补完它,而不是报 kept 或再开新代
|
|
136
136
|
const pending = pendingResume(registry, l.spec.name, post.id, located.handle);
|
|
137
137
|
if (pending && o.dryRun)
|
|
138
138
|
return { address, outcome: 'resumed', sessionId: pending.sessionId };
|
|
139
139
|
if (pending)
|
|
140
140
|
return await settleResume(l, address, located.handle, pending.sessionId, pending.rid, retry, remote);
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
if (located.connected) {
|
|
142
|
+
if (!o.dryRun) {
|
|
143
|
+
registry.setPostHandle(address, located.handle);
|
|
144
|
+
registry.setPostStatus(address, 'live');
|
|
145
|
+
}
|
|
146
|
+
return { address, outcome: 'kept' };
|
|
144
147
|
}
|
|
145
|
-
return { address, outcome: 'kept' };
|
|
146
148
|
}
|
|
147
149
|
const row = registry.getPost(address);
|
|
148
150
|
// path: 固定值直接用;选择器(阶段 3 之前的行)现解析一次
|
|
@@ -175,14 +177,12 @@ export async function restorePost(l, post, o) {
|
|
|
175
177
|
if (sessionId !== null && command !== null) {
|
|
176
178
|
if (o.dryRun)
|
|
177
179
|
return { address, outcome: 'resumed', sessionId };
|
|
178
|
-
const gen = restoreGen(registry, l.spec.name, post.id
|
|
180
|
+
const gen = restoreGen(registry, l.spec.name, post.id);
|
|
179
181
|
const rid = (step) => restoreKey(l.spec.name, post.id, gen, step);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
registry.setPostHandle(address, handle);
|
|
185
|
-
}
|
|
182
|
+
// 终端还在(哪怕暂时断连)就复用它并同样记入 create 账本,避免把同一会话接进两个终端或下一代重用旧代数
|
|
183
|
+
const created = await recorded(registry, rid('create'), async () => located ? { handle: located.handle } : orca.terminalCreate({ worktree: post.worktree, title: address, command }));
|
|
184
|
+
const handle = created.value.handle;
|
|
185
|
+
registry.setPostHandle(address, handle);
|
|
186
186
|
registry.setSessionId(address, sessionId);
|
|
187
187
|
return await settleResume(l, address, handle, sessionId, rid, retry, remote);
|
|
188
188
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
/** 会话 ID 形如 Orca 分配的 UUID / 内部 ID:字母、数字、连字符之外的字符一律拒绝。 */
|
|
2
2
|
export declare const SESSION_ID: RegExp;
|
|
3
|
-
export declare function supportsResume(agent: string): boolean;
|
|
4
3
|
/** 拼出该 agent 的接回命令;不支持接回的 agent 返回 null(调用方走 fresh)。 */
|
|
5
4
|
export declare function resumeCommand(agent: string, launch: string, sessionId: string): string | null;
|
|
@@ -5,9 +5,6 @@ export const SESSION_ID = /^[A-Za-z0-9-]+$/;
|
|
|
5
5
|
const RESUME_TEMPLATES = {
|
|
6
6
|
claude: (launch, sessionId) => `${launch} --resume ${sessionId}`,
|
|
7
7
|
};
|
|
8
|
-
export function supportsResume(agent) {
|
|
9
|
-
return agent in RESUME_TEMPLATES;
|
|
10
|
-
}
|
|
11
8
|
/** 拼出该 agent 的接回命令;不支持接回的 agent 返回 null(调用方走 fresh)。 */
|
|
12
9
|
export function resumeCommand(agent, launch, sessionId) {
|
|
13
10
|
if (!SESSION_ID.test(sessionId)) {
|
package/dist/commands/send.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { WinglineError } from '../errors.js';
|
|
3
|
-
import { nextRequestId, preflight } from './deps.js';
|
|
3
|
+
import { nextRequestId, preflight, WAIT_SUBMIT_SEC } from './deps.js';
|
|
4
4
|
import { locatePost } from './locate.js';
|
|
5
5
|
const MAX_MESSAGE = 4000;
|
|
6
|
-
const WAIT_SUBMIT_SEC = 10;
|
|
7
6
|
function checkMessage(deps, message) {
|
|
8
7
|
if (message.length > MAX_MESSAGE) {
|
|
9
8
|
throw new WinglineError('input', 'message_too_long', `消息 ${message.length} 字符,超过上限 ${MAX_MESSAGE}`);
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { CommandDeps } from '../deps.js';
|
|
2
2
|
export interface GateExecResult {
|
|
3
3
|
passed: boolean;
|
|
4
|
-
/**
|
|
4
|
+
/** 输出正文;超过 GATE_OUTPUT_LIMIT_BYTES 时只留尾部,并在末尾标明被截断。 */
|
|
5
5
|
output: string;
|
|
6
6
|
timedOut: boolean;
|
|
7
7
|
/** 被 Ctrl-C / SIGTERM 中断(不计为 gate 失败)。 */
|
|
8
8
|
interrupted?: boolean;
|
|
9
|
+
/** 输出超过上限,gate 被终止并计为失败。 */
|
|
10
|
+
overflowed?: boolean;
|
|
9
11
|
}
|
|
12
|
+
/** 输出只保留尾部这么多字节:`yes`、`tail -f` 这类跑飞的 gate 不能把内存吃光。 */
|
|
13
|
+
export declare const GATE_OUTPUT_LIMIT_BYTES: number;
|
|
10
14
|
export declare function runGate(command: string | null, opts: {
|
|
11
15
|
cwd: string;
|
|
12
16
|
timeoutMs: number;
|
|
@@ -2,9 +2,12 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { WinglineError } from '../../errors.js';
|
|
5
|
+
/** 输出只保留尾部这么多字节:`yes`、`tail -f` 这类跑飞的 gate 不能把内存吃光。 */
|
|
6
|
+
export const GATE_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
5
7
|
const TIMEOUT_MARK = '\n(gate 超时,已终止)';
|
|
8
|
+
const OVERFLOW_MARK = `\n(gate 输出超过 ${GATE_OUTPUT_LIMIT_BYTES / 1024 / 1024} MB,已终止,只保留尾部)`;
|
|
6
9
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
-
/**
|
|
10
|
+
/** 超时、输出超限都要杀整棵进程树:POSIX 杀进程组,Windows 用 taskkill /T。 */
|
|
8
11
|
function killTree(pid) {
|
|
9
12
|
try {
|
|
10
13
|
if (process.platform === 'win32') {
|
|
@@ -26,8 +29,10 @@ function spawnGate(command, opts) {
|
|
|
26
29
|
// gate 是用户在 team.yaml 里写的完整 shell 命令行,必须经 shell 解释(2A spec 第 3 节约束 6,总纲 §2.4);全项目只此一处。
|
|
27
30
|
const child = spawn(command, { shell: true, cwd: opts.cwd, detached: process.platform !== 'win32', windowsHide: true });
|
|
28
31
|
const chunks = [];
|
|
32
|
+
let kept = 0;
|
|
29
33
|
let timedOut = false;
|
|
30
34
|
let interrupted = false;
|
|
35
|
+
let overflowed = false;
|
|
31
36
|
// 运行期间 Ctrl-C / SIGTERM:把 gate 的整个进程组一起杀掉,不留孤儿
|
|
32
37
|
const onSignal = () => {
|
|
33
38
|
interrupted = true;
|
|
@@ -42,16 +47,35 @@ function spawnGate(command, opts) {
|
|
|
42
47
|
SIGNALS.forEach((s) => process.off(s, onSignal));
|
|
43
48
|
};
|
|
44
49
|
SIGNALS.forEach((s) => process.on(s, onSignal));
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
/** 只留尾部:超过上限就丢最前面的块,并终止 gate(整份输出既没必要也存不住)。 */
|
|
51
|
+
const collect = (d) => {
|
|
52
|
+
chunks.push(d);
|
|
53
|
+
kept += d.length;
|
|
54
|
+
if (kept <= GATE_OUTPUT_LIMIT_BYTES)
|
|
55
|
+
return;
|
|
56
|
+
if (!overflowed) {
|
|
57
|
+
overflowed = true;
|
|
58
|
+
killTree(child.pid);
|
|
59
|
+
}
|
|
60
|
+
while (chunks.length > 1 && kept > GATE_OUTPUT_LIMIT_BYTES)
|
|
61
|
+
kept -= chunks.shift().length;
|
|
62
|
+
};
|
|
63
|
+
child.stdout.on('data', collect);
|
|
64
|
+
child.stderr.on('data', collect);
|
|
47
65
|
child.on('error', (err) => {
|
|
48
66
|
cleanup();
|
|
49
67
|
resolve({ passed: false, output: `无法启动 gate:${err.message}`, timedOut: false });
|
|
50
68
|
});
|
|
51
69
|
child.on('close', (code) => {
|
|
52
70
|
cleanup();
|
|
53
|
-
const output = Buffer.concat(chunks).toString('utf8') + (timedOut ? TIMEOUT_MARK : '');
|
|
54
|
-
resolve({
|
|
71
|
+
const output = Buffer.concat(chunks).toString('utf8') + (timedOut ? TIMEOUT_MARK : '') + (overflowed ? OVERFLOW_MARK : '');
|
|
72
|
+
resolve({
|
|
73
|
+
passed: code === 0 && !timedOut && !interrupted && !overflowed,
|
|
74
|
+
output,
|
|
75
|
+
timedOut,
|
|
76
|
+
...(interrupted ? { interrupted } : {}),
|
|
77
|
+
...(overflowed ? { overflowed } : {}),
|
|
78
|
+
});
|
|
55
79
|
});
|
|
56
80
|
});
|
|
57
81
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CommandDeps } from '../deps.js';
|
|
2
2
|
export type ScreenState = 'clear' | 'at_prompt' | 'shell' | 'unknown';
|
|
3
3
|
/** 逐条对应 spec 第 7.4 节:agent 或包装器正停在需要人确认的画面上。 */
|
|
4
4
|
export declare const PROMPT_PATTERNS: readonly RegExp[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WinglineError } from '../../errors.js';
|
|
2
|
+
import { WAIT_SUBMIT_SEC } from '../deps.js';
|
|
2
3
|
import { locatePost } from '../locate.js';
|
|
3
4
|
/** 逐条对应 spec 第 7.4 节:agent 或包装器正停在需要人确认的画面上。 */
|
|
4
5
|
export const PROMPT_PATTERNS = [
|
|
@@ -19,7 +20,6 @@ export const PROMPT_PATTERNS = [
|
|
|
19
20
|
/** 终端已退回 shell:在那里打字会执行命令。只看最后一个非空行。 */
|
|
20
21
|
const SHELL_PROMPT = /(?:(?:[$#]|(?<!\d)%)\s*$)|(?:^PS .*>\s*$)|(?:^[A-Za-z]:\\.*>\s*$)/;
|
|
21
22
|
export const SCREEN_TAIL_LINES = 8;
|
|
22
|
-
const WAIT_SUBMIT_SEC = 10;
|
|
23
23
|
/** 纯函数:取 preview 最后 8 行,任一行命中 PROMPT_PATTERNS → at_prompt;否则最后一个非空行像 shell 提示符 → shell;空 → unknown;其余 clear。 */
|
|
24
24
|
export function classifyPreview(preview) {
|
|
25
25
|
const lines = preview.split('\n');
|
|
@@ -56,6 +56,8 @@ export declare function showTask(deps: CommandDeps, a: {
|
|
|
56
56
|
history?: boolean;
|
|
57
57
|
}): Promise<ShownTask>;
|
|
58
58
|
export type StaleReason = 'done_unfinished' | 'call_uncertain' | 'dead_assignee' | 'owner_idle' | 'bypassed' | 'blocked_waiting';
|
|
59
|
+
/** 卡住原因的短标签:TUI 与文档共用(控制台前端只 import type,标签在 web/src/labels.ts 另放一份)。 */
|
|
60
|
+
export declare const STALE_REASON_LABEL: Record<StaleReason, string>;
|
|
59
61
|
export interface StaleItem {
|
|
60
62
|
taskId: string;
|
|
61
63
|
title: string;
|
|
@@ -116,6 +116,15 @@ export async function showTask(deps, a) {
|
|
|
116
116
|
...(a.history ? { history: deps.registry.listTaskLog(a.id) } : {}),
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
+
/** 卡住原因的短标签:TUI 与文档共用(控制台前端只 import type,标签在 web/src/labels.ts 另放一份)。 */
|
|
120
|
+
export const STALE_REASON_LABEL = {
|
|
121
|
+
done_unfinished: '已完成但未收尾',
|
|
122
|
+
call_uncertain: '调用结果不确定',
|
|
123
|
+
dead_assignee: '派发失主',
|
|
124
|
+
owner_idle: '主人迟迟未开始',
|
|
125
|
+
bypassed: '绕过闸门上报',
|
|
126
|
+
blocked_waiting: '等待决策',
|
|
127
|
+
};
|
|
119
128
|
export const DEFAULT_STALE = { ownerIdleMinutes: 30, uncertainMinutes: 1 };
|
|
120
129
|
const MINUTE_MS = 60_000;
|
|
121
130
|
const as = (owner) => owner ?? '<主人>';
|
|
@@ -130,7 +139,7 @@ function doneUnfinished(x) {
|
|
|
130
139
|
});
|
|
131
140
|
}
|
|
132
141
|
/** 账本键 → 任务与重跑命令;oneshot:* 不归任何任务。 */
|
|
133
|
-
function commandOfKey(
|
|
142
|
+
function commandOfKey(key, t) {
|
|
134
143
|
const [kind] = key.split(':');
|
|
135
144
|
if (kind === 'dispatch')
|
|
136
145
|
return `wl task start ${t.id} --as ${as(t.claimedBy)}`;
|
|
@@ -178,7 +187,7 @@ function callUncertain(x, now, th) {
|
|
|
178
187
|
return [];
|
|
179
188
|
if (task && epochOfKey(r.id) < task.epoch)
|
|
180
189
|
return [];
|
|
181
|
-
const command = task ? commandOfKey(
|
|
190
|
+
const command = task ? commandOfKey(r.id, task) : null;
|
|
182
191
|
return task && command ? [item(task, 'call_uncertain', r.updatedAt, command)] : [];
|
|
183
192
|
});
|
|
184
193
|
}
|
|
@@ -101,6 +101,8 @@ export interface PatrolStateRow {
|
|
|
101
101
|
detail?: Record<string, unknown> | null;
|
|
102
102
|
}
|
|
103
103
|
export interface FrozenRow {
|
|
104
|
+
/** 冻结行自带的团队名:被冻结的可以是没登记的终端,所以别从地址里反推。 */
|
|
105
|
+
team: string;
|
|
104
106
|
post: string;
|
|
105
107
|
reason: string;
|
|
106
108
|
signal: string | null;
|
|
@@ -283,6 +285,11 @@ export declare class Registry {
|
|
|
283
285
|
id: number;
|
|
284
286
|
at: string;
|
|
285
287
|
}) | null;
|
|
288
|
+
/** 该信号主体最近一次 notify 事件(不受巡查事件保留窗口影响);task_id 为 NULL 的主体与空串比较。 */
|
|
289
|
+
lastNotifyEventOf(team: string, post: string, signal: string, taskId: string): (PatrolEvent & {
|
|
290
|
+
id: number;
|
|
291
|
+
at: string;
|
|
292
|
+
}) | null;
|
|
286
293
|
appendAudit(r: Omit<AuditRow, 'id' | 'at' | 'detail'> & {
|
|
287
294
|
at?: string;
|
|
288
295
|
detail?: unknown;
|
|
@@ -322,6 +329,12 @@ export declare class Registry {
|
|
|
322
329
|
/** team 是否写过 actions 之一的事件。 */
|
|
323
330
|
hasPatrolEventOf(team: string, actions: readonly string[]): boolean;
|
|
324
331
|
prunePatrolEvents(before: string): number;
|
|
332
|
+
/**
|
|
333
|
+
* 已结束状态的日常清理:已清除的巡查行、已回答的提问、已定案且过保留期的任务账本行。
|
|
334
|
+
* 不动:pending/uncertain 行(可能还在重放)、代数键(restore:*、<team>:<post>:*,删了会让代数从头数起)、
|
|
335
|
+
* done:*:delivered:* 交付行(解冻时按冻结时间点数被跳过的任务)。
|
|
336
|
+
*/
|
|
337
|
+
pruneFinishedState(before: string): void;
|
|
325
338
|
/** finished_at > after(null 表示不限)的已完成判定,按 finished_at 升序。 */
|
|
326
339
|
listFinishedVerdictsAfter(post: string, after: string | null): DoneVerdictRow[];
|
|
327
340
|
/** 2A done 写下的交付记录(requests 里 done:%:delivered:<target> 且 done 的行)。 */
|
|
@@ -74,7 +74,7 @@ const patrolRow = (r) => ({
|
|
|
74
74
|
detail: r.detail === null || r.detail === undefined ? null : JSON.parse(r.detail),
|
|
75
75
|
});
|
|
76
76
|
const frozenRow = (r) => ({
|
|
77
|
-
post: r.post, reason: r.reason, signal: r.signal, frozenAt: r.frozen_at,
|
|
77
|
+
team: r.team, post: r.post, reason: r.reason, signal: r.signal, frozenAt: r.frozen_at,
|
|
78
78
|
});
|
|
79
79
|
const questionRow = (r) => ({
|
|
80
80
|
team: r.team,
|
|
@@ -466,7 +466,9 @@ export class Registry {
|
|
|
466
466
|
}
|
|
467
467
|
/** unfreeze 用:该岗位所有巡查行回到 level 0 并标记已清除(spec 第 7 节)。画面信号的行不动:解冻不该清掉用量暂停,也不该在提示仍留屏时让它重新通知一次。 */
|
|
468
468
|
resetPatrolForPost(post, at) {
|
|
469
|
-
this.db
|
|
469
|
+
this.db
|
|
470
|
+
.prepare("UPDATE patrol_state SET level = 0, cleared_at = ?, last_nudged_at = NULL, notify_count = 0, next_due_at = NULL WHERE post = ? AND signal NOT IN ('context_low', 'usage_limit')")
|
|
471
|
+
.run(at, post);
|
|
470
472
|
}
|
|
471
473
|
freezePost(a) {
|
|
472
474
|
this.db
|
|
@@ -552,6 +554,13 @@ export class Registry {
|
|
|
552
554
|
const r = this.db.prepare(`SELECT * FROM patrol_events WHERE team = ? AND action IN (${marks}) ORDER BY id DESC LIMIT 1`).get(team, ...actions);
|
|
553
555
|
return r ? eventRow(r) : null;
|
|
554
556
|
}
|
|
557
|
+
/** 该信号主体最近一次 notify 事件(不受巡查事件保留窗口影响);task_id 为 NULL 的主体与空串比较。 */
|
|
558
|
+
lastNotifyEventOf(team, post, signal, taskId) {
|
|
559
|
+
const r = this.db
|
|
560
|
+
.prepare("SELECT * FROM patrol_events WHERE team = ? AND action = 'notify' AND post = ? AND signal = ? AND COALESCE(task_id, '') = ? ORDER BY id DESC LIMIT 1")
|
|
561
|
+
.get(team, post, signal, taskId);
|
|
562
|
+
return r ? eventRow(r) : null;
|
|
563
|
+
}
|
|
555
564
|
// —— 阶段 3A:审计事件、团队任务日志、时间线游标 ——
|
|
556
565
|
// 三者共用 pageRows 的分页语义:id 升序;不带 afterId 取最新 limit 条;带 afterId 取游标之后最早的 limit 条。
|
|
557
566
|
appendAudit(r) {
|
|
@@ -627,6 +636,18 @@ export class Registry {
|
|
|
627
636
|
prunePatrolEvents(before) {
|
|
628
637
|
return Number(this.db.prepare('DELETE FROM patrol_events WHERE at < ?').run(before).changes);
|
|
629
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* 已结束状态的日常清理:已清除的巡查行、已回答的提问、已定案且过保留期的任务账本行。
|
|
641
|
+
* 不动:pending/uncertain 行(可能还在重放)、代数键(restore:*、<team>:<post>:*,删了会让代数从头数起)、
|
|
642
|
+
* done:*:delivered:* 交付行(解冻时按冻结时间点数被跳过的任务)。
|
|
643
|
+
*/
|
|
644
|
+
pruneFinishedState(before) {
|
|
645
|
+
this.db.prepare('DELETE FROM patrol_state WHERE cleared_at IS NOT NULL AND cleared_at < ?').run(before);
|
|
646
|
+
this.db.prepare('DELETE FROM questions WHERE answered_at IS NOT NULL AND answered_at < ?').run(before);
|
|
647
|
+
this.db.prepare(`DELETE FROM requests WHERE state = 'done' AND updated_at < ?
|
|
648
|
+
AND (id LIKE 'dispatch:%' OR id LIKE 'recover:%' OR id LIKE 'resolve:%' OR id LIKE 'oneshot:%'
|
|
649
|
+
OR (id LIKE 'done:%' AND id NOT LIKE 'done:%:delivered:%'))`).run(before);
|
|
650
|
+
}
|
|
630
651
|
/** finished_at > after(null 表示不限)的已完成判定,按 finished_at 升序。 */
|
|
631
652
|
listFinishedVerdictsAfter(post, after) {
|
|
632
653
|
const sql = 'SELECT * FROM task_done WHERE post = ? AND finished_at IS NOT NULL AND finished_at > ? ORDER BY finished_at, task_id';
|
package/dist/server/api-read.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { checkParam } from './http.js';
|
|
2
|
+
import { WinglineError } from '../errors.js';
|
|
2
3
|
import { ps } from '../commands/ps.js';
|
|
3
4
|
import { dispatcherHealth } from '../commands/dispatcher/lifecycle.js';
|
|
4
|
-
import { attention, dispatcherStatus } from '../commands/dispatcher/attention.js';
|
|
5
|
+
import { attention, dispatcherStatus, pendingCount } from '../commands/dispatcher/attention.js';
|
|
5
6
|
import { signalSummary } from '../commands/dispatcher/signal-summary.js';
|
|
6
7
|
import { listTasks, showTask } from '../commands/task/query.js';
|
|
7
8
|
import { listStale } from '../commands/task/query.js';
|
|
@@ -12,17 +13,11 @@ async function teamSummary(ctx, name) {
|
|
|
12
13
|
const posts = await ps(ctx.deps, { team: name });
|
|
13
14
|
const disp = dispatcherHealth(ctx.deps, name).state;
|
|
14
15
|
const att = await attention(ctx.deps, name);
|
|
15
|
-
const attentionCount = (disp === 'stale' ? 1 : 0) +
|
|
16
|
-
att.frozen.length +
|
|
17
|
-
att.signals.length +
|
|
18
|
-
att.questions.length +
|
|
19
|
-
att.stale.length +
|
|
20
|
-
att.warnings.length;
|
|
21
16
|
return {
|
|
22
17
|
name,
|
|
23
18
|
postsCount: posts.length,
|
|
24
19
|
dispatcher: disp,
|
|
25
|
-
attentionCount,
|
|
20
|
+
attentionCount: pendingCount(att),
|
|
26
21
|
};
|
|
27
22
|
}
|
|
28
23
|
function registerTeamRoutes(r) {
|
|
@@ -140,7 +135,7 @@ function registerPostRoutes(r) {
|
|
|
140
135
|
const posts = await ps(ctx.deps, { team });
|
|
141
136
|
const pRow = posts.find((p) => p.address === address);
|
|
142
137
|
if (!pRow) {
|
|
143
|
-
throw new
|
|
138
|
+
throw new WinglineError('input', 'invalid_param', `岗位 ${address} 不存在`);
|
|
144
139
|
}
|
|
145
140
|
return await buildPostDetail(ctx, team, address, pRow);
|
|
146
141
|
});
|
|
@@ -154,8 +149,15 @@ function registerLogRoute(r) {
|
|
|
154
149
|
const post = ctx.query.get('post') ?? undefined;
|
|
155
150
|
const since = ctx.query.get('since') ?? undefined;
|
|
156
151
|
const source = ctx.query.get('source') ?? undefined;
|
|
152
|
+
// 与 wl log 的 --limit 同一口径:非正整数按用法错报,而不是 NaN 静默读空
|
|
157
153
|
const limitStr = ctx.query.get('limit');
|
|
158
|
-
|
|
154
|
+
let limit;
|
|
155
|
+
if (limitStr !== null) {
|
|
156
|
+
limit = Number(limitStr);
|
|
157
|
+
if (!(Number.isInteger(limit) && limit >= 1)) {
|
|
158
|
+
throw new WinglineError('input', 'limit_invalid', `limit ${limitStr} 无效:应为正整数`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
159
161
|
return await readTimeline(ctx.deps, { team, task, post, since, source, limit });
|
|
160
162
|
});
|
|
161
163
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -183,7 +183,15 @@ async function handleSse(req, res, team, sess, feeds, sseClients, warn) {
|
|
|
183
183
|
sseClients.delete(res);
|
|
184
184
|
});
|
|
185
185
|
const lastEventId = req.headers['last-event-id'];
|
|
186
|
-
|
|
186
|
+
// 背压:对端不读时 res.write 返回 false,继续写会把事件无限堆在内存里——直接断开;
|
|
187
|
+
// EventSource 会带 Last-Event-ID 自动重连,从断点补回,不丢事件。
|
|
188
|
+
const write = (chunk) => {
|
|
189
|
+
if (res.write(chunk))
|
|
190
|
+
return;
|
|
191
|
+
unsub?.();
|
|
192
|
+
res.destroy();
|
|
193
|
+
};
|
|
194
|
+
unsub = await feeds.subscribe(team, write, lastEventId);
|
|
187
195
|
if (closed || req.destroyed || res.destroyed) {
|
|
188
196
|
unsub();
|
|
189
197
|
sseClients.delete(res);
|
|
@@ -196,7 +204,7 @@ async function handleSse(req, res, team, sess, feeds, sseClients, warn) {
|
|
|
196
204
|
});
|
|
197
205
|
res.flushHeaders();
|
|
198
206
|
sseClients.add(res);
|
|
199
|
-
feeds.replaySnapshot(team,
|
|
207
|
+
feeds.replaySnapshot(team, write);
|
|
200
208
|
}
|
|
201
209
|
catch (err) {
|
|
202
210
|
respondError(res, err, warn, `GET /api/teams/${team}/events`);
|
package/dist/server/sse.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { currentCursor, readTimelineAfter } from '../commands/log.js';
|
|
3
3
|
import { ps } from '../commands/ps.js';
|
|
4
|
-
import { attention } from '../commands/dispatcher/attention.js';
|
|
4
|
+
import { attention, pendingCount } from '../commands/dispatcher/attention.js';
|
|
5
5
|
import { dispatcherHealth } from '../commands/dispatcher/lifecycle.js';
|
|
6
6
|
export function cursorFrom(lastEventId, start) {
|
|
7
7
|
if (!lastEventId)
|
|
@@ -49,6 +49,8 @@ export class TeamFeeds {
|
|
|
49
49
|
pingTimer: null,
|
|
50
50
|
lastSnapshotHash: null,
|
|
51
51
|
lastSnapshot: null,
|
|
52
|
+
timelineBusy: false,
|
|
53
|
+
snapshotBusy: false,
|
|
52
54
|
};
|
|
53
55
|
this.loops.set(team, loop);
|
|
54
56
|
this.startLoop(team, loop);
|
|
@@ -78,11 +80,27 @@ export class TeamFeeds {
|
|
|
78
80
|
startLoop(team, loop) {
|
|
79
81
|
// 1. timeline loop (2s)
|
|
80
82
|
loop.timelineTimer = this.setTimer(async () => {
|
|
81
|
-
|
|
83
|
+
if (loop.timelineBusy)
|
|
84
|
+
return;
|
|
85
|
+
loop.timelineBusy = true;
|
|
86
|
+
try {
|
|
87
|
+
await this.tickTimeline(team, loop);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
loop.timelineBusy = false;
|
|
91
|
+
}
|
|
82
92
|
}, this.timelineMs);
|
|
83
93
|
// 2. snapshot loop (5s)
|
|
84
94
|
loop.snapshotTimer = this.setTimer(async () => {
|
|
85
|
-
|
|
95
|
+
if (loop.snapshotBusy)
|
|
96
|
+
return;
|
|
97
|
+
loop.snapshotBusy = true;
|
|
98
|
+
try {
|
|
99
|
+
await this.tickSnapshot(team, loop);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
loop.snapshotBusy = false;
|
|
103
|
+
}
|
|
86
104
|
}, this.snapshotMs);
|
|
87
105
|
// 3. ping loop (15s)
|
|
88
106
|
loop.pingTimer = this.setTimer(() => {
|
|
@@ -118,13 +136,7 @@ export class TeamFeeds {
|
|
|
118
136
|
team,
|
|
119
137
|
posts,
|
|
120
138
|
dispatcher: disp,
|
|
121
|
-
|
|
122
|
-
frozen: att.frozen.length,
|
|
123
|
-
signals: att.signals.length,
|
|
124
|
-
questions: att.questions.length,
|
|
125
|
-
stale: att.stale.length,
|
|
126
|
-
warnings: att.warnings.length,
|
|
127
|
-
},
|
|
139
|
+
attention: pendingCount(att),
|
|
128
140
|
};
|
|
129
141
|
const serialized = JSON.stringify(data);
|
|
130
142
|
const hash = crypto.createHash('sha256').update(serialized).digest('hex');
|
package/dist/spec/plan-change.js
CHANGED
|
@@ -18,10 +18,11 @@ function snakeKeys(v) {
|
|
|
18
18
|
return v;
|
|
19
19
|
return Object.fromEntries(Object.entries(v).map(([k, x]) => [snake(k), snakeKeys(x)]));
|
|
20
20
|
}
|
|
21
|
-
/** 把 TeamSpec 还原成 YAML
|
|
21
|
+
/** 把 TeamSpec 还原成 YAML 里的字段形态,供按字段比较。岗位级字段(plan_first / self_claim / tags)也进哈希,预演必须同样列出。 */
|
|
22
22
|
function fieldView(s) {
|
|
23
23
|
const links = {};
|
|
24
24
|
const gates = {};
|
|
25
|
+
const posts = {};
|
|
25
26
|
for (const p of s.posts) {
|
|
26
27
|
const l = { reviewed_by: p.reviewedBy, hands_to: p.handsTo, consults: p.consults ?? [] };
|
|
27
28
|
const filled = Object.fromEntries(Object.entries(l).filter(([, v]) => v.length > 0));
|
|
@@ -29,8 +30,9 @@ function fieldView(s) {
|
|
|
29
30
|
links[p.id] = filled;
|
|
30
31
|
if (p.gate)
|
|
31
32
|
gates[p.id] = p.gate;
|
|
33
|
+
posts[p.id] = { plan_first: p.planFirst, self_claim: p.selfClaim, tags: p.tags };
|
|
32
34
|
}
|
|
33
|
-
return { lead: s.lead, links, gates, budgets: snakeKeys(s.budgets), patrol: snakeKeys(s.patrol) };
|
|
35
|
+
return { lead: s.lead, links, gates, budgets: snakeKeys(s.budgets), patrol: snakeKeys(s.patrol), posts };
|
|
34
36
|
}
|
|
35
37
|
function flatten(v, prefix, out) {
|
|
36
38
|
if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0) {
|