@william2000/dsh-nova-ui-task-board 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +5641 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +4873 -0
- package/lib/types/client/NovaTaskBoardSettingsCard.d.ts +17 -0
- package/lib/types/client/PlaceholderBoard.d.ts +9 -0
- package/lib/types/client/apply-guard.d.ts +25 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
- package/lib/types/client/board/ConfirmTaskDialog.d.ts +8 -0
- package/lib/types/client/board/ConvertTaskModal.d.ts +17 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -0
- package/lib/types/client/board/RequirementSplitModal.d.ts +14 -0
- package/lib/types/client/board/SplitConfirmDialog.d.ts +9 -0
- package/lib/types/client/board/TagEditor.d.ts +6 -0
- package/lib/types/client/board/TaskBoard.d.ts +9 -0
- package/lib/types/client/board/TaskCard.d.ts +52 -0
- package/lib/types/client/board/TaskDetail.d.ts +7 -0
- package/lib/types/client/board/status-key.d.ts +8 -0
- package/lib/types/client/board-controller.d.ts +274 -0
- package/lib/types/client/board-mount.d.ts +10 -0
- package/lib/types/client/chat-integration.d.ts +99 -0
- package/lib/types/client/drag-utils.d.ts +78 -0
- package/lib/types/client/filter.d.ts +42 -0
- package/lib/types/client/grouping.d.ts +54 -0
- package/lib/types/client/host-api.d.ts +62 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/legacy-store.d.ts +28 -0
- package/lib/types/client/locales.d.ts +202 -0
- package/lib/types/client/schedule-presets.d.ts +20 -0
- package/lib/types/client/sidebar-entry-core.d.ts +54 -0
- package/lib/types/client/sidebar-entry.d.ts +19 -0
- package/lib/types/core/context.d.ts +139 -0
- package/lib/types/core/cron.d.ts +54 -0
- package/lib/types/core/migrate.d.ts +39 -0
- package/lib/types/core/model.d.ts +446 -0
- package/lib/types/core/recovery.d.ts +25 -0
- package/lib/types/core/split.d.ts +81 -0
- package/lib/types/core/transitions.d.ts +436 -0
- package/lib/types/dsh-home.d.ts +21 -0
- package/lib/types/host-automation.d.ts +59 -0
- package/lib/types/host-ledger.d.ts +146 -0
- package/lib/types/host-routes.d.ts +65 -0
- package/lib/types/host-runner.d.ts +177 -0
- package/lib/types/host-service.d.ts +291 -0
- package/lib/types/index.d.ts +90 -0
- package/lib/types/loopback.d.ts +23 -0
- package/lib/types/mount-once.d.ts +9 -0
- package/lib/types/protocol.d.ts +210 -0
- package/package.json +89 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4873 @@
|
|
|
1
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import z from "schemastery";
|
|
3
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
//#region src/core/context.ts
|
|
9
|
+
/** 自动收集来源(§14.3:只进 backlog,必须人工 promote 才可执行)。 */
|
|
10
|
+
const AUTO_COLLECTED_SOURCES = ["github_issue", "bookmark_collector"];
|
|
11
|
+
/** 是否自动收集来源(人工审核闸门的判据之一)。 */
|
|
12
|
+
function isAutoCollected(task) {
|
|
13
|
+
return task.source !== void 0 && AUTO_COLLECTED_SOURCES.includes(task.source);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 清洗文本(§14.3):剥离控制字符(保留 \n \t)、trim、长度封顶。
|
|
17
|
+
* 用于自动收集的标题/描述、评论正文等一切外部输入。
|
|
18
|
+
*/
|
|
19
|
+
function sanitizeCollectedText(text, maxLength = 4e3) {
|
|
20
|
+
if (typeof text !== "string") return "";
|
|
21
|
+
return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, maxLength);
|
|
22
|
+
}
|
|
23
|
+
/** 收集去重键(§14.1/14.2):同来源同 URL 视为同一条外部内容。 */
|
|
24
|
+
function collectorDedupKey(source, metadata) {
|
|
25
|
+
const url = metadata?.url;
|
|
26
|
+
return url === void 0 || url === "" ? void 0 : `${source}:${url}`;
|
|
27
|
+
}
|
|
28
|
+
/** 账本中是否已存在同 (source, url) 的收集任务(去重判定,Host 落账前调用)。 */
|
|
29
|
+
function hasCollected(tasks, source, metadata) {
|
|
30
|
+
const key = collectorDedupKey(source, metadata);
|
|
31
|
+
if (key === void 0) return false;
|
|
32
|
+
return tasks.some((task) => task.source === source && task.metadata?.url !== void 0 && collectorDedupKey(source, task.metadata) === key);
|
|
33
|
+
}
|
|
34
|
+
/** GitHub 标签命中判定(§14.1 过滤器):规则标签非空时,Issue 至少命中一个(大小写不敏感)。 */
|
|
35
|
+
function matchesGithubLabels(issueLabels, ruleLabels) {
|
|
36
|
+
if (ruleLabels.length === 0) return true;
|
|
37
|
+
const wanted = ruleLabels.map((label) => label.toLowerCase());
|
|
38
|
+
return (issueLabels ?? []).some((label) => wanted.includes(label.toLowerCase()));
|
|
39
|
+
}
|
|
40
|
+
/** 从 Issue 载荷/API 响应的 labels 提取标签名数组(string 或 {name} 两种形态)。 */
|
|
41
|
+
function issueLabelNames(issue) {
|
|
42
|
+
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label?.name ?? "").filter((label) => label !== "");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 由收集输入构造一条标准的 backlog 任务(§14.3:只进 backlog;prompt 回退用
|
|
46
|
+
* 标题——promote 到 todo 后执行即以标题为 Prompt;tags 默认 [] 待人工标注)。
|
|
47
|
+
*/
|
|
48
|
+
function newCollectedTask(input, id, now, order) {
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
title: input.title,
|
|
52
|
+
description: input.description,
|
|
53
|
+
prompt: input.title,
|
|
54
|
+
status: "backlog",
|
|
55
|
+
source: input.source,
|
|
56
|
+
createdAt: now,
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
executions: [],
|
|
59
|
+
tags: [],
|
|
60
|
+
comments: [],
|
|
61
|
+
artifacts: [],
|
|
62
|
+
metadata: input.metadata,
|
|
63
|
+
order
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 严格解析 GitHub webhook `issues` 事件载荷(§14.1):只消费已知字段、未知
|
|
68
|
+
* 字段剥离(载荷为外部输入,永不被解释为任务字段);结构非法返回 undefined。
|
|
69
|
+
* 收集内容(标题/正文)在映射时经 sanitizeCollectedText 清洗(§14.3)。
|
|
70
|
+
*/
|
|
71
|
+
function parseGithubWebhookPayload(value) {
|
|
72
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
73
|
+
const payload = value;
|
|
74
|
+
if (typeof payload.action !== "string" || payload.action === "") return void 0;
|
|
75
|
+
const issue = payload.issue;
|
|
76
|
+
if (typeof issue !== "object" || issue === null || Array.isArray(issue)) return void 0;
|
|
77
|
+
const issueRaw = issue;
|
|
78
|
+
const labels = [];
|
|
79
|
+
if (Array.isArray(issueRaw.labels)) {
|
|
80
|
+
for (const label of issueRaw.labels) if (typeof label === "string") labels.push(label);
|
|
81
|
+
else if (typeof label === "object" && label !== null && typeof label.name === "string") labels.push({ name: label.name });
|
|
82
|
+
}
|
|
83
|
+
const out = {
|
|
84
|
+
action: payload.action,
|
|
85
|
+
issue: { labels }
|
|
86
|
+
};
|
|
87
|
+
if (typeof issueRaw.number === "number" && Number.isFinite(issueRaw.number)) out.issue.number = issueRaw.number;
|
|
88
|
+
if (typeof issueRaw.title === "string") out.issue.title = issueRaw.title;
|
|
89
|
+
if (typeof issueRaw.html_url === "string") out.issue.html_url = issueRaw.html_url;
|
|
90
|
+
if (typeof issueRaw.body === "string" || issueRaw.body === null) out.issue.body = issueRaw.body;
|
|
91
|
+
if (typeof payload.repository === "object" && payload.repository !== null) {
|
|
92
|
+
const repository = payload.repository;
|
|
93
|
+
if (typeof repository.full_name === "string" && repository.full_name !== "") out.repository = { full_name: repository.full_name };
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* GitHub Issue → 收集任务输入(§14.1):标题/描述清洗、来源链接等写入
|
|
99
|
+
* metadata(url/externalId/repo/labels),供去重与追溯。
|
|
100
|
+
*/
|
|
101
|
+
function githubIssueToCollectedTaskInput(issue, repo) {
|
|
102
|
+
const number = issue.number;
|
|
103
|
+
const title = sanitizeCollectedText(typeof issue.title === "string" ? issue.title : "", 200) || (number === void 0 ? "GitHub Issue" : `GitHub Issue #${number}`);
|
|
104
|
+
const description = sanitizeCollectedText(issue.body ?? "", 4e3);
|
|
105
|
+
const labels = (issue.labels ?? []).map((label) => typeof label === "string" ? label : label?.name ?? "").filter((label) => label !== "");
|
|
106
|
+
return {
|
|
107
|
+
title,
|
|
108
|
+
description,
|
|
109
|
+
source: "github_issue",
|
|
110
|
+
metadata: {
|
|
111
|
+
...typeof issue.html_url === "string" && issue.html_url !== "" ? { url: issue.html_url.slice(0, 2048) } : {},
|
|
112
|
+
...number !== void 0 ? { externalId: String(number) } : {},
|
|
113
|
+
...repo !== void 0 && repo !== "" ? { repo: repo.slice(0, 256) } : {},
|
|
114
|
+
...labels.length > 0 ? { labels: labels.join(",") } : {}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** 书签条目 → 收集任务输入(§14.2):URL 缺失丢弃;标题回退用 URL。 */
|
|
119
|
+
function bookmarkEntryToCollectedTaskInput(entry) {
|
|
120
|
+
const url = typeof entry.url === "string" ? sanitizeCollectedText(entry.url, 2048) : "";
|
|
121
|
+
if (url === "") return void 0;
|
|
122
|
+
return {
|
|
123
|
+
title: sanitizeCollectedText(entry.title, 200) || url,
|
|
124
|
+
description: sanitizeCollectedText(entry.note, 4e3),
|
|
125
|
+
source: "bookmark_collector",
|
|
126
|
+
metadata: { url }
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** 解析书签收件箱文件(JSONL:每行一个 JSON 对象;空行/非法行跳过)。 */
|
|
130
|
+
function parseBookmarkEntries(content) {
|
|
131
|
+
const entries = [];
|
|
132
|
+
for (const line of content.split("\n")) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (trimmed === "") continue;
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(trimmed);
|
|
137
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
|
|
138
|
+
const entry = parsed;
|
|
139
|
+
if (entry.title !== void 0 && typeof entry.title !== "string") continue;
|
|
140
|
+
if (entry.url !== void 0 && typeof entry.url !== "string") continue;
|
|
141
|
+
if (entry.note !== void 0 && typeof entry.note !== "string") continue;
|
|
142
|
+
entries.push({
|
|
143
|
+
...typeof entry.title === "string" ? { title: entry.title } : {},
|
|
144
|
+
...typeof entry.url === "string" ? { url: entry.url } : {},
|
|
145
|
+
...typeof entry.note === "string" ? { note: entry.note } : {}
|
|
146
|
+
});
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 注入 token 预算(B5 调研结论:Host 侧摘取 + 固定预算,避免把全量上下文灌给
|
|
153
|
+
* agent)。逐字段上限 + 注入块总量上限(约 400 token,中文按 0.6 token/字)。
|
|
154
|
+
*/
|
|
155
|
+
const CONTEXT_INJECTION_BUDGET = {
|
|
156
|
+
/** goal 上限(字符)。 */
|
|
157
|
+
goalMax: 200,
|
|
158
|
+
/** lastAiSummary 上限(字符)。 */
|
|
159
|
+
summaryMax: 600,
|
|
160
|
+
/** latestUserFeedback 上限(字符)。 */
|
|
161
|
+
feedbackMax: 300,
|
|
162
|
+
/** relatedLinks 合计上限(字符)。 */
|
|
163
|
+
linksMax: 400,
|
|
164
|
+
/** 注入块总量上限(字符)。 */
|
|
165
|
+
totalMax: 1500
|
|
166
|
+
};
|
|
167
|
+
/** 任务最近一条 user_feedback 评论的正文(供快照 latestUserFeedback)。 */
|
|
168
|
+
function latestUserFeedback(task) {
|
|
169
|
+
let latest;
|
|
170
|
+
for (const comment of task.comments) {
|
|
171
|
+
if (comment.type !== "user_feedback") continue;
|
|
172
|
+
if (latest === void 0 || comment.createdAt >= latest.createdAt) latest = comment;
|
|
173
|
+
}
|
|
174
|
+
return latest?.body;
|
|
175
|
+
}
|
|
176
|
+
/** 截断文本到上限(中文友好:按字符计)。 */
|
|
177
|
+
function truncateText(text, max) {
|
|
178
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 组合注入块(B5):只带摘要与必要片段——goal / lastAiSummary /
|
|
182
|
+
* latestUserFeedback / relatedLinks,逐字段按预算截断,总量再封顶。
|
|
183
|
+
* 快照不存在或无可注入内容时返回 undefined(不追加注入)。
|
|
184
|
+
*/
|
|
185
|
+
function composeInjectionContext(task) {
|
|
186
|
+
const snapshot = task.contextSnapshot;
|
|
187
|
+
if (snapshot === void 0) return void 0;
|
|
188
|
+
const lines = [];
|
|
189
|
+
if (snapshot.goal !== void 0 && snapshot.goal !== "") lines.push(`目标: ${truncateText(snapshot.goal, CONTEXT_INJECTION_BUDGET.goalMax)}`);
|
|
190
|
+
if (snapshot.lastAiSummary !== void 0 && snapshot.lastAiSummary !== "") lines.push(`最近 AI 摘要: ${truncateText(snapshot.lastAiSummary, CONTEXT_INJECTION_BUDGET.summaryMax)}`);
|
|
191
|
+
if (snapshot.latestUserFeedback !== void 0 && snapshot.latestUserFeedback !== "") lines.push(`最近用户反馈: ${truncateText(snapshot.latestUserFeedback, CONTEXT_INJECTION_BUDGET.feedbackMax)}`);
|
|
192
|
+
if (snapshot.relatedLinks.length > 0) {
|
|
193
|
+
const joined = snapshot.relatedLinks.join(" ").slice(0, CONTEXT_INJECTION_BUDGET.linksMax);
|
|
194
|
+
if (joined !== "") lines.push(`相关链接: ${joined}`);
|
|
195
|
+
}
|
|
196
|
+
if (lines.length === 0) return void 0;
|
|
197
|
+
let block = `[任务上下文 · 摘要注入]\n${lines.join("\n")}`;
|
|
198
|
+
if (block.length > CONTEXT_INJECTION_BUDGET.totalMax) block = block.slice(0, CONTEXT_INJECTION_BUDGET.totalMax);
|
|
199
|
+
return block;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 执行 Prompt 合成(B5 落定,runner.launch 使用):基线为任务 Prompt(空则
|
|
203
|
+
* 回退标题);任务有上下文快照时在尾部追加注入块——后续调用只使用摘要与
|
|
204
|
+
* 必要片段。无快照的任务行为与 T005 完全一致(不影响既有执行语义)。
|
|
205
|
+
*/
|
|
206
|
+
function composePrompt(task) {
|
|
207
|
+
const base = task.prompt.trim() !== "" ? task.prompt : task.title;
|
|
208
|
+
const injected = composeInjectionContext(task);
|
|
209
|
+
return injected === void 0 ? base : `${base}\n\n${injected}`;
|
|
210
|
+
}
|
|
211
|
+
/** 从快照补丁构造 Host 侧结算后快照更新(B5:只覆盖可注入字段)。 */
|
|
212
|
+
function contextPatchFromSettle(task, lastAiSummary) {
|
|
213
|
+
const patch = {};
|
|
214
|
+
if (lastAiSummary !== void 0 && lastAiSummary !== "") patch.lastAiSummary = truncateText(lastAiSummary, CONTEXT_INJECTION_BUDGET.summaryMax);
|
|
215
|
+
const feedback = latestUserFeedback(task);
|
|
216
|
+
if (feedback !== void 0) patch.latestUserFeedback = feedback;
|
|
217
|
+
const url = task.metadata?.url;
|
|
218
|
+
if (url !== void 0 && url !== "") patch.relatedLinks = [url];
|
|
219
|
+
return patch;
|
|
220
|
+
}
|
|
221
|
+
/** 上下文快照的数组字段归一化(跨模块共用:去空、去重、封顶)。 */
|
|
222
|
+
function normalizeSnapshotList(value) {
|
|
223
|
+
const seen = /* @__PURE__ */ new Set();
|
|
224
|
+
const items = [];
|
|
225
|
+
for (const item of value ?? []) {
|
|
226
|
+
const trimmed = item.trim();
|
|
227
|
+
if (trimmed === "" || seen.has(trimmed)) continue;
|
|
228
|
+
seen.add(trimmed);
|
|
229
|
+
items.push(trimmed);
|
|
230
|
+
if (items.length >= 50) break;
|
|
231
|
+
}
|
|
232
|
+
return items;
|
|
233
|
+
}
|
|
234
|
+
/** 从 AutomationRule 读取收集来源配置(带默认值)。 */
|
|
235
|
+
function ruleConfig(rule, key, fallback) {
|
|
236
|
+
const value = rule.config[key];
|
|
237
|
+
return value !== void 0 && value !== "" ? value : fallback;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/host-runner.ts
|
|
241
|
+
/** `/permission <id>` 命令的超时(对齐参考实现 30s)。 */
|
|
242
|
+
const PERMISSION_COMMAND_TIMEOUT_MS = 3e4;
|
|
243
|
+
/** 会话创建后的启动失败:仍标识会话(服务层先回填 sessionId 再结算 failed)。 */
|
|
244
|
+
var SessionLaunchError = class extends Error {
|
|
245
|
+
sessionId;
|
|
246
|
+
constructor(sessionId, cause) {
|
|
247
|
+
super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
248
|
+
this.sessionId = sessionId;
|
|
249
|
+
this.name = "SessionLaunchError";
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/** RPC 信封铸造(对齐参考实现的 request 助手;rpcId 带 nova 前缀标识本项目)。 */
|
|
253
|
+
function request(payload) {
|
|
254
|
+
return {
|
|
255
|
+
rpcId: `nova-task-board-${crypto.randomUUID()}`,
|
|
256
|
+
payload
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** RPC 业务错误 → 可读 Error(消息形如 `code: message`)。 */
|
|
260
|
+
function failure(error) {
|
|
261
|
+
return /* @__PURE__ */ new Error(`${error.code}: ${error.message}`);
|
|
262
|
+
}
|
|
263
|
+
/** turn/end 载荷是否以 error 结束(§13.3 lastAgentError 判定)。 */
|
|
264
|
+
function isErrorTurnEnd(data) {
|
|
265
|
+
if (typeof data !== "object" || data === null) return false;
|
|
266
|
+
const reason = data.reason;
|
|
267
|
+
return typeof reason === "object" && reason !== null && reason.kind === "error";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 从历史事件中摘取本次执行(time ≥ startedAt)最近的 assistant 消息文本
|
|
271
|
+
* (B5:Host 侧摘取摘要源,零额外 RPC——复用 inspect 已翻页的历史;长度上限
|
|
272
|
+
* 8000 字符,最终摘要长度由 CONTEXT_INJECTION_BUDGET 约束)。
|
|
273
|
+
*/
|
|
274
|
+
function lastAssistantTextFrom(events, startedAt) {
|
|
275
|
+
let best;
|
|
276
|
+
for (const entry of events) {
|
|
277
|
+
const event = entry.event;
|
|
278
|
+
if (typeof event.time !== "number" || event.time < startedAt) continue;
|
|
279
|
+
if (event.type !== "assistant/message") continue;
|
|
280
|
+
const message = event.data?.message;
|
|
281
|
+
if (typeof message?.text !== "string" || message.text === "") continue;
|
|
282
|
+
const seq = typeof event.seq === "number" ? event.seq : 0;
|
|
283
|
+
if (best === void 0 || seq > best.seq) best = {
|
|
284
|
+
seq,
|
|
285
|
+
text: message.text.slice(0, 8e3)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return best?.text;
|
|
289
|
+
}
|
|
290
|
+
/** 翻页读取会话历史事件(逐页向旧翻,直到无更多页;失败抛错由调用方处理)。 */
|
|
291
|
+
async function readHistoryEvents(api, sessionId, maxPages = 100) {
|
|
292
|
+
const events = [];
|
|
293
|
+
let beforeSeq;
|
|
294
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
295
|
+
const history = await api.history(request({
|
|
296
|
+
sessionId,
|
|
297
|
+
maxMessages: 100,
|
|
298
|
+
...beforeSeq === void 0 ? {} : { beforeSeq }
|
|
299
|
+
}));
|
|
300
|
+
if (!history.result.ok) throw failure(history.result.error);
|
|
301
|
+
events.push(...history.result.value.events);
|
|
302
|
+
if (!history.result.value.hasMore) return events;
|
|
303
|
+
const oldestSeq = history.result.value.events.reduce((oldest, entry) => {
|
|
304
|
+
const seq = entry.event.seq;
|
|
305
|
+
return oldest === void 0 ? seq : Math.min(oldest, seq);
|
|
306
|
+
}, void 0);
|
|
307
|
+
if (oldestSeq === void 0 || oldestSeq === beforeSeq) return events;
|
|
308
|
+
beforeSeq = oldestSeq;
|
|
309
|
+
}
|
|
310
|
+
return events;
|
|
311
|
+
}
|
|
312
|
+
/** 会话 transcript 全文读取上限(T012/B8:拆分会话结果解析;防超大会话内存放大)。 */
|
|
313
|
+
const TRANSCRIPT_TEXT_LIMIT = 2e5;
|
|
314
|
+
/**
|
|
315
|
+
* 真实执行与结算驱动(§13.2/§13.3)。无状态、无计时器——轮询调度由
|
|
316
|
+
* TaskBoardHostService 持有(5s 间隔);本类只做单次 launch/inspect。
|
|
317
|
+
*/
|
|
318
|
+
var HostExecutionRunner = class {
|
|
319
|
+
api;
|
|
320
|
+
commands;
|
|
321
|
+
/** @param api - apiProxy 窄面(workspace/agentPresets/sessions 的读写子集)。 */
|
|
322
|
+
constructor(api, commands) {
|
|
323
|
+
this.api = api;
|
|
324
|
+
this.commands = commands;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 启动一次执行(§13.2 流程):
|
|
328
|
+
* 1. fail-closed 校验钉住目标(工作区存在、预设存在且未损坏)——任一失败抛
|
|
329
|
+
* 错,调用方结算 failed,**不发送任务 Prompt**;
|
|
330
|
+
* 2. `sessions.create` 创建独立会话(携带钉住的 workspaceId/agentPreset);
|
|
331
|
+
* 3. rename 为任务标题(可失败但仅装饰);权限经 `/permission` 命令应用
|
|
332
|
+
* (被拒/未匹配 → 失败);
|
|
333
|
+
* 4. `sessions.prompt(mode:'queue', text = prompt || title)`。
|
|
334
|
+
*
|
|
335
|
+
* 会话创建之后的任何失败抛 `SessionLaunchError`(携带 sessionId)。
|
|
336
|
+
* @returns 创建的会话 id。
|
|
337
|
+
*/
|
|
338
|
+
async launch(task) {
|
|
339
|
+
if (task.workspaceId !== void 0) {
|
|
340
|
+
const workspaces = await this.api.workspace.list(request({}));
|
|
341
|
+
if (!workspaces.result.ok) throw failure(workspaces.result.error);
|
|
342
|
+
if (!workspaces.result.value.items.some((item) => item.workspaceId === task.workspaceId)) throw new Error(`workspace not found: ${task.workspaceId}`);
|
|
343
|
+
}
|
|
344
|
+
if (task.mode !== void 0) {
|
|
345
|
+
const presets = await this.api.agentPresets.list(request({}));
|
|
346
|
+
if (!presets.result.ok) throw failure(presets.result.error);
|
|
347
|
+
const preset = presets.result.value.presets.find((item) => item.id === task.mode);
|
|
348
|
+
if (preset === void 0) throw new Error(`agent preset not found: ${task.mode}`);
|
|
349
|
+
if (preset.broken !== void 0) throw new Error(`agent preset is unavailable: ${preset.broken}`);
|
|
350
|
+
}
|
|
351
|
+
const created = await this.api.sessions.create(request({
|
|
352
|
+
...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
|
|
353
|
+
...task.mode === void 0 ? {} : { agentPreset: task.mode }
|
|
354
|
+
}));
|
|
355
|
+
if (!created.result.ok) throw failure(created.result.error);
|
|
356
|
+
const sessionId = created.result.value.sessionId;
|
|
357
|
+
try {
|
|
358
|
+
try {
|
|
359
|
+
const renamed = await this.api.sessions.rename(request({
|
|
360
|
+
sessionId,
|
|
361
|
+
title: task.title
|
|
362
|
+
}));
|
|
363
|
+
if (!renamed.result.ok) console.warn(`[dsh-task-board] session rename failed (decorative): ${renamed.result.error.code}: ${renamed.result.error.message}`);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.warn(`[dsh-task-board] session rename failed (decorative): ${error instanceof Error ? error.message : String(error)}`);
|
|
366
|
+
}
|
|
367
|
+
if (task.permission !== void 0) {
|
|
368
|
+
if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
|
|
369
|
+
const command = await this.commands.execute(sessionId, `/permission ${task.permission}`, AbortSignal.timeout(PERMISSION_COMMAND_TIMEOUT_MS));
|
|
370
|
+
if (command === void 0) throw new Error("permission command was not acknowledged");
|
|
371
|
+
if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
|
|
372
|
+
}
|
|
373
|
+
const prompt = await this.api.sessions.prompt(request({
|
|
374
|
+
sessionId,
|
|
375
|
+
mode: "queue",
|
|
376
|
+
content: [{
|
|
377
|
+
type: "text",
|
|
378
|
+
text: composePrompt(task)
|
|
379
|
+
}]
|
|
380
|
+
}));
|
|
381
|
+
if (!prompt.result.ok) throw failure(prompt.result.error);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
throw new SessionLaunchError(sessionId, error);
|
|
384
|
+
}
|
|
385
|
+
return sessionId;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 轮询会话列表(§13.3,5s 一次由服务层调度)。RPC 失败/不可用 → known:false
|
|
389
|
+
* (服务层据此把 sessionStateKnown 置 false,不据此结算)。
|
|
390
|
+
*/
|
|
391
|
+
async listRunning() {
|
|
392
|
+
try {
|
|
393
|
+
const response = await this.api.sessions.list(request({}));
|
|
394
|
+
return response.result.ok ? {
|
|
395
|
+
known: true,
|
|
396
|
+
count: response.result.value.items.filter((item) => item.running).length,
|
|
397
|
+
items: response.result.value.items
|
|
398
|
+
} : { known: false };
|
|
399
|
+
} catch {
|
|
400
|
+
return { known: false };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* 单条执行的结算判定(§13.3):
|
|
405
|
+
* - 会话不在列表中 → cancelled(`execution session no longer exists`);
|
|
406
|
+
* - 会话仍在运行 → pending;
|
|
407
|
+
* - 已结束 → 翻页读历史直到越过 startedAt 边界,取时间 ≥ startedAt 的首条
|
|
408
|
+
* `turn/end`:reason.kind==='error' → failed,否则 → succeeded;边界内无
|
|
409
|
+
* turn/end(如 prompt 从未被消费)→ pending(保持观察)。
|
|
410
|
+
* @param sessions - 本次轮询已取的会话列表(避免 1 + E 次 list RPC,对齐参考
|
|
411
|
+
* 实现);缺省时自行拉取。
|
|
412
|
+
*/
|
|
413
|
+
async inspect(sessionId, startedAt, sessions) {
|
|
414
|
+
let items;
|
|
415
|
+
if (sessions !== void 0) items = sessions;
|
|
416
|
+
else {
|
|
417
|
+
const response = await this.api.sessions.list(request({}));
|
|
418
|
+
if (!response.result.ok) return { outcome: "pending" };
|
|
419
|
+
items = response.result.value.items;
|
|
420
|
+
}
|
|
421
|
+
const summary = items.find((item) => item.sessionId === sessionId);
|
|
422
|
+
if (summary === void 0) return {
|
|
423
|
+
outcome: "cancelled",
|
|
424
|
+
error: "execution session no longer exists"
|
|
425
|
+
};
|
|
426
|
+
if (summary.running) return { outcome: "pending" };
|
|
427
|
+
const events = [];
|
|
428
|
+
let beforeSeq;
|
|
429
|
+
let reachedExecutionBoundary = false;
|
|
430
|
+
for (let page = 0; page < 100; page += 1) {
|
|
431
|
+
const history = await this.api.sessions.history(request({
|
|
432
|
+
sessionId: summary.sessionId,
|
|
433
|
+
maxMessages: 100,
|
|
434
|
+
...beforeSeq === void 0 ? {} : { beforeSeq }
|
|
435
|
+
}));
|
|
436
|
+
if (!history.result.ok) return { outcome: "pending" };
|
|
437
|
+
events.push(...history.result.value.events);
|
|
438
|
+
const oldestTime = history.result.value.events.reduce((oldest, entry) => {
|
|
439
|
+
const time = entry.event.time;
|
|
440
|
+
return oldest === void 0 ? time : Math.min(oldest, time);
|
|
441
|
+
}, void 0);
|
|
442
|
+
if (!history.result.value.hasMore || oldestTime !== void 0 && oldestTime <= startedAt) {
|
|
443
|
+
reachedExecutionBoundary = true;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
const oldestSeq = history.result.value.events.reduce((oldest, entry) => {
|
|
447
|
+
const seq = entry.event.seq;
|
|
448
|
+
return oldest === void 0 ? seq : Math.min(oldest, seq);
|
|
449
|
+
}, void 0);
|
|
450
|
+
if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
|
|
451
|
+
beforeSeq = oldestSeq;
|
|
452
|
+
}
|
|
453
|
+
if (!reachedExecutionBoundary) return { outcome: "pending" };
|
|
454
|
+
const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || typeof entry.event.time === "number" && entry.event.time >= startedAt)).sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0];
|
|
455
|
+
if (turnEnd === void 0) return { outcome: "pending" };
|
|
456
|
+
const lastAssistantText = lastAssistantTextFrom(events, startedAt);
|
|
457
|
+
if (isErrorTurnEnd(turnEnd.event.data)) return {
|
|
458
|
+
outcome: "failed",
|
|
459
|
+
error: "agent turn ended with an error",
|
|
460
|
+
...lastAssistantText === void 0 ? {} : { lastAssistantText }
|
|
461
|
+
};
|
|
462
|
+
return {
|
|
463
|
+
outcome: "succeeded",
|
|
464
|
+
...lastAssistantText === void 0 ? {} : { lastAssistantText }
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* 读取会话 transcript 全文(T012/B8:拆分会话结果解析用)。翻页读取历史事件,
|
|
469
|
+
* 拼接全部 assistant 消息文本(顺序拼接、换行分隔;总长封顶
|
|
470
|
+
* TRANSCRIPT_TEXT_LIMIT)。会话不存在/历史不可读 → undefined(调用方按失败
|
|
471
|
+
* 处理)。拆分会话是独立专用会话,全文即拆分结果(无需按 startedAt 截取)。
|
|
472
|
+
*/
|
|
473
|
+
async readTranscriptText(sessionId) {
|
|
474
|
+
try {
|
|
475
|
+
const events = await readHistoryEvents(this.api.sessions, sessionId);
|
|
476
|
+
const parts = [];
|
|
477
|
+
let length = 0;
|
|
478
|
+
for (const entry of events) {
|
|
479
|
+
if (entry.event.type !== "assistant/message") continue;
|
|
480
|
+
const message = entry.event.data?.message;
|
|
481
|
+
if (typeof message?.text !== "string" || message.text === "") continue;
|
|
482
|
+
const remaining = TRANSCRIPT_TEXT_LIMIT - length;
|
|
483
|
+
if (remaining <= 0) break;
|
|
484
|
+
const slice = message.text.length > remaining ? message.text.slice(0, remaining) : message.text;
|
|
485
|
+
parts.push(slice);
|
|
486
|
+
length += slice.length;
|
|
487
|
+
}
|
|
488
|
+
const text = parts.join("\n");
|
|
489
|
+
return text === "" ? void 0 : text;
|
|
490
|
+
} catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/loopback.ts
|
|
497
|
+
/** IPv4 127/8 谓词(四个十进制八位组、首段 == 127)。 */
|
|
498
|
+
function isIPv4Loopback(v4) {
|
|
499
|
+
const parts = v4.split(".");
|
|
500
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
501
|
+
}
|
|
502
|
+
/** socket 远端地址是否属于回环范围(127/8、::1、IPv4-mapped)。 */
|
|
503
|
+
function isLoopbackAddress(address) {
|
|
504
|
+
if (address === void 0) return false;
|
|
505
|
+
const normalized = address.toLowerCase();
|
|
506
|
+
if (normalized === "::1") return true;
|
|
507
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
508
|
+
return isIPv4Loopback(normalized);
|
|
509
|
+
}
|
|
510
|
+
/** 归一化 URL 主机名是否为回环 authority(localhost、[::1]、127/8)。 */
|
|
511
|
+
function isLoopbackHostname(hostname) {
|
|
512
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
513
|
+
return isIPv4Loopback(hostname);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* 请求级信任栅栏:回环 socket 地址 AND 回环 Host 头,加上浏览器同源标记。
|
|
517
|
+
* socket 地址权威;`X-Forwarded-For` 永不信任。Origin 缺省时仅接受
|
|
518
|
+
* `sec-fetch-site: same-origin` 的显式标记。
|
|
519
|
+
*/
|
|
520
|
+
function isLoopbackRequest(request) {
|
|
521
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
522
|
+
const host = request.headers.host;
|
|
523
|
+
if (typeof host !== "string") return false;
|
|
524
|
+
let hostUrl;
|
|
525
|
+
try {
|
|
526
|
+
hostUrl = new URL("http://" + host);
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
531
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
532
|
+
const origin = request.headers.origin;
|
|
533
|
+
if (origin === void 0) return true;
|
|
534
|
+
try {
|
|
535
|
+
return new URL(origin).host === hostUrl.host;
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** 任务生命周期状态,与看板列一一对应(§10.1);`proposed` 为对话流转候选(D7/P3.1)。 */
|
|
541
|
+
const TASK_STATUSES = [
|
|
542
|
+
"proposed",
|
|
543
|
+
"backlog",
|
|
544
|
+
"todo",
|
|
545
|
+
"running",
|
|
546
|
+
"done",
|
|
547
|
+
"failed"
|
|
548
|
+
];
|
|
549
|
+
/** 任务来源(§9.2,v1.0 字段,P3 随对话流转启用)。 */
|
|
550
|
+
const TASK_SOURCES = [
|
|
551
|
+
"manual",
|
|
552
|
+
"conversation",
|
|
553
|
+
"github_issue",
|
|
554
|
+
"bookmark_collector",
|
|
555
|
+
"feishu",
|
|
556
|
+
"requirement",
|
|
557
|
+
"other"
|
|
558
|
+
];
|
|
559
|
+
/** 执行会话钉住的权限预设 id(`/permission <id>`,§9.2)。 */
|
|
560
|
+
const TASK_PERMISSIONS = [
|
|
561
|
+
"read-only",
|
|
562
|
+
"workspace-write",
|
|
563
|
+
"danger-full-access"
|
|
564
|
+
];
|
|
565
|
+
/** 执行结果(§9.3)。 */
|
|
566
|
+
const EXECUTION_RESULTS = [
|
|
567
|
+
"succeeded",
|
|
568
|
+
"failed",
|
|
569
|
+
"cancelled"
|
|
570
|
+
];
|
|
571
|
+
/** 评论类型(§9.5 comments:user_feedback/ai_log/system_event)。 */
|
|
572
|
+
const COMMENT_TYPES = [
|
|
573
|
+
"user_feedback",
|
|
574
|
+
"ai_log",
|
|
575
|
+
"system_event"
|
|
576
|
+
];
|
|
577
|
+
/** 产物类型(§9.5 artifacts;会话 transcript 即默认产物 `session`)。 */
|
|
578
|
+
const ARTIFACT_TYPES = [
|
|
579
|
+
"session",
|
|
580
|
+
"link",
|
|
581
|
+
"file",
|
|
582
|
+
"other"
|
|
583
|
+
];
|
|
584
|
+
/** 自动化收集来源(§14.1/14.2 automation_rules.source)。 */
|
|
585
|
+
const AUTOMATION_SOURCES = ["github_issue", "bookmark_collector"];
|
|
586
|
+
/** 来源会话引用写入 metadata 的键(§9.2/§14.4:可跳回对话)。 */
|
|
587
|
+
const SOURCE_CONVERSATION_META_KEY = "sourceConversationId";
|
|
588
|
+
/** 来源消息引用写入 metadata 的键(§9.2/§14.4:可跳回消息)。 */
|
|
589
|
+
const SOURCE_MESSAGE_META_KEY = "sourceMessageId";
|
|
590
|
+
/** 账本级损坏(schema 不兼容/不可解析),由 HostLedger 走隔离路径。 */
|
|
591
|
+
var LedgerSchemaError = class extends Error {
|
|
592
|
+
constructor(message) {
|
|
593
|
+
super(message);
|
|
594
|
+
this.name = "LedgerSchemaError";
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
/** 有限数字守卫。 */
|
|
598
|
+
function isFiniteNumber(value) {
|
|
599
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
600
|
+
}
|
|
601
|
+
/** 非空字符串(trim 后)守卫,空串/空白清除钉住字段(对齐参考实现 normalizeTargetId)。 */
|
|
602
|
+
function normalizeOptionalString(value) {
|
|
603
|
+
if (typeof value !== "string") return void 0;
|
|
604
|
+
const trimmed = value.trim();
|
|
605
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
606
|
+
}
|
|
607
|
+
/** 是否为已知任务状态。 */
|
|
608
|
+
function isTaskStatus(value) {
|
|
609
|
+
return typeof value === "string" && TASK_STATUSES.includes(value);
|
|
610
|
+
}
|
|
611
|
+
/** 是否为已知任务来源。 */
|
|
612
|
+
function isTaskSource(value) {
|
|
613
|
+
return typeof value === "string" && TASK_SOURCES.includes(value);
|
|
614
|
+
}
|
|
615
|
+
/** 是否为已知权限预设。 */
|
|
616
|
+
function isTaskPermission(value) {
|
|
617
|
+
return typeof value === "string" && TASK_PERMISSIONS.includes(value);
|
|
618
|
+
}
|
|
619
|
+
/** 是否为已知执行结果。 */
|
|
620
|
+
function isExecutionResult(value) {
|
|
621
|
+
return typeof value === "string" && EXECUTION_RESULTS.includes(value);
|
|
622
|
+
}
|
|
623
|
+
/** 是否为已知评论类型。 */
|
|
624
|
+
function isCommentType(value) {
|
|
625
|
+
return typeof value === "string" && COMMENT_TYPES.includes(value);
|
|
626
|
+
}
|
|
627
|
+
/** 是否为已知产物类型。 */
|
|
628
|
+
function isArtifactType(value) {
|
|
629
|
+
return typeof value === "string" && ARTIFACT_TYPES.includes(value);
|
|
630
|
+
}
|
|
631
|
+
/** 是否为已知自动化收集来源。 */
|
|
632
|
+
function isAutomationSource(value) {
|
|
633
|
+
return typeof value === "string" && AUTOMATION_SOURCES.includes(value);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* cron 表达式的基础形状校验(§9.4:5 段 cron「分 时 日 月 周」)。
|
|
637
|
+
* 这是结构级校验:段数、字符集(数字、`* , - / ?` 及名字字母)。cron 的到期
|
|
638
|
+
* 计算与完整合法性由 T006 的调度器实现;本层只保证持久化的规则形状可解析。
|
|
639
|
+
*/
|
|
640
|
+
const CRON_FIELD_RE = /^[0-9A-Za-z*,\-\/?]+$/;
|
|
641
|
+
function isPlausibleCron(cron) {
|
|
642
|
+
if (typeof cron !== "string" || cron.trim() === "") return false;
|
|
643
|
+
const fields = cron.trim().split(/\s+/);
|
|
644
|
+
if (fields.length !== 5) return false;
|
|
645
|
+
return fields.every((field) => CRON_FIELD_RE.test(field));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 归一化一条持久化的执行安排(§9.4 判别联合):
|
|
649
|
+
* - `kind: 'one-shot'` → one-shot 分支;`runAt` 非有限数则整条丢弃;
|
|
650
|
+
* - `kind: 'cron'` 或缺省 kind(v1/v2 旧形状)→ cron 分支;cron 形状非法则整条
|
|
651
|
+
* 丢弃(「修复或丢弃 schedule、绝不丢整行」——坏 schedule 不拖垮任务行);
|
|
652
|
+
* - 其他 kind → 丢弃。
|
|
653
|
+
*/
|
|
654
|
+
function normalizeSchedule(value) {
|
|
655
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
656
|
+
const rule = value;
|
|
657
|
+
if (rule.kind === "one-shot") {
|
|
658
|
+
if (!isFiniteNumber(rule.runAt)) return void 0;
|
|
659
|
+
const schedule = {
|
|
660
|
+
kind: "one-shot",
|
|
661
|
+
runAt: rule.runAt
|
|
662
|
+
};
|
|
663
|
+
if (isFiniteNumber(rule.firedAt)) schedule.firedAt = rule.firedAt;
|
|
664
|
+
return schedule;
|
|
665
|
+
}
|
|
666
|
+
if (rule.kind !== void 0 && rule.kind !== "cron") return void 0;
|
|
667
|
+
if (!isPlausibleCron(rule.cron)) return void 0;
|
|
668
|
+
const schedule = {
|
|
669
|
+
kind: "cron",
|
|
670
|
+
enabled: rule.enabled === true,
|
|
671
|
+
cron: rule.cron
|
|
672
|
+
};
|
|
673
|
+
if (isFiniteNumber(rule.nextRunAt)) schedule.nextRunAt = rule.nextRunAt;
|
|
674
|
+
if (isFiniteNumber(rule.lastTriggeredAt)) schedule.lastTriggeredAt = rule.lastTriggeredAt;
|
|
675
|
+
return schedule;
|
|
676
|
+
}
|
|
677
|
+
/** 归一化一条执行记录(§9.3);结构非法返回 undefined。 */
|
|
678
|
+
function normalizeExecution(value) {
|
|
679
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
680
|
+
const entry = value;
|
|
681
|
+
if (typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
682
|
+
if (!isFiniteNumber(entry.startedAt)) return void 0;
|
|
683
|
+
if (entry.sessionId !== void 0 && typeof entry.sessionId !== "string") return void 0;
|
|
684
|
+
if (entry.endedAt !== void 0 && !isFiniteNumber(entry.endedAt)) return void 0;
|
|
685
|
+
if (entry.result !== void 0 && !isExecutionResult(entry.result)) return void 0;
|
|
686
|
+
if (entry.error !== void 0 && typeof entry.error !== "string") return void 0;
|
|
687
|
+
const execution = {
|
|
688
|
+
id: entry.id,
|
|
689
|
+
startedAt: entry.startedAt
|
|
690
|
+
};
|
|
691
|
+
if (typeof entry.sessionId === "string") execution.sessionId = entry.sessionId;
|
|
692
|
+
if (isFiniteNumber(entry.endedAt)) execution.endedAt = entry.endedAt;
|
|
693
|
+
if (isExecutionResult(entry.result)) execution.result = entry.result;
|
|
694
|
+
if (typeof entry.error === "string") execution.error = entry.error;
|
|
695
|
+
return execution;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* 归一化标签数组(§9.2 默认 []):仅保留字符串元素,逐项 trim、丢弃空白项、
|
|
699
|
+
* 按首次出现顺序去重;缺省/非法 → []。标签是展示/过滤维度的原始字符串,
|
|
700
|
+
* 大小写敏感(`API` 与 `api` 是两个标签),去重不折叠大小写。
|
|
701
|
+
*/
|
|
702
|
+
function normalizeTags(value) {
|
|
703
|
+
if (!Array.isArray(value)) return [];
|
|
704
|
+
const seen = /* @__PURE__ */ new Set();
|
|
705
|
+
const tags = [];
|
|
706
|
+
for (const tag of value) {
|
|
707
|
+
if (typeof tag !== "string") continue;
|
|
708
|
+
const trimmed = tag.trim();
|
|
709
|
+
if (trimmed === "" || seen.has(trimmed)) continue;
|
|
710
|
+
seen.add(trimmed);
|
|
711
|
+
tags.push(trimmed);
|
|
712
|
+
}
|
|
713
|
+
return tags;
|
|
714
|
+
}
|
|
715
|
+
/** 通用字符串数组归一化(trim、丢空白、首现去重、条目长度封顶、数量封顶)。 */
|
|
716
|
+
function normalizeStringList(value, maxItems, maxLength) {
|
|
717
|
+
if (!Array.isArray(value)) return [];
|
|
718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
719
|
+
const items = [];
|
|
720
|
+
for (const item of value) {
|
|
721
|
+
if (typeof item !== "string") continue;
|
|
722
|
+
const trimmed = item.trim().slice(0, maxLength);
|
|
723
|
+
if (trimmed === "" || seen.has(trimmed)) continue;
|
|
724
|
+
seen.add(trimmed);
|
|
725
|
+
items.push(trimmed);
|
|
726
|
+
if (items.length >= maxItems) break;
|
|
727
|
+
}
|
|
728
|
+
return items;
|
|
729
|
+
}
|
|
730
|
+
/** 归一化来源元数据(§14.1/14.2):仅保留字符串键值对;缺省/非法 → undefined。 */
|
|
731
|
+
function normalizeMetadata(value) {
|
|
732
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
733
|
+
const metadata = {};
|
|
734
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
735
|
+
if (typeof entry !== "string" || key.trim() === "") continue;
|
|
736
|
+
metadata[key.trim().slice(0, 64)] = entry.slice(0, 2048);
|
|
737
|
+
}
|
|
738
|
+
return Object.keys(metadata).length > 0 ? metadata : void 0;
|
|
739
|
+
}
|
|
740
|
+
/** 归一化一条评论(§9.5);结构非法返回 undefined。 */
|
|
741
|
+
function normalizeComment(value) {
|
|
742
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
743
|
+
const entry = value;
|
|
744
|
+
if (typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
745
|
+
if (typeof entry.body !== "string" || entry.body === "") return void 0;
|
|
746
|
+
if (!isFiniteNumber(entry.createdAt)) return void 0;
|
|
747
|
+
if (entry.author !== void 0 && typeof entry.author !== "string") return void 0;
|
|
748
|
+
if (entry.type !== void 0 && !isCommentType(entry.type)) return void 0;
|
|
749
|
+
return {
|
|
750
|
+
id: entry.id,
|
|
751
|
+
author: typeof entry.author === "string" && entry.author.trim() !== "" ? entry.author.slice(0, 64) : "user",
|
|
752
|
+
body: entry.body.slice(0, 4e3),
|
|
753
|
+
type: isCommentType(entry.type) ? entry.type : "user_feedback",
|
|
754
|
+
createdAt: entry.createdAt
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/** 归一化评论集合(§9.5 comments,默认 []);非法条目丢弃。 */
|
|
758
|
+
function normalizeComments(value) {
|
|
759
|
+
if (!Array.isArray(value)) return [];
|
|
760
|
+
const comments = [];
|
|
761
|
+
for (const entry of value) {
|
|
762
|
+
const comment = normalizeComment(entry);
|
|
763
|
+
if (comment !== void 0) comments.push(comment);
|
|
764
|
+
}
|
|
765
|
+
return comments;
|
|
766
|
+
}
|
|
767
|
+
/** 归一化一条产物(§9.5 artifacts);结构非法返回 undefined。 */
|
|
768
|
+
function normalizeArtifact(value) {
|
|
769
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
770
|
+
const entry = value;
|
|
771
|
+
if (typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
772
|
+
if (!isArtifactType(entry.type)) return void 0;
|
|
773
|
+
if (typeof entry.title !== "string" || entry.title === "") return void 0;
|
|
774
|
+
if (!isFiniteNumber(entry.createdAt)) return void 0;
|
|
775
|
+
if (entry.url !== void 0 && typeof entry.url !== "string") return void 0;
|
|
776
|
+
if (entry.contentRef !== void 0 && typeof entry.contentRef !== "string") return void 0;
|
|
777
|
+
const artifact = {
|
|
778
|
+
id: entry.id,
|
|
779
|
+
type: entry.type,
|
|
780
|
+
title: entry.title.slice(0, 500),
|
|
781
|
+
createdAt: entry.createdAt
|
|
782
|
+
};
|
|
783
|
+
if (typeof entry.url === "string" && entry.url.trim() !== "") artifact.url = entry.url.slice(0, 2048);
|
|
784
|
+
if (typeof entry.contentRef === "string" && entry.contentRef.trim() !== "") artifact.contentRef = entry.contentRef.slice(0, 512);
|
|
785
|
+
return artifact;
|
|
786
|
+
}
|
|
787
|
+
/** 归一化产物集合(§9.5 artifacts,默认 []);非法条目丢弃。 */
|
|
788
|
+
function normalizeArtifacts(value) {
|
|
789
|
+
if (!Array.isArray(value)) return [];
|
|
790
|
+
const artifacts = [];
|
|
791
|
+
for (const entry of value) {
|
|
792
|
+
const artifact = normalizeArtifact(entry);
|
|
793
|
+
if (artifact !== void 0) artifacts.push(artifact);
|
|
794
|
+
}
|
|
795
|
+
return artifacts;
|
|
796
|
+
}
|
|
797
|
+
/** 归一化上下文快照(§9.5 context_snapshot);结构非法返回 undefined。 */
|
|
798
|
+
function normalizeContextSnapshot(value) {
|
|
799
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
800
|
+
const entry = value;
|
|
801
|
+
if (!isFiniteNumber(entry.updatedAt)) return void 0;
|
|
802
|
+
if (entry.goal !== void 0 && typeof entry.goal !== "string") return void 0;
|
|
803
|
+
if (entry.lastAiSummary !== void 0 && typeof entry.lastAiSummary !== "string") return void 0;
|
|
804
|
+
if (entry.latestUserFeedback !== void 0 && typeof entry.latestUserFeedback !== "string") return void 0;
|
|
805
|
+
const snapshot = {
|
|
806
|
+
keyDecisions: normalizeStringList(entry.keyDecisions, 50, 500),
|
|
807
|
+
filePaths: normalizeStringList(entry.filePaths, 100, 500),
|
|
808
|
+
relatedLinks: normalizeStringList(entry.relatedLinks, 20, 2048),
|
|
809
|
+
updatedAt: entry.updatedAt
|
|
810
|
+
};
|
|
811
|
+
if (typeof entry.goal === "string" && entry.goal.trim() !== "") snapshot.goal = entry.goal.trim().slice(0, 2e3);
|
|
812
|
+
if (typeof entry.lastAiSummary === "string" && entry.lastAiSummary.trim() !== "") snapshot.lastAiSummary = entry.lastAiSummary.trim().slice(0, 8e3);
|
|
813
|
+
if (typeof entry.latestUserFeedback === "string" && entry.latestUserFeedback.trim() !== "") snapshot.latestUserFeedback = entry.latestUserFeedback.trim().slice(0, 4e3);
|
|
814
|
+
return snapshot;
|
|
815
|
+
}
|
|
816
|
+
/** 归一化一条自动化规则(§9.5 automation_rules);结构非法返回 undefined。 */
|
|
817
|
+
function normalizeAutomationRule(value) {
|
|
818
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
819
|
+
const rule = value;
|
|
820
|
+
if (typeof rule.id !== "string" || rule.id === "") return void 0;
|
|
821
|
+
if (!isAutomationSource(rule.source)) return void 0;
|
|
822
|
+
if (typeof rule.enabled !== "boolean") return void 0;
|
|
823
|
+
if (typeof rule.createdAt !== "number" || !Number.isFinite(rule.createdAt)) return void 0;
|
|
824
|
+
if (typeof rule.updatedAt !== "number" || !Number.isFinite(rule.updatedAt)) return void 0;
|
|
825
|
+
const trigger = rule.trigger;
|
|
826
|
+
if (typeof trigger !== "object" || trigger === null) return void 0;
|
|
827
|
+
const triggerRaw = trigger;
|
|
828
|
+
if (triggerRaw.kind !== "cron" || typeof triggerRaw.cron !== "string") return void 0;
|
|
829
|
+
if (!isPlausibleCron(triggerRaw.cron)) return void 0;
|
|
830
|
+
const config = {};
|
|
831
|
+
if (rule.config !== void 0) {
|
|
832
|
+
if (typeof rule.config !== "object" || rule.config === null || Array.isArray(rule.config)) return void 0;
|
|
833
|
+
for (const [key, entry] of Object.entries(rule.config)) {
|
|
834
|
+
if (typeof entry !== "string" || key.trim() === "") continue;
|
|
835
|
+
config[key.trim().slice(0, 64)] = entry.slice(0, 2048);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
let labels = [];
|
|
839
|
+
if (rule.filter !== void 0) {
|
|
840
|
+
if (typeof rule.filter !== "object" || rule.filter === null) return void 0;
|
|
841
|
+
const filter = rule.filter;
|
|
842
|
+
if (!Array.isArray(filter.labels) || !filter.labels.every((label) => typeof label === "string")) return void 0;
|
|
843
|
+
labels = normalizeTags(filter.labels);
|
|
844
|
+
}
|
|
845
|
+
const automation = {
|
|
846
|
+
id: rule.id,
|
|
847
|
+
enabled: rule.enabled,
|
|
848
|
+
source: rule.source,
|
|
849
|
+
trigger: {
|
|
850
|
+
kind: "cron",
|
|
851
|
+
cron: triggerRaw.cron
|
|
852
|
+
},
|
|
853
|
+
config,
|
|
854
|
+
filter: { labels },
|
|
855
|
+
createdAt: rule.createdAt,
|
|
856
|
+
updatedAt: rule.updatedAt
|
|
857
|
+
};
|
|
858
|
+
if (isFiniteNumber(triggerRaw.nextRunAt)) automation.trigger.nextRunAt = triggerRaw.nextRunAt;
|
|
859
|
+
if (isFiniteNumber(triggerRaw.lastTriggeredAt)) automation.trigger.lastTriggeredAt = triggerRaw.lastTriggeredAt;
|
|
860
|
+
return automation;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* 归一化一条任务行(§9.2)。
|
|
864
|
+
*
|
|
865
|
+
* 结构非法(id/title/description/prompt/createdAt/updatedAt/executions 任一不
|
|
866
|
+
* 符合)→ 返回 undefined(整行丢弃,HostLedger 记入 scheduler.error);
|
|
867
|
+
* 语义非法 → 就地修复:
|
|
868
|
+
* - 未知状态 → `todo`(未来版本的未知状态落入待办而非丢行,对齐参考实现);
|
|
869
|
+
* - 未知 source/permission → undefined;空白 workspaceId/mode/project/parentId
|
|
870
|
+
* → undefined;archivedAt 非有限数 → undefined;
|
|
871
|
+
* - tags 非字符串数组 → [];order 非有限数 → 0(T009 重算列内唯一);
|
|
872
|
+
* - schedule 交给 normalizeSchedule(修复或丢弃,不丢行)。
|
|
873
|
+
*/
|
|
874
|
+
function normalizeTask(value) {
|
|
875
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
876
|
+
const record = value;
|
|
877
|
+
if (typeof record.id !== "string" || record.id === "") return void 0;
|
|
878
|
+
if (typeof record.title !== "string") return void 0;
|
|
879
|
+
if (typeof record.description !== "string") return void 0;
|
|
880
|
+
if (typeof record.prompt !== "string") return void 0;
|
|
881
|
+
if (!isFiniteNumber(record.createdAt) || !isFiniteNumber(record.updatedAt)) return void 0;
|
|
882
|
+
if (!Array.isArray(record.executions)) return void 0;
|
|
883
|
+
const executions = [];
|
|
884
|
+
for (const entry of record.executions) {
|
|
885
|
+
const execution = normalizeExecution(entry);
|
|
886
|
+
if (execution === void 0) return void 0;
|
|
887
|
+
executions.push(execution);
|
|
888
|
+
}
|
|
889
|
+
const task = {
|
|
890
|
+
id: record.id,
|
|
891
|
+
title: record.title,
|
|
892
|
+
description: record.description,
|
|
893
|
+
prompt: record.prompt,
|
|
894
|
+
status: isTaskStatus(record.status) ? record.status : "todo",
|
|
895
|
+
createdAt: record.createdAt,
|
|
896
|
+
updatedAt: record.updatedAt,
|
|
897
|
+
executions,
|
|
898
|
+
tags: normalizeTags(record.tags),
|
|
899
|
+
comments: normalizeComments(record.comments),
|
|
900
|
+
artifacts: normalizeArtifacts(record.artifacts),
|
|
901
|
+
order: isFiniteNumber(record.order) ? record.order : 0
|
|
902
|
+
};
|
|
903
|
+
if (isTaskSource(record.source)) task.source = record.source;
|
|
904
|
+
const metadata = normalizeMetadata(record.metadata);
|
|
905
|
+
if (metadata !== void 0) task.metadata = metadata;
|
|
906
|
+
const contextSnapshot = normalizeContextSnapshot(record.contextSnapshot);
|
|
907
|
+
if (contextSnapshot !== void 0) task.contextSnapshot = contextSnapshot;
|
|
908
|
+
const schedule = normalizeSchedule(record.schedule);
|
|
909
|
+
if (schedule !== void 0) task.schedule = schedule;
|
|
910
|
+
const workspaceId = normalizeOptionalString(record.workspaceId);
|
|
911
|
+
if (workspaceId !== void 0) task.workspaceId = workspaceId;
|
|
912
|
+
const mode = normalizeOptionalString(record.mode);
|
|
913
|
+
if (mode !== void 0) task.mode = mode;
|
|
914
|
+
if (isTaskPermission(record.permission)) task.permission = record.permission;
|
|
915
|
+
if (isFiniteNumber(record.archivedAt)) task.archivedAt = record.archivedAt;
|
|
916
|
+
const project = normalizeOptionalString(record.project);
|
|
917
|
+
if (project !== void 0) task.project = project;
|
|
918
|
+
const parentId = normalizeOptionalString(record.parentId);
|
|
919
|
+
if (parentId !== void 0) task.parentId = parentId;
|
|
920
|
+
return task;
|
|
921
|
+
}
|
|
922
|
+
/** Host 本地时区(§9.1 scheduler.timeZone)。 */
|
|
923
|
+
function hostTimeZone() {
|
|
924
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* 空账本文档(新账本/损坏隔离后的重建起点)。
|
|
928
|
+
* @param error - 首次持久化即可见的错误(如损坏隔离说明)。
|
|
929
|
+
*/
|
|
930
|
+
function emptyLedgerDocument(error) {
|
|
931
|
+
return {
|
|
932
|
+
schemaVersion: 2,
|
|
933
|
+
revision: 0,
|
|
934
|
+
tasks: [],
|
|
935
|
+
scheduler: {
|
|
936
|
+
timeZone: hostTimeZone(),
|
|
937
|
+
ledgerId: crypto.randomUUID(),
|
|
938
|
+
...error === void 0 ? {} : { error }
|
|
939
|
+
},
|
|
940
|
+
recentRequests: [],
|
|
941
|
+
automationRules: []
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* 解析并归一化账本文档(§9.1)。
|
|
946
|
+
*
|
|
947
|
+
* 账本级损坏(非 JSON、根非对象、schemaVersion ≠ 2、tasks 非数组)抛
|
|
948
|
+
* `LedgerSchemaError`,由 HostLedger 隔离原文件(`corrupt-*` 保留原字节)后
|
|
949
|
+
* 以空账本重建。行级损坏(非法任务行、非法执行记录行)丢弃该行并记入
|
|
950
|
+
* `scheduler.error`;`revision` 非负安全整数 → 0;`recentRequests` 仅保留
|
|
951
|
+
* 合法条目并截断到最近 256 条。
|
|
952
|
+
*/
|
|
953
|
+
function parseLedgerDocument(raw) {
|
|
954
|
+
let parsed;
|
|
955
|
+
try {
|
|
956
|
+
parsed = JSON.parse(raw);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
throw new LedgerSchemaError(`ledger is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
959
|
+
}
|
|
960
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new LedgerSchemaError("ledger root must be a JSON object");
|
|
961
|
+
const doc = parsed;
|
|
962
|
+
if (doc.schemaVersion !== 2) throw new LedgerSchemaError(`unsupported ledger schemaVersion ${JSON.stringify(doc.schemaVersion)} (expected 2)`);
|
|
963
|
+
if (!Array.isArray(doc.tasks)) throw new LedgerSchemaError("ledger tasks must be an array");
|
|
964
|
+
const dropped = [];
|
|
965
|
+
const tasks = [];
|
|
966
|
+
for (const row of doc.tasks) {
|
|
967
|
+
const task = normalizeTask(row);
|
|
968
|
+
if (task === void 0) {
|
|
969
|
+
const id = typeof row === "object" && row !== null ? row.id : void 0;
|
|
970
|
+
dropped.push(typeof id === "string" ? id : "<invalid row>");
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
tasks.push(task);
|
|
974
|
+
}
|
|
975
|
+
const schedulerRaw = typeof doc.scheduler === "object" && doc.scheduler !== null ? doc.scheduler : {};
|
|
976
|
+
const scheduler = {
|
|
977
|
+
timeZone: hostTimeZone(),
|
|
978
|
+
ledgerId: typeof schedulerRaw.ledgerId === "string" && schedulerRaw.ledgerId !== "" ? schedulerRaw.ledgerId : crypto.randomUUID()
|
|
979
|
+
};
|
|
980
|
+
if (isFiniteNumber(schedulerRaw.lastTickAt)) scheduler.lastTickAt = schedulerRaw.lastTickAt;
|
|
981
|
+
if (typeof schedulerRaw.error === "string") scheduler.error = schedulerRaw.error;
|
|
982
|
+
if (Array.isArray(schedulerRaw.importedSources)) scheduler.importedSources = schedulerRaw.importedSources.filter((source) => typeof source === "string");
|
|
983
|
+
if (dropped.length > 0) scheduler.error = `dropped ${dropped.length} invalid task row(s): ${dropped.join(", ")}`;
|
|
984
|
+
const droppedRules = [];
|
|
985
|
+
const automationRules = [];
|
|
986
|
+
if (doc.automationRules !== void 0 && !Array.isArray(doc.automationRules)) droppedRules.push("<invalid rules list>");
|
|
987
|
+
else if (Array.isArray(doc.automationRules)) for (const row of doc.automationRules) {
|
|
988
|
+
const rule = normalizeAutomationRule(row);
|
|
989
|
+
if (rule === void 0) {
|
|
990
|
+
const id = typeof row === "object" && row !== null ? row.id : void 0;
|
|
991
|
+
droppedRules.push(typeof id === "string" ? id : "<invalid rule>");
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
automationRules.push(rule);
|
|
995
|
+
}
|
|
996
|
+
if (droppedRules.length > 0) scheduler.error = `dropped ${droppedRules.length} invalid automation rule(s): ${droppedRules.join(", ")}`;
|
|
997
|
+
return {
|
|
998
|
+
schemaVersion: 2,
|
|
999
|
+
revision: Number.isSafeInteger(doc.revision) && doc.revision >= 0 ? doc.revision : 0,
|
|
1000
|
+
tasks,
|
|
1001
|
+
scheduler,
|
|
1002
|
+
recentRequests: Array.isArray(doc.recentRequests) ? doc.recentRequests.flatMap((entry) => {
|
|
1003
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
1004
|
+
const request = entry;
|
|
1005
|
+
return typeof request.requestId === "string" && request.requestId !== "" && typeof request.fingerprint === "string" ? [{
|
|
1006
|
+
requestId: request.requestId,
|
|
1007
|
+
fingerprint: request.fingerprint
|
|
1008
|
+
}] : [];
|
|
1009
|
+
}).slice(-256) : [],
|
|
1010
|
+
automationRules
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/core/cron.ts
|
|
1015
|
+
/** 每字段的闭区间,按 cron 顺序。 */
|
|
1016
|
+
const FIELD_RANGES = [
|
|
1017
|
+
[0, 59],
|
|
1018
|
+
[0, 23],
|
|
1019
|
+
[1, 31],
|
|
1020
|
+
[1, 12],
|
|
1021
|
+
[0, 7]
|
|
1022
|
+
];
|
|
1023
|
+
/**
|
|
1024
|
+
* 解析一个 5 段 cron 表达式。
|
|
1025
|
+
* @returns 各字段匹配集合;表达式非法时返回 null。
|
|
1026
|
+
*/
|
|
1027
|
+
function parseCron(expr) {
|
|
1028
|
+
const fields = expr.trim().split(/\s+/);
|
|
1029
|
+
if (fields.length !== 5) return null;
|
|
1030
|
+
const sets = [];
|
|
1031
|
+
for (let index = 0; index < 5; index += 1) {
|
|
1032
|
+
const [min, max] = FIELD_RANGES[index];
|
|
1033
|
+
const set = /* @__PURE__ */ new Set();
|
|
1034
|
+
if (!parseField(fields[index], min, max, set)) return null;
|
|
1035
|
+
sets.push(set);
|
|
1036
|
+
}
|
|
1037
|
+
const weekdays = /* @__PURE__ */ new Set();
|
|
1038
|
+
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day);
|
|
1039
|
+
return {
|
|
1040
|
+
minutes: sets[0],
|
|
1041
|
+
hours: sets[1],
|
|
1042
|
+
days: sets[2],
|
|
1043
|
+
months: sets[3],
|
|
1044
|
+
weekdays,
|
|
1045
|
+
dayWildcard: fields[2] === "*",
|
|
1046
|
+
weekdayWildcard: fields[4] === "*"
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/** 表达式是否可解析(语法合法)。 */
|
|
1050
|
+
function isValidCron(expr) {
|
|
1051
|
+
return parseCron(expr) !== null;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* 计算 `fromMs`(ms epoch)之后的下一个匹配时刻(T006 §9.4/§13.1)。返回匹配
|
|
1055
|
+
* 分钟起点的 ms epoch,**严格大于** fromMs;日历约束永不可能满足时返回
|
|
1056
|
+
* undefined(如 `0 0 30 2 *`——五年视界包含完整闰周期,合法的 2 月 29 日
|
|
1057
|
+
* 计划在任意非闰年仍可达)。
|
|
1058
|
+
*
|
|
1059
|
+
* 语义(对齐参考实现 core/schedule.ts,T006 验收 3):
|
|
1060
|
+
* - **Host 本地时区**:按 `new Date(y, m, d, h, min)` 墙钟字段遍历候选,不换算
|
|
1061
|
+
* 到 UTC——DST 由本地时间语义自然承担;
|
|
1062
|
+
* - **日/周 OR 语义**:日字段与周字段字面 `*` 时各自视为未限制(见 parseCron),
|
|
1063
|
+
* 二者都受限时用 OR 组合(标准 cron);
|
|
1064
|
+
* - **DST 正确性**:墙钟字段构造 + 最终 `matches` 复检复现旧分钟扫描的 DST
|
|
1065
|
+
* 语义——春令时不存在的时间(如 02:30 → 03:30)被 JS 归一化前移,回拨的
|
|
1066
|
+
* 重复小时(fall-back)只访问第一次出现;本实现不逐分钟扫描,而是直接从
|
|
1067
|
+
* 解析后的字段集合遍历年/月/日/时/分候选,稀疏表达式(如 `0 0 29 2 *`)不会
|
|
1068
|
+
* 扫过约 150 万分钟。
|
|
1069
|
+
*/
|
|
1070
|
+
function nextRunAtMs(expr, fromMs) {
|
|
1071
|
+
const schedule = parseCron(expr);
|
|
1072
|
+
if (schedule === null) return void 0;
|
|
1073
|
+
if (!hasPossibleCalendarDay(schedule)) return void 0;
|
|
1074
|
+
const from = new Date(fromMs);
|
|
1075
|
+
const limitMs = fromMs + 158112e6;
|
|
1076
|
+
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b);
|
|
1077
|
+
const sortedHours = [...schedule.hours].sort((a, b) => a - b);
|
|
1078
|
+
const sortedMonths = [...schedule.months].sort((a, b) => a - b);
|
|
1079
|
+
let year = from.getFullYear();
|
|
1080
|
+
let month = from.getMonth() + 1;
|
|
1081
|
+
let day = from.getDate();
|
|
1082
|
+
let hour = from.getHours();
|
|
1083
|
+
let minute = from.getMinutes() + 1;
|
|
1084
|
+
while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
|
|
1085
|
+
for (const candidateMonth of sortedMonths) {
|
|
1086
|
+
if (candidateMonth < month) continue;
|
|
1087
|
+
const daysInMonth = new Date(year, candidateMonth, 0).getDate();
|
|
1088
|
+
const dayStart = candidateMonth === month ? day : 1;
|
|
1089
|
+
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
1090
|
+
if (!dayCandidate(schedule, new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0))) continue;
|
|
1091
|
+
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0;
|
|
1092
|
+
for (const candidateHour of sortedHours) {
|
|
1093
|
+
if (candidateHour < hourStart) continue;
|
|
1094
|
+
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0;
|
|
1095
|
+
for (const candidateMinute of sortedMinutes) {
|
|
1096
|
+
if (candidateMinute < minuteStart) continue;
|
|
1097
|
+
const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0);
|
|
1098
|
+
const time = candidate.getTime();
|
|
1099
|
+
if (time <= fromMs) continue;
|
|
1100
|
+
if (time > limitMs) return void 0;
|
|
1101
|
+
if (matches(schedule, candidate)) return time;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
year += 1;
|
|
1107
|
+
month = 1;
|
|
1108
|
+
day = 1;
|
|
1109
|
+
hour = 0;
|
|
1110
|
+
minute = 0;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
/** 日/周 OR 门(`matches` 与候选扫描共用)。 */
|
|
1114
|
+
function dayCandidate(schedule, date) {
|
|
1115
|
+
const dayMatches = schedule.days.has(date.getDate());
|
|
1116
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay());
|
|
1117
|
+
if (schedule.dayWildcard) return weekdayMatches;
|
|
1118
|
+
if (schedule.weekdayWildcard) return dayMatches;
|
|
1119
|
+
return dayMatches || weekdayMatches;
|
|
1120
|
+
}
|
|
1121
|
+
/** 拒绝不可能匹配的月/日组合,免去多年扫描(如 `0 0 30 2 *`)。 */
|
|
1122
|
+
function hasPossibleCalendarDay(schedule) {
|
|
1123
|
+
if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
|
|
1124
|
+
const maximumDay = /* @__PURE__ */ new Map([
|
|
1125
|
+
[1, 31],
|
|
1126
|
+
[2, 29],
|
|
1127
|
+
[3, 31],
|
|
1128
|
+
[4, 30],
|
|
1129
|
+
[5, 31],
|
|
1130
|
+
[6, 30],
|
|
1131
|
+
[7, 31],
|
|
1132
|
+
[8, 31],
|
|
1133
|
+
[9, 30],
|
|
1134
|
+
[10, 31],
|
|
1135
|
+
[11, 30],
|
|
1136
|
+
[12, 31]
|
|
1137
|
+
]);
|
|
1138
|
+
for (const month of schedule.months) {
|
|
1139
|
+
const maximum = maximumDay.get(month) ?? 0;
|
|
1140
|
+
if ([...schedule.days].some((day) => day <= maximum)) return true;
|
|
1141
|
+
}
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
/** 日/周 OR 语义:受限的日字段单独门控,周字段反之亦然(标准 cron)。 */
|
|
1145
|
+
function matches(schedule, date) {
|
|
1146
|
+
if (!schedule.minutes.has(date.getMinutes())) return false;
|
|
1147
|
+
if (!schedule.hours.has(date.getHours())) return false;
|
|
1148
|
+
if (!schedule.months.has(date.getMonth() + 1)) return false;
|
|
1149
|
+
return dayCandidate(schedule, date);
|
|
1150
|
+
}
|
|
1151
|
+
/** 解析一个逗号列表字段,写入匹配集合;非法返回 false。 */
|
|
1152
|
+
function parseField(field, min, max, out) {
|
|
1153
|
+
if (field === "*") {
|
|
1154
|
+
for (let value = min; value <= max; value += 1) out.add(value);
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
for (const part of field.split(",")) {
|
|
1158
|
+
if (part === "") return false;
|
|
1159
|
+
const [range, stepRaw] = part.split("/");
|
|
1160
|
+
let low;
|
|
1161
|
+
let high;
|
|
1162
|
+
if (range === "*") {
|
|
1163
|
+
low = min;
|
|
1164
|
+
high = max;
|
|
1165
|
+
} else if (range.includes("-")) {
|
|
1166
|
+
const [a, b] = range.split("-");
|
|
1167
|
+
if (a === "" || b === "" || !isDigits(a) || !isDigits(b)) return false;
|
|
1168
|
+
low = Number(a);
|
|
1169
|
+
high = Number(b);
|
|
1170
|
+
} else if (isDigits(range)) {
|
|
1171
|
+
low = Number(range);
|
|
1172
|
+
high = Number(range);
|
|
1173
|
+
} else return false;
|
|
1174
|
+
if (low < min || high > max || low > high) return false;
|
|
1175
|
+
const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
|
|
1176
|
+
if (!Number.isInteger(step) || step < 1) return false;
|
|
1177
|
+
for (let value = low; value <= high; value += step) out.add(value);
|
|
1178
|
+
}
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
function isDigits(value) {
|
|
1182
|
+
return /^\d+$/.test(value);
|
|
1183
|
+
}
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/protocol.ts
|
|
1186
|
+
/** 本项目 API 前缀(AGENTS.md D2,2026-08-22 定值;参考实现占用 /api/task-board)。 */
|
|
1187
|
+
const TASK_BOARD_API_PREFIX = "/api/nova-task-board";
|
|
1188
|
+
function record(value) {
|
|
1189
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
1190
|
+
}
|
|
1191
|
+
function exactKeys(value, allowed) {
|
|
1192
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
1193
|
+
}
|
|
1194
|
+
function optionalString(value) {
|
|
1195
|
+
return value === void 0 || typeof value === "string";
|
|
1196
|
+
}
|
|
1197
|
+
function optionalFiniteNumber(value) {
|
|
1198
|
+
return value === void 0 || typeof value === "number" && Number.isFinite(value);
|
|
1199
|
+
}
|
|
1200
|
+
/** import/外部载荷递归拒绝的命令类字段(§16.2/§19-9,大小写不敏感)。 */
|
|
1201
|
+
const FORBIDDEN_IMPORT_FIELDS = /* @__PURE__ */ new Set([
|
|
1202
|
+
"args",
|
|
1203
|
+
"command",
|
|
1204
|
+
"executable",
|
|
1205
|
+
"powershell",
|
|
1206
|
+
"shell"
|
|
1207
|
+
]);
|
|
1208
|
+
/**
|
|
1209
|
+
* 载荷是否含命令类字段(§16.2/§19-9):对键名递归检查
|
|
1210
|
+
* `args/command/executable/powershell/shell`(大小写不敏感)。import 与
|
|
1211
|
+
* GitHub Webhook 外部载荷共用(T010 §14.3:自动收集内容不携带命令结构)。
|
|
1212
|
+
*/
|
|
1213
|
+
function hasForbiddenCommandFields(value) {
|
|
1214
|
+
if (Array.isArray(value)) return value.some(hasForbiddenCommandFields);
|
|
1215
|
+
const row = record(value);
|
|
1216
|
+
if (row === void 0) return false;
|
|
1217
|
+
return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenCommandFields(nested));
|
|
1218
|
+
}
|
|
1219
|
+
/** import 执行记录的严格校验与规范化(§9.3)。 */
|
|
1220
|
+
function strictImportedExecution(value) {
|
|
1221
|
+
const entry = record(value);
|
|
1222
|
+
if (entry === void 0 || typeof entry.id !== "string" || entry.id === "") return void 0;
|
|
1223
|
+
if (!isFiniteNumber(entry.startedAt)) return void 0;
|
|
1224
|
+
if (!optionalString(entry.sessionId)) return void 0;
|
|
1225
|
+
if (!optionalFiniteNumber(entry.endedAt)) return void 0;
|
|
1226
|
+
if (entry.result !== void 0 && ![
|
|
1227
|
+
"succeeded",
|
|
1228
|
+
"failed",
|
|
1229
|
+
"cancelled"
|
|
1230
|
+
].includes(String(entry.result))) return void 0;
|
|
1231
|
+
if (!optionalString(entry.error)) return void 0;
|
|
1232
|
+
const execution = {
|
|
1233
|
+
id: entry.id,
|
|
1234
|
+
startedAt: entry.startedAt
|
|
1235
|
+
};
|
|
1236
|
+
if (typeof entry.sessionId === "string") execution.sessionId = entry.sessionId;
|
|
1237
|
+
if (isFiniteNumber(entry.endedAt)) execution.endedAt = entry.endedAt;
|
|
1238
|
+
if ([
|
|
1239
|
+
"succeeded",
|
|
1240
|
+
"failed",
|
|
1241
|
+
"cancelled"
|
|
1242
|
+
].includes(String(entry.result))) execution.result = entry.result;
|
|
1243
|
+
if (typeof entry.error === "string") execution.error = entry.error;
|
|
1244
|
+
return execution;
|
|
1245
|
+
}
|
|
1246
|
+
/** import 执行安排(§9.4 判别联合)的严格校验:cron 语法合法(isValidCron)、时间戳有限。 */
|
|
1247
|
+
function strictImportedSchedule(value) {
|
|
1248
|
+
const rule = record(value);
|
|
1249
|
+
if (rule === void 0) return void 0;
|
|
1250
|
+
if (rule.kind === "one-shot") {
|
|
1251
|
+
if (!isFiniteNumber(rule.runAt) || !optionalFiniteNumber(rule.firedAt)) return void 0;
|
|
1252
|
+
const schedule = {
|
|
1253
|
+
kind: "one-shot",
|
|
1254
|
+
runAt: rule.runAt
|
|
1255
|
+
};
|
|
1256
|
+
if (isFiniteNumber(rule.firedAt)) schedule.firedAt = rule.firedAt;
|
|
1257
|
+
return schedule;
|
|
1258
|
+
}
|
|
1259
|
+
if (rule.kind !== void 0 && rule.kind !== "cron") return void 0;
|
|
1260
|
+
if (typeof rule.enabled !== "boolean" || typeof rule.cron !== "string") return void 0;
|
|
1261
|
+
if (!isValidCron(rule.cron)) return void 0;
|
|
1262
|
+
if (!optionalFiniteNumber(rule.nextRunAt) || !optionalFiniteNumber(rule.lastTriggeredAt)) return void 0;
|
|
1263
|
+
const schedule = {
|
|
1264
|
+
kind: "cron",
|
|
1265
|
+
enabled: rule.enabled,
|
|
1266
|
+
cron: rule.cron
|
|
1267
|
+
};
|
|
1268
|
+
if (isFiniteNumber(rule.nextRunAt)) schedule.nextRunAt = rule.nextRunAt;
|
|
1269
|
+
if (isFiniteNumber(rule.lastTriggeredAt)) schedule.lastTriggeredAt = rule.lastTriggeredAt;
|
|
1270
|
+
return schedule;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* import 任务行的严格校验与规范化。校验失败(命令类字段、类型/枚举/时间戳/
|
|
1274
|
+
* cron 非法)整行拒绝;未知字段剥离(v1 历史字段如 priority 不阻断迁移)。
|
|
1275
|
+
*/
|
|
1276
|
+
function strictImportedTask(value) {
|
|
1277
|
+
const input = record(value);
|
|
1278
|
+
if (input === void 0 || hasForbiddenCommandFields(input)) return void 0;
|
|
1279
|
+
if (typeof input.id !== "string" || input.id === "") return void 0;
|
|
1280
|
+
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return;
|
|
1281
|
+
if (!isTaskStatus(input.status)) return void 0;
|
|
1282
|
+
if (!isFiniteNumber(input.createdAt) || !isFiniteNumber(input.updatedAt)) return void 0;
|
|
1283
|
+
if (input.source !== void 0 && !isTaskSource(input.source)) return void 0;
|
|
1284
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return void 0;
|
|
1285
|
+
if (input.permission !== void 0 && !isTaskPermission(input.permission)) return void 0;
|
|
1286
|
+
if (input.archivedAt !== void 0 && !isFiniteNumber(input.archivedAt)) return void 0;
|
|
1287
|
+
if (input.tags !== void 0 && (!Array.isArray(input.tags) || !input.tags.every((tag) => typeof tag === "string"))) return;
|
|
1288
|
+
if (input.project !== void 0 && typeof input.project !== "string") return void 0;
|
|
1289
|
+
if (input.parentId !== void 0 && typeof input.parentId !== "string") return void 0;
|
|
1290
|
+
if (input.order !== void 0 && !isFiniteNumber(input.order)) return void 0;
|
|
1291
|
+
if (!Array.isArray(input.executions)) return void 0;
|
|
1292
|
+
const executions = [];
|
|
1293
|
+
for (const entry of input.executions) {
|
|
1294
|
+
const execution = strictImportedExecution(entry);
|
|
1295
|
+
if (execution === void 0) return void 0;
|
|
1296
|
+
executions.push(execution);
|
|
1297
|
+
}
|
|
1298
|
+
if (input.schedule !== void 0) {
|
|
1299
|
+
if (strictImportedSchedule(input.schedule) === void 0) return void 0;
|
|
1300
|
+
}
|
|
1301
|
+
const task = {
|
|
1302
|
+
id: input.id,
|
|
1303
|
+
title: input.title,
|
|
1304
|
+
description: input.description,
|
|
1305
|
+
prompt: input.prompt,
|
|
1306
|
+
status: input.status,
|
|
1307
|
+
createdAt: input.createdAt,
|
|
1308
|
+
updatedAt: input.updatedAt,
|
|
1309
|
+
executions,
|
|
1310
|
+
tags: input.tags === void 0 ? [] : [...input.tags],
|
|
1311
|
+
comments: [],
|
|
1312
|
+
artifacts: [],
|
|
1313
|
+
order: input.order === void 0 ? 0 : input.order
|
|
1314
|
+
};
|
|
1315
|
+
if (typeof input.source === "string" && isTaskSource(input.source)) task.source = input.source;
|
|
1316
|
+
if (typeof input.workspaceId === "string" && input.workspaceId.trim() !== "") task.workspaceId = input.workspaceId.trim();
|
|
1317
|
+
if (typeof input.mode === "string" && input.mode.trim() !== "") task.mode = input.mode.trim();
|
|
1318
|
+
if (isTaskPermission(input.permission)) task.permission = input.permission;
|
|
1319
|
+
if (isFiniteNumber(input.archivedAt)) task.archivedAt = input.archivedAt;
|
|
1320
|
+
const schedule = strictImportedSchedule(input.schedule);
|
|
1321
|
+
if (schedule !== void 0) task.schedule = schedule;
|
|
1322
|
+
if (typeof input.project === "string" && input.project.trim() !== "") task.project = input.project.trim();
|
|
1323
|
+
if (typeof input.parentId === "string" && input.parentId.trim() !== "") task.parentId = input.parentId.trim();
|
|
1324
|
+
return task;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* create 输入的严格校验(§11.2:exact keys、类型、枚举;无 schedule——归 T006)。
|
|
1328
|
+
* T008 扩展:`project` 可选字符串、`tags` 可选字符串数组(元素必须是字符串,
|
|
1329
|
+
* 内容清洗/去重由 transitions 的 normalizeTags 落账层完成)。
|
|
1330
|
+
*/
|
|
1331
|
+
function createInput(value) {
|
|
1332
|
+
const input = record(value);
|
|
1333
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1334
|
+
"title",
|
|
1335
|
+
"description",
|
|
1336
|
+
"prompt",
|
|
1337
|
+
"workspaceId",
|
|
1338
|
+
"mode",
|
|
1339
|
+
"permission",
|
|
1340
|
+
"project",
|
|
1341
|
+
"tags"
|
|
1342
|
+
])) return false;
|
|
1343
|
+
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
1344
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false;
|
|
1345
|
+
if (!optionalString(input.project)) return false;
|
|
1346
|
+
if (input.tags !== void 0 && (!Array.isArray(input.tags) || !input.tags.every((tag) => typeof tag === "string"))) return false;
|
|
1347
|
+
return input.permission === void 0 || isTaskPermission(input.permission);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* propose 输入的严格校验(§11.2/§16.7,T011):在 create 输入之上扩展
|
|
1351
|
+
* `sourceConversationId`/`sourceMessageId` 两个可选字符串(来源会话/消息引用,
|
|
1352
|
+
* 写入 metadata 供跳回对话追溯);**递归拒绝命令类字段**(§16.7 安全红线,
|
|
1353
|
+
* 与 import/Webhook 同一条红线——候选内容永不被解释为可执行结构)。
|
|
1354
|
+
* 标题/描述/prompt 的内容清洗(控制字符/长度)由状态机落账层完成
|
|
1355
|
+
* (transitions.applyProposeTask)。
|
|
1356
|
+
*/
|
|
1357
|
+
function proposeInput(value) {
|
|
1358
|
+
const input = record(value);
|
|
1359
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1360
|
+
"title",
|
|
1361
|
+
"description",
|
|
1362
|
+
"prompt",
|
|
1363
|
+
"workspaceId",
|
|
1364
|
+
"mode",
|
|
1365
|
+
"permission",
|
|
1366
|
+
"project",
|
|
1367
|
+
"tags",
|
|
1368
|
+
"sourceConversationId",
|
|
1369
|
+
"sourceMessageId"
|
|
1370
|
+
])) return false;
|
|
1371
|
+
if (hasForbiddenCommandFields(input)) return false;
|
|
1372
|
+
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
1373
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false;
|
|
1374
|
+
if (!optionalString(input.project)) return false;
|
|
1375
|
+
if (!optionalString(input.sourceConversationId) || !optionalString(input.sourceMessageId)) return false;
|
|
1376
|
+
if (input.tags !== void 0 && (!Array.isArray(input.tags) || !input.tags.every((tag) => typeof tag === "string"))) return false;
|
|
1377
|
+
return input.permission === void 0 || isTaskPermission(input.permission);
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* propose-batch 条目的严格校验(§11.2/§16.8,T012):在 propose 输入之上扩展
|
|
1381
|
+
* `parentId` 可选字符串(父需求任务引用;必须指向账本内已存在且非自身的任务、
|
|
1382
|
+
* 禁止环引用——引用合法性由状态机落账层校验,见 transitions.applyProposeBatch)。
|
|
1383
|
+
* 命令类字段递归拒绝(与 propose/import 同一条红线)。
|
|
1384
|
+
*/
|
|
1385
|
+
function batchProposeItem(value) {
|
|
1386
|
+
const input = record(value);
|
|
1387
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1388
|
+
"title",
|
|
1389
|
+
"description",
|
|
1390
|
+
"prompt",
|
|
1391
|
+
"workspaceId",
|
|
1392
|
+
"mode",
|
|
1393
|
+
"permission",
|
|
1394
|
+
"project",
|
|
1395
|
+
"tags",
|
|
1396
|
+
"sourceConversationId",
|
|
1397
|
+
"sourceMessageId",
|
|
1398
|
+
"parentId"
|
|
1399
|
+
])) return false;
|
|
1400
|
+
if (hasForbiddenCommandFields(input)) return false;
|
|
1401
|
+
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
1402
|
+
if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false;
|
|
1403
|
+
if (!optionalString(input.project)) return false;
|
|
1404
|
+
if (!optionalString(input.sourceConversationId) || !optionalString(input.sourceMessageId)) return false;
|
|
1405
|
+
if (!optionalString(input.parentId)) return false;
|
|
1406
|
+
if (input.tags !== void 0 && (!Array.isArray(input.tags) || !input.tags.every((tag) => typeof tag === "string"))) return false;
|
|
1407
|
+
return input.permission === void 0 || isTaskPermission(input.permission);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* start-split 输入的严格校验(§11.2/§12.9,T012):标题必填非空;`text` 与
|
|
1411
|
+
* `filePath` 至少其一(需求来源);project/parentTaskId 可选字符串、tags 可选
|
|
1412
|
+
* 字符串数组。需求文本是发给 agent 的数据(同任务 prompt 语义),**不做命令类
|
|
1413
|
+
* 字段拒绝**——控制字符/长度清洗在拆分结算落账层(transitions/split)。
|
|
1414
|
+
*/
|
|
1415
|
+
function splitRequestInput(value) {
|
|
1416
|
+
const input = record(value);
|
|
1417
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1418
|
+
"title",
|
|
1419
|
+
"text",
|
|
1420
|
+
"filePath",
|
|
1421
|
+
"project",
|
|
1422
|
+
"tags",
|
|
1423
|
+
"parentTaskId"
|
|
1424
|
+
])) return false;
|
|
1425
|
+
if (typeof input.title !== "string" || input.title.trim() === "") return false;
|
|
1426
|
+
if (!optionalString(input.text) || !optionalString(input.filePath)) return false;
|
|
1427
|
+
if (!optionalString(input.project) || !optionalString(input.parentTaskId)) return false;
|
|
1428
|
+
if (input.tags !== void 0 && (!Array.isArray(input.tags) || !input.tags.every((tag) => typeof tag === "string"))) return false;
|
|
1429
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
1430
|
+
const filePath = typeof input.filePath === "string" ? input.filePath.trim() : "";
|
|
1431
|
+
return text !== "" || filePath !== "";
|
|
1432
|
+
}
|
|
1433
|
+
/** update 补丁的严格校验(§11.2:可编辑字段全集;Host/runner 独占字段不在补丁面内)。T008:project/tags 并入补丁面。 */
|
|
1434
|
+
function updatePatch(value) {
|
|
1435
|
+
const patch = record(value);
|
|
1436
|
+
if (patch === void 0 || !exactKeys(patch, [
|
|
1437
|
+
"title",
|
|
1438
|
+
"description",
|
|
1439
|
+
"prompt",
|
|
1440
|
+
"workspaceId",
|
|
1441
|
+
"mode",
|
|
1442
|
+
"permission",
|
|
1443
|
+
"project",
|
|
1444
|
+
"tags"
|
|
1445
|
+
])) return false;
|
|
1446
|
+
for (const key of [
|
|
1447
|
+
"title",
|
|
1448
|
+
"description",
|
|
1449
|
+
"prompt",
|
|
1450
|
+
"workspaceId",
|
|
1451
|
+
"mode",
|
|
1452
|
+
"project"
|
|
1453
|
+
]) if (!optionalString(patch[key])) return false;
|
|
1454
|
+
if (patch.tags !== void 0 && (!Array.isArray(patch.tags) || !patch.tags.every((tag) => typeof tag === "string"))) return false;
|
|
1455
|
+
return patch.permission === void 0 || isTaskPermission(patch.permission);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* set-schedule 补丁的严格校验(§11.2:exact keys 仅 enabled/cron;cron 出现时
|
|
1459
|
+
* 必须语法合法;两者皆缺 = 空补丁拒绝)。nextRunAt/lastTriggeredAt 是 Host
|
|
1460
|
+
* 独占字段,不在补丁面内(§9.4,浏览器不可写)。
|
|
1461
|
+
*/
|
|
1462
|
+
function schedulePatch(value) {
|
|
1463
|
+
const patch = record(value);
|
|
1464
|
+
if (patch === void 0 || !exactKeys(patch, ["enabled", "cron"])) return false;
|
|
1465
|
+
if (patch.enabled !== void 0 && typeof patch.enabled !== "boolean") return false;
|
|
1466
|
+
if (patch.cron !== void 0 && (typeof patch.cron !== "string" || !isValidCron(patch.cron))) return false;
|
|
1467
|
+
return patch.enabled !== void 0 || patch.cron !== void 0;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* set-one-shot 补丁的严格校验(§11.2:exact keys 仅 runAt;出现时必须是有限
|
|
1471
|
+
* 毫秒时间戳)。空补丁合法——runAt 缺省或 0 = 取消(§9.4 未到时刻前可取消,
|
|
1472
|
+
* 与 set-schedule「空补丁拒绝」不同:cron 空补丁无意义,one-shot 空补丁即取消)。
|
|
1473
|
+
*/
|
|
1474
|
+
function oneShotPatch(value) {
|
|
1475
|
+
const patch = record(value);
|
|
1476
|
+
if (patch === void 0 || !exactKeys(patch, ["runAt"])) return false;
|
|
1477
|
+
if (patch.runAt !== void 0 && !isFiniteNumber(patch.runAt)) return false;
|
|
1478
|
+
return true;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* add-comment 输入的严格校验(§11.2:exact keys 仅 author/body/type)。
|
|
1482
|
+
* body 必填非空字符串;author/type 可选(author 字符串、type 枚举)。
|
|
1483
|
+
* 内容清洗(控制字符/长度)由状态机落账层完成(transitions.applyAddComment)。
|
|
1484
|
+
*/
|
|
1485
|
+
function commentInput(value) {
|
|
1486
|
+
const input = record(value);
|
|
1487
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1488
|
+
"author",
|
|
1489
|
+
"body",
|
|
1490
|
+
"type"
|
|
1491
|
+
])) return false;
|
|
1492
|
+
if (typeof input.body !== "string" || input.body.trim() === "") return false;
|
|
1493
|
+
if (!optionalString(input.author)) return false;
|
|
1494
|
+
if (input.type !== void 0 && !isCommentType(input.type)) return false;
|
|
1495
|
+
return true;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* add-artifact 输入的严格校验(§11.2:exact keys 仅 type/title/url/contentRef)。
|
|
1499
|
+
* type 枚举、title 必填非空字符串、url/contentRef 可选字符串。
|
|
1500
|
+
*/
|
|
1501
|
+
function artifactInput(value) {
|
|
1502
|
+
const input = record(value);
|
|
1503
|
+
if (input === void 0 || !exactKeys(input, [
|
|
1504
|
+
"type",
|
|
1505
|
+
"title",
|
|
1506
|
+
"url",
|
|
1507
|
+
"contentRef"
|
|
1508
|
+
])) return false;
|
|
1509
|
+
if (input.type === void 0 || !isArtifactType(input.type)) return false;
|
|
1510
|
+
if (typeof input.title !== "string" || input.title.trim() === "") return false;
|
|
1511
|
+
if (!optionalString(input.url) || !optionalString(input.contentRef)) return false;
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* update-context 补丁的严格校验(§11.2:exact keys 仅六个可编辑字段;空补丁
|
|
1516
|
+
* 拒绝)。字符串字段可选字符串、数组字段可选字符串数组;updatedAt 是 Host
|
|
1517
|
+
* 独占字段,不在补丁面内。
|
|
1518
|
+
*/
|
|
1519
|
+
function contextPatch(value) {
|
|
1520
|
+
const patch = record(value);
|
|
1521
|
+
if (patch === void 0 || !exactKeys(patch, [
|
|
1522
|
+
"goal",
|
|
1523
|
+
"keyDecisions",
|
|
1524
|
+
"filePaths",
|
|
1525
|
+
"lastAiSummary",
|
|
1526
|
+
"latestUserFeedback",
|
|
1527
|
+
"relatedLinks"
|
|
1528
|
+
])) return false;
|
|
1529
|
+
for (const key of [
|
|
1530
|
+
"goal",
|
|
1531
|
+
"lastAiSummary",
|
|
1532
|
+
"latestUserFeedback"
|
|
1533
|
+
]) if (!optionalString(patch[key])) return false;
|
|
1534
|
+
for (const key of [
|
|
1535
|
+
"keyDecisions",
|
|
1536
|
+
"filePaths",
|
|
1537
|
+
"relatedLinks"
|
|
1538
|
+
]) if (patch[key] !== void 0 && (!Array.isArray(patch[key]) || !patch[key].every((item) => typeof item === "string"))) return false;
|
|
1539
|
+
return Object.keys(patch).length > 0;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* upsert-automation 输入的严格校验(§11.2:exact keys 仅 id/enabled/source/
|
|
1543
|
+
* trigger/config/filter;trigger 仅 kind/cron;filter 仅 labels)。
|
|
1544
|
+
* cron 语法合法、source 枚举、config 全部字符串值;nextRunAt/lastTriggeredAt
|
|
1545
|
+
* 为 Host 独占字段,不在输入面内。
|
|
1546
|
+
*/
|
|
1547
|
+
function automationRuleInput(value) {
|
|
1548
|
+
const rule = record(value);
|
|
1549
|
+
if (rule === void 0 || !exactKeys(rule, [
|
|
1550
|
+
"id",
|
|
1551
|
+
"enabled",
|
|
1552
|
+
"source",
|
|
1553
|
+
"trigger",
|
|
1554
|
+
"config",
|
|
1555
|
+
"filter"
|
|
1556
|
+
])) return false;
|
|
1557
|
+
if (typeof rule.id !== "string" || rule.id.trim() === "" || rule.id.length > 128) return false;
|
|
1558
|
+
if (typeof rule.enabled !== "boolean") return false;
|
|
1559
|
+
if (rule.source === void 0 || !isAutomationSource(rule.source)) return false;
|
|
1560
|
+
const trigger = record(rule.trigger);
|
|
1561
|
+
if (trigger === void 0 || !exactKeys(trigger, ["kind", "cron"])) return false;
|
|
1562
|
+
if (trigger.kind !== "cron" || typeof trigger.cron !== "string" || !isValidCron(trigger.cron)) return false;
|
|
1563
|
+
const config = record(rule.config);
|
|
1564
|
+
if (config === void 0 || !Object.values(config).every((value) => typeof value === "string")) return false;
|
|
1565
|
+
const filter = record(rule.filter);
|
|
1566
|
+
if (filter === void 0 || !exactKeys(filter, ["labels"])) return false;
|
|
1567
|
+
if (!Array.isArray(filter.labels) || !filter.labels.every((label) => typeof label === "string")) return false;
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* 解析幂等 action 信封(§11.2/§11.3)。任何结构/类型/枚举/时间戳/cron/未知
|
|
1572
|
+
* 字段/未知 action 问题 → undefined(路由层 400 invalid-action)。返回的 action
|
|
1573
|
+
* 为规范化对象(import 任务行经严格校验与字段剥离)。
|
|
1574
|
+
*
|
|
1575
|
+
* T009(§11.2/D10):
|
|
1576
|
+
* - `reorder`:`order` 必填有限数字(浏览器提交的落位下标,0-based;Host 重算
|
|
1577
|
+
* 同列唯一,不信任浏览器序号——见 transitions.applyReorderTask);`status` 须
|
|
1578
|
+
* 为合法状态且与任务当前状态一致(防竞态,状态机层校验);`project` 可选字符串
|
|
1579
|
+
* (空串 = 清除归属,沿用 update 语义);`tags` 可选字符串数组(整体替换,
|
|
1580
|
+
* 沿用 update 的 normalizeTags 语义)。
|
|
1581
|
+
* - `move`:新增可选 `order`(有限数字 = 目标列落位下标,缺省追加目标列末尾)。
|
|
1582
|
+
*/
|
|
1583
|
+
function parseActionEnvelope(value) {
|
|
1584
|
+
const envelope = record(value);
|
|
1585
|
+
if (envelope === void 0 || !exactKeys(envelope, ["requestId", "action"])) return void 0;
|
|
1586
|
+
if (typeof envelope.requestId !== "string" || envelope.requestId.trim() === "" || envelope.requestId.length > 256) return;
|
|
1587
|
+
const action = record(envelope.action);
|
|
1588
|
+
if (action === void 0 || typeof action.kind !== "string") return void 0;
|
|
1589
|
+
const taskId = typeof action.taskId === "string" && action.taskId.trim() !== "" ? action.taskId : void 0;
|
|
1590
|
+
switch (action.kind) {
|
|
1591
|
+
case "import":
|
|
1592
|
+
if (!exactKeys(action, [
|
|
1593
|
+
"kind",
|
|
1594
|
+
"sourceId",
|
|
1595
|
+
"tasks"
|
|
1596
|
+
])) return void 0;
|
|
1597
|
+
if (typeof action.sourceId !== "string" || action.sourceId === "" || !Array.isArray(action.tasks)) return void 0;
|
|
1598
|
+
{
|
|
1599
|
+
const tasks = [];
|
|
1600
|
+
for (const row of action.tasks) {
|
|
1601
|
+
const task = strictImportedTask(row);
|
|
1602
|
+
if (task === void 0) return void 0;
|
|
1603
|
+
tasks.push(task);
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
requestId: envelope.requestId,
|
|
1607
|
+
action: {
|
|
1608
|
+
kind: "import",
|
|
1609
|
+
sourceId: action.sourceId,
|
|
1610
|
+
tasks
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
case "create":
|
|
1615
|
+
if (!exactKeys(action, [
|
|
1616
|
+
"kind",
|
|
1617
|
+
"id",
|
|
1618
|
+
"input"
|
|
1619
|
+
])) return void 0;
|
|
1620
|
+
return typeof action.id === "string" && action.id.trim() !== "" && createInput(action.input) ? {
|
|
1621
|
+
requestId: envelope.requestId,
|
|
1622
|
+
action: {
|
|
1623
|
+
kind: "create",
|
|
1624
|
+
id: action.id,
|
|
1625
|
+
input: action.input
|
|
1626
|
+
}
|
|
1627
|
+
} : void 0;
|
|
1628
|
+
case "propose":
|
|
1629
|
+
if (!exactKeys(action, [
|
|
1630
|
+
"kind",
|
|
1631
|
+
"id",
|
|
1632
|
+
"input"
|
|
1633
|
+
])) return void 0;
|
|
1634
|
+
return typeof action.id === "string" && action.id.trim() !== "" && proposeInput(action.input) ? {
|
|
1635
|
+
requestId: envelope.requestId,
|
|
1636
|
+
action: {
|
|
1637
|
+
kind: "propose",
|
|
1638
|
+
id: action.id,
|
|
1639
|
+
input: action.input
|
|
1640
|
+
}
|
|
1641
|
+
} : void 0;
|
|
1642
|
+
case "propose-batch":
|
|
1643
|
+
if (!exactKeys(action, [
|
|
1644
|
+
"kind",
|
|
1645
|
+
"id",
|
|
1646
|
+
"items"
|
|
1647
|
+
])) return void 0;
|
|
1648
|
+
if (typeof action.id !== "string" || action.id.trim() === "" || !Array.isArray(action.items)) return void 0;
|
|
1649
|
+
if (action.items.length === 0) return void 0;
|
|
1650
|
+
{
|
|
1651
|
+
const items = [];
|
|
1652
|
+
for (const row of action.items) {
|
|
1653
|
+
if (!batchProposeItem(row)) return void 0;
|
|
1654
|
+
items.push(row);
|
|
1655
|
+
}
|
|
1656
|
+
return {
|
|
1657
|
+
requestId: envelope.requestId,
|
|
1658
|
+
action: {
|
|
1659
|
+
kind: "propose-batch",
|
|
1660
|
+
id: action.id,
|
|
1661
|
+
items
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
case "start-split":
|
|
1666
|
+
if (!exactKeys(action, [
|
|
1667
|
+
"kind",
|
|
1668
|
+
"id",
|
|
1669
|
+
"input"
|
|
1670
|
+
])) return void 0;
|
|
1671
|
+
return typeof action.id === "string" && action.id.trim() !== "" && splitRequestInput(action.input) ? {
|
|
1672
|
+
requestId: envelope.requestId,
|
|
1673
|
+
action: {
|
|
1674
|
+
kind: "start-split",
|
|
1675
|
+
id: action.id,
|
|
1676
|
+
input: action.input
|
|
1677
|
+
}
|
|
1678
|
+
} : void 0;
|
|
1679
|
+
case "confirm":
|
|
1680
|
+
if (!exactKeys(action, [
|
|
1681
|
+
"kind",
|
|
1682
|
+
"taskId",
|
|
1683
|
+
"target"
|
|
1684
|
+
])) return void 0;
|
|
1685
|
+
return taskId !== void 0 && (action.target === "backlog" || action.target === "todo") ? {
|
|
1686
|
+
requestId: envelope.requestId,
|
|
1687
|
+
action: {
|
|
1688
|
+
kind: "confirm",
|
|
1689
|
+
taskId,
|
|
1690
|
+
target: action.target
|
|
1691
|
+
}
|
|
1692
|
+
} : void 0;
|
|
1693
|
+
case "dismiss":
|
|
1694
|
+
if (!exactKeys(action, ["kind", "taskId"])) return void 0;
|
|
1695
|
+
return taskId === void 0 ? void 0 : {
|
|
1696
|
+
requestId: envelope.requestId,
|
|
1697
|
+
action: {
|
|
1698
|
+
kind: "dismiss",
|
|
1699
|
+
taskId
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
case "update":
|
|
1703
|
+
if (!exactKeys(action, [
|
|
1704
|
+
"kind",
|
|
1705
|
+
"taskId",
|
|
1706
|
+
"patch"
|
|
1707
|
+
])) return void 0;
|
|
1708
|
+
return taskId !== void 0 && updatePatch(action.patch) ? {
|
|
1709
|
+
requestId: envelope.requestId,
|
|
1710
|
+
action: {
|
|
1711
|
+
kind: "update",
|
|
1712
|
+
taskId,
|
|
1713
|
+
patch: action.patch
|
|
1714
|
+
}
|
|
1715
|
+
} : void 0;
|
|
1716
|
+
case "set-schedule":
|
|
1717
|
+
if (!exactKeys(action, [
|
|
1718
|
+
"kind",
|
|
1719
|
+
"taskId",
|
|
1720
|
+
"patch"
|
|
1721
|
+
])) return void 0;
|
|
1722
|
+
return taskId !== void 0 && schedulePatch(action.patch) ? {
|
|
1723
|
+
requestId: envelope.requestId,
|
|
1724
|
+
action: {
|
|
1725
|
+
kind: "set-schedule",
|
|
1726
|
+
taskId,
|
|
1727
|
+
patch: action.patch
|
|
1728
|
+
}
|
|
1729
|
+
} : void 0;
|
|
1730
|
+
case "set-one-shot":
|
|
1731
|
+
if (!exactKeys(action, [
|
|
1732
|
+
"kind",
|
|
1733
|
+
"taskId",
|
|
1734
|
+
"patch"
|
|
1735
|
+
])) return void 0;
|
|
1736
|
+
return taskId !== void 0 && oneShotPatch(action.patch) ? {
|
|
1737
|
+
requestId: envelope.requestId,
|
|
1738
|
+
action: {
|
|
1739
|
+
kind: "set-one-shot",
|
|
1740
|
+
taskId,
|
|
1741
|
+
patch: action.patch
|
|
1742
|
+
}
|
|
1743
|
+
} : void 0;
|
|
1744
|
+
case "move":
|
|
1745
|
+
if (!exactKeys(action, [
|
|
1746
|
+
"kind",
|
|
1747
|
+
"taskId",
|
|
1748
|
+
"status",
|
|
1749
|
+
"order"
|
|
1750
|
+
])) return void 0;
|
|
1751
|
+
if (taskId === void 0 || !isTaskStatus(action.status)) return void 0;
|
|
1752
|
+
if (action.order !== void 0 && !isFiniteNumber(action.order)) return void 0;
|
|
1753
|
+
return action.order === void 0 ? {
|
|
1754
|
+
requestId: envelope.requestId,
|
|
1755
|
+
action: {
|
|
1756
|
+
kind: "move",
|
|
1757
|
+
taskId,
|
|
1758
|
+
status: action.status
|
|
1759
|
+
}
|
|
1760
|
+
} : {
|
|
1761
|
+
requestId: envelope.requestId,
|
|
1762
|
+
action: {
|
|
1763
|
+
kind: "move",
|
|
1764
|
+
taskId,
|
|
1765
|
+
status: action.status,
|
|
1766
|
+
order: action.order
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
case "reorder":
|
|
1770
|
+
if (!exactKeys(action, [
|
|
1771
|
+
"kind",
|
|
1772
|
+
"taskId",
|
|
1773
|
+
"status",
|
|
1774
|
+
"order",
|
|
1775
|
+
"project",
|
|
1776
|
+
"tags"
|
|
1777
|
+
])) return void 0;
|
|
1778
|
+
if (taskId === void 0 || !isTaskStatus(action.status) || !isFiniteNumber(action.order)) return void 0;
|
|
1779
|
+
if (action.project !== void 0 && typeof action.project !== "string") return void 0;
|
|
1780
|
+
if (action.tags !== void 0 && (!Array.isArray(action.tags) || !action.tags.every((tag) => typeof tag === "string"))) return;
|
|
1781
|
+
return {
|
|
1782
|
+
requestId: envelope.requestId,
|
|
1783
|
+
action: {
|
|
1784
|
+
kind: "reorder",
|
|
1785
|
+
taskId,
|
|
1786
|
+
status: action.status,
|
|
1787
|
+
order: action.order,
|
|
1788
|
+
...typeof action.project === "string" ? { project: action.project } : {},
|
|
1789
|
+
...Array.isArray(action.tags) ? { tags: action.tags } : {}
|
|
1790
|
+
}
|
|
1791
|
+
};
|
|
1792
|
+
case "delete":
|
|
1793
|
+
case "archive":
|
|
1794
|
+
case "restore":
|
|
1795
|
+
case "run":
|
|
1796
|
+
case "rerun":
|
|
1797
|
+
if (!exactKeys(action, ["kind", "taskId"])) return void 0;
|
|
1798
|
+
return taskId === void 0 ? void 0 : {
|
|
1799
|
+
requestId: envelope.requestId,
|
|
1800
|
+
action: {
|
|
1801
|
+
kind: action.kind,
|
|
1802
|
+
taskId
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
case "add-comment":
|
|
1806
|
+
if (!exactKeys(action, [
|
|
1807
|
+
"kind",
|
|
1808
|
+
"taskId",
|
|
1809
|
+
"comment"
|
|
1810
|
+
])) return void 0;
|
|
1811
|
+
return taskId !== void 0 && commentInput(action.comment) ? {
|
|
1812
|
+
requestId: envelope.requestId,
|
|
1813
|
+
action: {
|
|
1814
|
+
kind: "add-comment",
|
|
1815
|
+
taskId,
|
|
1816
|
+
comment: action.comment
|
|
1817
|
+
}
|
|
1818
|
+
} : void 0;
|
|
1819
|
+
case "add-artifact":
|
|
1820
|
+
if (!exactKeys(action, [
|
|
1821
|
+
"kind",
|
|
1822
|
+
"taskId",
|
|
1823
|
+
"artifact"
|
|
1824
|
+
])) return void 0;
|
|
1825
|
+
return taskId !== void 0 && artifactInput(action.artifact) ? {
|
|
1826
|
+
requestId: envelope.requestId,
|
|
1827
|
+
action: {
|
|
1828
|
+
kind: "add-artifact",
|
|
1829
|
+
taskId,
|
|
1830
|
+
artifact: action.artifact
|
|
1831
|
+
}
|
|
1832
|
+
} : void 0;
|
|
1833
|
+
case "update-context":
|
|
1834
|
+
if (!exactKeys(action, [
|
|
1835
|
+
"kind",
|
|
1836
|
+
"taskId",
|
|
1837
|
+
"patch"
|
|
1838
|
+
])) return void 0;
|
|
1839
|
+
return taskId !== void 0 && contextPatch(action.patch) ? {
|
|
1840
|
+
requestId: envelope.requestId,
|
|
1841
|
+
action: {
|
|
1842
|
+
kind: "update-context",
|
|
1843
|
+
taskId,
|
|
1844
|
+
patch: action.patch
|
|
1845
|
+
}
|
|
1846
|
+
} : void 0;
|
|
1847
|
+
case "upsert-automation":
|
|
1848
|
+
if (!exactKeys(action, ["kind", "rule"])) return void 0;
|
|
1849
|
+
return automationRuleInput(action.rule) ? {
|
|
1850
|
+
requestId: envelope.requestId,
|
|
1851
|
+
action: {
|
|
1852
|
+
kind: "upsert-automation",
|
|
1853
|
+
rule: action.rule
|
|
1854
|
+
}
|
|
1855
|
+
} : void 0;
|
|
1856
|
+
case "delete-automation":
|
|
1857
|
+
case "run-automation":
|
|
1858
|
+
if (!exactKeys(action, ["kind", "ruleId"])) return void 0;
|
|
1859
|
+
return typeof action.ruleId === "string" && action.ruleId.trim() !== "" ? {
|
|
1860
|
+
requestId: envelope.requestId,
|
|
1861
|
+
action: {
|
|
1862
|
+
kind: action.kind,
|
|
1863
|
+
ruleId: action.ruleId
|
|
1864
|
+
}
|
|
1865
|
+
} : void 0;
|
|
1866
|
+
default: return;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
/** SSE 心跳间隔(§17 性能:SSE 心跳 15s)。 */
|
|
1870
|
+
const HEARTBEAT_MS = 15e3;
|
|
1871
|
+
/** 同机认证反代在自身认证后替换注入的服务端 token 头(§16.1)。 */
|
|
1872
|
+
const TASK_BOARD_PROXY_TOKEN_HEADER = "x-dsh-task-board-proxy-token";
|
|
1873
|
+
function json(res, status, body) {
|
|
1874
|
+
res.writeHead(status, {
|
|
1875
|
+
"content-type": "application/json; charset=utf-8",
|
|
1876
|
+
"cache-control": "no-store"
|
|
1877
|
+
});
|
|
1878
|
+
res.end(JSON.stringify(body));
|
|
1879
|
+
}
|
|
1880
|
+
/** 解析并规范化一个 host[:port] authority(反代白名单条目)。 */
|
|
1881
|
+
function parseAuthority(authority) {
|
|
1882
|
+
if (authority.trim() !== authority) return void 0;
|
|
1883
|
+
const match = authority.startsWith("[") ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority) : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority);
|
|
1884
|
+
if (match === null) return void 0;
|
|
1885
|
+
try {
|
|
1886
|
+
const url = new URL(`http://${authority}`);
|
|
1887
|
+
if (url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") return;
|
|
1888
|
+
const rawPort = match[1];
|
|
1889
|
+
if (rawPort !== void 0 && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65535)) return void 0;
|
|
1890
|
+
return {
|
|
1891
|
+
canonical: url.hostname.toLowerCase() + (rawPort === void 0 ? "" : `:${rawPort}`),
|
|
1892
|
+
url
|
|
1893
|
+
};
|
|
1894
|
+
} catch {
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
/** 解析访问面并校验白名单条目的规范化形态(配置错误直接 throw,插件加载失败)。 */
|
|
1899
|
+
function resolveAccess(access) {
|
|
1900
|
+
const trustedProxyHosts = /* @__PURE__ */ new Set();
|
|
1901
|
+
for (const authority of access.trustedProxyHosts ?? []) {
|
|
1902
|
+
const parsed = parseAuthority(authority);
|
|
1903
|
+
if (parsed === void 0 || parsed.canonical !== authority.toLowerCase()) throw new Error(`nova-task-board: trustedProxyHosts entry ${JSON.stringify(authority)} is not a canonical host[:port] authority`);
|
|
1904
|
+
trustedProxyHosts.add(parsed.canonical);
|
|
1905
|
+
}
|
|
1906
|
+
if (trustedProxyHosts.size > 0 && (access.proxyToken === void 0 || access.proxyToken === "")) throw new Error("nova-task-board: authenticated proxy hosts require a non-empty proxy token");
|
|
1907
|
+
const webhookSecret = access.webhookSecret === void 0 ? void 0 : access.webhookSecret.trim() === "" ? void 0 : access.webhookSecret;
|
|
1908
|
+
return {
|
|
1909
|
+
trustedProxyHosts,
|
|
1910
|
+
...access.proxyToken === void 0 ? {} : { proxyToken: access.proxyToken },
|
|
1911
|
+
...webhookSecret === void 0 ? {} : { webhookSecret }
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* 浏览器信号触发器(非权威校验):裸 curl 两者皆无 → 拒绝;伪造 Origin 也能
|
|
1916
|
+
* 通过——真正边界是回环 socket + Host + origin 等价校验(isTrustedTaskBoardRequest)。
|
|
1917
|
+
*/
|
|
1918
|
+
function browserSameOriginMarker(req) {
|
|
1919
|
+
return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
|
|
1920
|
+
}
|
|
1921
|
+
/** origin 与 Host 的等价校验(§16.1 origin 等价)。 */
|
|
1922
|
+
function sameAuthority(req, host) {
|
|
1923
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1924
|
+
const origin = req.headers.origin;
|
|
1925
|
+
if (origin === void 0) return req.headers["sec-fetch-site"] === "same-origin";
|
|
1926
|
+
try {
|
|
1927
|
+
return new URL(origin).host === host.host;
|
|
1928
|
+
} catch {
|
|
1929
|
+
return false;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/** 恒定时间 token 比较。 */
|
|
1933
|
+
function matchesToken(candidate, expected) {
|
|
1934
|
+
if (typeof candidate !== "string" || expected === void 0 || candidate === "" || expected === "") return false;
|
|
1935
|
+
const actual = Buffer.from(candidate);
|
|
1936
|
+
const wanted = Buffer.from(expected);
|
|
1937
|
+
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* GitHub Webhook 签名校验(§14.1):`X-Hub-Signature-256: sha256=<hex>`,
|
|
1941
|
+
* 对原始请求体做 HMAC-SHA256 并恒定时间比较。签名头缺失/畸形/不匹配 → false。
|
|
1942
|
+
*/
|
|
1943
|
+
function verifyGithubSignature(rawBody, signature, secret) {
|
|
1944
|
+
if (typeof signature !== "string" || secret === "") return false;
|
|
1945
|
+
if (!signature.startsWith("sha256=")) return false;
|
|
1946
|
+
const expected = signature.slice(7);
|
|
1947
|
+
if (!/^[0-9a-f]{64}$/i.test(expected)) return false;
|
|
1948
|
+
return matchesToken(expected, createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"));
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* 请求级信任判定:浏览器同源标记 + (回环请求 | 白名单反代 + token)。
|
|
1952
|
+
* 回环判定包含 socket 地址、Host 头与 origin 等价校验(loopback.ts)。
|
|
1953
|
+
*/
|
|
1954
|
+
function isTrustedTaskBoardRequest(req, access) {
|
|
1955
|
+
if (!browserSameOriginMarker(req)) return false;
|
|
1956
|
+
if (isLoopbackRequest(req)) return true;
|
|
1957
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
|
|
1958
|
+
const host = req.headers.host;
|
|
1959
|
+
if (typeof host !== "string") return false;
|
|
1960
|
+
const parsed = parseAuthority(host);
|
|
1961
|
+
if (parsed === void 0 || parsed.canonical !== host.toLowerCase()) return false;
|
|
1962
|
+
if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false;
|
|
1963
|
+
return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken);
|
|
1964
|
+
}
|
|
1965
|
+
/** 读取请求体;超限(> IMPORT_LIMIT,含 content-length 快检)抛 'body-too-large';JSON 解析失败抛 'invalid-json'。 */
|
|
1966
|
+
async function readBody(req) {
|
|
1967
|
+
const declared = req.headers["content-length"];
|
|
1968
|
+
if (declared !== void 0 && Number(declared) > 2097152) throw new Error("body-too-large");
|
|
1969
|
+
const chunks = [];
|
|
1970
|
+
let size = 0;
|
|
1971
|
+
for await (const chunk of req) {
|
|
1972
|
+
const buffer = chunk;
|
|
1973
|
+
size += buffer.length;
|
|
1974
|
+
if (size > 2097152) throw new Error("body-too-large");
|
|
1975
|
+
chunks.push(buffer);
|
|
1976
|
+
}
|
|
1977
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1978
|
+
let value;
|
|
1979
|
+
try {
|
|
1980
|
+
value = JSON.parse(raw);
|
|
1981
|
+
} catch {
|
|
1982
|
+
throw new Error("invalid-json");
|
|
1983
|
+
}
|
|
1984
|
+
return {
|
|
1985
|
+
raw,
|
|
1986
|
+
value
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
/** 组装三个端点路由(宿主的 webServer.register 对重复 (kind, path) 直接 throw,勿重复注册)。 */
|
|
1990
|
+
function makeTaskBoardRoutes(service, access = {}) {
|
|
1991
|
+
const resolvedAccess = resolveAccess(access);
|
|
1992
|
+
const guard = (req, res) => {
|
|
1993
|
+
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true;
|
|
1994
|
+
json(res, 403, {
|
|
1995
|
+
ok: false,
|
|
1996
|
+
error: "forbidden"
|
|
1997
|
+
});
|
|
1998
|
+
return false;
|
|
1999
|
+
};
|
|
2000
|
+
return [
|
|
2001
|
+
{
|
|
2002
|
+
kind: "exact",
|
|
2003
|
+
path: `${TASK_BOARD_API_PREFIX}/state`,
|
|
2004
|
+
handler: (req, res) => {
|
|
2005
|
+
if (req.method !== "GET") return json(res, 405, {
|
|
2006
|
+
ok: false,
|
|
2007
|
+
error: "method-not-allowed"
|
|
2008
|
+
});
|
|
2009
|
+
if (!guard(req, res)) return;
|
|
2010
|
+
json(res, 200, service.snapshot());
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
kind: "exact",
|
|
2015
|
+
path: `${TASK_BOARD_API_PREFIX}/action`,
|
|
2016
|
+
handler: async (req, res) => {
|
|
2017
|
+
if (req.method !== "POST") return json(res, 405, {
|
|
2018
|
+
ok: false,
|
|
2019
|
+
error: "method-not-allowed"
|
|
2020
|
+
});
|
|
2021
|
+
if (!guard(req, res)) return;
|
|
2022
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return json(res, 415, {
|
|
2023
|
+
ok: false,
|
|
2024
|
+
error: "json-required"
|
|
2025
|
+
});
|
|
2026
|
+
try {
|
|
2027
|
+
const body = await readBody(req);
|
|
2028
|
+
const parsed = parseActionEnvelope(body.value);
|
|
2029
|
+
if (parsed === void 0) return json(res, 400, {
|
|
2030
|
+
ok: false,
|
|
2031
|
+
error: "invalid-action"
|
|
2032
|
+
});
|
|
2033
|
+
if (parsed.action.kind !== "import" && parsed.action.kind !== "propose-batch" && parsed.action.kind !== "start-split" && Buffer.byteLength(body.raw) > 65536) return json(res, 413, {
|
|
2034
|
+
ok: false,
|
|
2035
|
+
error: "body-too-large"
|
|
2036
|
+
});
|
|
2037
|
+
json(res, 200, service.apply(parsed.requestId, parsed.action));
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2040
|
+
json(res, message === "body-too-large" ? 413 : 400, {
|
|
2041
|
+
ok: false,
|
|
2042
|
+
error: message
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
kind: "exact",
|
|
2049
|
+
path: `${TASK_BOARD_API_PREFIX}/events`,
|
|
2050
|
+
handler: (req, res) => {
|
|
2051
|
+
if (req.method !== "GET") {
|
|
2052
|
+
res.writeHead(405);
|
|
2053
|
+
res.end();
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (!guard(req, res)) return;
|
|
2057
|
+
res.writeHead(200, {
|
|
2058
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2059
|
+
"cache-control": "no-cache",
|
|
2060
|
+
connection: "keep-alive"
|
|
2061
|
+
});
|
|
2062
|
+
const push = () => {
|
|
2063
|
+
const payload = service.eventPayload();
|
|
2064
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
2065
|
+
};
|
|
2066
|
+
const unsubscribe = service.subscribe(push);
|
|
2067
|
+
const heartbeat = setInterval(() => {
|
|
2068
|
+
res.write(": ping\n\n");
|
|
2069
|
+
}, HEARTBEAT_MS);
|
|
2070
|
+
const close = () => {
|
|
2071
|
+
clearInterval(heartbeat);
|
|
2072
|
+
unsubscribe();
|
|
2073
|
+
};
|
|
2074
|
+
req.once("close", close);
|
|
2075
|
+
res.once("close", close);
|
|
2076
|
+
push();
|
|
2077
|
+
}
|
|
2078
|
+
},
|
|
2079
|
+
{
|
|
2080
|
+
kind: "exact",
|
|
2081
|
+
path: `${TASK_BOARD_API_PREFIX}/webhooks/github`,
|
|
2082
|
+
handler: async (req, res) => {
|
|
2083
|
+
if (req.method !== "POST") return json(res, 405, {
|
|
2084
|
+
ok: false,
|
|
2085
|
+
error: "method-not-allowed"
|
|
2086
|
+
});
|
|
2087
|
+
if (resolvedAccess.webhookSecret === void 0) return json(res, 503, {
|
|
2088
|
+
ok: false,
|
|
2089
|
+
error: "webhook not configured"
|
|
2090
|
+
});
|
|
2091
|
+
let body;
|
|
2092
|
+
try {
|
|
2093
|
+
body = await readBody(req);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2096
|
+
return json(res, message === "body-too-large" ? 413 : 400, {
|
|
2097
|
+
ok: false,
|
|
2098
|
+
error: message
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
if (!verifyGithubSignature(body.raw, req.headers["x-hub-signature-256"], resolvedAccess.webhookSecret)) return json(res, 401, {
|
|
2102
|
+
ok: false,
|
|
2103
|
+
error: "invalid signature"
|
|
2104
|
+
});
|
|
2105
|
+
const event = req.headers["x-github-event"];
|
|
2106
|
+
if (event === "ping") return json(res, 200, {
|
|
2107
|
+
ok: true,
|
|
2108
|
+
ping: true
|
|
2109
|
+
});
|
|
2110
|
+
if (event !== "issues") return json(res, 200, {
|
|
2111
|
+
ok: true,
|
|
2112
|
+
ignored: String(event ?? "unknown")
|
|
2113
|
+
});
|
|
2114
|
+
try {
|
|
2115
|
+
return json(res, 200, {
|
|
2116
|
+
ok: true,
|
|
2117
|
+
...service.ingestGithubWebhook(body.value)
|
|
2118
|
+
});
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
return json(res, 400, {
|
|
2121
|
+
ok: false,
|
|
2122
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
];
|
|
2128
|
+
}
|
|
2129
|
+
//#endregion
|
|
2130
|
+
//#region src/core/migrate.ts
|
|
2131
|
+
/**
|
|
2132
|
+
* 二选一并取更「有信息量」的同 id 执行记录:
|
|
2133
|
+
* - 已结算胜过未结算(未结算多为陈旧副本的残留 open 记录);
|
|
2134
|
+
* - 同为已结算/未结算时取较晚者(以 endedAt ?? startedAt 为准)。
|
|
2135
|
+
*/
|
|
2136
|
+
function mergeExecution(a, b) {
|
|
2137
|
+
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
2138
|
+
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
2139
|
+
return (b.endedAt ?? b.startedAt) >= (a.endedAt ?? a.startedAt) ? b : a;
|
|
2140
|
+
}
|
|
2141
|
+
/** 按 id 合并两条任务:较新 `updatedAt` 为底,执行记录按 id 合并并按时序排序。 */
|
|
2142
|
+
function mergeTask(a, b) {
|
|
2143
|
+
const newer = b.updatedAt > a.updatedAt ? b : a;
|
|
2144
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2145
|
+
for (const entry of [...a.executions, ...b.executions]) {
|
|
2146
|
+
const previous = byId.get(entry.id);
|
|
2147
|
+
byId.set(entry.id, previous === void 0 ? entry : mergeExecution(previous, entry));
|
|
2148
|
+
}
|
|
2149
|
+
return {
|
|
2150
|
+
...newer,
|
|
2151
|
+
executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt)
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* 将一批 v1 任务按 sourceId 导入账本(就地修改 `doc`):
|
|
2156
|
+
* - `doc.scheduler.importedSources` 已含 sourceId → `{ changed: false }`(跳过);
|
|
2157
|
+
* - 否则逐条 `normalizeTask`(防御性校验:协议层已校验过类型,这里兜底),
|
|
2158
|
+
* 非法行丢弃并记入 error;合法行按 id 合并进 `doc.tasks`;
|
|
2159
|
+
* - 追加 sourceId 到 importedSources(导入 marker,随下次原子落账持久化)。
|
|
2160
|
+
*/
|
|
2161
|
+
function importIntoLedger(doc, sourceId, incoming) {
|
|
2162
|
+
const sources = new Set(doc.scheduler.importedSources ?? []);
|
|
2163
|
+
if (sources.has(sourceId)) return { changed: false };
|
|
2164
|
+
const dropped = [];
|
|
2165
|
+
const merged = new Map(doc.tasks.map((task) => [task.id, task]));
|
|
2166
|
+
for (const row of incoming) {
|
|
2167
|
+
const task = normalizeTask(row);
|
|
2168
|
+
if (task === void 0) {
|
|
2169
|
+
dropped.push(typeof row.id === "string" && row.id !== "" ? row.id : "<invalid row>");
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
const existing = merged.get(task.id);
|
|
2173
|
+
merged.set(task.id, existing === void 0 ? task : mergeTask(existing, task));
|
|
2174
|
+
}
|
|
2175
|
+
doc.tasks = [...merged.values()];
|
|
2176
|
+
doc.scheduler.importedSources = [...sources, sourceId];
|
|
2177
|
+
const error = dropped.length > 0 ? `import dropped ${dropped.length} invalid task row(s): ${dropped.join(", ")}` : void 0;
|
|
2178
|
+
return {
|
|
2179
|
+
changed: true,
|
|
2180
|
+
...error === void 0 ? {} : { error }
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
//#endregion
|
|
2184
|
+
//#region src/core/split.ts
|
|
2185
|
+
/**
|
|
2186
|
+
* 需求拆分核心(T012/P3.2,DSH-REQ-001 §12.9/§14.5 + 附录 B8 调研定案)。
|
|
2187
|
+
*
|
|
2188
|
+
* B8 结论(2026-08-23):
|
|
2189
|
+
* - **拆分执行通道**:`start-split` action 提交需求(粘贴文本/工作区文件路径)
|
|
2190
|
+
* 后,Host 复用 T005 执行通道(runner.launch)启动**独立拆分会话**
|
|
2191
|
+
* (标题「需求拆分:<标题>」),按 §14.5 方法论(粒度启发式 + 拆后自检)驱动
|
|
2192
|
+
* agent 拆分;会话结束时 Host 读取 transcript,解析结构化标记,经父需求任务
|
|
2193
|
+
* propose + `propose-batch` 原子落账(全部 proposed,人工确认闸门复用 T011)。
|
|
2194
|
+
* 会话不复用当前对话(不污染聊天记录);成本 = 每次拆分一个独立会话(额度
|
|
2195
|
+
* 成本随会话数量线性,递归拆分同理);会话标题可辨识、候选 metadata 携带
|
|
2196
|
+
* 拆分会话引用(可跳回追溯,§15)。
|
|
2197
|
+
* - **输出契约**:agent 逐条输出单行标记 `⟦task-board:split⟧ 标题 | 描述 | prompt`
|
|
2198
|
+
* (与 T011 的 `⟦task-board:propose⟧` 同格式契约,按 `|` 分段),最后一行
|
|
2199
|
+
* `⟦task-board:coverage⟧ 覆盖矩阵`。解析为纯函数(本文件),可脱离宿主单测。
|
|
2200
|
+
* - **粒度启发式**(§14.5):单一职责、会话边界、单次 3~15 个(超限递归);
|
|
2201
|
+
* 拆后自检:覆盖完备 / 无重叠 / 可验收 / 依赖明确;拆分维度:功能模块 /
|
|
2202
|
+
* 架构分层 / 交付顺序 / 验收逐条 / 混合。以上全部内嵌于指令模板
|
|
2203
|
+
* (composeSplitInstruction),由拆分会话的系统 Prompt 承载。
|
|
2204
|
+
* - **父需求任务**(§14.5):不引入独立「需求实体」——拆分结算时创建父任务
|
|
2205
|
+
* 「需求:<标题>」(proposed、source=requirement),描述承载需求原文/来源与
|
|
2206
|
+
* 覆盖矩阵(P3 产物表落地前存描述);子任务 parentId 指向该父任务;
|
|
2207
|
+
* 递归拆分(§12.9)时新父任务以原任务为父(parentId),形成树状层级。
|
|
2208
|
+
*/
|
|
2209
|
+
/** 子任务单行标记(agent 输出契约,与 T011 的 propose 标记同格式)。 */
|
|
2210
|
+
const SPLIT_MARKER = "⟦task-board:split⟧";
|
|
2211
|
+
/** 覆盖矩阵单行标记(agent 输出契约;其后内容 = 覆盖矩阵文本)。 */
|
|
2212
|
+
const COVERAGE_MARKER = "⟦task-board:coverage⟧";
|
|
2213
|
+
/** 父需求任务描述的长度封顶(§16.7 清洗:需求原文 + 覆盖矩阵整体封顶)。 */
|
|
2214
|
+
const REQUIREMENT_DESCRIPTION_LIMIT = 4e4;
|
|
2215
|
+
/** 拆分会话(独立执行会话)标题前缀:`需求拆分:<标题>`。 */
|
|
2216
|
+
const SPLIT_SESSION_TITLE_PREFIX = "需求拆分:";
|
|
2217
|
+
/** 父需求任务标题前缀:`需求:<标题>`(§14.5 父需求任务)。 */
|
|
2218
|
+
const REQUIREMENT_PARENT_TITLE_PREFIX = "需求:";
|
|
2219
|
+
/**
|
|
2220
|
+
* 解析 transcript 中的拆分标记(纯函数,可脱离宿主单测):
|
|
2221
|
+
* - 逐行扫描 `⟦task-board:split⟧`,每行一条子任务;行内按 `|` 分段(首段 =
|
|
2222
|
+
* 标题,空则丢弃该条;次段 = 描述;其余拼接为 prompt)——与 T011
|
|
2223
|
+
* parseProposeMarkers 同契约;
|
|
2224
|
+
* - 扫描 `⟦task-board:coverage⟧`,该行标记之后的内容 = 覆盖矩阵文本(首条
|
|
2225
|
+
* 为准);无标记 → 无覆盖矩阵;
|
|
2226
|
+
* - 其余文本(说明性内容)忽略。
|
|
2227
|
+
*/
|
|
2228
|
+
function parseSplitMarkers(text) {
|
|
2229
|
+
const items = [];
|
|
2230
|
+
let coverage;
|
|
2231
|
+
for (const line of text.split("\n")) {
|
|
2232
|
+
const splitIndex = line.indexOf(SPLIT_MARKER);
|
|
2233
|
+
if (splitIndex !== -1) {
|
|
2234
|
+
const parts = line.slice(splitIndex + 18).split("|").map((part) => part.trim());
|
|
2235
|
+
const title = parts[0] ?? "";
|
|
2236
|
+
if (title !== "") items.push({
|
|
2237
|
+
title,
|
|
2238
|
+
description: parts[1] ?? "",
|
|
2239
|
+
prompt: parts.slice(2).join("|").trim()
|
|
2240
|
+
});
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
const coverageIndex = line.indexOf(COVERAGE_MARKER);
|
|
2244
|
+
if (coverageIndex !== -1 && coverage === void 0) coverage = line.slice(coverageIndex + 21).trim();
|
|
2245
|
+
}
|
|
2246
|
+
return {
|
|
2247
|
+
items,
|
|
2248
|
+
...coverage === void 0 ? {} : { coverage }
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* 需求拆分指令模板(B8:粒度启发式 + 覆盖自检 + 输出 schema,§14.5)。
|
|
2253
|
+
* 作为拆分会话(独立执行会话)的首条 Prompt 发送:内嵌需求标题与需求原文
|
|
2254
|
+
* (text 优先;filePath 时指示 agent 先读文件),随后是拆分方法论与输出契约。
|
|
2255
|
+
* @param input - 拆分请求(title 必填;text/filePath 至少其一)。
|
|
2256
|
+
*/
|
|
2257
|
+
function composeSplitInstruction(input) {
|
|
2258
|
+
const title = input.title.trim();
|
|
2259
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
2260
|
+
const filePath = typeof input.filePath === "string" ? input.filePath.trim() : "";
|
|
2261
|
+
return [
|
|
2262
|
+
"请将以下需求文档拆分为一组可独立执行、可独立验收的子任务,并严格按输出格式给出结果。",
|
|
2263
|
+
"",
|
|
2264
|
+
"## 需求标题",
|
|
2265
|
+
title,
|
|
2266
|
+
"",
|
|
2267
|
+
text !== "" ? "【需求原文】\n" + text : `【需求来源】工作区文件路径:${filePath}\n(请先读取该文件,再按下列方法论拆分)`,
|
|
2268
|
+
"",
|
|
2269
|
+
"## 拆分方法论(必须遵守)",
|
|
2270
|
+
`1. 粒度:每个子任务约等于一次 DSH 执行会话可完成并验收的工作单元,自带完成标准(done 定义)。`,
|
|
2271
|
+
`2. 启发式:单一职责(每个子任务只产出一种可验收结果);会话边界(预计需多轮长会话或跨多文件大改动的继续细分,很快完成的相邻项可合并);数量界(单次拆分 3~15 个,少于 3 个提示合并,多于 15 个先拆 2~4 个子需求再各拆,或收紧粒度)。`,
|
|
2272
|
+
`3. 拆后自检(返回前执行):覆盖完备(原需求每个功能点/验收条目至少被一个子任务覆盖);无重叠(子任务职责互不重叠,MECE 近似);可验收(每个子任务含明确验收标准,而非模糊描述);依赖明确(子任务间的先后/依赖关系写入描述,完整 DAG 列为 P4)。`,
|
|
2273
|
+
`4. 拆分维度任选其一:按功能模块/用户故事;按架构分层(数据模型 → 协议 → Host 服务 → UI → 测试);按交付顺序/里程碑(先底层后上层、先核心后周边);按验收标准逐条;混合(先按模块、再对复杂模块递归细分)。`,
|
|
2274
|
+
"",
|
|
2275
|
+
"## 输出格式(严格,除标记行外不要输出其他内容)",
|
|
2276
|
+
"每行一条子任务,格式:",
|
|
2277
|
+
`${SPLIT_MARKER} 子任务标题 | 描述(含完成标准/验收标准与依赖说明) | 执行 Prompt(可直接执行,可省略则留空)`,
|
|
2278
|
+
"最后单独一行输出覆盖矩阵(原需求功能点/验收条目 → 子任务映射):",
|
|
2279
|
+
`${COVERAGE_MARKER} 覆盖矩阵内容`
|
|
2280
|
+
].join("\n");
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* 构造父需求任务的描述(§14.5:承载需求原文与覆盖矩阵,P3 产物表落地前存
|
|
2284
|
+
* 描述):按「需求原文(text)/ 需求来源(filePath)/ 覆盖矩阵」三段拼接。
|
|
2285
|
+
* 纯函数;长度封顶与清洗由调用方落账时经 sanitizeCollectedText 完成。
|
|
2286
|
+
*/
|
|
2287
|
+
function buildRequirementDescription(input) {
|
|
2288
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
2289
|
+
const filePath = typeof input.filePath === "string" ? input.filePath.trim() : "";
|
|
2290
|
+
const coverage = typeof input.coverage === "string" ? input.coverage.trim() : "";
|
|
2291
|
+
const parts = [];
|
|
2292
|
+
if (text !== "") parts.push(`【需求原文】\n${text}`);
|
|
2293
|
+
if (filePath !== "") parts.push(`【需求来源】工作区文件:${filePath}`);
|
|
2294
|
+
if (coverage !== "") parts.push(`【覆盖矩阵】\n${coverage}`);
|
|
2295
|
+
return parts.join("\n\n");
|
|
2296
|
+
}
|
|
2297
|
+
/** 父需求任务标题(§14.5:`需求:<标题>`),清洗后返回;空输入返回空串。 */
|
|
2298
|
+
function requirementParentTitle(title) {
|
|
2299
|
+
return sanitizeCollectedText(`${REQUIREMENT_PARENT_TITLE_PREFIX}${title}`, 200);
|
|
2300
|
+
}
|
|
2301
|
+
/** 拆分会话标题(`需求拆分:<标题>`),清洗后返回;空输入返回空串。 */
|
|
2302
|
+
function splitSessionTitle(title) {
|
|
2303
|
+
return sanitizeCollectedText(`${SPLIT_SESSION_TITLE_PREFIX}${title}`, 200);
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
//#region src/core/transitions.ts
|
|
2307
|
+
/** 用户可手动移动到的目标列(§10.2:backlog/todo 互移;done/failed 重开落回)。 */
|
|
2308
|
+
const MANUAL_TARGET_STATUSES = ["backlog", "todo"];
|
|
2309
|
+
/** 可归档的状态(§10.2:仅已结算状态可归档)。 */
|
|
2310
|
+
const ARCHIVABLE_STATUSES = ["done", "failed"];
|
|
2311
|
+
/**
|
|
2312
|
+
* 手动移动的来源约束:running 由 runner 独占、proposed 候选只读(T011 的
|
|
2313
|
+
* confirm/dismiss 是唯一路径),二者都不可作为 move 来源。
|
|
2314
|
+
*/
|
|
2315
|
+
const MANUAL_MOVE_SOURCES = [
|
|
2316
|
+
"backlog",
|
|
2317
|
+
"todo",
|
|
2318
|
+
"done",
|
|
2319
|
+
"failed"
|
|
2320
|
+
];
|
|
2321
|
+
/** 状态流转守卫错误:消息直接透出为路由 400 的错误信息。 */
|
|
2322
|
+
var TaskBoardTransitionError = class extends Error {
|
|
2323
|
+
constructor(message) {
|
|
2324
|
+
super(message);
|
|
2325
|
+
this.name = "TaskBoardTransitionError";
|
|
2326
|
+
}
|
|
2327
|
+
};
|
|
2328
|
+
/** 任务是否带有未结算的执行(running 独占性的第二道判据)。 */
|
|
2329
|
+
function hasOpenExecution(task) {
|
|
2330
|
+
return task.executions.some((execution) => execution.endedAt === void 0);
|
|
2331
|
+
}
|
|
2332
|
+
/** 手动移动合法性:来源 ∈ {backlog,todo,done,failed} 且目标 ∈ {backlog,todo}。 */
|
|
2333
|
+
function canMoveManually(from, to) {
|
|
2334
|
+
return MANUAL_MOVE_SOURCES.includes(from) && MANUAL_TARGET_STATUSES.includes(to);
|
|
2335
|
+
}
|
|
2336
|
+
/** 同状态任务的最大 order(无则 0)——新任务/跨列移动追加到目标列末尾。 */
|
|
2337
|
+
function maxOrderOf(tasks, status) {
|
|
2338
|
+
let max = 0;
|
|
2339
|
+
for (const task of tasks) {
|
|
2340
|
+
if (task.status !== status) continue;
|
|
2341
|
+
if (isFiniteNumber(task.order) && task.order > max) max = task.order;
|
|
2342
|
+
}
|
|
2343
|
+
return max;
|
|
2344
|
+
}
|
|
2345
|
+
/** 从用户输入构造一条新任务(create 的落地;§9.2 字段归一化)。T008:project/tags 落字段。 */
|
|
2346
|
+
function newTaskFromInput(input, id, now, order) {
|
|
2347
|
+
const workspaceId = normalizeOptionalString(input.workspaceId);
|
|
2348
|
+
const mode = normalizeOptionalString(input.mode);
|
|
2349
|
+
const project = normalizeOptionalString(input.project);
|
|
2350
|
+
const task = {
|
|
2351
|
+
id,
|
|
2352
|
+
title: input.title.trim(),
|
|
2353
|
+
description: input.description.trim(),
|
|
2354
|
+
prompt: input.prompt.trim(),
|
|
2355
|
+
status: "todo",
|
|
2356
|
+
createdAt: now,
|
|
2357
|
+
updatedAt: now,
|
|
2358
|
+
executions: [],
|
|
2359
|
+
tags: normalizeTags(input.tags),
|
|
2360
|
+
comments: [],
|
|
2361
|
+
artifacts: [],
|
|
2362
|
+
order
|
|
2363
|
+
};
|
|
2364
|
+
if (workspaceId !== void 0) task.workspaceId = workspaceId;
|
|
2365
|
+
if (mode !== void 0) task.mode = mode;
|
|
2366
|
+
if (isTaskPermission(input.permission)) task.permission = input.permission;
|
|
2367
|
+
if (project !== void 0) task.project = project;
|
|
2368
|
+
return task;
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* create(§11.2):新任务默认进 todo,追加到列末尾(order = 同列 max+1)。
|
|
2372
|
+
* 守卫:id 唯一、标题 trim 后非空。返回是否发生变更(恒 true)。
|
|
2373
|
+
*/
|
|
2374
|
+
function applyCreateTask(doc, input, id, now) {
|
|
2375
|
+
if (doc.tasks.some((task) => task.id === id)) throw new TaskBoardTransitionError("task id already exists");
|
|
2376
|
+
if (input.title.trim() === "") throw new TaskBoardTransitionError("invalid task");
|
|
2377
|
+
const order = maxOrderOf(doc.tasks, "todo") + 1;
|
|
2378
|
+
doc.tasks = [...doc.tasks, newTaskFromInput(input, id, now, order)];
|
|
2379
|
+
return true;
|
|
2380
|
+
}
|
|
2381
|
+
/** 描述/prompt 长度上限(§16.7 清洗:对齐评论正文封顶)。 */
|
|
2382
|
+
const PROPOSE_TEXT_LIMIT = 4e3;
|
|
2383
|
+
/**
|
|
2384
|
+
* propose(§11.2/§14.4,T011):对话提取/手动转任务 → 创建 proposed 候选任务。
|
|
2385
|
+
*
|
|
2386
|
+
* 守卫:id 唯一、清洗后标题非空。
|
|
2387
|
+
* 清洗(§16.7 安全红线):title/description/prompt 剥离控制字符 + trim + 长度
|
|
2388
|
+
* 封顶(sanitizeCollectedText,与自动收集同一条红线);命令类字段的协议层拒绝
|
|
2389
|
+
* 在 protocol.ts(hasForbiddenCommandFields)。
|
|
2390
|
+
* 数据(§9.2/§14.4):status=proposed、source=conversation、来源会话/消息引用
|
|
2391
|
+
* 写入 metadata(sourceConversationId/sourceMessageId,可跳回对话);order 追加
|
|
2392
|
+
* proposed 列尾;project/tags 沿用 create 的归一化语义。
|
|
2393
|
+
*/
|
|
2394
|
+
function applyProposeTask(doc, input, id, now) {
|
|
2395
|
+
if (doc.tasks.some((task) => task.id === id)) throw new TaskBoardTransitionError("task id already exists");
|
|
2396
|
+
const title = sanitizeCollectedText(input.title, 200);
|
|
2397
|
+
if (title === "") throw new TaskBoardTransitionError("invalid task");
|
|
2398
|
+
const workspaceId = normalizeOptionalString(input.workspaceId);
|
|
2399
|
+
const mode = normalizeOptionalString(input.mode);
|
|
2400
|
+
const project = normalizeOptionalString(input.project);
|
|
2401
|
+
const order = maxOrderOf(doc.tasks, "proposed") + 1;
|
|
2402
|
+
const task = {
|
|
2403
|
+
id,
|
|
2404
|
+
title,
|
|
2405
|
+
description: sanitizeCollectedText(input.description, PROPOSE_TEXT_LIMIT),
|
|
2406
|
+
prompt: sanitizeCollectedText(input.prompt, PROPOSE_TEXT_LIMIT),
|
|
2407
|
+
status: "proposed",
|
|
2408
|
+
source: "conversation",
|
|
2409
|
+
createdAt: now,
|
|
2410
|
+
updatedAt: now,
|
|
2411
|
+
executions: [],
|
|
2412
|
+
tags: normalizeTags(input.tags),
|
|
2413
|
+
comments: [],
|
|
2414
|
+
artifacts: [],
|
|
2415
|
+
order
|
|
2416
|
+
};
|
|
2417
|
+
if (workspaceId !== void 0) task.workspaceId = workspaceId;
|
|
2418
|
+
if (mode !== void 0) task.mode = mode;
|
|
2419
|
+
if (isTaskPermission(input.permission)) task.permission = input.permission;
|
|
2420
|
+
if (project !== void 0) task.project = project;
|
|
2421
|
+
const sourceConversationId = normalizeOptionalString(input.sourceConversationId);
|
|
2422
|
+
const sourceMessageId = normalizeOptionalString(input.sourceMessageId);
|
|
2423
|
+
if (sourceConversationId !== void 0 || sourceMessageId !== void 0) {
|
|
2424
|
+
const metadata = {};
|
|
2425
|
+
if (sourceConversationId !== void 0) metadata[SOURCE_CONVERSATION_META_KEY] = sourceConversationId;
|
|
2426
|
+
if (sourceMessageId !== void 0) metadata[SOURCE_MESSAGE_META_KEY] = sourceMessageId;
|
|
2427
|
+
task.metadata = metadata;
|
|
2428
|
+
}
|
|
2429
|
+
doc.tasks = [...doc.tasks, task];
|
|
2430
|
+
return true;
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* confirm(§10.2/§11.2,T011):确认候选任务 → 落列(backlog/todo)。
|
|
2434
|
+
* 守卫:任务存在、非归档、status=proposed、target ∈ {backlog, todo}。
|
|
2435
|
+
* 落列后:status=target、order 追加目标列尾(max+1)、source 缺省补
|
|
2436
|
+
* 'conversation'(§10.2「确认后 source 置 conversation」;propose 落账时已置,
|
|
2437
|
+
* 这里只兜底陈旧数据);来源引用 metadata 保留(可跳回对话)。confirm 是候选
|
|
2438
|
+
* 进入正式看板的唯一路径(防注入闸门,§14.4/§16.7)。
|
|
2439
|
+
*/
|
|
2440
|
+
function applyConfirmTask(doc, taskId, target, now) {
|
|
2441
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2442
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2443
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2444
|
+
if (task.status !== "proposed") throw new TaskBoardTransitionError("only proposed tasks can be confirmed");
|
|
2445
|
+
if (target !== "backlog" && target !== "todo") throw new TaskBoardTransitionError("invalid confirm target");
|
|
2446
|
+
const order = maxOrderOf(doc.tasks, target) + 1;
|
|
2447
|
+
const next = {
|
|
2448
|
+
...task,
|
|
2449
|
+
status: target,
|
|
2450
|
+
order,
|
|
2451
|
+
updatedAt: now
|
|
2452
|
+
};
|
|
2453
|
+
if (next.source === void 0) next.source = "conversation";
|
|
2454
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? next : candidate);
|
|
2455
|
+
return true;
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* dismiss(§10.2/§11.2,T011):拒绝/忽略候选任务 → 删除。
|
|
2459
|
+
* 守卫:任务存在、非归档、status=proposed——dismiss 是 proposed 专属路径
|
|
2460
|
+
* (删除正式任务请用 delete)。
|
|
2461
|
+
*/
|
|
2462
|
+
function applyDismissTask(doc, taskId) {
|
|
2463
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2464
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2465
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2466
|
+
if (task.status !== "proposed") throw new TaskBoardTransitionError("only proposed tasks can be dismissed");
|
|
2467
|
+
doc.tasks = doc.tasks.filter((candidate) => candidate.id !== taskId);
|
|
2468
|
+
return true;
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* parentId 引用校验(§16.8):指向的任务必须存在于账本中(`非自身`由调用方
|
|
2472
|
+
* 保证——本批条目的 id 为新建 uuid,不可能等于已有任务 id;递归父任务的自身
|
|
2473
|
+
* 引用同样在创建前生成新 id),并沿祖先链向上走查环:
|
|
2474
|
+
* - 链中出现本批新条目 id(newIds)或已访问任务 → 环引用,拒绝(防御账本
|
|
2475
|
+
* 既有损坏环 / 未来允许批内引用时的自环);
|
|
2476
|
+
* - 链上任务缺失 → 非法父引用,拒绝;
|
|
2477
|
+
* - 链长超过 MAX_PARENT_CHAIN → 视为异常链,拒绝。
|
|
2478
|
+
*/
|
|
2479
|
+
function assertValidParentRef(doc, parentId, newIds = /* @__PURE__ */ new Set()) {
|
|
2480
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2481
|
+
let current = parentId;
|
|
2482
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
2483
|
+
if (newIds.has(current)) throw new TaskBoardTransitionError("parent reference forms a cycle");
|
|
2484
|
+
if (seen.has(current)) throw new TaskBoardTransitionError("parent reference forms a cycle");
|
|
2485
|
+
seen.add(current);
|
|
2486
|
+
const parent = doc.tasks.find((task) => task.id === current);
|
|
2487
|
+
if (parent === void 0) throw new TaskBoardTransitionError("parent task not found");
|
|
2488
|
+
if (parent.parentId === void 0) return;
|
|
2489
|
+
current = parent.parentId;
|
|
2490
|
+
}
|
|
2491
|
+
throw new TaskBoardTransitionError("parent reference chain is too deep");
|
|
2492
|
+
}
|
|
2493
|
+
/** 由 propose 系输入构造一条 proposed 任务行(propose/批量共用的行构造)。 */
|
|
2494
|
+
function proposedTaskFromInput(item, id, title, now, order, source) {
|
|
2495
|
+
const workspaceId = normalizeOptionalString(item.workspaceId);
|
|
2496
|
+
const mode = normalizeOptionalString(item.mode);
|
|
2497
|
+
const project = normalizeOptionalString(item.project);
|
|
2498
|
+
const task = {
|
|
2499
|
+
id,
|
|
2500
|
+
title,
|
|
2501
|
+
description: sanitizeCollectedText(item.description, PROPOSE_TEXT_LIMIT),
|
|
2502
|
+
prompt: sanitizeCollectedText(item.prompt, PROPOSE_TEXT_LIMIT),
|
|
2503
|
+
status: "proposed",
|
|
2504
|
+
source,
|
|
2505
|
+
createdAt: now,
|
|
2506
|
+
updatedAt: now,
|
|
2507
|
+
executions: [],
|
|
2508
|
+
tags: normalizeTags(item.tags),
|
|
2509
|
+
comments: [],
|
|
2510
|
+
artifacts: [],
|
|
2511
|
+
order
|
|
2512
|
+
};
|
|
2513
|
+
if (workspaceId !== void 0) task.workspaceId = workspaceId;
|
|
2514
|
+
if (mode !== void 0) task.mode = mode;
|
|
2515
|
+
if (isTaskPermission(item.permission)) task.permission = item.permission;
|
|
2516
|
+
if (project !== void 0) task.project = project;
|
|
2517
|
+
const sourceConversationId = normalizeOptionalString(item.sourceConversationId);
|
|
2518
|
+
const sourceMessageId = normalizeOptionalString(item.sourceMessageId);
|
|
2519
|
+
if (sourceConversationId !== void 0 || sourceMessageId !== void 0) {
|
|
2520
|
+
const metadata = {};
|
|
2521
|
+
if (sourceConversationId !== void 0) metadata[SOURCE_CONVERSATION_META_KEY] = sourceConversationId;
|
|
2522
|
+
if (sourceMessageId !== void 0) metadata[SOURCE_MESSAGE_META_KEY] = sourceMessageId;
|
|
2523
|
+
task.metadata = metadata;
|
|
2524
|
+
}
|
|
2525
|
+
return task;
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* propose-batch(§11.2/§14.5/§16.8,T012):批量创建候选任务(需求拆分产物)。
|
|
2529
|
+
*
|
|
2530
|
+
* **原子性**:先全部校验(标题清洗后非空 + parentId 引用合法性),任一条目
|
|
2531
|
+
* 非法 → 抛 `TaskBoardTransitionError`,**整批拒绝、账本零变更**;全部合法后
|
|
2532
|
+
* 统一追加(单 revision)。id 由本函数生成(uuid,防客户端碰撞/自引用)。
|
|
2533
|
+
*
|
|
2534
|
+
* 数据:status=proposed、source=requirement、来源会话/消息引用写 metadata、
|
|
2535
|
+
* order 接 proposed 列尾、project/tags 沿用 create 归一化语义;命令类字段的
|
|
2536
|
+
* 协议层拒绝在 protocol.ts(hasForbiddenCommandFields)。
|
|
2537
|
+
*/
|
|
2538
|
+
function applyProposeBatch(doc, items, now) {
|
|
2539
|
+
if (items.length === 0) throw new TaskBoardTransitionError("propose-batch requires at least one item");
|
|
2540
|
+
const validated = [];
|
|
2541
|
+
const newIds = /* @__PURE__ */ new Set();
|
|
2542
|
+
for (const item of items) {
|
|
2543
|
+
const id = crypto.randomUUID();
|
|
2544
|
+
const title = sanitizeCollectedText(item.title, 200);
|
|
2545
|
+
if (title === "") throw new TaskBoardTransitionError("invalid task");
|
|
2546
|
+
const parentId = normalizeOptionalString(item.parentId);
|
|
2547
|
+
if (parentId !== void 0) {
|
|
2548
|
+
if (parentId === id) throw new TaskBoardTransitionError("parent reference forms a cycle");
|
|
2549
|
+
assertValidParentRef(doc, parentId, newIds);
|
|
2550
|
+
}
|
|
2551
|
+
newIds.add(id);
|
|
2552
|
+
validated.push({
|
|
2553
|
+
item,
|
|
2554
|
+
id,
|
|
2555
|
+
title,
|
|
2556
|
+
parentId
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
let order = maxOrderOf(doc.tasks, "proposed");
|
|
2560
|
+
const created = [];
|
|
2561
|
+
for (const { item, id, title, parentId } of validated) {
|
|
2562
|
+
order += 1;
|
|
2563
|
+
const task = proposedTaskFromInput(item, id, title, now, order, "requirement");
|
|
2564
|
+
if (parentId !== void 0) task.parentId = parentId;
|
|
2565
|
+
created.push(task);
|
|
2566
|
+
}
|
|
2567
|
+
doc.tasks = [...doc.tasks, ...created];
|
|
2568
|
+
return true;
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* 创建父需求任务(§14.5,T012):`需求:<标题>`,status=proposed、
|
|
2572
|
+
* source=requirement,描述 = 需求原文/来源 + 覆盖矩阵(P3 产物表落地前存
|
|
2573
|
+
* 描述),order 接 proposed 列尾。递归拆分(§12.9)时 `parentId` 指向原任务
|
|
2574
|
+
* (assertValidParentRef 校验);`parentId` 创建后不可改(update 补丁面不含)。
|
|
2575
|
+
* 守卫:id 唯一;清洗后标题非空。
|
|
2576
|
+
*/
|
|
2577
|
+
function applyCreateRequirementParent(doc, input, parentId, now) {
|
|
2578
|
+
if (doc.tasks.some((task) => task.id === parentId)) throw new TaskBoardTransitionError("task id already exists");
|
|
2579
|
+
if (typeof input.title !== "string" || input.title.trim() === "") throw new TaskBoardTransitionError("invalid task");
|
|
2580
|
+
const title = requirementParentTitle(input.title);
|
|
2581
|
+
if (title === "") throw new TaskBoardTransitionError("invalid task");
|
|
2582
|
+
const parentTaskId = normalizeOptionalString(input.parentId);
|
|
2583
|
+
if (parentTaskId !== void 0) {
|
|
2584
|
+
if (parentTaskId === parentId) throw new TaskBoardTransitionError("parent reference forms a cycle");
|
|
2585
|
+
assertValidParentRef(doc, parentTaskId);
|
|
2586
|
+
}
|
|
2587
|
+
const description = sanitizeCollectedText(buildRequirementDescription({
|
|
2588
|
+
text: input.requirementText,
|
|
2589
|
+
...input.coverage === void 0 ? {} : { coverage: input.coverage }
|
|
2590
|
+
}), REQUIREMENT_DESCRIPTION_LIMIT);
|
|
2591
|
+
const project = normalizeOptionalString(input.project);
|
|
2592
|
+
const order = maxOrderOf(doc.tasks, "proposed") + 1;
|
|
2593
|
+
const task = {
|
|
2594
|
+
id: parentId,
|
|
2595
|
+
title,
|
|
2596
|
+
description,
|
|
2597
|
+
prompt: "",
|
|
2598
|
+
status: "proposed",
|
|
2599
|
+
source: "requirement",
|
|
2600
|
+
createdAt: now,
|
|
2601
|
+
updatedAt: now,
|
|
2602
|
+
executions: [],
|
|
2603
|
+
tags: normalizeTags(input.tags),
|
|
2604
|
+
comments: [],
|
|
2605
|
+
artifacts: [],
|
|
2606
|
+
order
|
|
2607
|
+
};
|
|
2608
|
+
if (project !== void 0) task.project = project;
|
|
2609
|
+
if (parentTaskId !== void 0) task.parentId = parentTaskId;
|
|
2610
|
+
const sourceConversationId = normalizeOptionalString(input.sourceConversationId);
|
|
2611
|
+
if (sourceConversationId !== void 0) task.metadata = { [SOURCE_CONVERSATION_META_KEY]: sourceConversationId };
|
|
2612
|
+
doc.tasks = [...doc.tasks, task];
|
|
2613
|
+
return true;
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* update(§11.2):可编辑字段补丁(标题/描述/prompt/工作区/模式/权限/项目/标签)。
|
|
2617
|
+
* 守卫:任务存在、非归档。空字符串清除钉住字段(workspaceId/mode/project);
|
|
2618
|
+
* 未知权限串忽略(陈旧 UI 不得持久化执行服务拒绝的值);标题 trim 后非空。
|
|
2619
|
+
* T008:`project` 空串清除(normalizeOptionalString)、`tags` 整体替换
|
|
2620
|
+
* (normalizeTags:trim/去重/丢弃空白,空数组 = 清空)。
|
|
2621
|
+
*/
|
|
2622
|
+
function applyUpdateTask(doc, taskId, patch, now) {
|
|
2623
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2624
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2625
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2626
|
+
const title = "title" in patch && typeof patch.title === "string" ? patch.title.trim() : task.title;
|
|
2627
|
+
if (title === "") throw new TaskBoardTransitionError("invalid task");
|
|
2628
|
+
const next = {
|
|
2629
|
+
...task,
|
|
2630
|
+
title,
|
|
2631
|
+
...typeof patch.description === "string" ? { description: patch.description.trim() } : {},
|
|
2632
|
+
...typeof patch.prompt === "string" ? { prompt: patch.prompt.trim() } : {},
|
|
2633
|
+
updatedAt: now
|
|
2634
|
+
};
|
|
2635
|
+
if ("workspaceId" in patch) next.workspaceId = normalizeOptionalString(patch.workspaceId);
|
|
2636
|
+
if ("mode" in patch) next.mode = normalizeOptionalString(patch.mode);
|
|
2637
|
+
if ("permission" in patch) {
|
|
2638
|
+
if (isTaskPermission(patch.permission)) next.permission = patch.permission;
|
|
2639
|
+
else if (patch.permission === void 0) next.permission = void 0;
|
|
2640
|
+
}
|
|
2641
|
+
if ("project" in patch) next.project = normalizeOptionalString(patch.project);
|
|
2642
|
+
if ("tags" in patch) next.tags = normalizeTags(patch.tags);
|
|
2643
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? next : candidate);
|
|
2644
|
+
return true;
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* delete(§10.2/§19-8):守卫——任务存在、非 running、无未结算执行。
|
|
2648
|
+
* 归档任务可删除(归档只读的例外:恢复/删除/看 transcript)。
|
|
2649
|
+
*/
|
|
2650
|
+
function applyDeleteTask(doc, taskId) {
|
|
2651
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2652
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2653
|
+
if (task.status === "running" || hasOpenExecution(task)) throw new TaskBoardTransitionError("running task cannot be deleted");
|
|
2654
|
+
doc.tasks = doc.tasks.filter((candidate) => candidate.id !== taskId);
|
|
2655
|
+
return true;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* 列内排序的稳定比较器(§9.2 order 升序;order 相等时按 createdAt/id 决胜,
|
|
2659
|
+
* 兼容 T009 前的重复 order 历史数据)。客户端分组视图的排序必须与本函数
|
|
2660
|
+
* 一致(grouping.ts sortByOrder),否则浏览器计算的落位下标与 Host 重算不一致。
|
|
2661
|
+
*/
|
|
2662
|
+
function byOrderStable(a, b) {
|
|
2663
|
+
return a.order - b.order || a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
2664
|
+
}
|
|
2665
|
+
/** 目标列(status 相同、按 order 稳定升序)的任务列表。 */
|
|
2666
|
+
function sortedColumn(doc, status) {
|
|
2667
|
+
return doc.tasks.filter((task) => task.status === status).sort(byOrderStable);
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* 内部落位例程(reorder/move-with-order 共用,§11.2/D10):
|
|
2671
|
+
* 把任务按 0-based 落位下标插入目标列(列内按 byOrderStable 排序),随后对
|
|
2672
|
+
* 整个目标列重编号为 1..n——保证同列唯一(不信任浏览器序号,浏览器只提供
|
|
2673
|
+
* 落位提示)。可选 `patch` 在落位的同时原子变更该任务行(如 move 改 status、
|
|
2674
|
+
* reorder 的归属变更)。
|
|
2675
|
+
*
|
|
2676
|
+
* - 下标越界截断到 [0, 列长];
|
|
2677
|
+
* - 位置不变且无补丁(或补丁不产生实际变更)→ 返回 false(不 bump revision);
|
|
2678
|
+
* - 只有被落位的任务 bump updatedAt(整列重编号不污染其他任务的时间戳)。
|
|
2679
|
+
*/
|
|
2680
|
+
function applyReposition(doc, taskId, status, order, now, patch) {
|
|
2681
|
+
const column = sortedColumn(doc, status);
|
|
2682
|
+
const currentIndex = column.findIndex((task) => task.id === taskId);
|
|
2683
|
+
const rest = column.filter((task) => task.id !== taskId);
|
|
2684
|
+
const insert = Math.max(0, Math.min(Math.floor(order), rest.length));
|
|
2685
|
+
if (insert === currentIndex && patch === void 0) return false;
|
|
2686
|
+
const base = doc.tasks.find((task) => task.id === taskId);
|
|
2687
|
+
if (base === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2688
|
+
const moved = patch === void 0 ? {
|
|
2689
|
+
...base,
|
|
2690
|
+
updatedAt: now
|
|
2691
|
+
} : patch(base);
|
|
2692
|
+
const ordered = [
|
|
2693
|
+
...rest.slice(0, insert),
|
|
2694
|
+
moved,
|
|
2695
|
+
...rest.slice(insert)
|
|
2696
|
+
];
|
|
2697
|
+
const renumbered = new Map(ordered.map((task, index) => [task.id, index + 1]));
|
|
2698
|
+
let changed = false;
|
|
2699
|
+
doc.tasks = doc.tasks.map((task) => {
|
|
2700
|
+
if (task.id === taskId) {
|
|
2701
|
+
if (JSON.stringify(moved) === JSON.stringify(base) && renumbered.get(taskId) === base.order) return task;
|
|
2702
|
+
changed = true;
|
|
2703
|
+
return {
|
|
2704
|
+
...moved,
|
|
2705
|
+
order: renumbered.get(taskId)
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
const nextOrder = renumbered.get(task.id);
|
|
2709
|
+
if (nextOrder === void 0 || task.order === nextOrder) return task;
|
|
2710
|
+
changed = true;
|
|
2711
|
+
return {
|
|
2712
|
+
...task,
|
|
2713
|
+
order: nextOrder
|
|
2714
|
+
};
|
|
2715
|
+
});
|
|
2716
|
+
return changed;
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* move(§10.2/§11.2 基线 + T009 order 扩展):跨列移动/跨列拖拽。守卫——任务
|
|
2720
|
+
* 存在、非归档、非 running/无未结算执行、来源与目标合法(backlog/todo 互移、
|
|
2721
|
+
* done/failed 重开;proposed 与 running 不可作为来源)。目标列与当前列不同时
|
|
2722
|
+
* 追加到目标列末尾(order 由 Host 重算,不信任浏览器序号);T009 起 `order`
|
|
2723
|
+
* 可选:提供时为目标列落位下标(0-based,整列重编号),缺省追加末尾;同状态
|
|
2724
|
+
* move 保持 order(沿用基线行为,仅 bump updatedAt)。
|
|
2725
|
+
*/
|
|
2726
|
+
function applyMoveTask(doc, taskId, status, now, order) {
|
|
2727
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2728
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2729
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2730
|
+
if (task.status === "running" || hasOpenExecution(task)) throw new TaskBoardTransitionError("running task cannot be moved");
|
|
2731
|
+
if (!canMoveManually(task.status, status)) throw new TaskBoardTransitionError("invalid manual status");
|
|
2732
|
+
if (status === task.status && order === void 0) {
|
|
2733
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
2734
|
+
...candidate,
|
|
2735
|
+
updatedAt: now
|
|
2736
|
+
} : candidate);
|
|
2737
|
+
return true;
|
|
2738
|
+
}
|
|
2739
|
+
if (order === void 0) {
|
|
2740
|
+
const target = maxOrderOf(doc.tasks, status) + 1;
|
|
2741
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
2742
|
+
...candidate,
|
|
2743
|
+
status,
|
|
2744
|
+
order: target,
|
|
2745
|
+
updatedAt: now
|
|
2746
|
+
} : candidate);
|
|
2747
|
+
return true;
|
|
2748
|
+
}
|
|
2749
|
+
return applyReposition(doc, taskId, status, order, now, task.status === status ? void 0 : (candidate) => ({
|
|
2750
|
+
...candidate,
|
|
2751
|
+
status,
|
|
2752
|
+
updatedAt: now
|
|
2753
|
+
}));
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* reorder(§11.2/D10,T009):列内排序/分组落位。守卫——任务存在、非归档、
|
|
2757
|
+
* 非 running/无未结算执行、`status` 与任务当前状态一致(防竞态——reorder 只
|
|
2758
|
+
* 调整列内 order,绝不改状态,跨列走 move)。`order` 必填有限数字,语义为
|
|
2759
|
+
* 0-based 落位下标(目标列按 byOrderStable 排序后插入,Host 整列重编号保证
|
|
2760
|
+
* 同列唯一,不信任浏览器序号)。可选 `ownership`(project/tags)为分组视图
|
|
2761
|
+
* 跨组落位的归属变更(§12.10):`project` 空串 = 清除归属、`tags` 整体替换
|
|
2762
|
+
* (沿用 applyUpdateTask 的 normalizeOptionalString/normalizeTags 语义),与
|
|
2763
|
+
* order 原子落账。
|
|
2764
|
+
*/
|
|
2765
|
+
function applyReorderTask(doc, taskId, status, order, ownership, now) {
|
|
2766
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2767
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2768
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2769
|
+
if (task.status === "running" || hasOpenExecution(task)) throw new TaskBoardTransitionError("running task cannot be reordered");
|
|
2770
|
+
if (task.status === "proposed") throw new TaskBoardTransitionError("proposed task cannot be reordered");
|
|
2771
|
+
if (task.status !== status) throw new TaskBoardTransitionError("status mismatch: reorder must match the task current status");
|
|
2772
|
+
if (!isFiniteNumber(order)) throw new TaskBoardTransitionError("invalid order");
|
|
2773
|
+
return applyReposition(doc, taskId, status, order, now, ownership === void 0 ? void 0 : (candidate) => {
|
|
2774
|
+
const next = { ...candidate };
|
|
2775
|
+
if ("project" in ownership) next.project = normalizeOptionalString(ownership.project);
|
|
2776
|
+
if ("tags" in ownership) next.tags = normalizeTags(ownership.tags);
|
|
2777
|
+
return JSON.stringify(next) === JSON.stringify(candidate) ? candidate : {
|
|
2778
|
+
...next,
|
|
2779
|
+
updatedAt: now
|
|
2780
|
+
};
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* archive(§10.2/§19-6):仅已结算状态(done/failed)可归档;归档自动解除
|
|
2785
|
+
* 定时——cron 分支解除武装(enabled:false、清 nextRunAt,保留 cron 配置便于
|
|
2786
|
+
* 恢复后重新武装),one-shot 分支整体清除(§9.4:归档时自动清除 one-shot
|
|
2787
|
+
* 计划)。已归档任务重复归档为 no-op。
|
|
2788
|
+
*/
|
|
2789
|
+
function applyArchiveTask(doc, taskId, now) {
|
|
2790
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2791
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2792
|
+
if (task.archivedAt !== void 0) return false;
|
|
2793
|
+
if (!ARCHIVABLE_STATUSES.includes(task.status)) throw new TaskBoardTransitionError("task cannot be archived");
|
|
2794
|
+
const next = {
|
|
2795
|
+
...task,
|
|
2796
|
+
archivedAt: now,
|
|
2797
|
+
updatedAt: now
|
|
2798
|
+
};
|
|
2799
|
+
if (task.schedule !== void 0) {
|
|
2800
|
+
if (task.schedule.kind === "cron") next.schedule = {
|
|
2801
|
+
...task.schedule,
|
|
2802
|
+
enabled: false,
|
|
2803
|
+
nextRunAt: void 0
|
|
2804
|
+
};
|
|
2805
|
+
else delete next.schedule;
|
|
2806
|
+
}
|
|
2807
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? next : candidate);
|
|
2808
|
+
return true;
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* restore(§10.2):恢复归档任务回主看板(原状态列),保留执行历史;定时保持
|
|
2812
|
+
* 解除状态(恢复前不可运行,需用户重新武装——T006 联调)。非归档任务抛错。
|
|
2813
|
+
*/
|
|
2814
|
+
function applyRestoreTask(doc, taskId, now) {
|
|
2815
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2816
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2817
|
+
if (task.archivedAt === void 0) throw new TaskBoardTransitionError("task is not archived");
|
|
2818
|
+
const { archivedAt: _archived, ...rest } = task;
|
|
2819
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
2820
|
+
...rest,
|
|
2821
|
+
updatedAt: now
|
|
2822
|
+
} : candidate);
|
|
2823
|
+
return true;
|
|
2824
|
+
}
|
|
2825
|
+
/** run/rerun 允许的来源状态(§10.2/§13.2):非 running、非 proposed 候选、非归档。 */
|
|
2826
|
+
const RUNNABLE_SOURCES = [
|
|
2827
|
+
"backlog",
|
|
2828
|
+
"todo",
|
|
2829
|
+
"done",
|
|
2830
|
+
"failed"
|
|
2831
|
+
];
|
|
2832
|
+
/**
|
|
2833
|
+
* run/rerun 共同守卫(§13.2):任务非归档、非 running、无未结算执行、来源在
|
|
2834
|
+
* RUNNABLE_SOURCES(proposed 候选不可执行——确认闸门是唯一 promote 路径,T011;
|
|
2835
|
+
* T010:自动收集任务在 backlog 期间同样不可执行——人工审核闸门,§14.3)。
|
|
2836
|
+
*/
|
|
2837
|
+
function canRunTask(task) {
|
|
2838
|
+
return task.archivedAt === void 0 && task.status !== "running" && !hasOpenExecution(task) && RUNNABLE_SOURCES.includes(task.status) && !(isAutoCollected(task) && task.status === "backlog");
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* 开启一条新执行(§13.2):任务置 running + 追加 open ExecutionRecord
|
|
2842
|
+
* (startedAt=now;sessionId/endedAt/result 待创建会话后回填/结算)。
|
|
2843
|
+
* 返回新任务与执行记录(HostRunner 据此启动会话)。
|
|
2844
|
+
*/
|
|
2845
|
+
function openExecution(task, now, executionId) {
|
|
2846
|
+
const execution = {
|
|
2847
|
+
id: executionId,
|
|
2848
|
+
startedAt: now
|
|
2849
|
+
};
|
|
2850
|
+
return {
|
|
2851
|
+
task: {
|
|
2852
|
+
...task,
|
|
2853
|
+
status: "running",
|
|
2854
|
+
updatedAt: now,
|
|
2855
|
+
executions: [...task.executions, execution]
|
|
2856
|
+
},
|
|
2857
|
+
execution
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
/** run/rerun 守卫:任务存在、非归档、非 running、无未结算执行、来源合法;通过则返回任务。 */
|
|
2861
|
+
function requireRunnable(task) {
|
|
2862
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2863
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2864
|
+
if (isAutoCollected(task) && task.status === "backlog") throw new TaskBoardTransitionError("auto-collected task must be promoted to todo before it can run");
|
|
2865
|
+
if (!canRunTask(task)) throw new TaskBoardTransitionError("task is already running or missing");
|
|
2866
|
+
return task;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* run(§13.1 手动执行 / §13.2 起点):开启执行(置 running + 追加 open 执行
|
|
2870
|
+
* 记录)。守卫:任务存在、非归档、非 running、无未结算执行、来源合法。
|
|
2871
|
+
*/
|
|
2872
|
+
function applyRunTask(doc, taskId, now) {
|
|
2873
|
+
const opened = openExecution(requireRunnable(doc.tasks.find((candidate) => candidate.id === taskId)), now, crypto.randomUUID());
|
|
2874
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? opened.task : candidate);
|
|
2875
|
+
return true;
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* rerun(§13.2 重新执行):先落回 todo(重开语义——done/failed 重跑不改历史
|
|
2879
|
+
* 状态,见 §12.10「拖回重开」),再按 run 开启执行。守卫同 run。
|
|
2880
|
+
*/
|
|
2881
|
+
function applyRerunTask(doc, taskId, now) {
|
|
2882
|
+
const task = requireRunnable(doc.tasks.find((candidate) => candidate.id === taskId));
|
|
2883
|
+
const opened = openExecution(task.status === "todo" ? task : {
|
|
2884
|
+
...task,
|
|
2885
|
+
status: "todo",
|
|
2886
|
+
updatedAt: now
|
|
2887
|
+
}, now, crypto.randomUUID());
|
|
2888
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? opened.task : candidate);
|
|
2889
|
+
return true;
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* attachSession(§13.2 会话回填):把创建的 DSH 会话 id 回填到指定执行记录。
|
|
2893
|
+
* 执行不存在抛错;已结算记录也允许回填(崩溃后会话号晚到仍保留跳转线索,
|
|
2894
|
+
* 对齐参考实现 attachSession)。同值回填为 no-op。
|
|
2895
|
+
*/
|
|
2896
|
+
function applyAttachSession(doc, taskId, executionId, sessionId, now) {
|
|
2897
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2898
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2899
|
+
const index = task.executions.findIndex((execution) => execution.id === executionId);
|
|
2900
|
+
if (index === -1) throw new TaskBoardTransitionError("execution not found");
|
|
2901
|
+
if (task.executions[index].sessionId === sessionId) return false;
|
|
2902
|
+
const executions = [...task.executions];
|
|
2903
|
+
executions[index] = {
|
|
2904
|
+
...executions[index],
|
|
2905
|
+
sessionId
|
|
2906
|
+
};
|
|
2907
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
2908
|
+
...candidate,
|
|
2909
|
+
executions,
|
|
2910
|
+
updatedAt: now
|
|
2911
|
+
} : candidate);
|
|
2912
|
+
return true;
|
|
2913
|
+
}
|
|
2914
|
+
/** 结算结果 → 任务回落状态(§13.3):succeeded→done、failed→failed、cancelled→todo。 */
|
|
2915
|
+
function settleStatusOf(result, current) {
|
|
2916
|
+
if (result === "succeeded") return "done";
|
|
2917
|
+
if (result === "failed") return "failed";
|
|
2918
|
+
return current === "running" ? "todo" : current;
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* settleExecution(§13.3):结算一条运行中的执行——写 endedAt/result/error,
|
|
2922
|
+
* 任务按结果回落列(succeeded→done、failed→failed、cancelled→todo)。
|
|
2923
|
+
* 守卫:执行存在;已结算执行为 no-op(返回 false,不 bump revision)。
|
|
2924
|
+
*/
|
|
2925
|
+
function applySettleExecution(doc, taskId, executionId, result, now, error) {
|
|
2926
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2927
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2928
|
+
const index = task.executions.findIndex((execution) => execution.id === executionId);
|
|
2929
|
+
if (index === -1) throw new TaskBoardTransitionError("execution not found");
|
|
2930
|
+
const execution = task.executions[index];
|
|
2931
|
+
if (execution.endedAt !== void 0) return false;
|
|
2932
|
+
const executions = [...task.executions];
|
|
2933
|
+
executions[index] = {
|
|
2934
|
+
...execution,
|
|
2935
|
+
endedAt: now,
|
|
2936
|
+
result,
|
|
2937
|
+
...error === void 0 ? {} : { error }
|
|
2938
|
+
};
|
|
2939
|
+
const next = {
|
|
2940
|
+
...task,
|
|
2941
|
+
status: settleStatusOf(result, task.status),
|
|
2942
|
+
updatedAt: now,
|
|
2943
|
+
executions
|
|
2944
|
+
};
|
|
2945
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? next : candidate);
|
|
2946
|
+
return true;
|
|
2947
|
+
}
|
|
2948
|
+
/** 从 cron 分支构造一条新的 ScheduleRule(丢弃 lastTriggeredAt 之外的字段)。 */
|
|
2949
|
+
function cronRule(schedule, cron, enabled, nextRunAt) {
|
|
2950
|
+
const rule = {
|
|
2951
|
+
kind: "cron",
|
|
2952
|
+
enabled,
|
|
2953
|
+
cron
|
|
2954
|
+
};
|
|
2955
|
+
if (nextRunAt !== void 0) rule.nextRunAt = nextRunAt;
|
|
2956
|
+
if (schedule !== void 0 && schedule.kind === "cron" && schedule.lastTriggeredAt !== void 0) rule.lastTriggeredAt = schedule.lastTriggeredAt;
|
|
2957
|
+
return rule;
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* set-schedule(§11.2 / §9.4 cron 分支):设定/修改/关闭任务的 cron 周期定时。
|
|
2961
|
+
*
|
|
2962
|
+
* - 守卫:任务存在、非归档、非 proposed(§10.2:候选任务不可设置任何执行安排);
|
|
2963
|
+
* - cron 取 `patch.cron ?? 当前 cron`;空/语法非法抛错(§11.2 cron 合法性;
|
|
2964
|
+
* 状态机层再校验,Host 不信任浏览器预检);
|
|
2965
|
+
* - enabled 取 `patch.enabled ?? 当前 enabled ?? false`;置武装(true)时立即计算
|
|
2966
|
+
* nextRunAt(`nextRunAtMs(cron, now)`),不可能匹配的表达式(如 `0 0 30 2 *`)
|
|
2967
|
+
* 抛错;解除武装只清 nextRunAt、保留 cron 配置(归档同语义,见 applyArchiveTask);
|
|
2968
|
+
* - 与 one-shot 互斥(D6):无论 patch 内容,结果恒为 cron 分支,原 one-shot
|
|
2969
|
+
* 整体清除;
|
|
2970
|
+
* - 无实际变更(同值 patch)返回 false,不 bump revision。
|
|
2971
|
+
*/
|
|
2972
|
+
function applySetSchedule(doc, taskId, patch, now) {
|
|
2973
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
2974
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
2975
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
2976
|
+
if (task.status === "proposed") throw new TaskBoardTransitionError("proposed task cannot be scheduled");
|
|
2977
|
+
if (isAutoCollected(task) && task.status === "backlog") throw new TaskBoardTransitionError("auto-collected task must be promoted to todo before it can be scheduled");
|
|
2978
|
+
const current = task.schedule;
|
|
2979
|
+
const currentCron = current?.kind === "cron" ? current.cron : void 0;
|
|
2980
|
+
const currentEnabled = current?.kind === "cron" ? current.enabled : void 0;
|
|
2981
|
+
const cron = (patch.cron ?? currentCron ?? "").trim();
|
|
2982
|
+
if (cron === "" || !isValidCron(cron)) throw new TaskBoardTransitionError("invalid cron expression");
|
|
2983
|
+
const enabled = patch.enabled ?? currentEnabled ?? false;
|
|
2984
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
|
|
2985
|
+
if (enabled && nextRunAt === void 0) throw new TaskBoardTransitionError("cron expression can never match");
|
|
2986
|
+
const schedule = cronRule(current, cron, enabled, nextRunAt);
|
|
2987
|
+
if (current !== void 0 && JSON.stringify(current) === JSON.stringify(schedule)) return false;
|
|
2988
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
2989
|
+
...candidate,
|
|
2990
|
+
schedule,
|
|
2991
|
+
updatedAt: now
|
|
2992
|
+
} : candidate);
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* 到期滚动(调度 tick 用,§13.1):把一条 cron 规则滚动到下一个匹配点。
|
|
2997
|
+
* - 任务不存在/已归档/无 schedule/非 cron 分支 → no-op(返回 false);
|
|
2998
|
+
* - 写 nextRunAt 与 lastTriggeredAt(Host 独占字段);nextRunAt 传 undefined
|
|
2999
|
+
* 表示清空(不可能匹配的表达式滚动后不再到期);lastTriggeredAt 传 undefined
|
|
3000
|
+
* 表示保留现值(running 中到期只滚动不触发,本次不记触发点);
|
|
3001
|
+
* - 同值滚动为 no-op(不 bump revision)。
|
|
3002
|
+
*/
|
|
3003
|
+
function applyScheduleRoll(doc, taskId, nextRunAt, lastTriggeredAt, now) {
|
|
3004
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3005
|
+
if (task === void 0 || task.archivedAt !== void 0) return false;
|
|
3006
|
+
const schedule = task.schedule;
|
|
3007
|
+
if (schedule === void 0 || schedule.kind !== "cron") return false;
|
|
3008
|
+
const next = {
|
|
3009
|
+
kind: "cron",
|
|
3010
|
+
enabled: schedule.enabled,
|
|
3011
|
+
cron: schedule.cron
|
|
3012
|
+
};
|
|
3013
|
+
if (nextRunAt !== void 0) next.nextRunAt = nextRunAt;
|
|
3014
|
+
if (lastTriggeredAt !== void 0) next.lastTriggeredAt = lastTriggeredAt;
|
|
3015
|
+
else if (schedule.lastTriggeredAt !== void 0) next.lastTriggeredAt = schedule.lastTriggeredAt;
|
|
3016
|
+
if (JSON.stringify(schedule) === JSON.stringify(next)) return false;
|
|
3017
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3018
|
+
...candidate,
|
|
3019
|
+
schedule: next,
|
|
3020
|
+
updatedAt: now
|
|
3021
|
+
} : candidate);
|
|
3022
|
+
return true;
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* 恢复期跳过(§13.1:错过触发点跳过不补跑):Host 停机/睡眠/长暂停后首个 tick
|
|
3026
|
+
* 对全部到期的武装 cron 规则执行——不触发执行,直接把 nextRunAt 滚动到
|
|
3027
|
+
* `nextRunAtMs(cron, now)`(now 之后的下一个匹配点)。无实际变更返回 false。
|
|
3028
|
+
*
|
|
3029
|
+
* T007 扩展:one-shot 分支同样消费——runAt ≤ now 且未消费(firedAt 缺省)的
|
|
3030
|
+
* 一次性计划写 `firedAt = now` 标记为已错过(§13.5 错过恢复:跳过不补跑,
|
|
3031
|
+
* UI 展示「已过期/已跳过」),但不触发执行。
|
|
3032
|
+
*/
|
|
3033
|
+
function applySkipMissedSchedules(doc, now) {
|
|
3034
|
+
let changed = false;
|
|
3035
|
+
doc.tasks = doc.tasks.map((task) => {
|
|
3036
|
+
if (task.archivedAt !== void 0) return task;
|
|
3037
|
+
const schedule = task.schedule;
|
|
3038
|
+
if (schedule === void 0) return task;
|
|
3039
|
+
if (schedule.kind === "one-shot") {
|
|
3040
|
+
if (schedule.firedAt !== void 0 || schedule.runAt > now) return task;
|
|
3041
|
+
changed = true;
|
|
3042
|
+
return {
|
|
3043
|
+
...task,
|
|
3044
|
+
schedule: {
|
|
3045
|
+
...schedule,
|
|
3046
|
+
firedAt: now
|
|
3047
|
+
},
|
|
3048
|
+
updatedAt: now
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
if (!schedule.enabled) return task;
|
|
3052
|
+
if (schedule.nextRunAt === void 0 || schedule.nextRunAt > now) return task;
|
|
3053
|
+
const next = nextRunAtMs(schedule.cron, now);
|
|
3054
|
+
const rolled = {
|
|
3055
|
+
kind: "cron",
|
|
3056
|
+
enabled: schedule.enabled,
|
|
3057
|
+
cron: schedule.cron
|
|
3058
|
+
};
|
|
3059
|
+
if (next !== void 0) rolled.nextRunAt = next;
|
|
3060
|
+
if (schedule.lastTriggeredAt !== void 0) rolled.lastTriggeredAt = schedule.lastTriggeredAt;
|
|
3061
|
+
changed = true;
|
|
3062
|
+
return {
|
|
3063
|
+
...task,
|
|
3064
|
+
schedule: rolled,
|
|
3065
|
+
updatedAt: now
|
|
3066
|
+
};
|
|
3067
|
+
});
|
|
3068
|
+
return changed;
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* set-one-shot(§11.2 / §9.4 one-shot 分支):设定/修改/清除任务的一次性计划。
|
|
3072
|
+
*
|
|
3073
|
+
* - 守卫:任务存在、非归档、非 proposed(§10.2:候选任务不可设置任何执行安排);
|
|
3074
|
+
* - `runAt` 缺省或 0 → 清除 one-shot 计划(整条 schedule 删除;与 cron 分支
|
|
3075
|
+
* 无关——清除 one-shot 不触碰 cron,设定才互斥);
|
|
3076
|
+
* - `runAt` 为有限数 → 设定 `{ kind: 'one-shot', runAt }`,与 cron 分支互斥
|
|
3077
|
+
* (D6):无论账本当前是 cron 还是 one-shot,结果恒为 one-shot 分支,原
|
|
3078
|
+
* cron 整体清除;
|
|
3079
|
+
* - 无实际变更(同值 patch)返回 false,不 bump revision。
|
|
3080
|
+
*/
|
|
3081
|
+
function applySetOneShot(doc, taskId, patch, now) {
|
|
3082
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3083
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
3084
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
3085
|
+
if (task.status === "proposed") throw new TaskBoardTransitionError("proposed task cannot be scheduled");
|
|
3086
|
+
if (isAutoCollected(task) && task.status === "backlog") throw new TaskBoardTransitionError("auto-collected task must be promoted to todo before it can be scheduled");
|
|
3087
|
+
if (patch.runAt === void 0 || patch.runAt === 0) {
|
|
3088
|
+
if (task.schedule?.kind !== "one-shot") return false;
|
|
3089
|
+
const { schedule: _removed, ...rest } = task;
|
|
3090
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3091
|
+
...rest,
|
|
3092
|
+
updatedAt: now
|
|
3093
|
+
} : candidate);
|
|
3094
|
+
return true;
|
|
3095
|
+
}
|
|
3096
|
+
const schedule = {
|
|
3097
|
+
kind: "one-shot",
|
|
3098
|
+
runAt: patch.runAt
|
|
3099
|
+
};
|
|
3100
|
+
if (JSON.stringify(task.schedule ?? null) === JSON.stringify(schedule)) return false;
|
|
3101
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3102
|
+
...candidate,
|
|
3103
|
+
schedule,
|
|
3104
|
+
updatedAt: now
|
|
3105
|
+
} : candidate);
|
|
3106
|
+
return true;
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* 触发即消费(§13.5 tick 用):把一条到点的一次性计划标记为已消费(写
|
|
3110
|
+
* `firedAt = now`),防止重启后重复触发。
|
|
3111
|
+
* - 任务不存在/已归档/无 schedule/非 one-shot 分支/已消费 → no-op(false);
|
|
3112
|
+
* - 同值消费(firedAt 已等于 now)为 no-op(不 bump revision)。
|
|
3113
|
+
* 与 cron 的滚动(applyScheduleRoll)相对:one-shot 只触发一次,消费即终态。
|
|
3114
|
+
*/
|
|
3115
|
+
function applyMarkOneShotConsumed(doc, taskId, now) {
|
|
3116
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3117
|
+
if (task === void 0 || task.archivedAt !== void 0) return false;
|
|
3118
|
+
const schedule = task.schedule;
|
|
3119
|
+
if (schedule === void 0 || schedule.kind !== "one-shot" || schedule.firedAt !== void 0) return false;
|
|
3120
|
+
if (schedule.firedAt === now) return false;
|
|
3121
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3122
|
+
...candidate,
|
|
3123
|
+
schedule: {
|
|
3124
|
+
...schedule,
|
|
3125
|
+
firedAt: now
|
|
3126
|
+
},
|
|
3127
|
+
updatedAt: now
|
|
3128
|
+
} : candidate);
|
|
3129
|
+
return true;
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* add-comment(§9.5 comments):任务存在、非归档;正文清洗(控制字符 + 长度
|
|
3133
|
+
* 封顶 4000)后追加;作者缺省 'user'、类型缺省 'user_feedback'(user_feedback/
|
|
3134
|
+
* ai_log/system_event 三型,§9.5)。
|
|
3135
|
+
*/
|
|
3136
|
+
function applyAddComment(doc, taskId, input, now) {
|
|
3137
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3138
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
3139
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
3140
|
+
const body = sanitizeCollectedText(input.body, 4e3);
|
|
3141
|
+
if (body === "") throw new TaskBoardTransitionError("invalid comment");
|
|
3142
|
+
const comment = {
|
|
3143
|
+
id: crypto.randomUUID(),
|
|
3144
|
+
author: typeof input.author === "string" && input.author.trim() !== "" ? input.author.trim().slice(0, 64) : "user",
|
|
3145
|
+
body,
|
|
3146
|
+
type: isCommentType(input.type) ? input.type : "user_feedback",
|
|
3147
|
+
createdAt: now
|
|
3148
|
+
};
|
|
3149
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3150
|
+
...candidate,
|
|
3151
|
+
comments: [...candidate.comments, comment],
|
|
3152
|
+
updatedAt: now
|
|
3153
|
+
} : candidate);
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* add-artifact(§9.5 artifacts):任务存在、非归档;标题清洗后非空、类型合法;
|
|
3158
|
+
* 同 (type, contentRef) 或 (type, url) 视为同一条产物(会话产物防重),返回
|
|
3159
|
+
* false(不 bump revision)。会话 transcript 由 Host 在执行结算成功时自动记录
|
|
3160
|
+
* (type='session',contentRef=sessionId)。
|
|
3161
|
+
*/
|
|
3162
|
+
function applyAddArtifact(doc, taskId, input, now) {
|
|
3163
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3164
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
3165
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
3166
|
+
const title = sanitizeCollectedText(input.title, 500);
|
|
3167
|
+
if (title === "" || !isArtifactType(input.type)) throw new TaskBoardTransitionError("invalid artifact");
|
|
3168
|
+
const url = typeof input.url === "string" ? sanitizeCollectedText(input.url, 2048) : void 0;
|
|
3169
|
+
const contentRef = typeof input.contentRef === "string" ? sanitizeCollectedText(input.contentRef, 512) : void 0;
|
|
3170
|
+
if (task.artifacts.some((artifact) => artifact.type === input.type && (contentRef !== void 0 && artifact.contentRef === contentRef || url !== void 0 && artifact.url === url))) return false;
|
|
3171
|
+
const artifact = {
|
|
3172
|
+
id: crypto.randomUUID(),
|
|
3173
|
+
type: input.type,
|
|
3174
|
+
title,
|
|
3175
|
+
createdAt: now
|
|
3176
|
+
};
|
|
3177
|
+
if (url !== void 0 && url !== "") artifact.url = url;
|
|
3178
|
+
if (contentRef !== void 0 && contentRef !== "") artifact.contentRef = contentRef;
|
|
3179
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3180
|
+
...candidate,
|
|
3181
|
+
artifacts: [...candidate.artifacts, artifact],
|
|
3182
|
+
updatedAt: now
|
|
3183
|
+
} : candidate);
|
|
3184
|
+
return true;
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* update-context(§9.5 context_snapshot):部分补丁合并进既有快照(缺省字段
|
|
3188
|
+
* 保持现值);字符串清洗、数组 trim/去重/封顶;空串/空数组清除对应字段;
|
|
3189
|
+
* updatedAt 由 Host 写(now)。无实际内容变更返回 false(不 bump revision)。
|
|
3190
|
+
* 每次执行后由 Host 以 contextPatchFromSettle 合成更新(host-service.ts)。
|
|
3191
|
+
*/
|
|
3192
|
+
function applyUpdateContextSnapshot(doc, taskId, patch, now) {
|
|
3193
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
3194
|
+
if (task === void 0) throw new TaskBoardTransitionError("task not found");
|
|
3195
|
+
if (task.archivedAt !== void 0) throw new TaskBoardTransitionError("archived task is read-only");
|
|
3196
|
+
const current = task.contextSnapshot;
|
|
3197
|
+
const next = {
|
|
3198
|
+
keyDecisions: patch.keyDecisions !== void 0 ? normalizeSnapshotList(patch.keyDecisions) : current?.keyDecisions ?? [],
|
|
3199
|
+
filePaths: patch.filePaths !== void 0 ? normalizeSnapshotList(patch.filePaths) : current?.filePaths ?? [],
|
|
3200
|
+
relatedLinks: patch.relatedLinks !== void 0 ? normalizeSnapshotList(patch.relatedLinks) : current?.relatedLinks ?? [],
|
|
3201
|
+
updatedAt: now
|
|
3202
|
+
};
|
|
3203
|
+
if (patch.goal !== void 0) {
|
|
3204
|
+
const goal = sanitizeCollectedText(patch.goal, 2e3);
|
|
3205
|
+
if (goal !== "") next.goal = goal;
|
|
3206
|
+
} else if (current?.goal !== void 0) next.goal = current.goal;
|
|
3207
|
+
if (patch.lastAiSummary !== void 0) {
|
|
3208
|
+
const summary = sanitizeCollectedText(patch.lastAiSummary, 8e3);
|
|
3209
|
+
if (summary !== "") next.lastAiSummary = summary;
|
|
3210
|
+
} else if (current?.lastAiSummary !== void 0) next.lastAiSummary = current.lastAiSummary;
|
|
3211
|
+
if (patch.latestUserFeedback !== void 0) {
|
|
3212
|
+
const feedback = sanitizeCollectedText(patch.latestUserFeedback, 4e3);
|
|
3213
|
+
if (feedback !== "") next.latestUserFeedback = feedback;
|
|
3214
|
+
} else if (current?.latestUserFeedback !== void 0) next.latestUserFeedback = current.latestUserFeedback;
|
|
3215
|
+
if (current !== void 0 && current.goal === next.goal && current.lastAiSummary === next.lastAiSummary && current.latestUserFeedback === next.latestUserFeedback && JSON.stringify(current.keyDecisions) === JSON.stringify(next.keyDecisions) && JSON.stringify(current.filePaths) === JSON.stringify(next.filePaths) && JSON.stringify(current.relatedLinks) === JSON.stringify(next.relatedLinks)) return false;
|
|
3216
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? {
|
|
3217
|
+
...candidate,
|
|
3218
|
+
contextSnapshot: next,
|
|
3219
|
+
updatedAt: now
|
|
3220
|
+
} : candidate);
|
|
3221
|
+
return true;
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* upsert-automation(§9.5 automation_rules):创建或整条替换一条规则。
|
|
3225
|
+
* - 校验:id 非空、source 合法、cron 语法合法(不可匹配的表达式抛错);
|
|
3226
|
+
* - 武装(enabled=true)时立即计算 nextRunAt;解除武装清 nextRunAt、保留 cron
|
|
3227
|
+
* 配置(与 set-schedule 同语义);
|
|
3228
|
+
* - lastTriggeredAt 从既有规则延续(Host 独占字段);同内容 upsert 为 no-op。
|
|
3229
|
+
*/
|
|
3230
|
+
function applyUpsertAutomationRule(doc, input, now) {
|
|
3231
|
+
const id = input.id.trim().slice(0, 128);
|
|
3232
|
+
if (id === "") throw new TaskBoardTransitionError("invalid automation rule id");
|
|
3233
|
+
if (!isAutomationSource(input.source)) throw new TaskBoardTransitionError("invalid automation source");
|
|
3234
|
+
const cron = input.trigger.cron.trim();
|
|
3235
|
+
if (!isValidCron(cron)) throw new TaskBoardTransitionError("invalid cron expression");
|
|
3236
|
+
const config = {};
|
|
3237
|
+
for (const [key, value] of Object.entries(input.config)) {
|
|
3238
|
+
const trimmedKey = key.trim().slice(0, 64);
|
|
3239
|
+
const trimmedValue = value.trim().slice(0, 2048);
|
|
3240
|
+
if (trimmedKey === "" || trimmedValue === "") continue;
|
|
3241
|
+
config[trimmedKey] = trimmedValue;
|
|
3242
|
+
}
|
|
3243
|
+
const labels = normalizeTags(input.filter.labels);
|
|
3244
|
+
const existing = doc.automationRules.find((rule) => rule.id === id);
|
|
3245
|
+
const next = {
|
|
3246
|
+
id,
|
|
3247
|
+
enabled: input.enabled === true,
|
|
3248
|
+
source: input.source,
|
|
3249
|
+
trigger: {
|
|
3250
|
+
kind: "cron",
|
|
3251
|
+
cron
|
|
3252
|
+
},
|
|
3253
|
+
config,
|
|
3254
|
+
filter: { labels },
|
|
3255
|
+
createdAt: existing?.createdAt ?? now,
|
|
3256
|
+
updatedAt: now
|
|
3257
|
+
};
|
|
3258
|
+
if (input.enabled) {
|
|
3259
|
+
const nextRunAt = nextRunAtMs(cron, now);
|
|
3260
|
+
if (nextRunAt === void 0) throw new TaskBoardTransitionError("cron expression can never match");
|
|
3261
|
+
next.trigger.nextRunAt = nextRunAt;
|
|
3262
|
+
}
|
|
3263
|
+
if (existing?.trigger.lastTriggeredAt !== void 0) next.trigger.lastTriggeredAt = existing.trigger.lastTriggeredAt;
|
|
3264
|
+
if (existing !== void 0 && JSON.stringify({
|
|
3265
|
+
...existing,
|
|
3266
|
+
updatedAt: now
|
|
3267
|
+
}) === JSON.stringify(next)) return false;
|
|
3268
|
+
doc.automationRules = existing === void 0 ? [...doc.automationRules, next] : doc.automationRules.map((rule) => rule.id === id ? next : rule);
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
/** delete-automation(§11.2):规则不存在抛错。 */
|
|
3272
|
+
function applyDeleteAutomationRule(doc, ruleId) {
|
|
3273
|
+
if (!doc.automationRules.some((rule) => rule.id === ruleId)) throw new TaskBoardTransitionError("automation rule not found");
|
|
3274
|
+
doc.automationRules = doc.automationRules.filter((rule) => rule.id !== ruleId);
|
|
3275
|
+
return true;
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* 收集器滚动(tick/手动触发用,§14.2):写规则 trigger 的 nextRunAt 与
|
|
3279
|
+
* lastTriggeredAt(Host 独占字段)。nextRunAt 传 undefined 表示保留现值
|
|
3280
|
+
* (手动 run-automation 不消费计划触发点);lastTriggeredAt 传 undefined 表示
|
|
3281
|
+
* 保留现值。同值滚动为 no-op。
|
|
3282
|
+
*/
|
|
3283
|
+
function applyCollectorRoll(doc, ruleId, nextRunAt, lastTriggeredAt, now) {
|
|
3284
|
+
const rule = doc.automationRules.find((candidate) => candidate.id === ruleId);
|
|
3285
|
+
if (rule === void 0) return false;
|
|
3286
|
+
const next = {
|
|
3287
|
+
...rule,
|
|
3288
|
+
trigger: {
|
|
3289
|
+
kind: "cron",
|
|
3290
|
+
cron: rule.trigger.cron
|
|
3291
|
+
}
|
|
3292
|
+
};
|
|
3293
|
+
if (nextRunAt !== void 0) next.trigger.nextRunAt = nextRunAt;
|
|
3294
|
+
else if (rule.trigger.nextRunAt !== void 0) next.trigger.nextRunAt = rule.trigger.nextRunAt;
|
|
3295
|
+
if (lastTriggeredAt !== void 0) next.trigger.lastTriggeredAt = lastTriggeredAt;
|
|
3296
|
+
else if (rule.trigger.lastTriggeredAt !== void 0) next.trigger.lastTriggeredAt = rule.trigger.lastTriggeredAt;
|
|
3297
|
+
if (JSON.stringify(rule) === JSON.stringify(next)) return false;
|
|
3298
|
+
next.updatedAt = now;
|
|
3299
|
+
doc.automationRules = doc.automationRules.map((candidate) => candidate.id === ruleId ? next : candidate);
|
|
3300
|
+
return true;
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* 恢复期跳过(§14.2,与任务 cron 的 applySkipMissedSchedules 同语义):Host
|
|
3304
|
+
* 停机/睡眠/长暂停后首个 tick 对全部到期的武装规则——不触发收集,直接把
|
|
3305
|
+
* nextRunAt 滚动到 now 之后的下一个匹配点(错过不补跑)。
|
|
3306
|
+
*/
|
|
3307
|
+
function applySkipMissedAutomationRules(doc, now) {
|
|
3308
|
+
let changed = false;
|
|
3309
|
+
doc.automationRules = doc.automationRules.map((rule) => {
|
|
3310
|
+
if (!rule.enabled || rule.trigger.nextRunAt === void 0 || rule.trigger.nextRunAt > now) return rule;
|
|
3311
|
+
const nextRunAt = nextRunAtMs(rule.trigger.cron, now);
|
|
3312
|
+
if (nextRunAt === void 0) return rule;
|
|
3313
|
+
changed = true;
|
|
3314
|
+
const next = {
|
|
3315
|
+
...rule,
|
|
3316
|
+
trigger: {
|
|
3317
|
+
kind: "cron",
|
|
3318
|
+
cron: rule.trigger.cron,
|
|
3319
|
+
nextRunAt
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
if (rule.trigger.lastTriggeredAt !== void 0) next.trigger.lastTriggeredAt = rule.trigger.lastTriggeredAt;
|
|
3323
|
+
return {
|
|
3324
|
+
...next,
|
|
3325
|
+
updatedAt: now
|
|
3326
|
+
};
|
|
3327
|
+
});
|
|
3328
|
+
return changed;
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* 收集落账:逐条把收集到的任务追加为 backlog。守卫:
|
|
3332
|
+
* - id 已存在 → 跳过(幂等);
|
|
3333
|
+
* - 账本中已有同 (source, metadata.url) 的任务 → 跳过(去重,§14.1/14.2);
|
|
3334
|
+
* - order 由 Host 接 backlog 列尾重算(不信任收集器传入的 order)。
|
|
3335
|
+
* 返回是否发生实际变更。
|
|
3336
|
+
*/
|
|
3337
|
+
function applyAddCollectedTasks(doc, tasks) {
|
|
3338
|
+
let changed = false;
|
|
3339
|
+
let order = maxOrderOf(doc.tasks, "backlog");
|
|
3340
|
+
const existingIds = new Set(doc.tasks.map((task) => task.id));
|
|
3341
|
+
for (const input of tasks) {
|
|
3342
|
+
if (input.id === "" || input.source === void 0 || existingIds.has(input.id)) continue;
|
|
3343
|
+
if (hasCollected(doc.tasks, input.source, input.metadata)) continue;
|
|
3344
|
+
order += 1;
|
|
3345
|
+
const task = {
|
|
3346
|
+
...input,
|
|
3347
|
+
order
|
|
3348
|
+
};
|
|
3349
|
+
doc.tasks = [...doc.tasks, task];
|
|
3350
|
+
existingIds.add(task.id);
|
|
3351
|
+
changed = true;
|
|
3352
|
+
}
|
|
3353
|
+
return changed;
|
|
3354
|
+
}
|
|
3355
|
+
//#endregion
|
|
3356
|
+
//#region src/dsh-home.ts
|
|
3357
|
+
/**
|
|
3358
|
+
* DSH_HOME 解析(AGENTS.md §1.2 / D2:本项目账本目录
|
|
3359
|
+
* `$DSH_HOME/nova-ui/task-board/`,避开宿主 `$DSH_HOME/task-board/`)。
|
|
3360
|
+
*
|
|
3361
|
+
* 环境变量 `DSH_HOME` 优先(支持 `~` 展开与相对路径按 cwd 解析),否则回退
|
|
3362
|
+
* 平台家目录下的 `.dsh`。与宿主 dsh 系列插件的解析约定一致。
|
|
3363
|
+
*/
|
|
3364
|
+
/** 展开路径前导 `~`(或 `~user`)。 */
|
|
3365
|
+
function expandHome(path, home = homedir()) {
|
|
3366
|
+
if (path === "~") return home;
|
|
3367
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
3368
|
+
return path;
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* 解析 DSH 家目录。
|
|
3372
|
+
* @param env - 读取 DSH_HOME 的环境(测试可注入)。
|
|
3373
|
+
* @param home - 平台家目录回退(测试可注入)。
|
|
3374
|
+
*/
|
|
3375
|
+
function resolveDshHome(env = process.env, home = homedir()) {
|
|
3376
|
+
const raw = env.DSH_HOME;
|
|
3377
|
+
if (raw !== void 0 && raw.trim() !== "") {
|
|
3378
|
+
const expanded = expandHome(raw.trim(), home);
|
|
3379
|
+
return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
|
|
3380
|
+
}
|
|
3381
|
+
return join(home, ".dsh");
|
|
3382
|
+
}
|
|
3383
|
+
/** 从当前环境解析 DSH 家目录。 */
|
|
3384
|
+
function dshHome() {
|
|
3385
|
+
return resolveDshHome();
|
|
3386
|
+
}
|
|
3387
|
+
/** 本项目账本目录(相对 DSH_HOME 的子路径,见 AGENTS.md D2)。 */
|
|
3388
|
+
const LEDGER_DIR_RELATIVE = join("nova-ui", "task-board");
|
|
3389
|
+
/** 本项目账本目录的绝对路径。 */
|
|
3390
|
+
function ledgerDir() {
|
|
3391
|
+
return join(dshHome(), LEDGER_DIR_RELATIVE);
|
|
3392
|
+
}
|
|
3393
|
+
//#endregion
|
|
3394
|
+
//#region src/core/recovery.ts
|
|
3395
|
+
/** 无 sessionId 的中断执行被标记 cancelled 的原因文案。 */
|
|
3396
|
+
const INTERRUPTED_START_REASON = "host restarted before the execution session was recorded";
|
|
3397
|
+
/**
|
|
3398
|
+
* 结算中断执行:running 任务 + 最后一条执行 open 且无 sessionId → 置
|
|
3399
|
+
* cancelled(endedAt = now,记原因,bump updatedAt),状态按 §10.2
|
|
3400
|
+
* `running → settle(cancelled) → todo` 回落待办,避免卡死幽灵 running 态。
|
|
3401
|
+
* 其余任务原样保留。
|
|
3402
|
+
*/
|
|
3403
|
+
function settleInterruptedStarts(tasks, now) {
|
|
3404
|
+
let changed = false;
|
|
3405
|
+
return {
|
|
3406
|
+
tasks: tasks.map((task) => {
|
|
3407
|
+
if (task.status !== "running") return task;
|
|
3408
|
+
const execution = task.executions.at(-1);
|
|
3409
|
+
if (execution === void 0 || execution.endedAt !== void 0 || execution.sessionId !== void 0) return task;
|
|
3410
|
+
changed = true;
|
|
3411
|
+
return {
|
|
3412
|
+
...task,
|
|
3413
|
+
status: "todo",
|
|
3414
|
+
updatedAt: now,
|
|
3415
|
+
executions: [...task.executions.slice(0, -1), {
|
|
3416
|
+
...execution,
|
|
3417
|
+
endedAt: now,
|
|
3418
|
+
result: "cancelled",
|
|
3419
|
+
error: INTERRUPTED_START_REASON
|
|
3420
|
+
}]
|
|
3421
|
+
};
|
|
3422
|
+
}),
|
|
3423
|
+
changed
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
3426
|
+
//#endregion
|
|
3427
|
+
//#region src/host-ledger.ts
|
|
3428
|
+
/**
|
|
3429
|
+
* Host 权威账本(DSH-REQ-001 §8 架构 / §9.1 / §17 可靠性)。
|
|
3430
|
+
*
|
|
3431
|
+
* 「Host 权威、浏览器只是异步视图」的数据底座:所有任务状态持久化到
|
|
3432
|
+
* `$DSH_HOME/nova-ui/task-board/ledger-v2.json`(0600,原子写),保证重启不丢、
|
|
3433
|
+
* 并发一致、损坏可隔离。T003(协议)/T005(执行)/T006(调度)等在此之上工作。
|
|
3434
|
+
*
|
|
3435
|
+
* 可靠性机制(§17 / §19-7 数据部分):
|
|
3436
|
+
* - **原子写**:临时文件 + fsync + 原子 rename + 目录 fsync;任何失败清理临时
|
|
3437
|
+
* 文件并重新抛出,绝不产生半写文件;
|
|
3438
|
+
* - **损坏隔离**:schema 不兼容/不可解析的账本 rename 为 `ledger-v2.json.corrupt-*`
|
|
3439
|
+
* (保留原字节)并以空账本重建,不崩溃;
|
|
3440
|
+
* - **单写者锁**:`ledger-v2.lock`(0600,`wx` 排他创建)记录 PID + token +
|
|
3441
|
+
* 进程启动时间探测;PID 复用通过启动时间不匹配识别为陈旧锁并接管(防误判),
|
|
3442
|
+
* 僵尸/死亡进程(Z/X)不阻塞启动;
|
|
3443
|
+
* - **幂等缓存**:`recentRequests`(最近 256 条 requestId→SHA-256 指纹)随账本
|
|
3444
|
+
* 原子持久化,Host 重启后浏览器重试仍安全(§11.3);
|
|
3445
|
+
* - **v1 迁移**:`importSource` 按 sourceId 一次性导入、按 id 合并,导入 marker
|
|
3446
|
+
* 随原子落账持久化(Host 确认后浏览器才清除 v1 原值,见 core/migrate.ts)。
|
|
3447
|
+
*
|
|
3448
|
+
* 串行性:本类全部方法为同步实现(无 await),单进程内天然串行;跨进程互斥
|
|
3449
|
+
* 由锁保证。`revision` 每次原子落账严格 +1(scheduler 心跳类补丁除外,见
|
|
3450
|
+
* `setScheduler`)。
|
|
3451
|
+
*/
|
|
3452
|
+
function cloneTasks(tasks) {
|
|
3453
|
+
return JSON.parse(JSON.stringify(tasks));
|
|
3454
|
+
}
|
|
3455
|
+
function cloneAutomationRules(rules) {
|
|
3456
|
+
return JSON.parse(JSON.stringify(rules));
|
|
3457
|
+
}
|
|
3458
|
+
/**
|
|
3459
|
+
* 进程状态中「已死但仍占 PID 表」的状态:`Z`(僵尸)与 `X`(dead,正被回收)。
|
|
3460
|
+
* `process.kill(pid, 0)` 会把这类 PID 报为存活,若崩溃残留的子进程从未被回收,
|
|
3461
|
+
* 会把活的锁主误判成「正在运行」而永久阻塞账本启动。
|
|
3462
|
+
*/
|
|
3463
|
+
const DEAD_STATES = /* @__PURE__ */ new Set(["Z", "X"]);
|
|
3464
|
+
/**
|
|
3465
|
+
* 尽力而为的单字母进程状态('R','S','D','Z',...),平台不支持时返回 undefined。
|
|
3466
|
+
* Linux 直接读 /proc(无子进程);其他 POSIX 用 `ps -o stat=`;Windows 无僵尸
|
|
3467
|
+
* 态,返回 undefined(此时 kill(0) 探测为唯一权威)。
|
|
3468
|
+
*/
|
|
3469
|
+
function processState(pid) {
|
|
3470
|
+
if (process.platform === "linux") try {
|
|
3471
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
3472
|
+
const end = stat.lastIndexOf(")");
|
|
3473
|
+
if (end === -1) return void 0;
|
|
3474
|
+
return stat.slice(end + 2).split(" ")[0] || void 0;
|
|
3475
|
+
} catch {
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (process.platform === "win32") return void 0;
|
|
3479
|
+
try {
|
|
3480
|
+
const probe = spawnSync("ps", [
|
|
3481
|
+
"-o",
|
|
3482
|
+
"stat=",
|
|
3483
|
+
"-p",
|
|
3484
|
+
String(pid)
|
|
3485
|
+
], { timeout: PROCESS_PROBE_TIMEOUT_MS });
|
|
3486
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
3487
|
+
const state = probe.stdout.toString("utf8").trim();
|
|
3488
|
+
return state.length > 0 ? state[0] : void 0;
|
|
3489
|
+
} catch {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
/** PID 是否存活(僵尸/死亡态视为不存活)。 */
|
|
3494
|
+
function processIsAlive(pid) {
|
|
3495
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
3496
|
+
const state = processState(pid);
|
|
3497
|
+
if (state !== void 0 && DEAD_STATES.has(state)) return false;
|
|
3498
|
+
try {
|
|
3499
|
+
process.kill(pid, 0);
|
|
3500
|
+
return true;
|
|
3501
|
+
} catch (error) {
|
|
3502
|
+
return error.code !== "ESRCH";
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
3506
|
+
let ownStartTime;
|
|
3507
|
+
let ownStartTimeResolved = false;
|
|
3508
|
+
/**
|
|
3509
|
+
* Linux 下精确进程启动时间(Unix epoch ms):直接读 /proc(field 22 = 开机后的
|
|
3510
|
+
* ticks,btime = 开机 epoch 秒)。无子进程、无取整,与历史锁记录的 `startedAt`
|
|
3511
|
+
* 可精确比对。
|
|
3512
|
+
*/
|
|
3513
|
+
function linuxStartTimeMs(pid) {
|
|
3514
|
+
try {
|
|
3515
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
3516
|
+
const end = stat.lastIndexOf(")");
|
|
3517
|
+
if (end === -1) return void 0;
|
|
3518
|
+
const ticks = Number(stat.slice(end + 2).split(" ")[19]);
|
|
3519
|
+
if (!Number.isFinite(ticks)) return void 0;
|
|
3520
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
|
|
3521
|
+
if (bootMatch === null) return void 0;
|
|
3522
|
+
const btime = Number(bootMatch[1]);
|
|
3523
|
+
if (!Number.isFinite(btime)) return void 0;
|
|
3524
|
+
return btime * 1e3 + ticks * 1e3 / 100;
|
|
3525
|
+
} catch {
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
/**
|
|
3530
|
+
* 尽力而为的进程启动时间(Unix epoch ms),用于证明锁记录里的 PID 是否真的是
|
|
3531
|
+
* 之前那个进程(PID 复用会被识别为陈旧锁而不是永久阻塞启动)。平台探测不可用
|
|
3532
|
+
* 时返回 undefined,调用方 fail closed。
|
|
3533
|
+
*/
|
|
3534
|
+
function processStartTimeMs(pid) {
|
|
3535
|
+
if (process.platform === "linux") return linuxStartTimeMs(pid);
|
|
3536
|
+
if (process.platform === "win32") {
|
|
3537
|
+
const probe = spawnSync("powershell", [
|
|
3538
|
+
"-NoProfile",
|
|
3539
|
+
"-NonInteractive",
|
|
3540
|
+
"-Command",
|
|
3541
|
+
"[DateTimeOffset]::FromFileTime((Get-Process -Id " + String(pid) + " -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()"
|
|
3542
|
+
], {
|
|
3543
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
3544
|
+
windowsHide: true
|
|
3545
|
+
});
|
|
3546
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
3547
|
+
const started = Number(probe.stdout.toString("utf8").trim());
|
|
3548
|
+
return Number.isFinite(started) ? started : void 0;
|
|
3549
|
+
}
|
|
3550
|
+
const env = {
|
|
3551
|
+
...process.env,
|
|
3552
|
+
LC_ALL: "C"
|
|
3553
|
+
};
|
|
3554
|
+
const lstart = spawnSync("ps", [
|
|
3555
|
+
"-o",
|
|
3556
|
+
"lstart=",
|
|
3557
|
+
"-p",
|
|
3558
|
+
String(pid)
|
|
3559
|
+
], {
|
|
3560
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
3561
|
+
env
|
|
3562
|
+
});
|
|
3563
|
+
if (lstart.status === 0 && lstart.stdout.length > 0) {
|
|
3564
|
+
const started = Date.parse(lstart.stdout.toString("utf8").trim());
|
|
3565
|
+
if (Number.isFinite(started)) return started;
|
|
3566
|
+
}
|
|
3567
|
+
const elapsed = spawnSync("ps", [
|
|
3568
|
+
"-o",
|
|
3569
|
+
"etimes=",
|
|
3570
|
+
"-p",
|
|
3571
|
+
String(pid)
|
|
3572
|
+
], {
|
|
3573
|
+
timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
3574
|
+
env
|
|
3575
|
+
});
|
|
3576
|
+
if (elapsed.status !== 0 || elapsed.stdout.length === 0) return void 0;
|
|
3577
|
+
const seconds = Number(elapsed.stdout.toString("utf8").trim());
|
|
3578
|
+
if (!Number.isFinite(seconds)) return void 0;
|
|
3579
|
+
return Date.now() - seconds * 1e3;
|
|
3580
|
+
}
|
|
3581
|
+
function ownProcessStartTimeMs() {
|
|
3582
|
+
if (!ownStartTimeResolved) {
|
|
3583
|
+
ownStartTimeResolved = true;
|
|
3584
|
+
ownStartTime = processStartTimeMs(process.pid);
|
|
3585
|
+
}
|
|
3586
|
+
return ownStartTime;
|
|
3587
|
+
}
|
|
3588
|
+
/**
|
|
3589
|
+
* 旧锁记录的容差:ms 级精确探测落地前,锁的 `startedAt` 来自 `ps -o lstart=`
|
|
3590
|
+
* 秒级分辨率;用 /proc 精确探测同一存活进程会差出亚秒余量,若当作 PID 复用会
|
|
3591
|
+
* 在滚动升级时偷走活锁主、出现第二个账本写者。ms 级探测写出的记录带
|
|
3592
|
+
* `probe: 'exact'` 严格比对;其余(旧锁、秒级探测)走本容差。
|
|
3593
|
+
*/
|
|
3594
|
+
const LEGACY_START_TOLERANCE_MS = 2e3;
|
|
3595
|
+
/** 记录的启动时间是否证明记录的 PID 已是另一个进程(PID 复用探测核心)。 */
|
|
3596
|
+
function startTimeMismatch(recorded, actual, exact) {
|
|
3597
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS;
|
|
3598
|
+
}
|
|
3599
|
+
const defaultProbes = {
|
|
3600
|
+
isAlive: processIsAlive,
|
|
3601
|
+
startTimeMs: processStartTimeMs,
|
|
3602
|
+
ownStartTimeMs: () => ownProcessStartTimeMs()
|
|
3603
|
+
};
|
|
3604
|
+
/**
|
|
3605
|
+
* Host 权威账本。构造即获取单写者锁并加载/恢复账本;进程内所有写入经
|
|
3606
|
+
* `applyRequest`/`importSource`/`mutate`/`setScheduler` 串行原子落账。
|
|
3607
|
+
*/
|
|
3608
|
+
var HostLedger = class {
|
|
3609
|
+
now;
|
|
3610
|
+
/** 账本文件绝对路径。 */
|
|
3611
|
+
file;
|
|
3612
|
+
/** 锁文件绝对路径。 */
|
|
3613
|
+
lockFile;
|
|
3614
|
+
/** 账本目录。 */
|
|
3615
|
+
dir;
|
|
3616
|
+
document;
|
|
3617
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3618
|
+
requestCache = /* @__PURE__ */ new Map();
|
|
3619
|
+
lockToken = crypto.randomUUID();
|
|
3620
|
+
lockFd;
|
|
3621
|
+
probes;
|
|
3622
|
+
/**
|
|
3623
|
+
* @param dir - 账本目录(默认 `$DSH_HOME/nova-ui/task-board/`,见 AGENTS.md D2)。
|
|
3624
|
+
* @param now - 时间源(测试可注入)。
|
|
3625
|
+
* @param probes - 进程探测面(默认真实探测;测试可注入 fake,见 §17 可测试性)。
|
|
3626
|
+
*/
|
|
3627
|
+
constructor(dir = ledgerDir(), now = Date.now, probes = defaultProbes) {
|
|
3628
|
+
this.now = now;
|
|
3629
|
+
this.probes = probes;
|
|
3630
|
+
mkdirSync(dir, { recursive: true });
|
|
3631
|
+
this.dir = dir;
|
|
3632
|
+
this.file = join(dir, "ledger-v2.json");
|
|
3633
|
+
this.lockFile = join(dir, "ledger-v2.lock");
|
|
3634
|
+
this.lockFd = this.acquireLock();
|
|
3635
|
+
try {
|
|
3636
|
+
this.document = this.load();
|
|
3637
|
+
for (const request of this.document.recentRequests) this.requestCache.set(request.requestId, { fingerprint: request.fingerprint });
|
|
3638
|
+
const recovered = settleInterruptedStarts(this.document.tasks, this.now());
|
|
3639
|
+
if (recovered.changed) this.document.tasks = recovered.tasks;
|
|
3640
|
+
this.commit(false);
|
|
3641
|
+
} catch (error) {
|
|
3642
|
+
this.dispose();
|
|
3643
|
+
throw error;
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
/** revision + scheduler(不克隆任务),供 SSE 事件帧使用。 */
|
|
3647
|
+
summary() {
|
|
3648
|
+
const { importedSources: _internal, ...scheduler } = this.document.scheduler;
|
|
3649
|
+
return {
|
|
3650
|
+
revision: this.document.revision,
|
|
3651
|
+
scheduler: { ...scheduler }
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
/** 全量状态快照(任务/规则深拷贝,调用方修改不影响账本)。 */
|
|
3655
|
+
state() {
|
|
3656
|
+
return {
|
|
3657
|
+
...this.summary(),
|
|
3658
|
+
tasks: cloneTasks(this.document.tasks),
|
|
3659
|
+
automationRules: cloneAutomationRules(this.document.automationRules)
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* 只读任务数组(不克隆,O(1))。仅供服务层扫描/计数(如 power 快照的
|
|
3664
|
+
* runningSessions/armedSchedules);**绝不可修改**——任何变更必须走
|
|
3665
|
+
* `applyRequest`/`mutate` 的原子落账路径。
|
|
3666
|
+
*/
|
|
3667
|
+
peekTasks() {
|
|
3668
|
+
return this.document.tasks;
|
|
3669
|
+
}
|
|
3670
|
+
/** 只读自动化规则数组(不克隆;变更必须走 applyRequest/mutate 原子落账)。 */
|
|
3671
|
+
peekAutomationRules() {
|
|
3672
|
+
return this.document.automationRules;
|
|
3673
|
+
}
|
|
3674
|
+
/** 订阅账本变更(每次原子落账后触发)。 */
|
|
3675
|
+
subscribe(listener) {
|
|
3676
|
+
this.listeners.add(listener);
|
|
3677
|
+
return () => {
|
|
3678
|
+
this.listeners.delete(listener);
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
/** 释放锁(仅当锁仍属本实例时删除锁文件,避免误删外部替换的锁)。 */
|
|
3682
|
+
dispose() {
|
|
3683
|
+
const fd = this.lockFd;
|
|
3684
|
+
if (fd === void 0) return;
|
|
3685
|
+
this.lockFd = void 0;
|
|
3686
|
+
closeSync(fd);
|
|
3687
|
+
try {
|
|
3688
|
+
if (JSON.parse(readFileSync(this.lockFile, "utf8")).token === this.lockToken) unlinkSync(this.lockFile);
|
|
3689
|
+
} catch {}
|
|
3690
|
+
}
|
|
3691
|
+
/**
|
|
3692
|
+
* 幂等 action 应用(§11.3)。调用方(T003 协议层)提供序列化的 action(仅
|
|
3693
|
+
* 用于 SHA-256 指纹)与变更闭包;闭包返回 true 表示文档有实际变更。
|
|
3694
|
+
*
|
|
3695
|
+
* - 重复 requestId + 相同指纹 → 直接返回当前 state(不重新应用、不 bump);
|
|
3696
|
+
* - 同 requestId + 不同动作 → 抛错(`request id was reused with a different action`);
|
|
3697
|
+
* - 首次请求 → 指纹先行入缓存,随后应用;变更闭包抛错则回滚缓存条目;
|
|
3698
|
+
* - 成功且 changed → 原子落账(revision+1),指纹随账本一并持久化——
|
|
3699
|
+
* Host 重启后浏览器重试仍安全。
|
|
3700
|
+
*
|
|
3701
|
+
* @param requestId - 浏览器侧 uuid。
|
|
3702
|
+
* @param action - 序列化 action(指纹源;必须可 JSON 序列化)。
|
|
3703
|
+
* @param apply - 变更闭包,就地修改文档;返回 true 表示发生变更(需落账)。
|
|
3704
|
+
*/
|
|
3705
|
+
applyRequest(requestId, action, apply) {
|
|
3706
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(action)).digest("hex");
|
|
3707
|
+
const cached = this.requestCache.get(requestId);
|
|
3708
|
+
if (cached !== void 0) {
|
|
3709
|
+
if (cached.fingerprint !== fingerprint) throw new Error("request id was reused with a different action");
|
|
3710
|
+
return this.state();
|
|
3711
|
+
}
|
|
3712
|
+
this.requestCache.set(requestId, { fingerprint });
|
|
3713
|
+
while (this.requestCache.size > 256) this.requestCache.delete(this.requestCache.keys().next().value);
|
|
3714
|
+
this.syncRecentRequests();
|
|
3715
|
+
try {
|
|
3716
|
+
return this.mutate(apply);
|
|
3717
|
+
} catch (error) {
|
|
3718
|
+
this.requestCache.delete(requestId);
|
|
3719
|
+
this.syncRecentRequests();
|
|
3720
|
+
throw error;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* v1 迁移导入(§11.2 `import` / §9.5):按 sourceId 一次性导入、按 id 合并、
|
|
3725
|
+
* 写入 importedSources marker(原子落账确认后才写入)。重复 sourceId 幂等
|
|
3726
|
+
* no-op(不落账不 bump)。浏览器以成功响应 + 记住 ledgerId 作为确认凭证
|
|
3727
|
+
* (T003/T004 接线;marker 不进 state/summary,见 `summary`)。
|
|
3728
|
+
*/
|
|
3729
|
+
importSource(requestId, sourceId, tasks) {
|
|
3730
|
+
return this.applyRequest(requestId, {
|
|
3731
|
+
kind: "import",
|
|
3732
|
+
sourceId,
|
|
3733
|
+
tasks
|
|
3734
|
+
}, (doc) => {
|
|
3735
|
+
const result = importIntoLedger(doc, sourceId, tasks);
|
|
3736
|
+
if (result.error !== void 0) doc.scheduler.error = result.error;
|
|
3737
|
+
return result.changed;
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* 通用原子变更(Host 编排用,T005/T006 的 runner/scheduler 也走这里)。
|
|
3742
|
+
* `apply` 返回 true 才落账(revision+1);返回 false 保持 revision 不变。
|
|
3743
|
+
*/
|
|
3744
|
+
mutate(apply) {
|
|
3745
|
+
if (apply(this.document)) this.commit(true);
|
|
3746
|
+
return this.state();
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* 更新 scheduler 补丁(心跳 lastTickAt、错误可见性等,§9.1)。
|
|
3750
|
+
* 不 bump revision(避免 30s 心跳驱动客户端全量重拉);无实际变更不写盘。
|
|
3751
|
+
*/
|
|
3752
|
+
setScheduler(patch) {
|
|
3753
|
+
const merged = {
|
|
3754
|
+
...this.document.scheduler,
|
|
3755
|
+
...patch
|
|
3756
|
+
};
|
|
3757
|
+
if (JSON.stringify(merged) === JSON.stringify(this.document.scheduler)) return;
|
|
3758
|
+
this.document.scheduler = merged;
|
|
3759
|
+
this.commit(false);
|
|
3760
|
+
}
|
|
3761
|
+
syncRecentRequests() {
|
|
3762
|
+
this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({
|
|
3763
|
+
requestId,
|
|
3764
|
+
fingerprint: request.fingerprint
|
|
3765
|
+
}));
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* 原子落账:临时文件 + fsync + 原子 rename + 目录 fsync(0600)。
|
|
3769
|
+
* 任一步失败清理临时文件并重新抛出——绝不产生半写文件。
|
|
3770
|
+
*/
|
|
3771
|
+
commit(bumpRevision) {
|
|
3772
|
+
if (bumpRevision) this.document.revision += 1;
|
|
3773
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
3774
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
3775
|
+
let fd;
|
|
3776
|
+
try {
|
|
3777
|
+
fd = openSync(tmp, "w", 384);
|
|
3778
|
+
writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: "utf8" });
|
|
3779
|
+
fsyncSync(fd);
|
|
3780
|
+
closeSync(fd);
|
|
3781
|
+
fd = void 0;
|
|
3782
|
+
try {
|
|
3783
|
+
chmodSync(tmp, 384);
|
|
3784
|
+
} catch {}
|
|
3785
|
+
renameSync(tmp, this.file);
|
|
3786
|
+
try {
|
|
3787
|
+
const dirFd = openSync(dirname(this.file), "r");
|
|
3788
|
+
try {
|
|
3789
|
+
fsyncSync(dirFd);
|
|
3790
|
+
} finally {
|
|
3791
|
+
closeSync(dirFd);
|
|
3792
|
+
}
|
|
3793
|
+
} catch {}
|
|
3794
|
+
} catch (error) {
|
|
3795
|
+
if (fd !== void 0) closeSync(fd);
|
|
3796
|
+
try {
|
|
3797
|
+
unlinkSync(tmp);
|
|
3798
|
+
} catch {}
|
|
3799
|
+
throw error;
|
|
3800
|
+
}
|
|
3801
|
+
this.notify();
|
|
3802
|
+
}
|
|
3803
|
+
/** 加载账本;损坏/schema 不兼容 → 隔离原文件并以空账本重建(§17 损坏隔离)。 */
|
|
3804
|
+
load() {
|
|
3805
|
+
if (!existsSync(this.file)) return emptyLedgerDocument();
|
|
3806
|
+
let raw;
|
|
3807
|
+
try {
|
|
3808
|
+
raw = readFileSync(this.file, "utf8");
|
|
3809
|
+
} catch (error) {
|
|
3810
|
+
if (!this.quarantine(error instanceof Error ? error.message : String(error))) throw error;
|
|
3811
|
+
return emptyLedgerDocument(`corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}`);
|
|
3812
|
+
}
|
|
3813
|
+
try {
|
|
3814
|
+
return parseLedgerDocument(raw);
|
|
3815
|
+
} catch (error) {
|
|
3816
|
+
this.quarantine(error instanceof Error ? error.message : String(error));
|
|
3817
|
+
return emptyLedgerDocument(`corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}`);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
/** 损坏隔离:原文件 rename 为 `ledger-v2.json.corrupt-<ts>-<pid>-<uuid>`,原字节保留。 */
|
|
3821
|
+
quarantine(reason) {
|
|
3822
|
+
const target = `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`;
|
|
3823
|
+
try {
|
|
3824
|
+
renameSync(this.file, target);
|
|
3825
|
+
console.warn(`[dsh-task-board] corrupt ledger quarantined to ${target}: ${reason}`);
|
|
3826
|
+
return true;
|
|
3827
|
+
} catch (error) {
|
|
3828
|
+
console.error(`[dsh-task-board] failed to quarantine corrupt ledger ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3829
|
+
return false;
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
notify() {
|
|
3833
|
+
for (const listener of [...this.listeners]) listener();
|
|
3834
|
+
}
|
|
3835
|
+
/**
|
|
3836
|
+
* 获取单写者锁:`wx` 排他创建(已存在则 EEXIST 走锁主判定)。
|
|
3837
|
+
* 锁记录 PID + token + 启动时间探测;PID 复用(启动时间不匹配)识别为陈旧锁
|
|
3838
|
+
* 并接管;锁不可读时 fail closed(提示人工清理)。
|
|
3839
|
+
*/
|
|
3840
|
+
acquireLock() {
|
|
3841
|
+
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
3842
|
+
const fd = openSync(this.lockFile, "wx", 384);
|
|
3843
|
+
const startedAt = this.probes.ownStartTimeMs();
|
|
3844
|
+
const probe = process.platform === "linux" || process.platform === "win32" ? "exact" : "legacy";
|
|
3845
|
+
writeFileSync(fd, JSON.stringify({
|
|
3846
|
+
pid: process.pid,
|
|
3847
|
+
token: this.lockToken,
|
|
3848
|
+
startedAt,
|
|
3849
|
+
probe
|
|
3850
|
+
}), { encoding: "utf8" });
|
|
3851
|
+
fsyncSync(fd);
|
|
3852
|
+
try {
|
|
3853
|
+
chmodSync(this.lockFile, 384);
|
|
3854
|
+
} catch {}
|
|
3855
|
+
return fd;
|
|
3856
|
+
} catch (error) {
|
|
3857
|
+
if (error.code !== "EEXIST") throw error;
|
|
3858
|
+
let owner;
|
|
3859
|
+
try {
|
|
3860
|
+
const parsed = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
3861
|
+
if (typeof parsed.pid === "number" && typeof parsed.token === "string") owner = {
|
|
3862
|
+
pid: parsed.pid,
|
|
3863
|
+
token: parsed.token,
|
|
3864
|
+
...typeof parsed.startedAt === "number" ? { startedAt: parsed.startedAt } : {},
|
|
3865
|
+
...parsed.probe === "exact" ? { probe: "exact" } : {}
|
|
3866
|
+
};
|
|
3867
|
+
} catch {
|
|
3868
|
+
throw new Error(`nova-task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
|
|
3869
|
+
}
|
|
3870
|
+
if (owner === void 0) throw new Error(`nova-task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
|
|
3871
|
+
if (this.probes.isAlive(owner.pid)) {
|
|
3872
|
+
const actualStartedAt = owner.pid === process.pid ? this.probes.ownStartTimeMs() : this.probes.startTimeMs(owner.pid);
|
|
3873
|
+
if (!(actualStartedAt !== void 0 && (owner.startedAt !== void 0 ? startTimeMismatch(owner.startedAt, actualStartedAt, owner.probe === "exact") : (() => {
|
|
3874
|
+
try {
|
|
3875
|
+
return statSync(this.lockFile).mtimeMs < actualStartedAt;
|
|
3876
|
+
} catch {
|
|
3877
|
+
return true;
|
|
3878
|
+
}
|
|
3879
|
+
})()))) throw new Error(`nova-task-board ledger is already owned by process ${owner.pid}`);
|
|
3880
|
+
}
|
|
3881
|
+
try {
|
|
3882
|
+
unlinkSync(this.lockFile);
|
|
3883
|
+
} catch (unlinkError) {
|
|
3884
|
+
if (unlinkError.code !== "ENOENT") throw unlinkError;
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
throw new Error(`nova-task-board ledger lock could not be acquired: ${this.lockFile}`);
|
|
3888
|
+
}
|
|
3889
|
+
};
|
|
3890
|
+
//#endregion
|
|
3891
|
+
//#region src/host-automation.ts
|
|
3892
|
+
/**
|
|
3893
|
+
* 自动化收集器(DSH-REQ-001 §14.1/14.2,T010/P3):把外部内容拉取为标准化
|
|
3894
|
+
* 的 backlog `TaskRecord`(去重键、清洗与映射在 core/context.ts,本模块只做
|
|
3895
|
+
* I/O 与编排)。
|
|
3896
|
+
*
|
|
3897
|
+
* 两个收集源:
|
|
3898
|
+
* - **bookmark_collector(§14.2 收藏收集)**:读取本地「收藏收件箱」文件
|
|
3899
|
+
* (JSONL:每行 `{"title","url","note"}`),逐条映射为 backlog 任务。
|
|
3900
|
+
* 默认文件 `$DSH_HOME/nova-ui/task-board/bookmarks.jsonl`(规则 config 的
|
|
3901
|
+
* `filePath` 可覆盖);浏览器侧导出书签/手动追加均可(B4 调研:浏览器
|
|
3902
|
+
* 书签 API 需扩展能力,v1.0 由「收件箱文件 + 人工维护」承载)。
|
|
3903
|
+
* - **github_issue(§14.1 GitHub Issue → backlog)**:经 GitHub REST API 拉取
|
|
3904
|
+
* 仓库 open issues(规则 config 的 `repo`),按 `filter.labels` 过滤(非空时
|
|
3905
|
+
* Issue 必须命中至少一个标签),跳过 PR。Webhook 推送是另一入口
|
|
3906
|
+
* (host-routes.ts `POST /api/nova-task-board/webhooks/github`,HMAC 校验)。
|
|
3907
|
+
*
|
|
3908
|
+
* 依赖面(§17 可测试性):fetch/readFile 结构面注入(测试注入 fake);token
|
|
3909
|
+
* 从环境解析(不落配置/账本)。收集结果经 transitions.applyAddCollectedTasks
|
|
3910
|
+
* 原子落账(同 (source, url) 去重)。
|
|
3911
|
+
*/
|
|
3912
|
+
/** 默认文件读取:ENOENT/不可读 → undefined(收集器报可见错误,不崩溃)。 */
|
|
3913
|
+
function defaultReadFile(file) {
|
|
3914
|
+
try {
|
|
3915
|
+
return readFileSync(file, "utf8");
|
|
3916
|
+
} catch {
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
/**
|
|
3921
|
+
* 自动化收集器:两个收集源的 I/O 实现。无状态、无计时器——由
|
|
3922
|
+
* TaskBoardHostService 在 tick / 手动触发 / Webhook 时调用。
|
|
3923
|
+
*/
|
|
3924
|
+
var TaskBoardAutomationCollector = class {
|
|
3925
|
+
deps;
|
|
3926
|
+
constructor(deps = {}) {
|
|
3927
|
+
this.deps = deps;
|
|
3928
|
+
}
|
|
3929
|
+
readFile(file) {
|
|
3930
|
+
return this.deps.readFile !== void 0 ? this.deps.readFile(file) : defaultReadFile(file);
|
|
3931
|
+
}
|
|
3932
|
+
/**
|
|
3933
|
+
* 收藏收集(§14.2):读收件箱 JSONL → 逐条映射 backlog 任务。
|
|
3934
|
+
* 文件缺失/不可读 → error(不产出任务)。去重(同 url)由落账层完成。
|
|
3935
|
+
*/
|
|
3936
|
+
collectBookmarks(rule, now) {
|
|
3937
|
+
const filePath = ruleConfig(rule, "filePath") ?? join(this.deps.bookmarkDir ?? ".", "bookmarks.jsonl");
|
|
3938
|
+
const content = this.readFile(filePath);
|
|
3939
|
+
if (content === void 0) return {
|
|
3940
|
+
tasks: [],
|
|
3941
|
+
error: `bookmark inbox not found or unreadable: ${filePath}`
|
|
3942
|
+
};
|
|
3943
|
+
const tasks = [];
|
|
3944
|
+
for (const entry of parseBookmarkEntries(content)) {
|
|
3945
|
+
const input = bookmarkEntryToCollectedTaskInput(entry);
|
|
3946
|
+
if (input === void 0) continue;
|
|
3947
|
+
tasks.push(newCollectedTask(input, crypto.randomUUID(), now, 0));
|
|
3948
|
+
}
|
|
3949
|
+
return { tasks };
|
|
3950
|
+
}
|
|
3951
|
+
/**
|
|
3952
|
+
* GitHub Issue 收集(§14.1 轮询路径):`GET /repos/{repo}/issues?state=open`,
|
|
3953
|
+
* 跳过 PR、按规则标签过滤;token 缺省时匿名请求。网络/HTTP 失败 → error
|
|
3954
|
+
* (Host 记入可见错误,不产出任务)。
|
|
3955
|
+
*/
|
|
3956
|
+
async collectGithubIssues(rule, now) {
|
|
3957
|
+
const repo = ruleConfig(rule, "repo");
|
|
3958
|
+
if (repo === void 0 || repo === "") return {
|
|
3959
|
+
tasks: [],
|
|
3960
|
+
error: "github_issue rule requires config.repo"
|
|
3961
|
+
};
|
|
3962
|
+
const fetchImpl = this.deps.fetchImpl ?? globalThis.fetch;
|
|
3963
|
+
const url = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`;
|
|
3964
|
+
const headers = {
|
|
3965
|
+
accept: "application/vnd.github+json",
|
|
3966
|
+
"user-agent": "dsh-nova-ui-task-board"
|
|
3967
|
+
};
|
|
3968
|
+
if (this.deps.githubToken !== void 0 && this.deps.githubToken !== "") headers.authorization = `Bearer ${this.deps.githubToken}`;
|
|
3969
|
+
let response;
|
|
3970
|
+
try {
|
|
3971
|
+
response = await fetchImpl(url, { headers });
|
|
3972
|
+
} catch (error) {
|
|
3973
|
+
return {
|
|
3974
|
+
tasks: [],
|
|
3975
|
+
error: `github api request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
if (!response.ok) return {
|
|
3979
|
+
tasks: [],
|
|
3980
|
+
error: `github api error: ${response.status} ${response.statusText}`
|
|
3981
|
+
};
|
|
3982
|
+
let body;
|
|
3983
|
+
try {
|
|
3984
|
+
body = await response.json();
|
|
3985
|
+
} catch {
|
|
3986
|
+
return {
|
|
3987
|
+
tasks: [],
|
|
3988
|
+
error: "github api returned a non-JSON payload"
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
if (!Array.isArray(body)) return {
|
|
3992
|
+
tasks: [],
|
|
3993
|
+
error: "github api returned an unexpected payload"
|
|
3994
|
+
};
|
|
3995
|
+
const tasks = [];
|
|
3996
|
+
for (const item of body) {
|
|
3997
|
+
if (typeof item !== "object" || item === null) continue;
|
|
3998
|
+
const issue = item;
|
|
3999
|
+
if (issue.pull_request !== void 0) continue;
|
|
4000
|
+
if (!matchesGithubLabels(issueLabelNames(issue), rule.filter.labels)) continue;
|
|
4001
|
+
const input = githubIssueToCollectedTaskInput(issue, repo);
|
|
4002
|
+
tasks.push(newCollectedTask(input, crypto.randomUUID(), now, 0));
|
|
4003
|
+
}
|
|
4004
|
+
return { tasks };
|
|
4005
|
+
}
|
|
4006
|
+
};
|
|
4007
|
+
//#endregion
|
|
4008
|
+
//#region src/host-service.ts
|
|
4009
|
+
/**
|
|
4010
|
+
* 任务看板协议服务层(DSH-REQ-001 §8 架构「Host(控制面)」)。
|
|
4011
|
+
*
|
|
4012
|
+
* T003 交付控制面的串行应用骨架:浏览器提交 `{requestId, action}` → 幂等判定
|
|
4013
|
+
* (复用 T002 HostLedger 的 applyRequest + recentRequests 持久化)→ 状态机
|
|
4014
|
+
* 守卫(core/transitions.ts)→ 原子落账(revision+1)→ 返回全量 snapshot。
|
|
4015
|
+
* T005 接入真实执行(§13):`run/rerun` action 经状态机开执行记录后由
|
|
4016
|
+
* HostExecutionRunner 启动独立 DSH 会话;5s 轮询会话列表按 turn 结算
|
|
4017
|
+
* (§13.3);Host 重启后带 sessionId 的 running 执行由轮询继续观察结算。
|
|
4018
|
+
* T006 接入 cron 周期定时(§9.4/§13.1):30s tick 扫描到期任务并触发执行
|
|
4019
|
+
* (浏览器关闭也生效)——启动/恢复首个 tick 跳过错过的触发点(不补跑)、
|
|
4020
|
+
* 到期且 enabled → 复用 T005 的 launch 入口开启执行、同任务 running 中到期
|
|
4021
|
+
* 只滚动到下一匹配点不并发;`set-schedule` action(§11.2)设定/修改/关闭
|
|
4022
|
+
* cron(nextRunAt/lastTriggeredAt 为 Host 独占字段)。
|
|
4023
|
+
* T007 接入一次性计划(§9.4/§11.2 set-one-shot/§13.5):同一 30s tick 同时
|
|
4024
|
+
* 扫描 one-shot——`runAt ≤ now && firedAt 缺省` 即触发一次并写 firedAt(触发
|
|
4025
|
+
* 即消费,绝不重复);触发时任务 running → 不并发不排队、本次仍标记消费;
|
|
4026
|
+
* 错过(停机/睡眠/长暂停,恢复首 tick)→ 跳过不补跑、写 firedAt 展示「已过期
|
|
4027
|
+
* /已跳过」;`set-one-shot` 设定/修改/清除(runAt 缺省或 0 = 取消),与 cron
|
|
4028
|
+
* 互斥(D6)。
|
|
4029
|
+
* T009 接入列内排序与跨列拖拽(§9.2/§10.2/§11.2/§12.10):`reorder` 经状态机
|
|
4030
|
+
* 重算列内 order(同列唯一、整列重编号;status 须与当前状态一致防竞态;可携带
|
|
4031
|
+
* project/tags 归属变更原子落账)、`move.order` 可选落位(缺省追加目标列末尾,
|
|
4032
|
+
* done/failed 拖回 backlog/todo 为重开不触发执行)。分组视图模式由浏览器端持有,
|
|
4033
|
+
* Host 不感知(§12.10)。
|
|
4034
|
+
* T010 接入自动化收集与上下文闭环(§9.5/§14,P3):
|
|
4035
|
+
* - 自动化规则(§9.5 automation_rules):`upsert-automation`/`delete-automation`
|
|
4036
|
+
* 落账;30s tick 与任务 schedule 同轮扫描规则 cron——到期滚动
|
|
4037
|
+
* (applyCollectorRoll)后异步执行收集(收藏收件箱文件 / GitHub API 轮询,
|
|
4038
|
+
* host-automation.ts),恢复期首 tick 跳过(applySkipMissedAutomationRules,
|
|
4039
|
+
* 错过不补跑);`run-automation` 手动触发一次收集(只滚 lastTriggeredAt,
|
|
4040
|
+
* 不消费计划触发点);收集产物经 applyAddCollectedTasks 去重落 backlog
|
|
4041
|
+
* (§14.3:只进 backlog,人工 promote 前不可执行/不可定时,守卫在状态机);
|
|
4042
|
+
* - GitHub Webhook(§14.1):`ingestGithubWebhook` 把 `issues` 事件按启用规则
|
|
4043
|
+
* 的标签过滤创建 backlog 任务(命令类字段递归拒绝,§14.3/§16.2);HMAC
|
|
4044
|
+
* 校验在路由层(host-routes.ts);
|
|
4045
|
+
* - 评论/产物/上下文快照(§9.5):`add-comment`/`add-artifact`/`update-context`
|
|
4046
|
+
* 经状态机落账;每次执行结算后 Host 自动更新——成功时记录会话产物
|
|
4047
|
+
* (type=session,transcript 即默认产物)并摘取最近 assistant 文本写入
|
|
4048
|
+
* `contextSnapshot.lastAiSummary`(B5:Host 侧摘取 + token 预算),
|
|
4049
|
+
* latestUserFeedback/relatedLinks 同步刷新;后续调用只注入摘要与必要片段
|
|
4050
|
+
* (runner.launch 经 composePrompt,见 core/context.ts)。
|
|
4051
|
+
* T011 接入对话候选流转(§11.2/§14.4,P3.1):`propose`(对话提取/手动转任务
|
|
4052
|
+
* → proposed 候选,source=conversation,输入清洗与命令字段拒绝在状态机/协议
|
|
4053
|
+
* 层)、`confirm`(确认候选 → 落 backlog/todo,确认是唯一 promote 路径——
|
|
4054
|
+
* 防注入闸门)、`dismiss`(拒绝/忽略删除)三个 action 经状态机落账。
|
|
4055
|
+
* T012 接入需求拆分(§11.2/§12.9/§14.5,P3.2,见 B8):`start-split` 提交需求
|
|
4056
|
+
* (粘贴文本/工作区文件路径)→ 注册拆分作业 → 启动独立拆分会话(复用 T005
|
|
4057
|
+
* 执行通道,指令模板 = 粒度启发式 + 覆盖自检 + 输出 schema)→ 会话结束后读取
|
|
4058
|
+
* transcript、解析结构化标记 → 创建父需求任务「需求:<标题>」+ `propose-batch`
|
|
4059
|
+
* 原子落账(全部 proposed、source=requirement、children parentId 指向父任务、
|
|
4060
|
+
* 递归拆分挂到原任务下;人工确认闸门复用 T011);`propose-batch` 亦可独立提交
|
|
4061
|
+
* (批量候选,parentId 引用校验与防环在状态机层,§16.8)。
|
|
4062
|
+
*
|
|
4063
|
+
* 分层:路由层(host-routes.ts)负责安全栅栏与载荷校验;本服务只认已解析的
|
|
4064
|
+
* 判别联合(protocol.ts 的输出),内部不含 HTTP 语义。调度(cron/one-shot/
|
|
4065
|
+
* 自动化规则,T006/T007/T010)、电源保护实现(后续任务)不在本基线内;
|
|
4066
|
+
* power 快照仅提供稳定的类型契约与基础值(sessionStateKnown 由轮询驱动)。
|
|
4067
|
+
*/
|
|
4068
|
+
/** 会话轮询间隔(§17 性能:5s,对齐参考实现)。 */
|
|
4069
|
+
const SESSION_POLL_MS = 5e3;
|
|
4070
|
+
/** 调度 tick 间隔(§13.1/§17 性能:30s,对齐参考实现)。 */
|
|
4071
|
+
const SCHEDULE_TICK_MS = 3e4;
|
|
4072
|
+
/** 任务看板协议服务:串行 apply + 幂等 + snapshot/event/power + 执行轮询。 */
|
|
4073
|
+
var TaskBoardHostService = class {
|
|
4074
|
+
/** 权威账本。 */
|
|
4075
|
+
ledger;
|
|
4076
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4077
|
+
now;
|
|
4078
|
+
platform;
|
|
4079
|
+
runner;
|
|
4080
|
+
automation;
|
|
4081
|
+
active = true;
|
|
4082
|
+
preventIdleSleep = false;
|
|
4083
|
+
disposed = false;
|
|
4084
|
+
pollTimer;
|
|
4085
|
+
pollInFlight = false;
|
|
4086
|
+
sessionStateKnown = false;
|
|
4087
|
+
scheduleTimer;
|
|
4088
|
+
tickInFlight = false;
|
|
4089
|
+
lastScheduleTick;
|
|
4090
|
+
/** 需求拆分作业(T012/B8:独立拆分会话驱动;key = action.id)。 */
|
|
4091
|
+
splitJobs = /* @__PURE__ */ new Map();
|
|
4092
|
+
constructor(options = {}) {
|
|
4093
|
+
this.ledger = options.ledger ?? new HostLedger();
|
|
4094
|
+
this.now = options.now ?? Date.now;
|
|
4095
|
+
this.platform = options.platform ?? process.platform;
|
|
4096
|
+
this.runner = options.runner;
|
|
4097
|
+
this.automation = new TaskBoardAutomationCollector({
|
|
4098
|
+
...options.automation ?? {},
|
|
4099
|
+
bookmarkDir: options.automation?.bookmarkDir ?? this.ledger.dir
|
|
4100
|
+
});
|
|
4101
|
+
this.ledger.subscribe(() => this.emit());
|
|
4102
|
+
}
|
|
4103
|
+
/**
|
|
4104
|
+
* 应用配置(插件 enabled / preventIdleSleep)。enabled=false 时 apply 抛错
|
|
4105
|
+
* (路由层 400),SSE 订阅方通过事件帧感知 power 变化。从禁用恢复启用时
|
|
4106
|
+
* 立即补一轮会话轮询与调度 tick(首 tick 跳过停机期错过的触发点)。
|
|
4107
|
+
*/
|
|
4108
|
+
setConfiguration(active, preventIdleSleep) {
|
|
4109
|
+
const resumed = !this.active && active;
|
|
4110
|
+
this.active = active;
|
|
4111
|
+
this.preventIdleSleep = preventIdleSleep;
|
|
4112
|
+
if (resumed) {
|
|
4113
|
+
this.schedulePoll();
|
|
4114
|
+
this.scheduleTick(true);
|
|
4115
|
+
}
|
|
4116
|
+
this.emit();
|
|
4117
|
+
}
|
|
4118
|
+
/** 全量状态快照(§11.1 `GET /state`)。 */
|
|
4119
|
+
snapshot() {
|
|
4120
|
+
const state = this.ledger.state();
|
|
4121
|
+
return {
|
|
4122
|
+
schemaVersion: 2,
|
|
4123
|
+
revision: state.revision,
|
|
4124
|
+
tasks: state.tasks,
|
|
4125
|
+
scheduler: state.scheduler,
|
|
4126
|
+
power: this.powerSnapshot(),
|
|
4127
|
+
automationRules: state.automationRules
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
/** SSE 事件帧载荷(§11.1 `GET /events`):只推 revision/scheduler/power。 */
|
|
4131
|
+
eventPayload() {
|
|
4132
|
+
const { revision, scheduler } = this.ledger.summary();
|
|
4133
|
+
return {
|
|
4134
|
+
revision,
|
|
4135
|
+
scheduler,
|
|
4136
|
+
power: this.powerSnapshot()
|
|
4137
|
+
};
|
|
4138
|
+
}
|
|
4139
|
+
/** 订阅账本/配置变更(SSE 推送源;路由层在推送前先主动推一帧)。 */
|
|
4140
|
+
subscribe(listener) {
|
|
4141
|
+
this.listeners.add(listener);
|
|
4142
|
+
return () => {
|
|
4143
|
+
this.listeners.delete(listener);
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
4146
|
+
/**
|
|
4147
|
+
* 幂等应用一个已解析的 action(§11.3):账本层判定 requestId + SHA-256 指纹,
|
|
4148
|
+
* 命中缓存直接返回当前 state(不 bump);首次请求经状态机守卫原子落账。
|
|
4149
|
+
* 守卫/校验失败抛错(`TaskBoardTransitionError` 等),路由层转为 400。
|
|
4150
|
+
*
|
|
4151
|
+
* T005:`run/rerun` 在本次 apply 真实开启执行(revision +1)时,异步调度
|
|
4152
|
+
* launch(创建会话 + 发送 Prompt);幂等重放(revision 不变)不再重复启动。
|
|
4153
|
+
*/
|
|
4154
|
+
apply(requestId, action) {
|
|
4155
|
+
if (!this.active) throw new Error("task board is disabled");
|
|
4156
|
+
const before = this.ledger.summary().revision;
|
|
4157
|
+
const state = this.ledger.applyRequest(requestId, action, (doc) => this.applyAction(doc, action));
|
|
4158
|
+
if (state.revision > before) {
|
|
4159
|
+
if (action.kind === "run" || action.kind === "rerun") {
|
|
4160
|
+
const task = state.tasks.find((candidate) => candidate.id === action.taskId);
|
|
4161
|
+
const execution = task?.executions.at(-1);
|
|
4162
|
+
if (task !== void 0 && execution !== void 0) this.scheduleLaunch(task, execution);
|
|
4163
|
+
} else if (action.kind === "run-automation") this.scheduleAutomationRun(action.ruleId);
|
|
4164
|
+
} else if (action.kind === "start-split") {
|
|
4165
|
+
const job = this.splitJobs.get(action.id);
|
|
4166
|
+
if (job !== void 0 && !job.launched) {
|
|
4167
|
+
job.launched = true;
|
|
4168
|
+
this.scheduleSplitLaunch(job);
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
return {
|
|
4172
|
+
schemaVersion: 2,
|
|
4173
|
+
revision: state.revision,
|
|
4174
|
+
tasks: state.tasks,
|
|
4175
|
+
scheduler: state.scheduler,
|
|
4176
|
+
power: this.powerSnapshot(),
|
|
4177
|
+
automationRules: state.automationRules
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
/**
|
|
4181
|
+
* 启动执行轮询(§13.3):5s 间隔轮询会话列表结算 open 执行,启动时立即
|
|
4182
|
+
* 轮询一次(覆盖 Host 重启后带 sessionId 的 running 执行)。幂等;无 runner
|
|
4183
|
+
* 时 no-op(纯账本模式不轮询)。dispose 时停止。
|
|
4184
|
+
*
|
|
4185
|
+
* T006/T007 调度 tick(§13.1/§13.5):30s 间隔扫描到期 cron 与 one-shot,
|
|
4186
|
+
* 启动时立即 tick 一次——首个 tick 视为恢复(错过触发点跳过不补跑,见
|
|
4187
|
+
* tickSchedule)。
|
|
4188
|
+
*/
|
|
4189
|
+
start() {
|
|
4190
|
+
if (this.disposed || this.pollTimer !== void 0 || this.runner === void 0) return;
|
|
4191
|
+
this.pollTimer = setInterval(() => {
|
|
4192
|
+
this.schedulePoll();
|
|
4193
|
+
}, SESSION_POLL_MS);
|
|
4194
|
+
this.schedulePoll();
|
|
4195
|
+
this.scheduleTimer = setInterval(() => {
|
|
4196
|
+
this.scheduleTick(false);
|
|
4197
|
+
}, SCHEDULE_TICK_MS);
|
|
4198
|
+
this.scheduleTick(true);
|
|
4199
|
+
}
|
|
4200
|
+
/** 释放:轮询/调度定时器、账本锁、订阅。 */
|
|
4201
|
+
dispose() {
|
|
4202
|
+
if (this.disposed) return;
|
|
4203
|
+
this.disposed = true;
|
|
4204
|
+
if (this.pollTimer !== void 0) clearInterval(this.pollTimer);
|
|
4205
|
+
this.pollTimer = void 0;
|
|
4206
|
+
if (this.scheduleTimer !== void 0) clearInterval(this.scheduleTimer);
|
|
4207
|
+
this.scheduleTimer = void 0;
|
|
4208
|
+
this.ledger.dispose();
|
|
4209
|
+
this.listeners.clear();
|
|
4210
|
+
}
|
|
4211
|
+
/** 电源保护快照(§11.1 power 字段;保护实现随后续任务,本期为基础值 + 配置态)。 */
|
|
4212
|
+
powerSnapshot() {
|
|
4213
|
+
const tasks = this.ledger.peekTasks();
|
|
4214
|
+
let runningSessions = 0;
|
|
4215
|
+
let armedSchedules = 0;
|
|
4216
|
+
for (const task of tasks) {
|
|
4217
|
+
if (task.executions.some((execution) => execution.endedAt === void 0)) runningSessions += 1;
|
|
4218
|
+
if (task.archivedAt === void 0 && isArmedSchedule(task)) armedSchedules += 1;
|
|
4219
|
+
}
|
|
4220
|
+
const enabled = this.active && this.preventIdleSleep;
|
|
4221
|
+
return {
|
|
4222
|
+
platform: this.platform,
|
|
4223
|
+
phase: enabled ? "idle" : "disabled",
|
|
4224
|
+
enabled,
|
|
4225
|
+
runningSessions,
|
|
4226
|
+
armedSchedules,
|
|
4227
|
+
sessionStateKnown: this.sessionStateKnown
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
/** 状态机分发:协议 action → transitions 纯函数(就地修改账本文档并返回是否变更)。 */
|
|
4231
|
+
applyAction(doc, action) {
|
|
4232
|
+
switch (action.kind) {
|
|
4233
|
+
case "import": {
|
|
4234
|
+
const result = importIntoLedger(doc, action.sourceId, action.tasks);
|
|
4235
|
+
if (result.error !== void 0) doc.scheduler.error = result.error;
|
|
4236
|
+
return result.changed;
|
|
4237
|
+
}
|
|
4238
|
+
case "create": return applyCreateTask(doc, action.input, action.id, this.now());
|
|
4239
|
+
case "propose": return applyProposeTask(doc, action.input, action.id, this.now());
|
|
4240
|
+
case "confirm": return applyConfirmTask(doc, action.taskId, action.target, this.now());
|
|
4241
|
+
case "dismiss": return applyDismissTask(doc, action.taskId);
|
|
4242
|
+
case "start-split":
|
|
4243
|
+
if (this.runner === void 0) throw new Error("execution runner is not available");
|
|
4244
|
+
return this.registerSplitJob(doc, action.id, action.input);
|
|
4245
|
+
case "propose-batch": return applyProposeBatch(doc, action.items, this.now());
|
|
4246
|
+
case "update": return applyUpdateTask(doc, action.taskId, action.patch, this.now());
|
|
4247
|
+
case "set-schedule": return applySetSchedule(doc, action.taskId, action.patch, this.now());
|
|
4248
|
+
case "set-one-shot": return applySetOneShot(doc, action.taskId, action.patch, this.now());
|
|
4249
|
+
case "delete": return applyDeleteTask(doc, action.taskId);
|
|
4250
|
+
case "move": return applyMoveTask(doc, action.taskId, action.status, this.now(), action.order);
|
|
4251
|
+
case "reorder": return applyReorderTask(doc, action.taskId, action.status, action.order, {
|
|
4252
|
+
...action.project !== void 0 ? { project: action.project } : {},
|
|
4253
|
+
...action.tags !== void 0 ? { tags: action.tags } : {}
|
|
4254
|
+
}, this.now());
|
|
4255
|
+
case "archive": return applyArchiveTask(doc, action.taskId, this.now());
|
|
4256
|
+
case "restore": return applyRestoreTask(doc, action.taskId, this.now());
|
|
4257
|
+
case "run":
|
|
4258
|
+
case "rerun":
|
|
4259
|
+
if (this.runner === void 0) throw new Error("execution runner is not available");
|
|
4260
|
+
return action.kind === "run" ? applyRunTask(doc, action.taskId, this.now()) : applyRerunTask(doc, action.taskId, this.now());
|
|
4261
|
+
case "add-comment": return applyAddComment(doc, action.taskId, action.comment, this.now());
|
|
4262
|
+
case "add-artifact": return applyAddArtifact(doc, action.taskId, action.artifact, this.now());
|
|
4263
|
+
case "update-context": return applyUpdateContextSnapshot(doc, action.taskId, action.patch, this.now());
|
|
4264
|
+
case "upsert-automation": return applyUpsertAutomationRule(doc, action.rule, this.now());
|
|
4265
|
+
case "delete-automation": return applyDeleteAutomationRule(doc, action.ruleId);
|
|
4266
|
+
case "run-automation":
|
|
4267
|
+
if (!doc.automationRules.some((rule) => rule.id === action.ruleId)) throw new Error("automation rule not found");
|
|
4268
|
+
return applyCollectorRoll(doc, action.ruleId, void 0, this.now(), this.now());
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
/** 异步调度 launch(fire-and-forget;结算失败仅记录日志,不中断控制面)。 */
|
|
4272
|
+
scheduleLaunch(task, execution) {
|
|
4273
|
+
this.launchExecution(task, execution).catch((error) => {
|
|
4274
|
+
console.error("[dsh-task-board] execution launch settlement failed", error);
|
|
4275
|
+
});
|
|
4276
|
+
}
|
|
4277
|
+
/**
|
|
4278
|
+
* launch 编排(§13.2 尾部):runner 创建会话成功 → 回填 sessionId(之后由
|
|
4279
|
+
* 轮询结算);任何失败 → 结算 failed 并保留错误文本(会话创建后的失败携带
|
|
4280
|
+
* sessionId,先回填再结算,详情页保留跳转线索)。
|
|
4281
|
+
*/
|
|
4282
|
+
async launchExecution(task, execution) {
|
|
4283
|
+
const runner = this.runner;
|
|
4284
|
+
if (runner === void 0) return;
|
|
4285
|
+
try {
|
|
4286
|
+
const sessionId = await runner.launch(task);
|
|
4287
|
+
this.ledger.mutate((doc) => applyAttachSession(doc, task.id, execution.id, sessionId, this.now()));
|
|
4288
|
+
} catch (error) {
|
|
4289
|
+
if (error instanceof SessionLaunchError) this.ledger.mutate((doc) => applyAttachSession(doc, task.id, execution.id, error.sessionId, this.now()));
|
|
4290
|
+
this.ledger.mutate((doc) => applySettleExecution(doc, task.id, execution.id, "failed", this.now(), error instanceof Error ? error.message : String(error)));
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
/** 轮询调度(防重入):单轮在途时跳过,dispose 后不再启动。 */
|
|
4294
|
+
schedulePoll() {
|
|
4295
|
+
if (this.pollInFlight || this.disposed) return;
|
|
4296
|
+
this.pollInFlight = true;
|
|
4297
|
+
this.pollSessions().catch((error) => {
|
|
4298
|
+
console.error("[dsh-task-board] session polling failed", error);
|
|
4299
|
+
}).finally(() => {
|
|
4300
|
+
this.pollInFlight = false;
|
|
4301
|
+
});
|
|
4302
|
+
}
|
|
4303
|
+
/**
|
|
4304
|
+
* 单轮结算轮询(§13.3):会话列表 → 逐条判定 open 执行;会话列表不可知时
|
|
4305
|
+
* 仅降级 sessionStateKnown(不结算,避免误判)。重启恢复语义:
|
|
4306
|
+
* - 有 sessionId 的 running 执行 → 继续观察结算(本方法覆盖);
|
|
4307
|
+
* - 无 sessionId 的启动中断 → HostLedger 加载时已标 cancelled(core/recovery.ts),
|
|
4308
|
+
* 本方法跳过(禁止自动重发)。
|
|
4309
|
+
* T012:同一轮次顺带结算需求拆分作业(复用会话列表,1 + E,B8)。
|
|
4310
|
+
*/
|
|
4311
|
+
async pollSessions() {
|
|
4312
|
+
const runner = this.runner;
|
|
4313
|
+
if (runner === void 0) return;
|
|
4314
|
+
const open = this.openExecutions();
|
|
4315
|
+
if (!this.active && open.length === 0 && this.splitJobs.size === 0) return;
|
|
4316
|
+
const state = await runner.listRunning();
|
|
4317
|
+
if (!state.known) {
|
|
4318
|
+
this.setSessionStateKnown(false);
|
|
4319
|
+
return;
|
|
4320
|
+
}
|
|
4321
|
+
this.setSessionStateKnown(true);
|
|
4322
|
+
for (const execution of open) {
|
|
4323
|
+
if (execution.sessionId === void 0) continue;
|
|
4324
|
+
try {
|
|
4325
|
+
const result = await runner.inspect(execution.sessionId, execution.startedAt, state.items);
|
|
4326
|
+
const outcome = result.outcome;
|
|
4327
|
+
if (outcome === "pending") continue;
|
|
4328
|
+
const settledAt = this.now();
|
|
4329
|
+
this.ledger.mutate((doc) => {
|
|
4330
|
+
let changed = applySettleExecution(doc, execution.taskId, execution.executionId, outcome, settledAt, result.error);
|
|
4331
|
+
const task = doc.tasks.find((candidate) => candidate.id === execution.taskId);
|
|
4332
|
+
if (task !== void 0) {
|
|
4333
|
+
if (outcome === "succeeded") changed = applyAddArtifact(doc, task.id, {
|
|
4334
|
+
type: "session",
|
|
4335
|
+
title: task.title,
|
|
4336
|
+
contentRef: execution.sessionId
|
|
4337
|
+
}, settledAt) || changed;
|
|
4338
|
+
const patch = contextPatchFromSettle(task, outcome === "succeeded" ? result.lastAssistantText : void 0);
|
|
4339
|
+
if (Object.keys(patch).length > 0) changed = applyUpdateContextSnapshot(doc, task.id, patch, settledAt) || changed;
|
|
4340
|
+
}
|
|
4341
|
+
return changed;
|
|
4342
|
+
});
|
|
4343
|
+
} catch (error) {}
|
|
4344
|
+
}
|
|
4345
|
+
await this.pollSplitJobs(state.items);
|
|
4346
|
+
}
|
|
4347
|
+
/** 账本中全部未结算执行的最小投影(taskId/executionId/sessionId/startedAt)。 */
|
|
4348
|
+
openExecutions() {
|
|
4349
|
+
const open = [];
|
|
4350
|
+
for (const task of this.ledger.peekTasks()) for (const execution of task.executions) {
|
|
4351
|
+
if (execution.endedAt !== void 0) continue;
|
|
4352
|
+
open.push({
|
|
4353
|
+
taskId: task.id,
|
|
4354
|
+
executionId: execution.id,
|
|
4355
|
+
sessionId: execution.sessionId,
|
|
4356
|
+
startedAt: execution.startedAt
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
return open;
|
|
4360
|
+
}
|
|
4361
|
+
setSessionStateKnown(known) {
|
|
4362
|
+
if (this.sessionStateKnown === known) return;
|
|
4363
|
+
this.sessionStateKnown = known;
|
|
4364
|
+
this.emit();
|
|
4365
|
+
}
|
|
4366
|
+
/** 调度 tick 调度(防重入):单轮在途时跳过,dispose 后不再启动。 */
|
|
4367
|
+
scheduleTick(first) {
|
|
4368
|
+
if (this.tickInFlight || this.disposed) return;
|
|
4369
|
+
this.tickInFlight = true;
|
|
4370
|
+
this.tickSchedule(first).catch((error) => {
|
|
4371
|
+
console.error("[dsh-task-board] scheduler tick failed", error);
|
|
4372
|
+
}).finally(() => {
|
|
4373
|
+
this.tickInFlight = false;
|
|
4374
|
+
});
|
|
4375
|
+
}
|
|
4376
|
+
/**
|
|
4377
|
+
* 单轮调度 tick(§13.1/§13.5):
|
|
4378
|
+
* - 记录 lastTickAt(setScheduler 不 bump revision,§17 性能);
|
|
4379
|
+
* - **恢复判定**:首个 tick(启动/从禁用恢复)或距上次 tick 超过
|
|
4380
|
+
* RESUME_GAP_MS(Host 停机/睡眠/长暂停)→ 跳过错过的触发点(skipMissed,
|
|
4381
|
+
* 不补跑),并把全部到期规则滚动到 now 之后的下一个匹配点(cron)或写
|
|
4382
|
+
* firedAt 消费(one-shot);
|
|
4383
|
+
* - 正常 tick:扫描到期 cron(enabled 且 nextRunAt ≤ now 且非归档)与到期
|
|
4384
|
+
* one-shot(firedAt 缺省且 runAt ≤ now 且非归档),逐条开启执行(复用 T005
|
|
4385
|
+
* launch 入口);同任务 running 中到期 → cron 只滚动不触发、one-shot 只写
|
|
4386
|
+
* firedAt 不触发(openScheduled/openScheduledOneShot 内部判定)。
|
|
4387
|
+
*/
|
|
4388
|
+
async tickSchedule(first) {
|
|
4389
|
+
if (this.disposed || !this.active) return;
|
|
4390
|
+
const now = this.now();
|
|
4391
|
+
const recovered = first || this.lastScheduleTick !== void 0 && now - this.lastScheduleTick > 45e3;
|
|
4392
|
+
this.lastScheduleTick = now;
|
|
4393
|
+
this.ledger.setScheduler({ lastTickAt: now });
|
|
4394
|
+
if (recovered) {
|
|
4395
|
+
this.ledger.mutate((doc) => applySkipMissedSchedules(doc, now));
|
|
4396
|
+
this.ledger.mutate((doc) => applySkipMissedAutomationRules(doc, now));
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
for (const due of this.dueAutomationRules(now)) {
|
|
4400
|
+
const next = nextRunAtMs(due.cron, due.nextRunAt);
|
|
4401
|
+
this.ledger.mutate((doc) => applyCollectorRoll(doc, due.ruleId, next, now, now));
|
|
4402
|
+
this.scheduleAutomationRun(due.ruleId);
|
|
4403
|
+
}
|
|
4404
|
+
if (this.runner === void 0) return;
|
|
4405
|
+
for (const due of this.dueSchedules(now)) {
|
|
4406
|
+
const next = nextRunAtMs(due.cron, due.nextRunAt);
|
|
4407
|
+
const opened = this.openScheduled(due.taskId, next, now);
|
|
4408
|
+
if (opened !== void 0) this.scheduleLaunch(opened.task, opened.execution);
|
|
4409
|
+
}
|
|
4410
|
+
for (const due of this.dueOneShots(now)) {
|
|
4411
|
+
const opened = this.openScheduledOneShot(due.taskId, now);
|
|
4412
|
+
if (opened !== void 0) this.scheduleLaunch(opened.task, opened.execution);
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
/** 账本中当前到期(enabled 且 nextRunAt ≤ now)的自动化规则最小投影。 */
|
|
4416
|
+
dueAutomationRules(now) {
|
|
4417
|
+
const due = [];
|
|
4418
|
+
for (const rule of this.ledger.peekAutomationRules()) {
|
|
4419
|
+
if (!rule.enabled) continue;
|
|
4420
|
+
const nextRunAt = rule.trigger.nextRunAt;
|
|
4421
|
+
if (nextRunAt === void 0 || nextRunAt > now) continue;
|
|
4422
|
+
due.push({
|
|
4423
|
+
ruleId: rule.id,
|
|
4424
|
+
cron: rule.trigger.cron,
|
|
4425
|
+
nextRunAt
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
return due;
|
|
4429
|
+
}
|
|
4430
|
+
/** 账本中当前到期(enabled 且 nextRunAt ≤ now)的 cron 规则最小投影。 */
|
|
4431
|
+
dueSchedules(now) {
|
|
4432
|
+
const due = [];
|
|
4433
|
+
for (const task of this.ledger.peekTasks()) {
|
|
4434
|
+
if (task.archivedAt !== void 0) continue;
|
|
4435
|
+
const schedule = task.schedule;
|
|
4436
|
+
if (schedule === void 0 || schedule.kind !== "cron" || !schedule.enabled) continue;
|
|
4437
|
+
if (schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
4438
|
+
due.push({
|
|
4439
|
+
taskId: task.id,
|
|
4440
|
+
cron: schedule.cron,
|
|
4441
|
+
nextRunAt: schedule.nextRunAt
|
|
4442
|
+
});
|
|
4443
|
+
}
|
|
4444
|
+
return due;
|
|
4445
|
+
}
|
|
4446
|
+
/**
|
|
4447
|
+
* 开启一次到期的调度执行(§13.1/§13.2 尾部):
|
|
4448
|
+
* - 任务不存在/已归档 → no-op;
|
|
4449
|
+
* - 任务 running 或带未结算执行 → **不并发、不排队**:本次只把 nextRunAt
|
|
4450
|
+
* 滚动到下一匹配点(lastTriggeredAt 保留现值,本次不记触发),返回 undefined;
|
|
4451
|
+
* - 否则开启执行(置 running + 追加 open 执行记录,startedAt=触发时刻)并把
|
|
4452
|
+
* 规则滚动到下一匹配点(lastTriggeredAt=本次触发点),返回开启的执行供
|
|
4453
|
+
* scheduleLaunch 启动会话。
|
|
4454
|
+
*/
|
|
4455
|
+
openScheduled(taskId, nextRunAt, now) {
|
|
4456
|
+
let opened;
|
|
4457
|
+
this.ledger.mutate((doc) => {
|
|
4458
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
4459
|
+
if (task === void 0 || task.archivedAt !== void 0) return false;
|
|
4460
|
+
if (task.status === "running" || hasOpenExecution(task)) return applyScheduleRoll(doc, taskId, nextRunAt, void 0, now);
|
|
4461
|
+
opened = openExecution(task, now, crypto.randomUUID());
|
|
4462
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? opened.task : candidate);
|
|
4463
|
+
applyScheduleRoll(doc, taskId, nextRunAt, now, now);
|
|
4464
|
+
return true;
|
|
4465
|
+
});
|
|
4466
|
+
return opened;
|
|
4467
|
+
}
|
|
4468
|
+
/**
|
|
4469
|
+
* 开启一次到期的 one-shot 执行(§13.5):
|
|
4470
|
+
* - 任务不存在/已归档 → no-op;
|
|
4471
|
+
* - 任务 running 或带未结算执行 → **不并发、不排队**:本次仍标记消费
|
|
4472
|
+
* (写 firedAt),但不触发执行,返回 undefined;
|
|
4473
|
+
* - 否则先标记消费(firedAt=now,触发即消费——防重启后重复触发),再开启
|
|
4474
|
+
* 执行(置 running + 追加 open 执行记录,startedAt=触发时刻),返回开启的
|
|
4475
|
+
* 执行供 scheduleLaunch 启动会话。
|
|
4476
|
+
*/
|
|
4477
|
+
openScheduledOneShot(taskId, now) {
|
|
4478
|
+
let opened;
|
|
4479
|
+
this.ledger.mutate((doc) => {
|
|
4480
|
+
const task = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
4481
|
+
if (task === void 0 || task.archivedAt !== void 0) return false;
|
|
4482
|
+
if (task.status === "running" || hasOpenExecution(task)) return applyMarkOneShotConsumed(doc, taskId, now);
|
|
4483
|
+
applyMarkOneShotConsumed(doc, taskId, now);
|
|
4484
|
+
const consumed = doc.tasks.find((candidate) => candidate.id === taskId);
|
|
4485
|
+
if (consumed === void 0) return false;
|
|
4486
|
+
opened = openExecution(consumed, now, crypto.randomUUID());
|
|
4487
|
+
doc.tasks = doc.tasks.map((candidate) => candidate.id === taskId ? opened.task : candidate);
|
|
4488
|
+
return true;
|
|
4489
|
+
});
|
|
4490
|
+
return opened;
|
|
4491
|
+
}
|
|
4492
|
+
/** 账本中当前到期(firedAt 缺省且 runAt ≤ now)的 one-shot 规则最小投影。 */
|
|
4493
|
+
dueOneShots(now) {
|
|
4494
|
+
const due = [];
|
|
4495
|
+
for (const task of this.ledger.peekTasks()) {
|
|
4496
|
+
if (task.archivedAt !== void 0) continue;
|
|
4497
|
+
const schedule = task.schedule;
|
|
4498
|
+
if (schedule === void 0 || schedule.kind !== "one-shot") continue;
|
|
4499
|
+
if (schedule.firedAt !== void 0 || schedule.runAt > now) continue;
|
|
4500
|
+
due.push({ taskId: task.id });
|
|
4501
|
+
}
|
|
4502
|
+
return due;
|
|
4503
|
+
}
|
|
4504
|
+
/** 异步调度一次收集(fire-and-forget;失败记日志,不中断控制面)。 */
|
|
4505
|
+
scheduleAutomationRun(ruleId) {
|
|
4506
|
+
this.runAutomationRule(ruleId).catch((error) => {
|
|
4507
|
+
console.error("[dsh-task-board] automation collection failed", error);
|
|
4508
|
+
});
|
|
4509
|
+
}
|
|
4510
|
+
/**
|
|
4511
|
+
* 执行一次收集(§14.2):按规则来源分发(书签收件箱文件 / GitHub API),产物
|
|
4512
|
+
* 经 applyAddCollectedTasks 去重落 backlog(同 (source, url) 幂等);收集失败
|
|
4513
|
+
* 写入可见错误(scheduler.error,UI 可见),不抛给控制面。
|
|
4514
|
+
*/
|
|
4515
|
+
async runAutomationRule(ruleId) {
|
|
4516
|
+
const rule = this.ledger.peekAutomationRules().find((candidate) => candidate.id === ruleId);
|
|
4517
|
+
if (rule === void 0) return;
|
|
4518
|
+
const now = this.now();
|
|
4519
|
+
let batch;
|
|
4520
|
+
try {
|
|
4521
|
+
batch = rule.source === "bookmark_collector" ? this.automation.collectBookmarks(rule, now) : await this.automation.collectGithubIssues(rule, now);
|
|
4522
|
+
} catch (error) {
|
|
4523
|
+
const message = `[automation:${rule.id}] collection failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
4524
|
+
this.ledger.setScheduler({ error: message });
|
|
4525
|
+
console.error(`[dsh-task-board] ${message}`);
|
|
4526
|
+
return;
|
|
4527
|
+
}
|
|
4528
|
+
let created = 0;
|
|
4529
|
+
if (batch.tasks.length > 0) {
|
|
4530
|
+
const before = this.ledger.summary().revision;
|
|
4531
|
+
this.ledger.mutate((doc) => applyAddCollectedTasks(doc, batch.tasks));
|
|
4532
|
+
created = this.ledger.summary().revision - before;
|
|
4533
|
+
}
|
|
4534
|
+
if (batch.error !== void 0) this.ledger.setScheduler({ error: `[automation:${rule.id}] ${batch.error}` });
|
|
4535
|
+
if (created > 0 || batch.error !== void 0) console.log(`[dsh-task-board] automation ${rule.id} (${rule.source}) collected ${batch.tasks.length} item(s), created ${created}` + (batch.error === void 0 ? "" : `: ${batch.error}`));
|
|
4536
|
+
}
|
|
4537
|
+
/**
|
|
4538
|
+
* GitHub Webhook `issues` 事件摄入(§14.1):命令类字段递归拒绝(§14.3/§16.2,
|
|
4539
|
+
* 与 import 同一条红线);只处理 opened/reopened;按启用 github_issue 规则的
|
|
4540
|
+
* repo(配置了时须匹配)与标签过滤创建 backlog 任务(跨规则去重,同 url 只建
|
|
4541
|
+
* 一条)。返回本次实际创建数(含账本去重)。
|
|
4542
|
+
*/
|
|
4543
|
+
ingestGithubWebhook(payload) {
|
|
4544
|
+
if (hasForbiddenCommandFields(payload)) throw new Error("webhook payload contains forbidden fields");
|
|
4545
|
+
const parsed = parseGithubWebhookPayload(payload);
|
|
4546
|
+
if (parsed === void 0 || parsed.issue === void 0) return { created: 0 };
|
|
4547
|
+
if (parsed.action !== "opened" && parsed.action !== "reopened") return { created: 0 };
|
|
4548
|
+
const now = this.now();
|
|
4549
|
+
const repo = parsed.repository?.full_name;
|
|
4550
|
+
const tasks = [];
|
|
4551
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4552
|
+
for (const rule of this.ledger.peekAutomationRules()) {
|
|
4553
|
+
if (!rule.enabled || rule.source !== "github_issue") continue;
|
|
4554
|
+
const ruleRepo = rule.config.repo;
|
|
4555
|
+
if (ruleRepo !== void 0 && ruleRepo !== "" && repo !== void 0 && ruleRepo !== repo) continue;
|
|
4556
|
+
if (!matchesGithubLabels(issueLabelNames(parsed.issue), rule.filter.labels)) continue;
|
|
4557
|
+
const input = githubIssueToCollectedTaskInput(parsed.issue, repo);
|
|
4558
|
+
const key = input.metadata.url ?? input.title;
|
|
4559
|
+
if (seen.has(key)) continue;
|
|
4560
|
+
seen.add(key);
|
|
4561
|
+
tasks.push(newCollectedTask(input, crypto.randomUUID(), now, 0));
|
|
4562
|
+
}
|
|
4563
|
+
if (tasks.length === 0) return { created: 0 };
|
|
4564
|
+
const before = this.ledger.summary().revision;
|
|
4565
|
+
this.ledger.mutate((doc) => applyAddCollectedTasks(doc, tasks));
|
|
4566
|
+
return { created: this.ledger.summary().revision - before };
|
|
4567
|
+
}
|
|
4568
|
+
/**
|
|
4569
|
+
* 注册拆分作业(start-split 的变更闭包):校验递归父任务引用(存在性 + 防环,
|
|
4570
|
+
* §16.8)后登记作业并返回 false(不产生账本变更、revision 不 bump——拆分
|
|
4571
|
+
* 结果在拆分会话结算时落账)。幂等重放命中缓存不会调用本闭包 → 不重复注册。
|
|
4572
|
+
*/
|
|
4573
|
+
registerSplitJob(doc, id, input) {
|
|
4574
|
+
if (this.splitJobs.has(id)) throw new Error("split job already exists");
|
|
4575
|
+
const parentTaskId = normalizeOptionalString(input.parentTaskId);
|
|
4576
|
+
if (parentTaskId !== void 0) assertValidParentRef(doc, parentTaskId);
|
|
4577
|
+
this.splitJobs.set(id, {
|
|
4578
|
+
id,
|
|
4579
|
+
input,
|
|
4580
|
+
startedAt: this.now(),
|
|
4581
|
+
parentId: crypto.randomUUID()
|
|
4582
|
+
});
|
|
4583
|
+
return false;
|
|
4584
|
+
}
|
|
4585
|
+
/** 异步调度拆分会话启动(fire-and-forget;失败记 scheduler.error,不中断控制面)。 */
|
|
4586
|
+
scheduleSplitLaunch(job) {
|
|
4587
|
+
this.launchSplit(job).catch((error) => {
|
|
4588
|
+
console.error(`[dsh-task-board] split launch settlement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4589
|
+
});
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* 启动拆分会话(B8):合成拆分驱动任务(标题「需求拆分:<标题>」、Prompt =
|
|
4593
|
+
* 拆分指令模板)复用 runner.launch(fail-closed 校验 + 创建会话 + 发送 Prompt);
|
|
4594
|
+
* 会话 id 回填作业(供轮询结算)。启动失败 → 记 scheduler.error 并移除作业。
|
|
4595
|
+
*/
|
|
4596
|
+
async launchSplit(job) {
|
|
4597
|
+
const runner = this.runner;
|
|
4598
|
+
if (runner === void 0) return;
|
|
4599
|
+
const now = this.now();
|
|
4600
|
+
const driver = {
|
|
4601
|
+
id: `split-${job.id}`,
|
|
4602
|
+
title: splitSessionTitle(job.input.title),
|
|
4603
|
+
description: "",
|
|
4604
|
+
prompt: composeSplitInstruction(job.input),
|
|
4605
|
+
status: "todo",
|
|
4606
|
+
createdAt: now,
|
|
4607
|
+
updatedAt: now,
|
|
4608
|
+
executions: [],
|
|
4609
|
+
tags: [],
|
|
4610
|
+
comments: [],
|
|
4611
|
+
artifacts: [],
|
|
4612
|
+
order: 0
|
|
4613
|
+
};
|
|
4614
|
+
try {
|
|
4615
|
+
const sessionId = await runner.launch(driver);
|
|
4616
|
+
const current = this.splitJobs.get(job.id);
|
|
4617
|
+
if (current === void 0) return;
|
|
4618
|
+
this.splitJobs.set(job.id, {
|
|
4619
|
+
...current,
|
|
4620
|
+
sessionId
|
|
4621
|
+
});
|
|
4622
|
+
} catch (error) {
|
|
4623
|
+
this.failSplit(job, `split launch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
/** 拆分作业失败:移除作业 + scheduler.error(UI 可见)。 */
|
|
4627
|
+
failSplit(job, message) {
|
|
4628
|
+
this.splitJobs.delete(job.id);
|
|
4629
|
+
this.ledger.setScheduler({ error: `[split:${job.id}] ${message}` });
|
|
4630
|
+
console.error(`[dsh-task-board] ${message}`);
|
|
4631
|
+
}
|
|
4632
|
+
/**
|
|
4633
|
+
* 单轮拆分作业结算(B8,复用 pollSessions 已取的会话列表,1 + E):
|
|
4634
|
+
* - sessionId 未回填(launch 进行中)→ 跳过;
|
|
4635
|
+
* - 会话不在列表(被删/从未创建成功)→ 失败;
|
|
4636
|
+
* - 会话仍在运行 → 跳过(等待 agent 完成拆分);
|
|
4637
|
+
* - 会话已结束 → 读取 transcript、解析标记、原子落账(settleSplit)。
|
|
4638
|
+
*/
|
|
4639
|
+
async pollSplitJobs(items) {
|
|
4640
|
+
if (this.splitJobs.size === 0) return;
|
|
4641
|
+
const runner = this.runner;
|
|
4642
|
+
if (runner === void 0) return;
|
|
4643
|
+
for (const job of [...this.splitJobs.values()]) {
|
|
4644
|
+
const sessionId = job.sessionId;
|
|
4645
|
+
if (sessionId === void 0) continue;
|
|
4646
|
+
const summary = items.find((item) => item.sessionId === sessionId);
|
|
4647
|
+
if (summary === void 0) {
|
|
4648
|
+
this.failSplit(job, `split session ${sessionId} no longer exists`);
|
|
4649
|
+
continue;
|
|
4650
|
+
}
|
|
4651
|
+
if (summary.running) continue;
|
|
4652
|
+
try {
|
|
4653
|
+
const text = await runner.readTranscriptText(sessionId);
|
|
4654
|
+
if (text === void 0) {
|
|
4655
|
+
this.failSplit(job, "split session transcript is unavailable");
|
|
4656
|
+
continue;
|
|
4657
|
+
}
|
|
4658
|
+
this.settleSplit(job, text);
|
|
4659
|
+
} catch (error) {
|
|
4660
|
+
this.failSplit(job, `split settlement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
/**
|
|
4665
|
+
* 拆分结算(§12.9/§14.5):解析 transcript 的结构化标记(`⟦task-board:split⟧`
|
|
4666
|
+
* 逐条 + `⟦task-board:coverage⟧` 覆盖矩阵)→ 创建父需求任务 + `propose-batch`
|
|
4667
|
+
* 原子落账(同一 mutate 闭包,任一失败整体回滚、revision 不变——§16.8)。
|
|
4668
|
+
* 产物全部 proposed(source=requirement,人工确认闸门复用 T011);children
|
|
4669
|
+
* parentId 指向父需求任务;父任务递归拆分时挂到原任务下(§12.9);候选
|
|
4670
|
+
* metadata 携带拆分会话引用(可跳回追溯,§15)。解析不到任何条目 → 失败。
|
|
4671
|
+
*/
|
|
4672
|
+
settleSplit(job, transcript) {
|
|
4673
|
+
const parsed = parseSplitMarkers(transcript);
|
|
4674
|
+
if (parsed.items.length === 0) {
|
|
4675
|
+
this.failSplit(job, "split result contains no valid task markers");
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
const input = job.input;
|
|
4679
|
+
const text = typeof input.text === "string" ? input.text.trim() : "";
|
|
4680
|
+
const filePath = typeof input.filePath === "string" ? input.filePath.trim() : "";
|
|
4681
|
+
const items = parsed.items.map((item) => ({
|
|
4682
|
+
title: item.title,
|
|
4683
|
+
description: item.description,
|
|
4684
|
+
prompt: item.prompt,
|
|
4685
|
+
...input.project === void 0 ? {} : { project: input.project },
|
|
4686
|
+
...input.tags !== void 0 && input.tags.length > 0 ? { tags: input.tags } : {},
|
|
4687
|
+
parentId: job.parentId,
|
|
4688
|
+
...job.sessionId === void 0 ? {} : { sourceConversationId: job.sessionId }
|
|
4689
|
+
}));
|
|
4690
|
+
const now = this.now();
|
|
4691
|
+
this.ledger.mutate((doc) => {
|
|
4692
|
+
if (!applyCreateRequirementParent(doc, {
|
|
4693
|
+
title: input.title,
|
|
4694
|
+
requirementText: text !== "" ? text : `工作区文件:${filePath}`,
|
|
4695
|
+
...parsed.coverage === void 0 ? {} : { coverage: parsed.coverage },
|
|
4696
|
+
...input.project === void 0 ? {} : { project: input.project },
|
|
4697
|
+
...input.tags !== void 0 && input.tags.length > 0 ? { tags: input.tags } : {},
|
|
4698
|
+
...normalizeOptionalString(input.parentTaskId) === void 0 ? {} : { parentId: normalizeOptionalString(input.parentTaskId) },
|
|
4699
|
+
...job.sessionId === void 0 ? {} : { sourceConversationId: job.sessionId }
|
|
4700
|
+
}, job.parentId, now)) throw new Error("requirement parent task was not created");
|
|
4701
|
+
return applyProposeBatch(doc, items, now);
|
|
4702
|
+
});
|
|
4703
|
+
this.splitJobs.delete(job.id);
|
|
4704
|
+
console.log(`[dsh-task-board] split ${job.id} settled: ${parsed.items.length} candidate(s) + parent task (session ${job.sessionId ?? "unknown"})`);
|
|
4705
|
+
}
|
|
4706
|
+
emit() {
|
|
4707
|
+
for (const listener of [...this.listeners]) listener();
|
|
4708
|
+
}
|
|
4709
|
+
};
|
|
4710
|
+
/** 执行安排是否处于武装状态(§12.5 启用计划数):cron enabled 或 one-shot 未触发。 */
|
|
4711
|
+
function isArmedSchedule(task) {
|
|
4712
|
+
const schedule = task.schedule;
|
|
4713
|
+
if (schedule === void 0) return false;
|
|
4714
|
+
if (schedule.kind === "cron") return schedule.enabled;
|
|
4715
|
+
return schedule.firedAt === void 0;
|
|
4716
|
+
}
|
|
4717
|
+
//#endregion
|
|
4718
|
+
//#region src/mount-once.ts
|
|
4719
|
+
const MOUNTED = Symbol.for("dsh-nova-ui.mounted-plugins");
|
|
4720
|
+
function mountedSet() {
|
|
4721
|
+
const registry = globalThis;
|
|
4722
|
+
return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
|
|
4723
|
+
}
|
|
4724
|
+
/**
|
|
4725
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
4726
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
4727
|
+
* any later mount of the same package name is a no-op.
|
|
4728
|
+
* @param packageName - npm package identity shared by every install source.
|
|
4729
|
+
* @param fn - the original plugin apply.
|
|
4730
|
+
* @returns an apply of the same shape.
|
|
4731
|
+
*/
|
|
4732
|
+
function mountOnce(packageName, fn) {
|
|
4733
|
+
return ((...args) => {
|
|
4734
|
+
const mounted = mountedSet();
|
|
4735
|
+
if (mounted.has(packageName)) return;
|
|
4736
|
+
mounted.add(packageName);
|
|
4737
|
+
args[0]?.effect?.(() => () => {
|
|
4738
|
+
mounted.delete(packageName);
|
|
4739
|
+
});
|
|
4740
|
+
return fn(...args);
|
|
4741
|
+
});
|
|
4742
|
+
}
|
|
4743
|
+
//#endregion
|
|
4744
|
+
//#region src/index.ts
|
|
4745
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
4746
|
+
const SECTION_ORDER = 200;
|
|
4747
|
+
/** Default environment variable holding the authenticated proxy token. */
|
|
4748
|
+
const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
4749
|
+
/** Default environment variable holding the GitHub Webhook HMAC secret. */
|
|
4750
|
+
const DEFAULT_GITHUB_WEBHOOK_SECRET_ENV = "DSH_TASK_BOARD_GITHUB_WEBHOOK_SECRET";
|
|
4751
|
+
/** Default environment variable holding the GitHub API token (issue polling). */
|
|
4752
|
+
const DEFAULT_GITHUB_TOKEN_ENV = "DSH_TASK_BOARD_GITHUB_TOKEN";
|
|
4753
|
+
/** Required services (the announcement section needs the system-prompt seam; the control plane registers on the webServer; execution uses the apiProxy + agent/command registries). */
|
|
4754
|
+
const inject = [
|
|
4755
|
+
"systemPrompt",
|
|
4756
|
+
"webServer",
|
|
4757
|
+
"apiProxy",
|
|
4758
|
+
"agents",
|
|
4759
|
+
"commands"
|
|
4760
|
+
];
|
|
4761
|
+
/**
|
|
4762
|
+
* Settings namespace of the board — the section the web settings surface
|
|
4763
|
+
* edits. Spelled here rather than imported: the browser half spells the same
|
|
4764
|
+
* value and must not depend on a Host package.
|
|
4765
|
+
*/
|
|
4766
|
+
const NOVA_TASK_BOARD_SETTINGS_NAMESPACE = settingsNamespace("nova-task-board");
|
|
4767
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
4768
|
+
const TASK_BOARD_GUIDANCE = "本机已安装 dsh-nova-ui-task-board 插件(DSH Web GUI 的任务看板,Nova 系列):侧边栏「任务看板」入口。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护。对话候选任务流转(P3.1):用户要求「做某事」「记下来」「后续要处理」等明确任务意图时,在回复末尾输出一行结构化候选标记 `⟦task-board:propose⟧ 标题 | 描述 | prompt`(描述可为空,prompt 为执行时发送给 agent 的完整指令、可省略),前端解析后进入看板「待确认」列,经用户确认后方可执行。用户提到「任务看板 / 看板 / 定时任务 / 候选任务」时即指本插件,请据此协作。";
|
|
4769
|
+
const Config = z.object({
|
|
4770
|
+
announceToAgent: z.boolean().default(false),
|
|
4771
|
+
enabled: z.boolean().default(true),
|
|
4772
|
+
preventIdleSleep: z.boolean().default(false),
|
|
4773
|
+
trustedProxyHosts: z.array(z.string()).default([]),
|
|
4774
|
+
proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
|
|
4775
|
+
githubWebhookSecretEnv: z.string().min(1).default(DEFAULT_GITHUB_WEBHOOK_SECRET_ENV),
|
|
4776
|
+
githubTokenEnv: z.string().min(1).default(DEFAULT_GITHUB_TOKEN_ENV)
|
|
4777
|
+
});
|
|
4778
|
+
/** Resolve proxy access without ever placing the token value in plugin config. */
|
|
4779
|
+
function resolveProxyAccess(config, env = process.env) {
|
|
4780
|
+
const trustedProxyHosts = config?.trustedProxyHosts ?? [];
|
|
4781
|
+
if (trustedProxyHosts.length === 0) return { trustedProxyHosts };
|
|
4782
|
+
const proxyTokenEnv = config?.proxyTokenEnv ?? "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
4783
|
+
if (proxyTokenEnv.trim() === "") throw new Error("nova-task-board: proxyTokenEnv must not be empty");
|
|
4784
|
+
const proxyToken = env[proxyTokenEnv];
|
|
4785
|
+
if (proxyToken === void 0 || proxyToken === "") throw new Error(`nova-task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`);
|
|
4786
|
+
return {
|
|
4787
|
+
trustedProxyHosts,
|
|
4788
|
+
proxyToken
|
|
4789
|
+
};
|
|
4790
|
+
}
|
|
4791
|
+
/**
|
|
4792
|
+
* Resolve T010 automation secrets from environment variables (never placed in
|
|
4793
|
+
* plugin config or the ledger). Empty/absent env values resolve to undefined:
|
|
4794
|
+
* - webhookSecret 缺省 → GitHub Webhook 端点 503(未配置);
|
|
4795
|
+
* - githubToken 缺省 → Issue 轮询走匿名(GitHub 限速)。
|
|
4796
|
+
*/
|
|
4797
|
+
function resolveAutomationSecrets(config, env = process.env) {
|
|
4798
|
+
const webhookEnv = config?.githubWebhookSecretEnv ?? "DSH_TASK_BOARD_GITHUB_WEBHOOK_SECRET";
|
|
4799
|
+
const tokenEnv = config?.githubTokenEnv ?? "DSH_TASK_BOARD_GITHUB_TOKEN";
|
|
4800
|
+
const webhookSecret = webhookEnv.trim() === "" ? void 0 : env[webhookEnv];
|
|
4801
|
+
const githubToken = tokenEnv.trim() === "" ? void 0 : env[tokenEnv];
|
|
4802
|
+
return {
|
|
4803
|
+
...webhookSecret !== void 0 && webhookSecret !== "" ? { webhookSecret } : {},
|
|
4804
|
+
...githubToken !== void 0 && githubToken !== "" ? { githubToken } : {}
|
|
4805
|
+
};
|
|
4806
|
+
}
|
|
4807
|
+
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
4808
|
+
const DEFAULT_ANNOUNCE = false;
|
|
4809
|
+
/**
|
|
4810
|
+
* Register the board's announcement section, gated on the composition entry's
|
|
4811
|
+
* `announceToAgent` (and the live settings value once the web settings
|
|
4812
|
+
* surface is served). The section is re-registered whenever the source
|
|
4813
|
+
* changes, so a settings edit takes effect without a restart.
|
|
4814
|
+
* @param ctx - the plugin context (systemPrompt injected).
|
|
4815
|
+
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
4816
|
+
*/
|
|
4817
|
+
const apply = mountOnce("dsh-nova-ui-task-board", applyImpl);
|
|
4818
|
+
function applyImpl(ctx, config) {
|
|
4819
|
+
const automationSecrets = resolveAutomationSecrets(config);
|
|
4820
|
+
const host = new TaskBoardHostService({
|
|
4821
|
+
runner: new HostExecutionRunner(ctx.apiProxy, { execute: async (sessionId, line, signal) => {
|
|
4822
|
+
const agent = ctx.agents.get(sessionId);
|
|
4823
|
+
if (agent === void 0) throw new Error(`execution session ${sessionId} is not available`);
|
|
4824
|
+
return (await ctx.commands.execute(agent, line, [], signal))?.result;
|
|
4825
|
+
} }),
|
|
4826
|
+
automation: { ...automationSecrets.githubToken === void 0 ? {} : { githubToken: automationSecrets.githubToken } }
|
|
4827
|
+
});
|
|
4828
|
+
host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false);
|
|
4829
|
+
host.start();
|
|
4830
|
+
ctx.effect(() => {
|
|
4831
|
+
const disposers = [];
|
|
4832
|
+
try {
|
|
4833
|
+
for (const route of makeTaskBoardRoutes(host, {
|
|
4834
|
+
...resolveProxyAccess(config),
|
|
4835
|
+
...automationSecrets.webhookSecret === void 0 ? {} : { webhookSecret: automationSecrets.webhookSecret }
|
|
4836
|
+
})) disposers.push(ctx.webServer.register(route));
|
|
4837
|
+
} catch (error) {
|
|
4838
|
+
for (const dispose of disposers) dispose();
|
|
4839
|
+
host.dispose();
|
|
4840
|
+
throw error;
|
|
4841
|
+
}
|
|
4842
|
+
return () => {
|
|
4843
|
+
for (const dispose of disposers) dispose();
|
|
4844
|
+
host.dispose();
|
|
4845
|
+
};
|
|
4846
|
+
}, "nova-task-board: host ledger, state machine, execution runner, automation, and routes");
|
|
4847
|
+
let current = () => config ?? {};
|
|
4848
|
+
let disposeSection;
|
|
4849
|
+
const sync = () => {
|
|
4850
|
+
if (disposeSection !== void 0) {
|
|
4851
|
+
disposeSection();
|
|
4852
|
+
disposeSection = void 0;
|
|
4853
|
+
}
|
|
4854
|
+
const active = current().enabled ?? true;
|
|
4855
|
+
host.setConfiguration(active, current().preventIdleSleep ?? false);
|
|
4856
|
+
if (!active) return;
|
|
4857
|
+
if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return;
|
|
4858
|
+
disposeSection = ctx.systemPrompt.section({
|
|
4859
|
+
name: "plugin:nova-task-board",
|
|
4860
|
+
order: SECTION_ORDER,
|
|
4861
|
+
text: TASK_BOARD_GUIDANCE
|
|
4862
|
+
});
|
|
4863
|
+
};
|
|
4864
|
+
installSettingsSection(ctx, NOVA_TASK_BOARD_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
4865
|
+
setSource: (source) => {
|
|
4866
|
+
current = source;
|
|
4867
|
+
},
|
|
4868
|
+
onChange: sync
|
|
4869
|
+
});
|
|
4870
|
+
sync();
|
|
4871
|
+
}
|
|
4872
|
+
//#endregion
|
|
4873
|
+
export { Config, DEFAULT_GITHUB_TOKEN_ENV, DEFAULT_GITHUB_WEBHOOK_SECRET_ENV, DEFAULT_PROXY_TOKEN_ENV, NOVA_TASK_BOARD_SETTINGS_NAMESPACE, TASK_BOARD_GUIDANCE, apply, inject, resolveAutomationSecrets, resolveProxyAccess };
|