@co0ontty/wand 4.82.0 → 4.83.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/README.md +47 -9
- package/dist/agent-dispatch.d.ts +3 -2
- package/dist/agent-dispatch.js +19 -3
- package/dist/ai-team-delivery-types.d.ts +40 -0
- package/dist/ai-team-delivery-types.js +1 -0
- package/dist/ai-team-delivery.d.ts +13 -0
- package/dist/ai-team-delivery.js +89 -0
- package/dist/ai-team-employee-binding.d.ts +16 -0
- package/dist/ai-team-employee-binding.js +77 -0
- package/dist/ai-team-prompts.js +5 -0
- package/dist/ai-team-runner.d.ts +9 -2
- package/dist/ai-team-runner.js +52 -7
- package/dist/ai-team-types.d.ts +17 -3
- package/dist/ai-team-types.js +43 -3
- package/dist/build-info.json +3 -3
- package/dist/cli.js +36 -0
- package/dist/config.d.ts +18 -10
- package/dist/config.js +63 -105
- package/dist/decision-client.d.ts +4 -0
- package/dist/decision-client.js +91 -0
- package/dist/decision-runner.d.ts +9 -0
- package/dist/decision-runner.js +36 -0
- package/dist/decision-service.d.ts +46 -0
- package/dist/decision-service.js +230 -0
- package/dist/decision-skill.d.ts +6 -0
- package/dist/decision-skill.js +70 -0
- package/dist/decision-tool.d.ts +21 -0
- package/dist/decision-tool.js +346 -0
- package/dist/decision-types.d.ts +36 -0
- package/dist/decision-types.js +123 -0
- package/dist/decision-worker.py +86 -0
- package/dist/default-employee.d.ts +4 -1
- package/dist/default-employee.js +11 -1
- package/dist/env-utils.js +3 -0
- package/dist/git-quick-commit.js +7 -1
- package/dist/message-truncator.d.ts +1 -1
- package/dist/message-truncator.js +29 -22
- package/dist/models.d.ts +1 -0
- package/dist/models.js +31 -4
- package/dist/process-manager.d.ts +39 -1
- package/dist/process-manager.js +112 -8
- package/dist/provider-catalog.d.ts +47 -0
- package/dist/provider-catalog.js +138 -0
- package/dist/provider-cli-updater.d.ts +1 -1
- package/dist/pty-foreground.d.ts +37 -0
- package/dist/pty-foreground.js +56 -0
- package/dist/pty-turn-activity.d.ts +6 -5
- package/dist/pty-turn-activity.js +6 -5
- package/dist/retention.d.ts +29 -0
- package/dist/retention.js +142 -0
- package/dist/server-ai-team-routes.d.ts +2 -1
- package/dist/server-ai-team-routes.js +27 -9
- package/dist/server-decision-routes.d.ts +10 -0
- package/dist/server-decision-routes.js +52 -0
- package/dist/server-employee-routes.js +9 -1
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +97 -1
- package/dist/server-settings-routes.js +10 -36
- package/dist/server-update-routes.js +2 -1
- package/dist/server-workspace-routes.js +8 -0
- package/dist/server.js +37 -5
- package/dist/session-completion-state.d.ts +26 -0
- package/dist/session-completion-state.js +30 -0
- package/dist/session-completion.d.ts +12 -0
- package/dist/session-completion.js +62 -0
- package/dist/session-provider.d.ts +1 -15
- package/dist/session-provider.js +4 -54
- package/dist/session-registry.d.ts +5 -0
- package/dist/session-registry.js +19 -1
- package/dist/session-transport.js +2 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +119 -16
- package/dist/structured-client-protocol.js +36 -1
- package/dist/structured-provider-common.js +3 -3
- package/dist/structured-session-manager.d.ts +13 -1
- package/dist/structured-session-manager.js +196 -27
- package/dist/system-employee.js +1 -0
- package/dist/task-types.d.ts +4 -2
- package/dist/tool-preview.d.ts +8 -0
- package/dist/tool-preview.js +158 -0
- package/dist/types.d.ts +35 -2
- package/dist/web-ui/content/ai-teams.js +13 -13
- package/dist/web-ui/content/scripts.js +66 -64
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +3 -0
- package/dist/web-ui/embedded-assets.js +3 -3
- package/dist/web-ui/provider-identity.d.ts +7 -13
- package/dist/web-ui/provider-identity.js +8 -71
- package/package.json +3 -2
- package/skills/wand-decision/SKILL.md +61 -0
- package/skills/wand-decision/scripts/decide.mjs +16 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider 的唯一真源:受支持的 CLI 列表、展示名、别名表与推断规则。
|
|
3
|
+
*
|
|
4
|
+
* 这个模块必须保持"纯数据 + 纯函数",不能 import 任何 `node:*`:
|
|
5
|
+
* 服务端(`src/session-provider.ts`)与浏览器 bundle(`src/web-ui/provider-identity.ts`)
|
|
6
|
+
* 都直接复用它,避免同一个 provider 概念散落在两处各写一份。
|
|
7
|
+
*/
|
|
8
|
+
/** 全部受支持的会话 provider;顺序即 UI / 派发选择器里的展示顺序。 */
|
|
9
|
+
export const SESSION_PROVIDERS = ["claude", "codex", "opencode", "grok", "qoder", "pi", "gemini"];
|
|
10
|
+
/**
|
|
11
|
+
* 拥有原生思考档位(`provider:level`)的 provider。
|
|
12
|
+
* Gemini CLI 没有档位开关,所以不出现在这里:`gemini:max` 这种值不应该被当成合法档位保留。
|
|
13
|
+
*/
|
|
14
|
+
export const NATIVE_THINKING_EFFORT_PROVIDERS = ["claude", "codex", "opencode", "grok", "qoder", "pi"];
|
|
15
|
+
/** 原生思考档位的 `provider:level` 形状;各端必须共用同一张 provider 名单。 */
|
|
16
|
+
export const NATIVE_THINKING_EFFORT_PATTERN = `^(?:${NATIVE_THINKING_EFFORT_PROVIDERS.join("|")}):[a-z0-9][a-z0-9_-]{0,31}$`;
|
|
17
|
+
/** 判断字符串是否为 `provider:level` 形状的原生思考档。 */
|
|
18
|
+
export function isNativeThinkingEffort(value) {
|
|
19
|
+
return typeof value === "string" && NATIVE_THINKING_EFFORT_RE.test(value);
|
|
20
|
+
}
|
|
21
|
+
const NATIVE_THINKING_EFFORT_RE = new RegExp(NATIVE_THINKING_EFFORT_PATTERN);
|
|
22
|
+
const SESSION_PROVIDER_SET = new Set(SESSION_PROVIDERS);
|
|
23
|
+
/** 运行时守卫:把配置、数据库、请求体里的任意值收敛成 SessionProvider。 */
|
|
24
|
+
export function isSessionProvider(value) {
|
|
25
|
+
return typeof value === "string" && SESSION_PROVIDER_SET.has(value);
|
|
26
|
+
}
|
|
27
|
+
/** provider 对应的 CLI 可执行文件名(Qoder 的 CLI 叫 qodercli)。 */
|
|
28
|
+
export function providerCliCommand(provider) {
|
|
29
|
+
return provider === "qoder" ? "qodercli" : provider;
|
|
30
|
+
}
|
|
31
|
+
/** provider 的展示名。缺省不认识的字符串一律回落到 "AI"。 */
|
|
32
|
+
export const PROVIDER_LABELS = {
|
|
33
|
+
claude: "Claude",
|
|
34
|
+
codex: "Codex",
|
|
35
|
+
opencode: "OpenCode",
|
|
36
|
+
grok: "Grok",
|
|
37
|
+
qoder: "Qoder",
|
|
38
|
+
pi: "Pi",
|
|
39
|
+
gemini: "Gemini",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* provider id 别名表:既接受用户/客户端可能写出的别名(`anthropic`、`open-code`),
|
|
43
|
+
* 也接受历史 runner 名(`claude-cli-print`、`codex-cli-exec` …),
|
|
44
|
+
* 供 `normalizeProviderId` 与 `inferProviderFromRunner` 共用。
|
|
45
|
+
*/
|
|
46
|
+
export const PROVIDER_ALIASES = {
|
|
47
|
+
anthropic: "claude",
|
|
48
|
+
claude: "claude",
|
|
49
|
+
"claude-cli": "claude",
|
|
50
|
+
"claude-cli-print": "claude",
|
|
51
|
+
// SDK 执行路径已移除;这里仅用于识别旧会话里的历史 runner 值。
|
|
52
|
+
"claude-sdk": "claude",
|
|
53
|
+
codex: "codex",
|
|
54
|
+
"codex-cli": "codex",
|
|
55
|
+
"codex-cli-exec": "codex",
|
|
56
|
+
grok: "grok",
|
|
57
|
+
"grok-cli": "grok",
|
|
58
|
+
"grok-cli-headless": "grok",
|
|
59
|
+
"open-code": "opencode",
|
|
60
|
+
open_code: "opencode",
|
|
61
|
+
opencode: "opencode",
|
|
62
|
+
"opencode-cli-run": "opencode",
|
|
63
|
+
qoder: "qoder",
|
|
64
|
+
"qoder-cli": "qoder",
|
|
65
|
+
"qoder-cli-print": "qoder",
|
|
66
|
+
qodercli: "qoder",
|
|
67
|
+
pi: "pi",
|
|
68
|
+
"pi-cli": "pi",
|
|
69
|
+
"pi-cli-json": "pi",
|
|
70
|
+
gemini: "gemini",
|
|
71
|
+
"gemini-cli": "gemini",
|
|
72
|
+
"gemini-cli-json": "gemini",
|
|
73
|
+
};
|
|
74
|
+
/** 归一化一个已持久化的 provider id 或 runner/别名;无法识别时返回 null。 */
|
|
75
|
+
export function normalizeProviderId(value) {
|
|
76
|
+
if (typeof value !== "string")
|
|
77
|
+
return null;
|
|
78
|
+
const normalized = value.trim().toLowerCase();
|
|
79
|
+
return normalized ? PROVIDER_ALIASES[normalized] ?? null : null;
|
|
80
|
+
}
|
|
81
|
+
/** 从 runner 名推断 provider;`pty` 等与 provider 无关的 runner 返回 undefined。 */
|
|
82
|
+
export function inferProviderFromRunner(runner) {
|
|
83
|
+
return normalizeProviderId(runner) ?? undefined;
|
|
84
|
+
}
|
|
85
|
+
/** 这些是包管理器式的启动前缀:`npx claude`、`pnpm dlx codex` 等要看到后面的真实可执行名。 */
|
|
86
|
+
const PACKAGE_RUNNER_PREFIXES = new Set(["npx", "pnpx", "bunx", "yarn", "pnpm", "corepack"]);
|
|
87
|
+
/** 包管理器的子命令(没有它们时,`pnpm dlx codex` 会误把 `dlx` 当可执行名)。 */
|
|
88
|
+
const PACKAGE_RUNNER_SUBCOMMANDS = new Set(["dlx", "exec", "x"]);
|
|
89
|
+
/** 拆出命令行第一个可执行 token,去掉引号、目录前缀与 Windows 可执行后缀。 */
|
|
90
|
+
function commandExecutableName(command) {
|
|
91
|
+
const match = command.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
|
92
|
+
const executablePath = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
|
93
|
+
return executablePath.split(/[\\/]/).pop()?.replace(/\.(?:cmd|exe)$/i, "") ?? "";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 从启动命令推断 provider;识别的是命令行里真正的可执行名。
|
|
97
|
+
*
|
|
98
|
+
* - `/opt/homebrew/bin/claude --resume abc` → claude
|
|
99
|
+
* - `open-code run` / `qodercli --print` → opencode / qoder
|
|
100
|
+
* - `npx claude` / `pnpm dlx codex` → 看包管理器后面的真实 CLI
|
|
101
|
+
*
|
|
102
|
+
* 参数与父目录名不能冒充别的 provider(`claude -p codex` 仍是 claude)。
|
|
103
|
+
*/
|
|
104
|
+
export function inferProviderFromCommand(command) {
|
|
105
|
+
if (typeof command !== "string")
|
|
106
|
+
return undefined;
|
|
107
|
+
const value = command.trim();
|
|
108
|
+
if (!value)
|
|
109
|
+
return undefined;
|
|
110
|
+
// 先把命令行按空白切成 token,跳过包管理器前缀与它自己的 flag(--yes 之类)。
|
|
111
|
+
const tokens = value.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
112
|
+
let index = 0;
|
|
113
|
+
if (PACKAGE_RUNNER_PREFIXES.has(commandExecutableName(tokens[0] ?? "").toLowerCase())) {
|
|
114
|
+
index = 1;
|
|
115
|
+
while (index < tokens.length) {
|
|
116
|
+
const token = tokens[index];
|
|
117
|
+
if (token.startsWith("-")) {
|
|
118
|
+
index += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (PACKAGE_RUNNER_SUBCOMMANDS.has(commandExecutableName(token).toLowerCase())) {
|
|
122
|
+
index += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return normalizeProviderId(commandExecutableName(tokens[index] ?? "")) ?? undefined;
|
|
129
|
+
}
|
|
130
|
+
/** 每个 provider 在自由文本里可能出现的写法,用于把命令名收敛成可读标签。 */
|
|
131
|
+
export function providerDisplayName(value) {
|
|
132
|
+
if (typeof value === "string" && value.trim().toLowerCase() === "terminal")
|
|
133
|
+
return "终端";
|
|
134
|
+
const provider = normalizeProviderId(value);
|
|
135
|
+
if (provider)
|
|
136
|
+
return PROVIDER_LABELS[provider];
|
|
137
|
+
return typeof value === "string" && value.trim() ? value.trim() : "AI";
|
|
138
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY 前台进程组探测。
|
|
3
|
+
*
|
|
4
|
+
* 非 Claude 的 provider CLI 都写在 PTY 里,Server 看不到它们的回合边界,
|
|
5
|
+
* 只能靠「输出活动」猜(见 `pty-turn-activity.ts`)。猜的前提是「CLI 还在前台」,
|
|
6
|
+
* 而这件事以前靠一次性启动标记 `providerCliActive` 判断:标记只在 CLI **第一次**
|
|
7
|
+
* 退出时被消费,之后同一个 PTY 里再启动 CLI(自更新重启、手动重跑)就再也认不出来,
|
|
8
|
+
* 会话会永久停在「不像在跑」的状态(ptyBusy 永远为 false),任务列表也就不显示运行中。
|
|
9
|
+
*
|
|
10
|
+
* 这里换成读内核事实:终端的前台进程组(`tpgid`)。CLI 在前台时
|
|
11
|
+
* `tpgid != shell 自己的 pgid`;CLI 退回提示符后两者相等。这个判断对任意 provider 成立,
|
|
12
|
+
* 且 CLI 每次重新启动都会自动重新成立,不需要任何一次性标记。
|
|
13
|
+
*
|
|
14
|
+
* 平台覆盖 darwin / linux(两边的 `ps` 都提供 `tpgid`)。其他平台返回 null,
|
|
15
|
+
* 调用方回退到旧的 providerCliActive 判定。
|
|
16
|
+
*/
|
|
17
|
+
export interface PtyForegroundRow {
|
|
18
|
+
pid: number;
|
|
19
|
+
pgid: number;
|
|
20
|
+
tpgid: number;
|
|
21
|
+
}
|
|
22
|
+
/** 解析 `ps -o pid=,pgid=,tpgid=` 的输出(空行与不可解析行直接跳过)。 */
|
|
23
|
+
export declare function parsePtyForegroundRows(stdout: string): PtyForegroundRow[];
|
|
24
|
+
/**
|
|
25
|
+
* shell 进程的终端上是否有别的进程组在前台。
|
|
26
|
+
* tpgid 为 0(没有控制终端)或等于 shell 自己的进程组时都是「CLI 不在前台」。
|
|
27
|
+
*/
|
|
28
|
+
export declare function cliIsForeground(row: PtyForegroundRow): boolean;
|
|
29
|
+
export interface SamplePtyForegroundsOptions {
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 采样若干 shell 进程的前台状态。返回 pid → 是否有前台 CLI 的映射:
|
|
35
|
+
* 输出里缺失的 pid 不会出现在结果里(调用方保留上次结论)。整体不可用时返回 null。
|
|
36
|
+
*/
|
|
37
|
+
export declare function samplePtyForegrounds(pids: readonly number[], options?: SamplePtyForegroundsOptions): Promise<Map<number, boolean> | null>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
|
|
3
|
+
/** 解析 `ps -o pid=,pgid=,tpgid=` 的输出(空行与不可解析行直接跳过)。 */
|
|
4
|
+
export function parsePtyForegroundRows(stdout) {
|
|
5
|
+
const rows = [];
|
|
6
|
+
for (const line of stdout.split("\n")) {
|
|
7
|
+
const parts = line.trim().split(/\s+/);
|
|
8
|
+
if (parts.length < 3)
|
|
9
|
+
continue;
|
|
10
|
+
const pid = Number(parts[0]);
|
|
11
|
+
const pgid = Number(parts[1]);
|
|
12
|
+
const tpgid = Number(parts[2]);
|
|
13
|
+
if (!Number.isInteger(pid) || !Number.isInteger(pgid) || !Number.isInteger(tpgid))
|
|
14
|
+
continue;
|
|
15
|
+
if (pid <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
rows.push({ pid, pgid, tpgid });
|
|
18
|
+
}
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* shell 进程的终端上是否有别的进程组在前台。
|
|
23
|
+
* tpgid 为 0(没有控制终端)或等于 shell 自己的进程组时都是「CLI 不在前台」。
|
|
24
|
+
*/
|
|
25
|
+
export function cliIsForeground(row) {
|
|
26
|
+
return row.tpgid > 0 && row.tpgid !== row.pgid;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 采样若干 shell 进程的前台状态。返回 pid → 是否有前台 CLI 的映射:
|
|
30
|
+
* 输出里缺失的 pid 不会出现在结果里(调用方保留上次结论)。整体不可用时返回 null。
|
|
31
|
+
*/
|
|
32
|
+
export function samplePtyForegrounds(pids, options = {}) {
|
|
33
|
+
const platform = options.platform ?? process.platform;
|
|
34
|
+
const wanted = pids.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
35
|
+
if (!SUPPORTED_PLATFORMS.has(platform) || wanted.length === 0)
|
|
36
|
+
return Promise.resolve(null);
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile("ps", ["-p", wanted.join(","), "-o", "pid=,pgid=,tpgid="], { timeout: options.timeoutMs ?? 2000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout) => {
|
|
39
|
+
const text = typeof stdout === "string" ? stdout : "";
|
|
40
|
+
const rows = parsePtyForegroundRows(text);
|
|
41
|
+
// `ps` 对含「已退出 pid」的列表会以非 0 退出码仍然打印存活行,所以先看解析结果;
|
|
42
|
+
// 一行都没有才算整体失败(例如 ps 不可用),交给调用方保留上次结论。
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
resolve(error ? null : new Map());
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const result = new Map();
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
if (!wanted.includes(row.pid))
|
|
50
|
+
continue;
|
|
51
|
+
result.set(row.pid, cliIsForeground(row));
|
|
52
|
+
}
|
|
53
|
+
resolve(result);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* bytes stop. So a turn is opened on submit and closed once the session has been
|
|
10
10
|
* silent for a quiet window, refreshed on every chunk.
|
|
11
11
|
*
|
|
12
|
-
* The principled
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* The principled input — is the CLI still in the foreground? — now comes from
|
|
13
|
+
* the kernel instead of a one-shot marker: `pty-foreground.ts` samples each
|
|
14
|
+
* live terminal's foreground process group (`tpgid`), so a CLI that exits and is
|
|
15
|
+
* started again in the same terminal (self-update, manual re-run) is recognised
|
|
16
|
+
* again. Output activity then only decides whether the current turn is still
|
|
17
|
+
* running. This module remains the quiet-window fallback for that last call.
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
19
20
|
* Whether a PTY input chunk is a line submit rather than body text.
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* bytes stop. So a turn is opened on submit and closed once the session has been
|
|
10
10
|
* silent for a quiet window, refreshed on every chunk.
|
|
11
11
|
*
|
|
12
|
-
* The principled
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* The principled input — is the CLI still in the foreground? — now comes from
|
|
13
|
+
* the kernel instead of a one-shot marker: `pty-foreground.ts` samples each
|
|
14
|
+
* live terminal's foreground process group (`tpgid`), so a CLI that exits and is
|
|
15
|
+
* started again in the same terminal (self-update, manual re-run) is recognised
|
|
16
|
+
* again. Output activity then only decides whether the current turn is still
|
|
17
|
+
* running. This module remains the quiet-window fallback for that last call.
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
19
20
|
* Whether a PTY input chunk is a line submit rather than body text.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SessionRegistry } from "./session-registry.js";
|
|
2
|
+
import type { WandStorage } from "./storage.js";
|
|
3
|
+
/**
|
|
4
|
+
* 统一保留期策略(会话与看板任务同一套):
|
|
5
|
+
* 1. 超过 IDLE 没有活动的会话 / 任务 → 自动归档;
|
|
6
|
+
* 2. 归档后经过 ARCHIVED 仍没被恢复 → 自动清理(删除)。
|
|
7
|
+
*
|
|
8
|
+
* 会话的自动归档此前散在两个 runner 里(24h),这里收敛成同一份常量,避免
|
|
9
|
+
* 「会话 24h、任务 7 天」两套口径。运行中的会话/任务永不归档或清理。
|
|
10
|
+
*/
|
|
11
|
+
export declare const RETENTION_IDLE_MS: number;
|
|
12
|
+
export declare const RETENTION_ARCHIVED_MS: number;
|
|
13
|
+
export interface RetentionDeps {
|
|
14
|
+
storage: WandStorage;
|
|
15
|
+
sessions: SessionRegistry;
|
|
16
|
+
}
|
|
17
|
+
export interface RetentionResult {
|
|
18
|
+
archivedSessions: number;
|
|
19
|
+
purgedSessions: number;
|
|
20
|
+
archivedTasks: number;
|
|
21
|
+
purgedTasks: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 跑一遍保留期清理。返回本轮的实际动作计数,便于日志与测试断言。
|
|
25
|
+
* 纯函数式编排:只依赖 storage / registry,方便在单测里注入固定时间。
|
|
26
|
+
*/
|
|
27
|
+
export declare function runRetentionSweep(deps: RetentionDeps, now?: number): RetentionResult;
|
|
28
|
+
/** 每小时的保留期扫描;返回清理定时器,调用方负责 clearInterval。 */
|
|
29
|
+
export declare function startRetentionTimer(deps: RetentionDeps, intervalMs?: number): NodeJS.Timeout;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { cleanupWorktreeSync } from "./git-worktree.js";
|
|
2
|
+
/**
|
|
3
|
+
* 统一保留期策略(会话与看板任务同一套):
|
|
4
|
+
* 1. 超过 IDLE 没有活动的会话 / 任务 → 自动归档;
|
|
5
|
+
* 2. 归档后经过 ARCHIVED 仍没被恢复 → 自动清理(删除)。
|
|
6
|
+
*
|
|
7
|
+
* 会话的自动归档此前散在两个 runner 里(24h),这里收敛成同一份常量,避免
|
|
8
|
+
* 「会话 24h、任务 7 天」两套口径。运行中的会话/任务永不归档或清理。
|
|
9
|
+
*/
|
|
10
|
+
export const RETENTION_IDLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
export const RETENTION_ARCHIVED_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
function timestamp(value) {
|
|
13
|
+
if (!value)
|
|
14
|
+
return null;
|
|
15
|
+
const parsed = Date.parse(value);
|
|
16
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 任务的「最后一次活动」:卡片自身的 updated_at、容器最后一次被打开的时间,
|
|
20
|
+
* 以及它名下会话的结束/开始时间,取三者里最新的一个。
|
|
21
|
+
*/
|
|
22
|
+
function taskLastActivity(storage, task, bound) {
|
|
23
|
+
const times = [];
|
|
24
|
+
const updated = timestamp(task?.updatedAt);
|
|
25
|
+
if (updated !== null)
|
|
26
|
+
times.push(updated);
|
|
27
|
+
const container = task?.workspaceTaskId ? storage.getWorkspaceTask(task.workspaceTaskId) : null;
|
|
28
|
+
const opened = timestamp(container?.lastOpenedAt);
|
|
29
|
+
if (opened !== null)
|
|
30
|
+
times.push(opened);
|
|
31
|
+
for (const session of bound) {
|
|
32
|
+
const reference = timestamp(session.endedAt ?? session.startedAt);
|
|
33
|
+
if (reference !== null)
|
|
34
|
+
times.push(reference);
|
|
35
|
+
}
|
|
36
|
+
return times.length > 0 ? Math.max(...times) : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 跑一遍保留期清理。返回本轮的实际动作计数,便于日志与测试断言。
|
|
40
|
+
* 纯函数式编排:只依赖 storage / registry,方便在单测里注入固定时间。
|
|
41
|
+
*/
|
|
42
|
+
export function runRetentionSweep(deps, now = Date.now()) {
|
|
43
|
+
const result = { archivedSessions: 0, purgedSessions: 0, archivedTasks: 0, purgedTasks: 0 };
|
|
44
|
+
const snapshots = deps.sessions.listSlim();
|
|
45
|
+
for (const session of snapshots) {
|
|
46
|
+
// 运行中的会话既不属于「没动」,也不能被杀掉。
|
|
47
|
+
if (session.status === "running")
|
|
48
|
+
continue;
|
|
49
|
+
if (session.archived) {
|
|
50
|
+
const archivedAt = timestamp(session.archivedAt);
|
|
51
|
+
if (archivedAt === null) {
|
|
52
|
+
// 历史归档行没有时间戳:先补上,让 7 天窗口从这一刻起算,而不是立刻删除。
|
|
53
|
+
try {
|
|
54
|
+
deps.sessions.setArchived(session.id, true);
|
|
55
|
+
}
|
|
56
|
+
catch { /* already gone */ }
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (now - archivedAt < RETENTION_ARCHIVED_MS)
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
deps.sessions.delete(session.id);
|
|
63
|
+
result.purgedSessions += 1;
|
|
64
|
+
}
|
|
65
|
+
catch { /* best-effort: a session may disappear between the read and the purge */ }
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const reference = timestamp(session.endedAt ?? session.startedAt);
|
|
69
|
+
if (reference === null || now - reference < RETENTION_IDLE_MS)
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
deps.sessions.setArchived(session.id, true);
|
|
73
|
+
result.archivedSessions += 1;
|
|
74
|
+
}
|
|
75
|
+
catch { /* best-effort */ }
|
|
76
|
+
}
|
|
77
|
+
const sessionsByTask = new Map();
|
|
78
|
+
for (const session of snapshots) {
|
|
79
|
+
const taskId = session.workspaceTaskId;
|
|
80
|
+
if (!taskId)
|
|
81
|
+
continue;
|
|
82
|
+
const bucket = sessionsByTask.get(taskId);
|
|
83
|
+
if (bucket)
|
|
84
|
+
bucket.push(session);
|
|
85
|
+
else
|
|
86
|
+
sessionsByTask.set(taskId, [session]);
|
|
87
|
+
}
|
|
88
|
+
for (const task of deps.storage.listWandTasks()) {
|
|
89
|
+
const bound = task.workspaceTaskId ? sessionsByTask.get(task.workspaceTaskId) ?? [] : [];
|
|
90
|
+
const running = bound.some((session) => session.status === "running");
|
|
91
|
+
if (task.status === "archived") {
|
|
92
|
+
if (running)
|
|
93
|
+
continue;
|
|
94
|
+
const archivedAt = timestamp(task.archivedAt);
|
|
95
|
+
if (archivedAt === null) {
|
|
96
|
+
// 历史归档行(归档时间尚未落库):补写时间戳,给用户留出恢复窗口。
|
|
97
|
+
deps.storage.updateWandTask(task.id, { status: "archived" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (now - archivedAt < RETENTION_ARCHIVED_MS)
|
|
101
|
+
continue;
|
|
102
|
+
const container = task.workspaceTaskId ? deps.storage.getWorkspaceTask(task.workspaceTaskId) : null;
|
|
103
|
+
if (container?.worktree) {
|
|
104
|
+
try {
|
|
105
|
+
cleanupWorktreeSync(container.worktree);
|
|
106
|
+
}
|
|
107
|
+
catch { /* never block cleanup */ }
|
|
108
|
+
}
|
|
109
|
+
deps.storage.deleteWandTask(task.id);
|
|
110
|
+
result.purgedTasks += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (running)
|
|
114
|
+
continue;
|
|
115
|
+
const lastActivity = taskLastActivity(deps.storage, task, bound);
|
|
116
|
+
if (lastActivity === null || now - lastActivity < RETENTION_IDLE_MS)
|
|
117
|
+
continue;
|
|
118
|
+
deps.storage.updateWandTask(task.id, { status: "archived" });
|
|
119
|
+
result.archivedTasks += 1;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** 每小时的保留期扫描;返回清理定时器,调用方负责 clearInterval。 */
|
|
124
|
+
export function startRetentionTimer(deps, intervalMs = 60 * 60 * 1000) {
|
|
125
|
+
const sweep = () => {
|
|
126
|
+
try {
|
|
127
|
+
const result = runRetentionSweep(deps);
|
|
128
|
+
const acted = result.archivedSessions + result.purgedSessions + result.archivedTasks + result.purgedTasks;
|
|
129
|
+
if (acted > 0) {
|
|
130
|
+
console.log(`[retention] archived sessions=${result.archivedSessions} tasks=${result.archivedTasks}; `
|
|
131
|
+
+ `purged sessions=${result.purgedSessions} tasks=${result.purgedTasks}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error(`[retention] sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
sweep();
|
|
139
|
+
const timer = setInterval(sweep, intervalMs);
|
|
140
|
+
timer.unref?.();
|
|
141
|
+
return timer;
|
|
142
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { Express } from "express";
|
|
2
2
|
import { type AiTeamRunner } from "./ai-team-runner.js";
|
|
3
3
|
import { type AiTeam } from "./ai-team-types.js";
|
|
4
|
+
import { type AiTeamEmployeeSource } from "./ai-team-employee-binding.js";
|
|
4
5
|
import type { WandStorage } from "./storage.js";
|
|
5
6
|
/** 团队页「直接开工」自动建卡的标记(§4.2 / Q3):看板靠它区分团队卡与手写卡。 */
|
|
6
7
|
export declare const TEAM_DIRECT_LABEL = "team_direct";
|
|
7
|
-
export declare function parseAiTeamInput(value: unknown, existing: AiTeam | null, now: string): AiTeam;
|
|
8
|
+
export declare function parseAiTeamInput(value: unknown, existing: AiTeam | null, now: string, source?: AiTeamEmployeeSource): AiTeam;
|
|
8
9
|
export declare function registerAiTeamRoutes(app: Express, deps: {
|
|
9
10
|
storage: WandStorage;
|
|
10
11
|
runner: AiTeamRunner;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { AiTeamConflictError } from "./ai-team-runner.js";
|
|
3
3
|
import { AI_TEAM_AVATAR_MAX_CHARS, AI_TEAM_DEFAULT_MAX_STEPS, AI_TEAM_MAX_CANDIDATES, AI_TEAM_MAX_MEMBERS, AI_TEAM_MAX_STEPS, AI_TEAM_MIN_MEMBERS, AI_TEAM_MIN_STEPS, agentKey, isTeamMemberRole, memberAgents, } from "./ai-team-types.js";
|
|
4
|
+
import { requireTeamEmployee } from "./ai-team-employee-binding.js";
|
|
4
5
|
import { asyncRoute } from "./express-async.js";
|
|
5
6
|
import { getErrorMessage } from "./error-utils.js";
|
|
6
7
|
import { defaultMilestoneIdForWrite, scopedMilestoneId } from "./milestone-scope.js";
|
|
@@ -37,7 +38,7 @@ function parseAvatar(value, name) {
|
|
|
37
38
|
throw new Error(`成员「${name}」的头像太大。`);
|
|
38
39
|
return avatar;
|
|
39
40
|
}
|
|
40
|
-
function parseMembers(value) {
|
|
41
|
+
function parseMembers(value, existing, source) {
|
|
41
42
|
if (!Array.isArray(value))
|
|
42
43
|
throw new Error("成员列表必须是数组。");
|
|
43
44
|
if (value.length < AI_TEAM_MIN_MEMBERS || value.length > AI_TEAM_MAX_MEMBERS) {
|
|
@@ -45,14 +46,27 @@ function parseMembers(value) {
|
|
|
45
46
|
}
|
|
46
47
|
const names = new Set();
|
|
47
48
|
const ids = new Set();
|
|
49
|
+
const employees = new Set();
|
|
48
50
|
const members = value.map((raw, index) => {
|
|
49
51
|
const body = bodyObject(raw);
|
|
50
|
-
const
|
|
52
|
+
const rawId = text(body.id);
|
|
53
|
+
const prior = existing?.members.find((member) => member.id === rawId);
|
|
54
|
+
if (body.employeeId !== undefined && body.employeeId !== null && typeof body.employeeId !== "string") {
|
|
55
|
+
throw new Error("员工 ID 必须是文字。");
|
|
56
|
+
}
|
|
57
|
+
const employeeId = body.employeeId === undefined ? prior?.employeeId : text(body.employeeId) || undefined;
|
|
58
|
+
if (employeeId && !source)
|
|
59
|
+
throw new Error("员工绑定需要真实员工来源。");
|
|
60
|
+
if (employeeId && employees.has(employeeId))
|
|
61
|
+
throw new Error("同一员工不能重复加入同一团队。");
|
|
62
|
+
if (employeeId)
|
|
63
|
+
employees.add(employeeId);
|
|
64
|
+
const employee = employeeId ? requireTeamEmployee(source, employeeId) : null;
|
|
65
|
+
const name = employee?.name ?? boundedText(body.name, `第 ${index + 1} 位成员的名字`, 1, 40);
|
|
51
66
|
const key = name.toLowerCase();
|
|
52
67
|
if (names.has(key))
|
|
53
68
|
throw new Error(`成员名字「${name}」重复。`);
|
|
54
69
|
names.add(key);
|
|
55
|
-
const rawId = text(body.id);
|
|
56
70
|
const id = /^m_[A-Za-z0-9]{1,32}$/.test(rawId) && !ids.has(rawId)
|
|
57
71
|
? rawId
|
|
58
72
|
: `m_${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
@@ -62,7 +76,10 @@ function parseMembers(value) {
|
|
|
62
76
|
throw new Error(`成员「${name}」的职责不能超过 2000 个字符。`);
|
|
63
77
|
let agents;
|
|
64
78
|
try {
|
|
65
|
-
if (
|
|
79
|
+
if (employee) {
|
|
80
|
+
agents = employee.agents.map((candidate) => ({ ...candidate }));
|
|
81
|
+
}
|
|
82
|
+
else if (Array.isArray(body.agents)) {
|
|
66
83
|
if (body.agents.length === 0)
|
|
67
84
|
throw new Error("至少需要一个执行候选。");
|
|
68
85
|
if (body.agents.length > AI_TEAM_MAX_CANDIDATES)
|
|
@@ -92,10 +109,11 @@ function parseMembers(value) {
|
|
|
92
109
|
throw new Error(`成员「${name}」的执行候选重复。`);
|
|
93
110
|
const member = {
|
|
94
111
|
id, name, duty, agents,
|
|
112
|
+
...(employeeId ? { employeeId } : {}),
|
|
95
113
|
// 服务端强制兼容字段 = 首选候选,不信任客户端传来的 agent。
|
|
96
114
|
agent: agents[0],
|
|
97
115
|
isLeader: body.isLeader === true,
|
|
98
|
-
avatar: parseAvatar(body.avatar, name),
|
|
116
|
+
avatar: employee?.avatar ?? parseAvatar(body.avatar, name),
|
|
99
117
|
};
|
|
100
118
|
if (isTeamMemberRole(body.role))
|
|
101
119
|
member.role = body.role;
|
|
@@ -105,7 +123,7 @@ function parseMembers(value) {
|
|
|
105
123
|
throw new Error("团队需要恰好一位负责人。");
|
|
106
124
|
return members;
|
|
107
125
|
}
|
|
108
|
-
export function parseAiTeamInput(value, existing, now) {
|
|
126
|
+
export function parseAiTeamInput(value, existing, now, source) {
|
|
109
127
|
const body = bodyObject(value);
|
|
110
128
|
const maxSteps = body.maxSteps === undefined ? existing?.maxSteps ?? AI_TEAM_DEFAULT_MAX_STEPS : Number(body.maxSteps);
|
|
111
129
|
if (!Number.isInteger(maxSteps) || maxSteps < AI_TEAM_MIN_STEPS || maxSteps > AI_TEAM_MAX_STEPS) {
|
|
@@ -122,7 +140,7 @@ export function parseAiTeamInput(value, existing, now) {
|
|
|
122
140
|
name: boundedText(body.name, "团队名", 1, 60),
|
|
123
141
|
description,
|
|
124
142
|
instructions,
|
|
125
|
-
members: parseMembers(body.members),
|
|
143
|
+
members: parseMembers(body.members, existing, source),
|
|
126
144
|
requirePlanApproval: typeof body.requirePlanApproval === "boolean"
|
|
127
145
|
? body.requirePlanApproval
|
|
128
146
|
: existing?.requirePlanApproval ?? true,
|
|
@@ -146,7 +164,7 @@ export function registerAiTeamRoutes(app, deps) {
|
|
|
146
164
|
});
|
|
147
165
|
app.post("/api/ai-teams", (req, res) => {
|
|
148
166
|
try {
|
|
149
|
-
const team = parseAiTeamInput(req.body, null, new Date().toISOString());
|
|
167
|
+
const team = parseAiTeamInput(req.body, null, new Date().toISOString(), storage);
|
|
150
168
|
storage.saveAiTeam(team);
|
|
151
169
|
res.status(201).json(team);
|
|
152
170
|
}
|
|
@@ -159,7 +177,7 @@ export function registerAiTeamRoutes(app, deps) {
|
|
|
159
177
|
const existing = storage.getAiTeam(req.params.id);
|
|
160
178
|
if (!existing)
|
|
161
179
|
throw new Error("团队不存在。");
|
|
162
|
-
const team = parseAiTeamInput(req.body, existing, new Date().toISOString());
|
|
180
|
+
const team = parseAiTeamInput(req.body, existing, new Date().toISOString(), storage);
|
|
163
181
|
storage.saveAiTeam(team);
|
|
164
182
|
notifyTeam(team.id);
|
|
165
183
|
res.json(team);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Express, RequestHandler } from "express";
|
|
2
|
+
import type { WandStorage } from "./storage.js";
|
|
3
|
+
import type { DecisionService } from "./decision-service.js";
|
|
4
|
+
/** Mounted before generic /api auth so inference-only capabilities cannot authorize any other route. */
|
|
5
|
+
export declare function registerDecisionRoutes(app: Express, deps: {
|
|
6
|
+
storage: WandStorage;
|
|
7
|
+
decisions: DecisionService;
|
|
8
|
+
requireAuth: RequestHandler;
|
|
9
|
+
requireSessions: RequestHandler;
|
|
10
|
+
}): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DecisionError } from "./decision-types.js";
|
|
2
|
+
/** Mounted before generic /api auth so inference-only capabilities cannot authorize any other route. */
|
|
3
|
+
export function registerDecisionRoutes(app, deps) {
|
|
4
|
+
const authorize = (req, res, next) => {
|
|
5
|
+
res.setHeader("Cache-Control", "no-store");
|
|
6
|
+
const header = req.headers.authorization ?? "";
|
|
7
|
+
const token = /^Bearer (wd_[A-Za-z0-9_-]{43})$/.exec(header)?.[1];
|
|
8
|
+
const sessionId = token ? deps.storage.resolveDecisionAccess(token) : null;
|
|
9
|
+
if (sessionId) {
|
|
10
|
+
res.locals.decisionCaller = `session:${sessionId}`;
|
|
11
|
+
next();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
deps.requireAuth(req, res, (error) => {
|
|
15
|
+
if (error) {
|
|
16
|
+
next(error);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
deps.requireSessions(req, res, (scopeError) => {
|
|
20
|
+
if (scopeError) {
|
|
21
|
+
next(scopeError);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
res.locals.decisionCaller = "authenticated-client";
|
|
25
|
+
next();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
app.get("/api/decisions/status", authorize, (_req, res) => {
|
|
30
|
+
res.json(deps.decisions.status());
|
|
31
|
+
});
|
|
32
|
+
app.post("/api/decisions/evaluate", authorize, async (req, res) => {
|
|
33
|
+
const abort = new AbortController();
|
|
34
|
+
const disconnected = () => { if (!res.writableEnded)
|
|
35
|
+
abort.abort(); };
|
|
36
|
+
res.once("close", disconnected);
|
|
37
|
+
try {
|
|
38
|
+
const result = await deps.decisions.evaluate(req.body, res.locals.decisionCaller, abort.signal);
|
|
39
|
+
if (!res.destroyed)
|
|
40
|
+
res.json(result);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (res.destroyed)
|
|
44
|
+
return;
|
|
45
|
+
const failure = error instanceof DecisionError ? error : new DecisionError("INTERNAL_ERROR", "决策请求失败。", 500);
|
|
46
|
+
res.status(failure.status).json({ error: failure.message, code: failure.code });
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
res.off("close", disconnected);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|