@weiping/wingline-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/audit-table.d.ts +11 -0
  2. package/dist/audit-table.js +85 -0
  3. package/dist/audit.js +1 -1
  4. package/dist/commands/about.d.ts +13 -0
  5. package/dist/commands/about.js +12 -1
  6. package/dist/commands/autostart/sys.d.ts +3 -0
  7. package/dist/commands/autostart/sys.js +23 -0
  8. package/dist/commands/deps.d.ts +2 -0
  9. package/dist/commands/deps.js +2 -0
  10. package/dist/commands/dispatcher/attention.d.ts +2 -0
  11. package/dist/commands/dispatcher/attention.js +10 -9
  12. package/dist/commands/dispatcher/controls.d.ts +1 -1
  13. package/dist/commands/dispatcher/controls.js +5 -3
  14. package/dist/commands/dispatcher/lifecycle.d.ts +4 -0
  15. package/dist/commands/dispatcher/lifecycle.js +7 -1
  16. package/dist/commands/dispatcher/loop.js +2 -0
  17. package/dist/commands/down.js +3 -0
  18. package/dist/commands/init.d.ts +17 -0
  19. package/dist/commands/init.js +106 -0
  20. package/dist/commands/launch.d.ts +10 -2
  21. package/dist/commands/launch.js +14 -3
  22. package/dist/commands/restore/index.js +6 -0
  23. package/dist/commands/restore/posts.d.ts +1 -1
  24. package/dist/commands/restore/posts.js +17 -17
  25. package/dist/commands/restore/resume-command.d.ts +0 -1
  26. package/dist/commands/restore/resume-command.js +0 -3
  27. package/dist/commands/send.js +1 -2
  28. package/dist/commands/spec.d.ts +8 -6
  29. package/dist/commands/spec.js +66 -9
  30. package/dist/commands/task/gate-exec.d.ts +5 -1
  31. package/dist/commands/task/gate-exec.js +29 -5
  32. package/dist/commands/task/notify.d.ts +1 -1
  33. package/dist/commands/task/notify.js +1 -1
  34. package/dist/commands/task/plan.d.ts +1 -0
  35. package/dist/commands/task/plan.js +1 -1
  36. package/dist/commands/task/query.d.ts +2 -0
  37. package/dist/commands/task/query.js +11 -2
  38. package/dist/commands/templates.d.ts +32 -0
  39. package/dist/commands/templates.js +120 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.js +7 -0
  42. package/dist/orca/client.js +27 -9
  43. package/dist/orca/types.d.ts +6 -0
  44. package/dist/registry/registry.d.ts +20 -0
  45. package/dist/registry/registry.js +35 -2
  46. package/dist/server/api-read.js +55 -37
  47. package/dist/server/api-spec.js +43 -7
  48. package/dist/server/api-write.d.ts +86 -2
  49. package/dist/server/api-write.js +285 -12
  50. package/dist/server/index.js +14 -6
  51. package/dist/server/sse.js +22 -10
  52. package/dist/spec/doc.d.ts +13 -0
  53. package/dist/spec/doc.js +62 -0
  54. package/dist/spec/draft.d.ts +16 -0
  55. package/dist/spec/draft.js +15 -8
  56. package/dist/spec/edit.d.ts +21 -0
  57. package/dist/spec/edit.js +136 -0
  58. package/dist/spec/facade.d.ts +41 -0
  59. package/dist/spec/facade.js +68 -0
  60. package/dist/spec/load.d.ts +1 -0
  61. package/dist/spec/load.js +7 -6
  62. package/dist/spec/plan-change.js +4 -2
  63. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ import type { AuditTable } from './audit.js';
2
+ import type { CommandDeps } from './commands/deps.js';
3
+ /** 审计表里命令的最小形状:吃 deps 打头,后面参数不限于一个。 */
4
+ type AnyCommand = (deps: CommandDeps, ...a: any[]) => Promise<unknown>;
5
+ /** 取一张按调用方命令集定型的审计表;内容始终是同一张表。 */
6
+ export declare function coreAuditTable<C extends {
7
+ [K in keyof C]: (deps: CommandDeps, ...a: any[]) => Promise<unknown>;
8
+ }>(): AuditTable<C>;
9
+ /** 宽松版:给不想定型的一方(如插件 worker)用。 */
10
+ export declare const CORE_AUDIT: AuditTable<Record<string, AnyCommand>>;
11
+ export {};
@@ -0,0 +1,85 @@
1
+ /** 地址参数取 `@` 之后的团队名;解析不出(没有 `@`)记 `-`。 */
2
+ const ofAddress = (a) => a.address.split('@')[1] ?? '-';
3
+ /** 任务 id 的团队名从注册表查;查不到(任务不存在、已被清理)记 `-`。 */
4
+ const byTask = (idOf) => (_a, ctx) => ctx.deps.registry.getTaskExt(idOf(_a))?.team ?? '-';
5
+ const byGate = (gateId) => (_a, ctx) => ctx.deps.registry.getGate(gateId(_a))?.team ?? '-';
6
+ /** 只有结果里才有团队名(up 成功时);失败或没跑出结果记 `-`。 */
7
+ const byResult = (_a, ctx) => ctx.result?.team ?? '-';
8
+ const id = (a) => a.id;
9
+ const ENTRIES = {
10
+ init: {
11
+ command: 'init',
12
+ team: byResult,
13
+ summary: (a, ctx) => ({ mode: a.mode, template: a.template, created: ctx.result?.created })
14
+ },
15
+ up: {
16
+ command: 'up',
17
+ team: byResult,
18
+ summary: (a) => ({ adoptRun: a.adoptRun, newRun: a.newRun, draft: a.draft })
19
+ },
20
+ down: { command: 'down', team: (a) => a.team, summary: (a) => ({ close: a.close }) },
21
+ restore: {
22
+ command: 'restore',
23
+ team: (a) => a.team,
24
+ skip: (a) => !!a.dryRun,
25
+ summary: (a) => ({ dryRun: !!a.dryRun, noTasks: !!a.noTasks, session: Object.keys(a.sessions ?? {}) })
26
+ },
27
+ send: { command: 'send', team: ofAddress, target: (a) => a.address, summary: (a) => ({ message: a.message }) },
28
+ createTask: {
29
+ command: 'task create',
30
+ team: (a) => a.team,
31
+ target: (_a, ctx) => ctx.result?.id ?? null,
32
+ summary: (a) => ({ title: a.title, post: a.post, tags: a.tags, parent: a.parent, as: a.as })
33
+ },
34
+ claimTask: { command: 'task claim', team: byTask(id), target: id, summary: (a) => ({ as: a.as }) },
35
+ releaseTask: { command: 'task release', team: byTask(id), target: id, summary: (a) => ({ as: a.as }) },
36
+ reassignTask: { command: 'task reassign', team: byTask(id), target: id, summary: (a) => ({ to: a.to }) },
37
+ startTask: { command: 'task start', team: byTask(id), target: id, summary: (a) => ({ as: a.as }) },
38
+ planTask: { command: 'task plan', team: byTask(id), target: id, summary: (a) => ({ text: a.text }) },
39
+ doneTask: { command: 'task done', team: byTask(id), target: id, summary: (a) => ({ as: a.as, resumeEpoch: a.resumeEpoch }) },
40
+ escalateTask: {
41
+ command: 'task escalate',
42
+ team: byTask(id),
43
+ target: (a) => a.id,
44
+ summary: (a) => ({ reason: a.reason, reopen: a.reopen, as: a.as })
45
+ },
46
+ resolveGate: {
47
+ command: 'task resolve',
48
+ team: byGate((a) => a.gateId),
49
+ target: (a) => a.gateId,
50
+ summary: (a) => ({ resolution: a.resolution, reassign: a.reassign, as: a.as })
51
+ },
52
+ recoverTask: { command: 'task recover', team: byTask(id), target: (a) => a.id, summary: (a) => ({ as: a.as }) },
53
+ dispatcherUp: { command: 'dispatcher up', team: (a) => a.team, summary: (a) => ({ platform: a.platform }) },
54
+ dispatcherDown: { command: 'dispatcher down', team: (a) => a.team, summary: (a) => ({ close: a.close }) },
55
+ freezePostCmd: { command: 'dispatcher freeze', team: ofAddress, target: (a) => a.address, summary: (a) => ({ reason: a.reason }) },
56
+ unfreezePostCmd: { command: 'dispatcher unfreeze', team: ofAddress, target: (a) => a.address },
57
+ hushCmd: { command: 'dispatcher hush', team: ofAddress, target: (a) => a.address, summary: (a) => ({ forMs: a.forMs }) },
58
+ unhushCmd: { command: 'dispatcher unhush', team: ofAddress, target: (a) => a.address },
59
+ dispatcherReply: {
60
+ command: 'dispatcher reply',
61
+ team: (a) => a.team,
62
+ target: (a) => a.messageId,
63
+ summary: (a) => ({ body: a.body, again: a.again })
64
+ },
65
+ saveSpecCmd: {
66
+ command: 'spec save',
67
+ team: (a) => a.team,
68
+ target: (_a, ctx) => ctx.result?.path ?? null,
69
+ summary: (a, ctx) => {
70
+ const r = ctx.result;
71
+ return { baseHash: a.baseHash?.slice(0, 16), running: a.running, wrote: r?.wrote, hash: r?.hash?.slice(0, 16) };
72
+ }
73
+ },
74
+ discardDraftCmd: {
75
+ command: 'spec discard-draft',
76
+ team: (a) => a.team,
77
+ summary: (_a, ctx) => ({ discarded: ctx.result?.discarded })
78
+ }
79
+ };
80
+ /** 取一张按调用方命令集定型的审计表;内容始终是同一张表。 */
81
+ export function coreAuditTable() {
82
+ return ENTRIES;
83
+ }
84
+ /** 宽松版:给不想定型的一方(如插件 worker)用。 */
85
+ export const CORE_AUDIT = coreAuditTable();
package/dist/audit.js CHANGED
@@ -4,7 +4,7 @@ import { actorOf, resolveActor } from './commands/task/identity.js';
4
4
  export const WRITE_COMMANDS = [
5
5
  'up', 'down', 'restore', 'send', 'createTask', 'claimTask', 'releaseTask', 'reassignTask', 'startTask', 'planTask',
6
6
  'doneTask', 'escalateTask', 'resolveGate', 'recoverTask', 'dispatcherUp', 'dispatcherDown', 'freezePostCmd',
7
- 'unfreezePostCmd', 'hushCmd', 'unhushCmd', 'dispatcherReply',
7
+ 'unfreezePostCmd', 'hushCmd', 'unhushCmd', 'dispatcherReply', 'init',
8
8
  ];
9
9
  /** 换行与控制字符换成空格(一一对应,长度不变),超长截断并标明原长。 */
10
10
  const CONTROL = /[\x00-\x1f\x7f]/g;
@@ -1,12 +1,23 @@
1
1
  import type { ConsoleFile } from '../server/control.js';
2
2
  import type { CommandDeps } from './deps.js';
3
3
  import { type DispatcherHealth } from './dispatcher/lifecycle.js';
4
+ import { type AutostartStatus, type AutostartSys } from './autostart/index.js';
4
5
  export interface AboutItem {
5
6
  key: string;
6
7
  value: string | number | boolean | null;
7
8
  source: 'env' | 'default' | 'registry' | 'file' | 'orca';
8
9
  note?: string;
9
10
  }
11
+ export type AboutAutostart = ({
12
+ supported: true;
13
+ } & AutostartStatus & {
14
+ platform: string;
15
+ user?: string;
16
+ logPath: string;
17
+ }) | {
18
+ supported: false;
19
+ reason: string;
20
+ };
10
21
  export interface AboutTeam {
11
22
  name: string;
12
23
  specPath: string;
@@ -19,7 +30,9 @@ export declare function about(deps: CommandDeps, o: {
19
30
  version: string;
20
31
  consoleFile: ConsoleFile | null;
21
32
  env?: NodeJS.ProcessEnv;
33
+ autostartSys?: AutostartSys;
22
34
  }): Promise<{
23
35
  items: AboutItem[];
24
36
  teams: AboutTeam[];
37
+ autostart: AboutAutostart;
25
38
  }>;
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { WinglineError } from '../errors.js';
4
4
  import { draftPathOf } from '../spec/draft.js';
5
5
  import { dispatcherHealth } from './dispatcher/lifecycle.js';
6
+ import { autostartStatusOf } from './autostart/index.js';
7
+ import { realAutostartSys } from './autostart/sys.js';
6
8
  import { inRange } from './version-range.js';
7
9
  /** 目录下所有文件的字节数之和;目录不存在算 0。 */
8
10
  function dirBytes(dir) {
@@ -57,6 +59,15 @@ function consoleItems(f) {
57
59
  { key: 'console.startedAt', value: f.startedAt, source: 'file' },
58
60
  ];
59
61
  }
62
+ /** 开机自启状态:取不到(平台不支持 / 权限不足)时显式说原因,不抛错。 */
63
+ async function autostartItem(deps, sys) {
64
+ try {
65
+ return { supported: true, ...(await autostartStatusOf(deps, sys)) };
66
+ }
67
+ catch (err) {
68
+ return { supported: false, reason: err instanceof Error ? err.message : String(err) };
69
+ }
70
+ }
60
71
  /** 控制台“关于与诊断”页的数据:只读,取不到的项目显式标 null,不影响其余各项。 */
61
72
  export async function about(deps, o) {
62
73
  const env = o.env ?? deps.env;
@@ -80,5 +91,5 @@ export async function about(deps, o) {
80
91
  draft: fs.existsSync(draftPathOf(t.specPath)),
81
92
  dispatcher: dispatcherHealth(deps, t.name).state,
82
93
  }));
83
- return { items, teams };
94
+ return { items, teams, autostart: await autostartItem(deps, o.autostartSys ?? realAutostartSys()) };
84
95
  }
@@ -0,0 +1,3 @@
1
+ import type { AutostartSys } from './index.js';
2
+ /** 控制台服务在 core 内,需要同样的宿主信息:系统命令以参数数组启动(不经 shell),入口脚本取符号链接解析后的真实路径。 */
3
+ export declare function realAutostartSys(): AutostartSys;
@@ -0,0 +1,23 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ /** 控制台服务在 core 内,需要同样的宿主信息:系统命令以参数数组启动(不经 shell),入口脚本取符号链接解析后的真实路径。 */
6
+ export function realAutostartSys() {
7
+ const exec = (file, args) => new Promise((resolve, reject) => {
8
+ const child = spawn(file, args, { shell: false });
9
+ let stdout = '';
10
+ let stderr = '';
11
+ child.stdout.on('data', (d) => { stdout += String(d); });
12
+ child.stderr.on('data', (d) => { stderr += String(d); });
13
+ child.on('error', reject);
14
+ child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
15
+ });
16
+ const entry = process.argv[1] ?? '';
17
+ let script = path.resolve(entry);
18
+ try {
19
+ script = fs.realpathSync(entry);
20
+ }
21
+ catch { /* 保持 resolve 的结果 */ }
22
+ return { platform: process.platform, homeDir: os.homedir(), uid: process.getuid?.(), user: os.userInfo().username, node: process.execPath, script, exec, fs };
23
+ }
@@ -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)。
@@ -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 events = deps.registry.listPatrolEvents(team, { limit: 500 });
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
- const events = registry.listPatrolEvents(team, { limit: 500 });
98
- const last = (match) => {
99
- const e = events.findLast((x) => match(x.action));
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: last((a) => a.startsWith('check_')),
108
- lastScan: last((a) => a === 'scan' || a === 'scan_skipped'),
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.frozen.length + report.signals.length + report.questions.length + report.stale.length,
112
- warnings: groupWarnings(events.filter((e) => e.at >= since && WARNING_ACTIONS.has(e.action))),
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);
@@ -21,6 +21,10 @@ export declare function dispatcherLaunchCommand(a: {
21
21
  export interface UpArgs {
22
22
  team: string;
23
23
  platform?: NodeJS.Platform;
24
+ /** 覆盖启动命令里的 node 可执行文件。插件 worker 的 process.execPath 是宿主(Orca)的二进制,必须传真实 node。 */
25
+ node?: string;
26
+ /** 覆盖启动命令里的 wingline 入口脚本。插件等宿主进程的 argv[1] 不是 wingline,必须显式传。 */
27
+ script?: string;
24
28
  confirmMs?: number;
25
29
  pollMs?: number;
26
30
  sleep?: (ms: number) => Promise<void>;
@@ -62,7 +62,13 @@ export async function dispatcherUp(deps, a) {
62
62
  }
63
63
  const platform = a.platform ?? process.platform;
64
64
  const command = dispatcherLaunchCommand({
65
- platform, node: process.execPath, script: process.argv[1] ?? '', team: a.team, home: deps.home, orca: deps.orcaPath, nodeOnPath: platform === 'win32' ? nodeOnPath(deps) : undefined,
65
+ platform,
66
+ node: a.node ?? process.execPath,
67
+ script: a.script ?? process.argv[1] ?? '',
68
+ team: a.team,
69
+ home: deps.home,
70
+ orca: deps.orcaPath,
71
+ nodeOnPath: platform === 'win32' ? nodeOnPath(deps) : undefined,
66
72
  });
67
73
  const lead = deps.registry.listPosts(a.team).find((p) => p.isLead);
68
74
  const { handle } = await deps.orca.terminalCreate({ worktree: lead.worktree, title: address, command });
@@ -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);
@@ -8,6 +8,7 @@ async function closePost(deps, post, specHash) {
8
8
  const located = await locatePost(orca, address);
9
9
  if (!located) {
10
10
  registry.setPostStatus(address, 'closed');
11
+ registry.finalizePostRequests(post.team, address.split('@')[0]);
11
12
  return { address, outcome: 'already_gone' };
12
13
  }
13
14
  const id = nextRequestId(registry, post.team, address.split('@')[0], 'close', specHash);
@@ -18,6 +19,8 @@ async function closePost(deps, post, specHash) {
18
19
  });
19
20
  registry.finishRequest(id, ptyKilled ? 'done' : 'uncertain', { ptyKilled });
20
21
  registry.setPostStatus(address, ptyKilled ? 'closed' : 'uncertain');
22
+ if (ptyKilled)
23
+ registry.finalizePostRequests(post.team, address.split('@')[0]);
21
24
  return ptyKilled ? { address, outcome: 'closed' } : { address, outcome: 'uncertain', reason: 'Orca 未确认进程已退出' };
22
25
  }
23
26
  catch (err) {
@@ -0,0 +1,17 @@
1
+ import { type CommandDeps } from './deps.js';
2
+ import { type TeamTemplateId } from './templates.js';
3
+ export type InitTeamMode = 'template' | 'import';
4
+ export interface InitTeamArgs {
5
+ mode: InitTeamMode;
6
+ name?: string;
7
+ repo?: string;
8
+ template?: TeamTemplateId;
9
+ dir?: string;
10
+ specPath?: string;
11
+ }
12
+ export interface InitTeamResult {
13
+ team: string;
14
+ specPath: string;
15
+ created: boolean;
16
+ }
17
+ export declare function initTeamCmd(deps: CommandDeps, a: InitTeamArgs): Promise<InitTeamResult>;
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { WinglineError } from '../errors.js';
4
+ import { specHash } from '../spec/hash.js';
5
+ import { loadTeamSpec } from '../spec/load.js';
6
+ import { postAddress } from './deps.js';
7
+ import { TEAM_TEMPLATES, renderBrief, renderNorms, renderTeamYaml, } from './templates.js';
8
+ const TEAM_NAME_RE = /^[a-z0-9-]+$/;
9
+ function writeTemplate(targetDir, tpl, name, repo) {
10
+ const briefsDir = path.join(targetDir, 'briefs');
11
+ fs.mkdirSync(briefsDir, { recursive: true });
12
+ const normsPath = path.join(targetDir, 'NORMS.md');
13
+ if (!fs.existsSync(normsPath)) {
14
+ fs.writeFileSync(normsPath, renderNorms({ team: name, posts: tpl.posts.map((p) => p.id) }), 'utf8');
15
+ }
16
+ for (const p of tpl.posts) {
17
+ const briefPath = path.join(targetDir, p.brief);
18
+ fs.mkdirSync(path.dirname(briefPath), { recursive: true });
19
+ if (!fs.existsSync(briefPath)) {
20
+ fs.writeFileSync(briefPath, renderBrief(p.role, { team: name, post: p.id }), 'utf8');
21
+ }
22
+ }
23
+ const specPath = path.join(targetDir, 'team.yaml');
24
+ fs.writeFileSync(specPath, renderTeamYaml(tpl, { name, repo }), 'utf8');
25
+ return specPath;
26
+ }
27
+ function registerSpec(deps, spec) {
28
+ const hash = specHash(spec);
29
+ deps.registry.upsertTeam({
30
+ name: spec.name,
31
+ specPath: spec.specPath,
32
+ specHash: hash,
33
+ runId: null,
34
+ coordinatorHandle: null,
35
+ status: 'stopped',
36
+ });
37
+ for (const p of spec.posts) {
38
+ deps.registry.upsertPost({
39
+ address: postAddress(p.id, spec.name),
40
+ team: spec.name,
41
+ agent: p.agent,
42
+ worktree: p.worktree,
43
+ handle: null,
44
+ status: 'offline',
45
+ isLead: p.id === spec.lead,
46
+ sessionMarker: null,
47
+ sessionId: null,
48
+ sessionAt: null,
49
+ });
50
+ }
51
+ }
52
+ function initFromTemplate(deps, a) {
53
+ const name = a.name ?? '';
54
+ if (!TEAM_NAME_RE.test(name)) {
55
+ throw new WinglineError('input', 'team_name_invalid', '团队名只能用小写字母、数字与连字符');
56
+ }
57
+ const rawRepo = a.repo?.trim() ?? '';
58
+ if (!rawRepo || /[\x00-\x1f\x7f]/.test(a.repo ?? '')) {
59
+ throw new WinglineError('input', 'invalid_param', rawRepo ? '代码库目录不能包含控制字符' : '缺少代码库目录');
60
+ }
61
+ const tplId = a.template ?? 'duo';
62
+ const tpl = TEAM_TEMPLATES[tplId];
63
+ if (!tpl) {
64
+ throw new WinglineError('input', 'invalid_param', `未知团队模板:${String(tplId)}`);
65
+ }
66
+ const targetDir = a.dir ? path.resolve(a.dir) : path.join(deps.home, 'teams', name);
67
+ const specPath = path.join(targetDir, 'team.yaml');
68
+ const repo = rawRepo.startsWith('path:') ? rawRepo : `path:${rawRepo}`;
69
+ const existing = deps.registry.getTeam(name);
70
+ if (existing) {
71
+ if (existing.specPath === specPath && fs.existsSync(specPath)) {
72
+ return { team: name, specPath, created: false };
73
+ }
74
+ throw new WinglineError('input', 'team_already_exists', `团队 ${name} 已存在(${existing.specPath})`);
75
+ }
76
+ if (fs.existsSync(specPath)) {
77
+ throw new WinglineError('input', 'spec_already_exists', `配置文件已存在:${specPath},请改用“导入已有配置”`);
78
+ }
79
+ writeTemplate(targetDir, tpl, name, repo);
80
+ const spec = loadTeamSpec(specPath);
81
+ registerSpec(deps, spec);
82
+ return { team: name, specPath, created: true };
83
+ }
84
+ function initFromImport(deps, a) {
85
+ const rawPath = a.specPath?.trim() ?? '';
86
+ if (!rawPath) {
87
+ throw new WinglineError('input', 'invalid_param', '缺少配置文件路径');
88
+ }
89
+ const spec = loadTeamSpec(rawPath);
90
+ const existing = deps.registry.getTeam(spec.name);
91
+ if (existing) {
92
+ if (existing.specPath === spec.specPath) {
93
+ return { team: spec.name, specPath: spec.specPath, created: false };
94
+ }
95
+ throw new WinglineError('input', 'team_already_exists', `同名团队 ${spec.name} 已登记在另一路径:${existing.specPath}`);
96
+ }
97
+ registerSpec(deps, spec);
98
+ return { team: spec.name, specPath: path.resolve(rawPath), created: true };
99
+ }
100
+ export async function initTeamCmd(deps, a) {
101
+ if (a.mode === 'template')
102
+ return initFromTemplate(deps, a);
103
+ if (a.mode === 'import')
104
+ return initFromImport(deps, a);
105
+ throw new WinglineError('input', 'invalid_param', `未知初始化模式:${String(a.mode)}`);
106
+ }
@@ -7,14 +7,22 @@ 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;
14
13
  value: T;
15
14
  }>;
16
15
  export declare const stepDone: (reg: Registry, id: string) => boolean;
17
- /** 终端被关后重新拉起要换新 id,否则 Orca 会按旧 id 回放旧回执。 */
16
+ /**
17
+ * 终端被关后重新拉起要换新 id,否则 Orca 会按旧 id 回放旧回执。
18
+ *
19
+ * 两段推进:
20
+ * ① brief 段(原语义,located 时也生效):create+brief 都 done 才推进——
21
+ * at_prompt 拒发后 brief 行缺失,重跑必须复用同一代把 brief 补发。
22
+ * ② 重启段(仅 !located,终端已消失):create+wait 都 done 的代整代跳过——
23
+ * 没有 brief 行的岗位(wait 未确认就中断,brief 从未发生)原来会永远卡在
24
+ * 「create 已 done 的代」上,beginRequest 撞 request_already_done。
25
+ */
18
26
  export declare function lifetimeGen(l: Launch, post: string, located: boolean): number;
19
27
  /** 会话标记 `WLS-` + 8 位小写十六进制;restore 靠它引号精确搜索找回岗位会话(spec §4)。 */
20
28
  export declare function newSessionMarker(rand?: () => string): string;
@@ -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);
@@ -22,12 +21,24 @@ export async function recorded(reg, id, fn, ok = () => true) {
22
21
  return { confirmed, value };
23
22
  }
24
23
  export const stepDone = (reg, id) => reg.getRequest(id)?.state === 'done';
25
- /** 终端被关后重新拉起要换新 id,否则 Orca 会按旧 id 回放旧回执。 */
24
+ /**
25
+ * 终端被关后重新拉起要换新 id,否则 Orca 会按旧 id 回放旧回执。
26
+ *
27
+ * 两段推进:
28
+ * ① brief 段(原语义,located 时也生效):create+brief 都 done 才推进——
29
+ * at_prompt 拒发后 brief 行缺失,重跑必须复用同一代把 brief 补发。
30
+ * ② 重启段(仅 !located,终端已消失):create+wait 都 done 的代整代跳过——
31
+ * 没有 brief 行的岗位(wait 未确认就中断,brief 从未发生)原来会永远卡在
32
+ * 「create 已 done 的代」上,beginRequest 撞 request_already_done。
33
+ */
26
34
  export function lifetimeGen(l, post, located) {
27
35
  const done = (step, g) => stepDone(l.deps.registry, requestId(l.spec.name, post, step, l.hash, g));
28
36
  let g = 0;
29
37
  while (done('create', g) && done('brief', g))
30
38
  g++;
39
+ if (!located)
40
+ while (done('create', g) && done('wait', g))
41
+ g++;
31
42
  if (!located && done('create', g))
32
43
  g++;
33
44
  return g;
@@ -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, _located: boolean): number;
24
+ export declare function restoreGen(reg: Registry, team: string, post: string): number;
25
25
  /**
26
26
  * 远端岗位的接回提示带一个每次接回唯一的标识(`WLR-` 加 8 位十六进制,由该次接回的请求键哈希得到,重试时不变)。
27
27
  * 提示原文每次相同,原会话里可能早有旧的同样的提示;不带标识,丢了 `--resume` 的这一次会被旧提示误判为通过。