@konglx/rotom 2.21.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 +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseSlashCommand } from "../../shared/slash-commands.js";
|
|
3
|
+
import { truncateTitle } from "../../shared/title.js";
|
|
4
|
+
import { ISSUE_STATUSES } from "../../shared/constants.js";
|
|
5
|
+
import { safeJsonParse } from "../../shared/parse.js";
|
|
6
|
+
import { validateWorkingDir } from "../util/paths.js";
|
|
7
|
+
import { buildMemoryExtractPrompt } from "../services/memory-extract-prompt.js";
|
|
8
|
+
import { resolveGroupAgentWorkingDir } from "../group-paths.js";
|
|
9
|
+
import { createLogger } from "../../shared/logger.js";
|
|
10
|
+
import { nowBeijing } from "../../shared/time.js";
|
|
11
|
+
const log = createLogger("mesh-api");
|
|
12
|
+
/**
|
|
13
|
+
* 在 DB 行上附 latest_todos 字段(解析后的 TodoItem[])。dashboard 直接消费
|
|
14
|
+
* 这个字段渲染常驻面板;原 latest_todos_json 字段保留不动供审计 / 兼容。
|
|
15
|
+
*
|
|
16
|
+
* 解析失败(空字符串 / 非法 JSON)一律返回 undefined,dashboard 视作"未上报"。
|
|
17
|
+
*/
|
|
18
|
+
function withLatestTodos(row) {
|
|
19
|
+
if (!row.latest_todos_json)
|
|
20
|
+
return { ...row, latest_todos: undefined };
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(row.latest_todos_json);
|
|
23
|
+
if (Array.isArray(parsed)) {
|
|
24
|
+
const todos = [];
|
|
25
|
+
for (const item of parsed) {
|
|
26
|
+
if (!item || typeof item !== "object")
|
|
27
|
+
continue;
|
|
28
|
+
const r = item;
|
|
29
|
+
const content = typeof r.content === "string" ? r.content : "";
|
|
30
|
+
if (!content)
|
|
31
|
+
continue;
|
|
32
|
+
const status = r.status === "in_progress" ? "in_progress" :
|
|
33
|
+
r.status === "completed" ? "completed" :
|
|
34
|
+
"pending";
|
|
35
|
+
const activeForm = typeof r.activeForm === "string" && r.activeForm ? r.activeForm : undefined;
|
|
36
|
+
todos.push({ content, status, ...(activeForm ? { activeForm } : {}) });
|
|
37
|
+
}
|
|
38
|
+
return { ...row, latest_todos: todos.length > 0 ? todos : undefined };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { /* fall through */ }
|
|
42
|
+
return { ...row, latest_todos: undefined };
|
|
43
|
+
}
|
|
44
|
+
export function registerIssueRoutes(apiRouter, db, _auth, hub) {
|
|
45
|
+
apiRouter.get("/issues", (req, res) => {
|
|
46
|
+
const status = req.query.status;
|
|
47
|
+
// 分页:看板每列独立拉取首屏 50 条,completed/cancelled 累积过多时避免
|
|
48
|
+
// 一次性把全表塞进 HTTP 响应。limit/offset 任一存在即返回 { items, total }
|
|
49
|
+
// 包装;不带则保持旧行为(返回全量数组)以兼容未升级的调用方。
|
|
50
|
+
const limitRaw = req.query.limit;
|
|
51
|
+
const offsetRaw = req.query.offset;
|
|
52
|
+
if (limitRaw !== undefined || offsetRaw !== undefined) {
|
|
53
|
+
const limit = Math.max(1, Math.min(500, Number(limitRaw) || 50));
|
|
54
|
+
const offset = Math.max(0, Number(offsetRaw) || 0);
|
|
55
|
+
const page = db.listIssuesPage({ status, limit, offset });
|
|
56
|
+
res.json({ items: page.items.map(withLatestTodos), total: page.total });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.json(db.listAllIssues(status).map(withLatestTodos));
|
|
60
|
+
});
|
|
61
|
+
apiRouter.get("/groups/:groupId/issues", (req, res) => {
|
|
62
|
+
const group = db.getGroupById(req.params.groupId);
|
|
63
|
+
if (!group) {
|
|
64
|
+
res.status(404).json({ error: "Group not found" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const status = req.query.status;
|
|
68
|
+
const type = req.query.type;
|
|
69
|
+
res.json(db.listIssuesByGroup(req.params.groupId, status, type).map(withLatestTodos));
|
|
70
|
+
});
|
|
71
|
+
apiRouter.post("/groups/:groupId/issues", (req, res) => {
|
|
72
|
+
const group = db.getGroupById(req.params.groupId);
|
|
73
|
+
if (!group) {
|
|
74
|
+
res.status(404).json({ error: "Group not found" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (group.archived_at) {
|
|
78
|
+
res.status(403).json({ error: "Group is archived, cannot create issues" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const { title, description, priority, createdBy, workingDir, approvalPolicy, repoUrl, repoBranch } = req.body;
|
|
82
|
+
if (!createdBy) {
|
|
83
|
+
res.status(400).json({ error: "createdBy is required" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// 合并 title/description 后,title 可选:缺失时从 description 截断生成。
|
|
87
|
+
// 这样前端/CLI 只需传一个内容字段,体验对齐 Claude Code 终端开箱即用。
|
|
88
|
+
let finalTitle = (typeof title === "string" ? title : "").trim();
|
|
89
|
+
const desc = (typeof description === "string" ? description : "").trim();
|
|
90
|
+
if (!finalTitle) {
|
|
91
|
+
if (!desc) {
|
|
92
|
+
res.status(400).json({ error: "title or description is required" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
finalTitle = truncateTitle(desc);
|
|
96
|
+
}
|
|
97
|
+
let normalizedApprovalPolicy;
|
|
98
|
+
if (approvalPolicy !== undefined) {
|
|
99
|
+
if (approvalPolicy !== "r_allow" && approvalPolicy !== "rw_allow") {
|
|
100
|
+
res.status(400).json({ error: "approvalPolicy must be 'r_allow' or 'rw_allow'" });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
normalizedApprovalPolicy = approvalPolicy;
|
|
104
|
+
}
|
|
105
|
+
let issueWorkDir;
|
|
106
|
+
// group 配了 repo_url 时,issue 真正 cwd 由 executor 在本机起 worktree 后产生,
|
|
107
|
+
// 此时 workingDir 字段不再由用户填(master 不该知道本机 worktree 路径)。
|
|
108
|
+
// 我们仍写一个派生路径到 issues.working_dir 作为兜底/审计(老路径,executor
|
|
109
|
+
// 在 worktree 模式下会忽略它,因为 repoCtx 优先级高于 workingDir override)。
|
|
110
|
+
const groupRepoUrl = group.repo_url?.trim();
|
|
111
|
+
if (typeof workingDir === "string" && workingDir.trim() && !groupRepoUrl) {
|
|
112
|
+
const v = validateWorkingDir(workingDir);
|
|
113
|
+
if (!v.ok) {
|
|
114
|
+
res.status(400).json({ error: v.error });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
issueWorkDir = v.path;
|
|
118
|
+
}
|
|
119
|
+
else if (!groupRepoUrl) {
|
|
120
|
+
// No explicit workingDir 且 group 无 repo:走现状 per-(group, createdBy) override →
|
|
121
|
+
// group.working_dir → default。
|
|
122
|
+
issueWorkDir = resolveGroupAgentWorkingDir(db, req.params.groupId, createdBy);
|
|
123
|
+
}
|
|
124
|
+
// groupRepoUrl 非空时 issueWorkDir 保持 undefined;worktree 路径由 executor 解析。
|
|
125
|
+
// 仍写一个占位:用 group 工作目录,让 issues.working_dir 不空(artifacts API 兜底)。
|
|
126
|
+
if (groupRepoUrl) {
|
|
127
|
+
issueWorkDir = resolveGroupAgentWorkingDir(db, req.params.groupId, createdBy);
|
|
128
|
+
}
|
|
129
|
+
let slashCommand;
|
|
130
|
+
const parsed = parseSlashCommand(finalTitle);
|
|
131
|
+
if (parsed?.known) {
|
|
132
|
+
if (!parsed.stripped) {
|
|
133
|
+
res.status(400).json({ error: `Slash command "${parsed.command}" 后必须跟任务正文` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
slashCommand = parsed.command;
|
|
137
|
+
}
|
|
138
|
+
const id = randomUUID();
|
|
139
|
+
db.createIssue({
|
|
140
|
+
id, groupId: req.params.groupId, title: finalTitle, description: desc,
|
|
141
|
+
priority, createdBy, workingDir: issueWorkDir, slashCommand,
|
|
142
|
+
approvalPolicy: normalizedApprovalPolicy,
|
|
143
|
+
repoUrl: typeof repoUrl === "string" && repoUrl.trim() ? repoUrl.trim() : undefined,
|
|
144
|
+
repoBranch: typeof repoBranch === "string" && repoBranch.trim() ? repoBranch.trim() : undefined,
|
|
145
|
+
});
|
|
146
|
+
log.info(`Issue created: "${finalTitle}" (${id}) in group ${req.params.groupId}`);
|
|
147
|
+
if (hub) {
|
|
148
|
+
hub.notifyIssueChanged(id, req.params.groupId, "created");
|
|
149
|
+
}
|
|
150
|
+
res.status(201).json({ id, title: finalTitle, status: "open" });
|
|
151
|
+
});
|
|
152
|
+
apiRouter.get("/issues/:id", (req, res) => {
|
|
153
|
+
const issue = db.getIssueById(req.params.id);
|
|
154
|
+
if (!issue) {
|
|
155
|
+
res.status(404).json({ error: "Issue not found" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const events = db.getIssueEvents(req.params.id);
|
|
159
|
+
res.json({ ...withLatestTodos(issue), events });
|
|
160
|
+
});
|
|
161
|
+
apiRouter.put("/issues/:id", (req, res) => {
|
|
162
|
+
const issue = db.getIssueById(req.params.id);
|
|
163
|
+
if (!issue) {
|
|
164
|
+
res.status(404).json({ error: "Issue not found" });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const { assignedTo, priority, title, description, approvalPolicy, status } = req.body;
|
|
168
|
+
if (assignedTo !== undefined) {
|
|
169
|
+
const normalized = (assignedTo === null || assignedTo === "") ? null : String(assignedTo);
|
|
170
|
+
db.updateIssueStatus(req.params.id, issue.status, { assignedTo: normalized });
|
|
171
|
+
db.addIssueEvent({
|
|
172
|
+
issueId: req.params.id, eventType: "assigned",
|
|
173
|
+
// agent_name 即被指派的 agent(dashboard chip 会显示);content 留空 →
|
|
174
|
+
// 走 system chip 渲染,不再伪装成 agent 自己说"Assigned to me"。
|
|
175
|
+
agentName: normalized || "system",
|
|
176
|
+
});
|
|
177
|
+
// Re-resolve working_dir from per-(group, agent) override → group.working_dir → default.
|
|
178
|
+
if (normalized && (issue.status === "open" || issue.status === "in_progress")) {
|
|
179
|
+
const resolved = resolveGroupAgentWorkingDir(db, issue.group_id, normalized);
|
|
180
|
+
if (resolved !== issue.working_dir) {
|
|
181
|
+
db.updateIssueWorkingDir(req.params.id, resolved);
|
|
182
|
+
db.addIssueEvent({
|
|
183
|
+
issueId: req.params.id, eventType: "working_dir_resolved",
|
|
184
|
+
agentName: "system",
|
|
185
|
+
content: `working_dir → ${resolved}`,
|
|
186
|
+
metadata: { source: "assignment", assignee: normalized },
|
|
187
|
+
});
|
|
188
|
+
log.info(`Issue ${req.params.id} working_dir re-resolved to ${resolved} for assignee ${normalized}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (priority !== undefined) {
|
|
193
|
+
db.updateIssuePriority(req.params.id, priority);
|
|
194
|
+
}
|
|
195
|
+
if (title !== undefined || description !== undefined) {
|
|
196
|
+
const fields = {};
|
|
197
|
+
const hasExplicitTitle = title !== undefined;
|
|
198
|
+
if (title !== undefined) {
|
|
199
|
+
const t = String(title).trim();
|
|
200
|
+
if (!t) {
|
|
201
|
+
res.status(400).json({ error: "title cannot be empty" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
fields.title = t;
|
|
205
|
+
const parsed = parseSlashCommand(t);
|
|
206
|
+
if (parsed?.known) {
|
|
207
|
+
if (!parsed.stripped) {
|
|
208
|
+
res.status(400).json({ error: `Slash command "${parsed.command}" 后必须跟任务正文` });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
fields.slashCommand = parsed.command;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
fields.slashCommand = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (description !== undefined) {
|
|
218
|
+
fields.description = description === null ? "" : String(description);
|
|
219
|
+
}
|
|
220
|
+
// 合并 title/description 后:若未显式传 title 但传了 description,
|
|
221
|
+
// title 从新 description 重新截断,保持二者同步。slash_command 也跟着重解析。
|
|
222
|
+
if (!hasExplicitTitle && fields.description !== undefined) {
|
|
223
|
+
const newTitle = truncateTitle(fields.description);
|
|
224
|
+
if (newTitle && newTitle !== issue.title) {
|
|
225
|
+
fields.title = newTitle;
|
|
226
|
+
const parsed = parseSlashCommand(newTitle);
|
|
227
|
+
fields.slashCommand = parsed?.known ? parsed.command : null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
db.updateIssueContent(req.params.id, fields);
|
|
231
|
+
const changedLabels = [
|
|
232
|
+
fields.title !== undefined ? "标题" : null,
|
|
233
|
+
fields.description !== undefined ? "描述" : null,
|
|
234
|
+
].filter(Boolean).join("、");
|
|
235
|
+
db.addIssueEvent({
|
|
236
|
+
issueId: req.params.id, eventType: "edited",
|
|
237
|
+
agentName: "system",
|
|
238
|
+
content: `手动编辑 ${changedLabels}`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (approvalPolicy !== undefined) {
|
|
242
|
+
if (approvalPolicy !== "r_allow" && approvalPolicy !== "rw_allow") {
|
|
243
|
+
res.status(400).json({ error: "approvalPolicy must be 'r_allow' or 'rw_allow'" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (approvalPolicy !== issue.approval_policy) {
|
|
247
|
+
db.updateIssueContent(req.params.id, { approvalPolicy });
|
|
248
|
+
db.addIssueEvent({
|
|
249
|
+
issueId: req.params.id, eventType: "edited",
|
|
250
|
+
agentName: "system",
|
|
251
|
+
content: `审批策略改为 ${approvalPolicy === "rw_allow" ? "读写默认通过" : "读默认通过"}`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (status !== undefined) {
|
|
256
|
+
if (!ISSUE_STATUSES.includes(status)) {
|
|
257
|
+
res.status(400).json({ error: `status must be one of ${ISSUE_STATUSES.join("|")}` });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (status !== issue.status) {
|
|
261
|
+
db.updateIssueStatus(req.params.id, status);
|
|
262
|
+
db.addIssueEvent({
|
|
263
|
+
issueId: req.params.id, eventType: "status_changed",
|
|
264
|
+
agentName: "system",
|
|
265
|
+
content: `${issue.status} → ${status}`,
|
|
266
|
+
metadata: { from: issue.status, to: status },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (hub)
|
|
271
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "updated");
|
|
272
|
+
res.json({ ok: true });
|
|
273
|
+
});
|
|
274
|
+
apiRouter.post("/issues/:id/cancel", (req, res) => {
|
|
275
|
+
const issue = db.getIssueById(req.params.id);
|
|
276
|
+
if (!issue) {
|
|
277
|
+
res.status(404).json({ error: "Issue not found" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (issue.status === "completed" || issue.status === "failed" || issue.status === "cancelled") {
|
|
281
|
+
res.status(400).json({ error: `Cannot cancel an issue in status "${issue.status}"` });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const cancelledBy = req.body.cancelledBy || "system";
|
|
285
|
+
const wasInProgress = issue.status === "in_progress";
|
|
286
|
+
db.updateIssueStatus(req.params.id, "cancelled");
|
|
287
|
+
db.addIssueEvent({
|
|
288
|
+
issueId: req.params.id, eventType: "cancelled",
|
|
289
|
+
agentName: cancelledBy,
|
|
290
|
+
});
|
|
291
|
+
if (hub && wasInProgress && issue.assigned_to) {
|
|
292
|
+
const agent = db.getAgentByName(issue.assigned_to);
|
|
293
|
+
if (agent) {
|
|
294
|
+
const delivered = hub.sendToAgent(agent.id, {
|
|
295
|
+
type: "issue_cancelled",
|
|
296
|
+
issueId: req.params.id,
|
|
297
|
+
groupId: issue.group_id,
|
|
298
|
+
reason: `cancelled by ${cancelledBy}`,
|
|
299
|
+
});
|
|
300
|
+
log.info(`Issue ${req.params.id} cancel → ${issue.assigned_to}: sent=${delivered}`);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
log.warn(`Issue ${req.params.id} assigned to "${issue.assigned_to}" but agent not registered`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (hub && issue.group_id) {
|
|
307
|
+
hub.postSystemToGroup(issue.group_id, `🚫 Issue 「${issue.title}」已被 ${cancelledBy} 取消`);
|
|
308
|
+
}
|
|
309
|
+
if (hub)
|
|
310
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "updated");
|
|
311
|
+
res.json({ ok: true });
|
|
312
|
+
});
|
|
313
|
+
// 中断当前步骤但保留 issue in_progress(对齐 codex CLI 的 ESC 行为)。
|
|
314
|
+
// 与 /cancel 的区别:不翻转 status,session_id 保留,worker abort 后由
|
|
315
|
+
// runIssueExecution 的 finally 块决定是否 --resume 续跑(pendingAppends
|
|
316
|
+
// 非空时合并队列续跑,空则保持 idle 等用户下一次 append)。
|
|
317
|
+
apiRouter.post("/issues/:id/interrupt", (req, res) => {
|
|
318
|
+
const issue = db.getIssueById(req.params.id);
|
|
319
|
+
if (!issue) {
|
|
320
|
+
res.status(404).json({ error: "Issue not found" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (issue.status !== "in_progress") {
|
|
324
|
+
res.status(400).json({ error: `Cannot interrupt an issue in status "${issue.status}" (expected in_progress)` });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!issue.assigned_to) {
|
|
328
|
+
res.status(400).json({ error: "Issue has no assignee — cannot interrupt" });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const agent = db.getAgentByName(issue.assigned_to);
|
|
332
|
+
if (!agent) {
|
|
333
|
+
res.status(400).json({ error: `Assignee "${issue.assigned_to}" is not a registered agent` });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (agent.status !== "online") {
|
|
337
|
+
res.status(409).json({ error: `Assignee "${issue.assigned_to}" is offline — nothing to interrupt` });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const interruptedBy = typeof req.body?.interruptedBy === "string" && req.body.interruptedBy
|
|
341
|
+
? req.body.interruptedBy
|
|
342
|
+
: "dashboard-user";
|
|
343
|
+
db.addIssueEvent({
|
|
344
|
+
issueId: req.params.id,
|
|
345
|
+
eventType: "interrupted",
|
|
346
|
+
// agent_name 即谁触发中断(dashboard chip 显示);content 留空 →
|
|
347
|
+
// 走 system chip,不再伪装成 agent 自己说"Interrupted by X"。
|
|
348
|
+
agentName: interruptedBy,
|
|
349
|
+
});
|
|
350
|
+
const delivered = hub ? hub.sendToAgent(agent.id, {
|
|
351
|
+
type: "issue_interrupt",
|
|
352
|
+
issueId: req.params.id,
|
|
353
|
+
groupId: issue.group_id,
|
|
354
|
+
}) : false;
|
|
355
|
+
log.info(`Issue ${req.params.id} interrupt → ${issue.assigned_to}: sent=${delivered}`);
|
|
356
|
+
if (hub)
|
|
357
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "updated");
|
|
358
|
+
res.json({ ok: true, delivered });
|
|
359
|
+
});
|
|
360
|
+
apiRouter.post("/issues/:id/continue", (req, res) => {
|
|
361
|
+
const issue = db.getIssueById(req.params.id);
|
|
362
|
+
if (!issue) {
|
|
363
|
+
res.status(404).json({ error: "Issue not found" });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const prompt = typeof req.body?.prompt === "string" ? req.body.prompt.trim() : "";
|
|
367
|
+
if (!prompt) {
|
|
368
|
+
res.status(400).json({ error: "prompt is required" });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (issue.status !== "completed" && issue.status !== "failed") {
|
|
372
|
+
res.status(400).json({ error: `Cannot continue an issue in status "${issue.status}"` });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (!issue.assigned_to) {
|
|
376
|
+
res.status(400).json({ error: "Issue has no assignee — cannot continue" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const agent = db.getAgentByName(issue.assigned_to);
|
|
380
|
+
if (!agent) {
|
|
381
|
+
res.status(400).json({ error: `Assignee "${issue.assigned_to}" is not a registered agent` });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (agent.status !== "online") {
|
|
385
|
+
res.status(409).json({ error: `Assignee "${issue.assigned_to}" is offline — bring the worker online and retry` });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const continuedBy = typeof req.body?.continuedBy === "string" && req.body.continuedBy
|
|
389
|
+
? req.body.continuedBy
|
|
390
|
+
: "dashboard-user";
|
|
391
|
+
db.updateIssueStatus(req.params.id, "in_progress", {
|
|
392
|
+
result: null,
|
|
393
|
+
errorMessage: null,
|
|
394
|
+
});
|
|
395
|
+
db.addIssueEvent({
|
|
396
|
+
issueId: req.params.id,
|
|
397
|
+
eventType: "continued",
|
|
398
|
+
agentName: continuedBy,
|
|
399
|
+
content: prompt,
|
|
400
|
+
metadata: { sessionId: issue.session_id || undefined, cliTool: issue.cli_tool || undefined },
|
|
401
|
+
});
|
|
402
|
+
let pushed = false;
|
|
403
|
+
if (hub) {
|
|
404
|
+
pushed = hub.pushIssueContinue(req.params.id, prompt);
|
|
405
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "event_appended");
|
|
406
|
+
}
|
|
407
|
+
log.info(`Issue ${req.params.id} continue by ${continuedBy} → ${issue.assigned_to}: pushed=${pushed}, session=${issue.session_id || "(none)"}`);
|
|
408
|
+
res.json({ ok: true, pushed });
|
|
409
|
+
});
|
|
410
|
+
apiRouter.post("/issues/:id/append", (req, res) => {
|
|
411
|
+
const issue = db.getIssueById(req.params.id);
|
|
412
|
+
if (!issue) {
|
|
413
|
+
res.status(404).json({ error: "Issue not found" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const prompt = typeof req.body?.prompt === "string" ? req.body.prompt.trim() : "";
|
|
417
|
+
if (!prompt) {
|
|
418
|
+
res.status(400).json({ error: "prompt is required" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (issue.status !== "open" && issue.status !== "in_progress" && issue.status !== "paused") {
|
|
422
|
+
res.status(400).json({ error: `Cannot append to an issue in status "${issue.status}"` });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!issue.assigned_to) {
|
|
426
|
+
res.status(400).json({ error: "Issue has no assignee — cannot append" });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const agent = db.getAgentByName(issue.assigned_to);
|
|
430
|
+
if (!agent) {
|
|
431
|
+
res.status(400).json({ error: `Assignee "${issue.assigned_to}" is not a registered agent` });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (agent.status !== "online") {
|
|
435
|
+
res.status(409).json({ error: `Assignee "${issue.assigned_to}" is offline — bring the worker online and retry` });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const appendedBy = typeof req.body?.appendedBy === "string" && req.body.appendedBy
|
|
439
|
+
? req.body.appendedBy
|
|
440
|
+
: "dashboard-user";
|
|
441
|
+
db.addIssueEvent({
|
|
442
|
+
issueId: req.params.id,
|
|
443
|
+
eventType: "appended",
|
|
444
|
+
agentName: appendedBy,
|
|
445
|
+
content: prompt,
|
|
446
|
+
metadata: { status: "queued", queuedAt: nowBeijing() },
|
|
447
|
+
});
|
|
448
|
+
let pushed = false;
|
|
449
|
+
if (hub) {
|
|
450
|
+
pushed = hub.pushIssueAppend(req.params.id, prompt);
|
|
451
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "event_appended");
|
|
452
|
+
}
|
|
453
|
+
log.info(`Issue ${req.params.id} append by ${appendedBy} → ${issue.assigned_to}: pushed=${pushed}`);
|
|
454
|
+
res.json({ ok: true, queued: true, pushed });
|
|
455
|
+
});
|
|
456
|
+
apiRouter.post("/issues/:id/complete", (req, res) => {
|
|
457
|
+
const issue = db.getIssueById(req.params.id);
|
|
458
|
+
if (!issue) {
|
|
459
|
+
res.status(404).json({ error: "Issue not found" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (issue.status === "completed" || issue.status === "failed" || issue.status === "cancelled") {
|
|
463
|
+
res.status(400).json({ error: "Cannot complete an issue in status \"" + issue.status + "\"" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const completedBy = req.body.completedBy || "system";
|
|
467
|
+
db.updateIssueStatus(req.params.id, "completed");
|
|
468
|
+
db.addIssueEvent({
|
|
469
|
+
issueId: req.params.id, eventType: "completed",
|
|
470
|
+
agentName: completedBy,
|
|
471
|
+
});
|
|
472
|
+
if (hub && issue.group_id) {
|
|
473
|
+
hub.postSystemToGroup(issue.group_id, `✅ Issue 「${issue.title}」已完成`);
|
|
474
|
+
}
|
|
475
|
+
if (hub)
|
|
476
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "updated");
|
|
477
|
+
res.json({ ok: true });
|
|
478
|
+
});
|
|
479
|
+
apiRouter.post("/issues/claim-next", (req, res) => {
|
|
480
|
+
const { agentName } = req.body;
|
|
481
|
+
if (!agentName) {
|
|
482
|
+
res.status(400).json({ error: "agentName is required" });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const issue = db.claimNextIssue(agentName);
|
|
486
|
+
if (!issue) {
|
|
487
|
+
res.json(null);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
db.addIssueEvent({
|
|
491
|
+
issueId: issue.id, eventType: "started",
|
|
492
|
+
// agent_name 即 claim 的 worker;content 留空 → system chip,
|
|
493
|
+
// 不再伪装成 agent 自己说"Claimed and started by me"。
|
|
494
|
+
agentName,
|
|
495
|
+
});
|
|
496
|
+
if (hub)
|
|
497
|
+
hub.notifyIssueChanged(issue.id, issue.group_id, "updated");
|
|
498
|
+
res.json(withLatestTodos(issue));
|
|
499
|
+
});
|
|
500
|
+
apiRouter.post("/issues/:id/approvals/:approvalId", (req, res) => {
|
|
501
|
+
const issue = db.getIssueById(req.params.id);
|
|
502
|
+
if (!issue) {
|
|
503
|
+
res.status(404).json({ error: "Issue not found" });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const decision = req.body?.decision;
|
|
507
|
+
if (decision !== "accept" && decision !== "deny") {
|
|
508
|
+
res.status(400).json({ error: "decision must be 'accept' or 'deny'" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const event = db.findApprovalEvent(req.params.id, req.params.approvalId);
|
|
512
|
+
if (!event) {
|
|
513
|
+
res.status(404).json({ error: "Approval not found" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const meta = safeJsonParse(event.metadata, {});
|
|
517
|
+
if (meta.status && meta.status !== "pending") {
|
|
518
|
+
res.status(409).json({ error: `Approval already ${meta.status}` });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const resolvedBy = typeof req.body?.resolvedBy === "string" && req.body.resolvedBy
|
|
522
|
+
? req.body.resolvedBy
|
|
523
|
+
: "dashboard-user";
|
|
524
|
+
const feedback = decision === "deny"
|
|
525
|
+
&& typeof req.body?.feedback === "string"
|
|
526
|
+
&& req.body.feedback.trim()
|
|
527
|
+
? req.body.feedback.trim().slice(0, 2000)
|
|
528
|
+
: undefined;
|
|
529
|
+
const updated = db.updateApprovalStatus(event.id, decision === "accept" ? "accepted" : "denied", resolvedBy, feedback);
|
|
530
|
+
if (!updated) {
|
|
531
|
+
res.status(500).json({ error: "Failed to persist approval status" });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (hub) {
|
|
535
|
+
hub.pushApprovalResponse(req.params.id, req.params.approvalId, decision, feedback);
|
|
536
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "event_appended");
|
|
537
|
+
}
|
|
538
|
+
res.json({ ok: true });
|
|
539
|
+
});
|
|
540
|
+
apiRouter.get("/issues/:id/events", (req, res) => {
|
|
541
|
+
const issue = db.getIssueById(req.params.id);
|
|
542
|
+
if (!issue) {
|
|
543
|
+
res.status(404).json({ error: "Issue not found" });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
res.json(db.getIssueEvents(req.params.id));
|
|
547
|
+
});
|
|
548
|
+
apiRouter.get("/issues/:id/messages", (req, res) => {
|
|
549
|
+
const issue = db.getIssueById(req.params.id);
|
|
550
|
+
if (!issue) {
|
|
551
|
+
res.status(404).json({ error: "Issue not found" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const messages = db.getIssueMessages(req.params.id);
|
|
555
|
+
res.json(messages);
|
|
556
|
+
});
|
|
557
|
+
apiRouter.post("/issues/:id/comments", (req, res) => {
|
|
558
|
+
const issue = db.getIssueById(req.params.id);
|
|
559
|
+
if (!issue) {
|
|
560
|
+
res.status(404).json({ error: "Issue not found" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const { agentName, content, replyTo } = req.body;
|
|
564
|
+
if (!agentName || !content) {
|
|
565
|
+
res.status(400).json({ error: "agentName and content are required" });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const agent = db.getAgentByName(agentName);
|
|
569
|
+
if (!agent) {
|
|
570
|
+
res.status(400).json({ error: `Agent \"${agentName}\" not found` });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// Validate replyTo if provided: must be an existing event on this issue
|
|
574
|
+
if (replyTo !== undefined && replyTo !== null) {
|
|
575
|
+
const target = db.getIssueEventById(replyTo);
|
|
576
|
+
if (!target || target.issue_id !== req.params.id) {
|
|
577
|
+
res.status(400).json({ error: `replyTo event ${replyTo} not found on this issue` });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const eventId = db.addIssueComment(req.params.id, agentName, content, replyTo ?? undefined);
|
|
582
|
+
if (hub) {
|
|
583
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "event_appended");
|
|
584
|
+
}
|
|
585
|
+
log.info(`Comment added to issue ${req.params.id} by ${agentName} (event=${eventId}, replyTo=${replyTo ?? "none"})`);
|
|
586
|
+
res.status(201).json({ id: eventId, ok: true });
|
|
587
|
+
});
|
|
588
|
+
apiRouter.delete("/issues/:id", (req, res) => {
|
|
589
|
+
const issue = db.getIssueById(req.params.id);
|
|
590
|
+
if (!issue) {
|
|
591
|
+
res.status(404).json({ error: "Issue not found" });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// 先通知 assignee 清理本机 worktree(若 repo 模式)。issue_cancelled 路径在
|
|
595
|
+
// worker 端会 cleanupIssueWorktrees。DELETE 时 issue 还在 DB,能查到 assigned_to。
|
|
596
|
+
if (hub && issue.assigned_to) {
|
|
597
|
+
const agent = db.getAgentByName(issue.assigned_to);
|
|
598
|
+
if (agent) {
|
|
599
|
+
hub.sendToAgent(agent.id, {
|
|
600
|
+
type: "issue_cancelled",
|
|
601
|
+
issueId: req.params.id,
|
|
602
|
+
groupId: issue.group_id,
|
|
603
|
+
reason: `deleted`,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
db.deleteIssue(req.params.id);
|
|
608
|
+
if (hub)
|
|
609
|
+
hub.notifyIssueChanged(req.params.id, issue.group_id, "deleted");
|
|
610
|
+
res.json({ ok: true });
|
|
611
|
+
});
|
|
612
|
+
// ── Issue → 记忆提取(用户点「生成记忆」触发,非自动)──────────────────
|
|
613
|
+
// 创建一个"记忆提取"任务 Issue,push 给指定 agent 执行。
|
|
614
|
+
// agent 读原 Issue 产出 → 提炼记忆 → 调 `rotom memory add --pending` 写入。
|
|
615
|
+
// 写入的记忆 pending_review=1,需用户在 MemoryPanel「待审核」tab 审核。
|
|
616
|
+
apiRouter.post("/issues/:id/extract-memory", (req, res) => {
|
|
617
|
+
const sourceIssue = db.getIssueById(req.params.id);
|
|
618
|
+
if (!sourceIssue) {
|
|
619
|
+
res.status(404).json({ error: "Issue not found" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (sourceIssue.status !== "completed") {
|
|
623
|
+
res.status(400).json({ error: "只能对 completed 状态的 Issue 生成记忆" });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const { agentName } = req.body ?? {};
|
|
627
|
+
const targetAgent = (typeof agentName === "string" && agentName.trim()) ? agentName.trim() : sourceIssue.assigned_to;
|
|
628
|
+
if (!targetAgent) {
|
|
629
|
+
res.status(400).json({ error: "原 Issue 无 assignee,需传 agentName 指定执行 agent" });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const targetAgentRow = db.getAgentByName(targetAgent);
|
|
633
|
+
if (!targetAgentRow) {
|
|
634
|
+
res.status(404).json({ error: `Agent "${targetAgent}" 不存在` });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (!hub) {
|
|
638
|
+
res.status(500).json({ error: "WSHub 未初始化" });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const sourceShortId = req.params.id.slice(0, 8);
|
|
642
|
+
const extractIssueId = randomUUID();
|
|
643
|
+
const extractPrompt = buildMemoryExtractPrompt(sourceIssue, sourceShortId);
|
|
644
|
+
const workingDir = resolveGroupAgentWorkingDir(db, sourceIssue.group_id, targetAgent);
|
|
645
|
+
db.createIssue({
|
|
646
|
+
id: extractIssueId,
|
|
647
|
+
groupId: sourceIssue.group_id,
|
|
648
|
+
title: `[记忆提取] #${sourceShortId} ${sourceIssue.title}`.slice(0, 200),
|
|
649
|
+
description: extractPrompt,
|
|
650
|
+
createdBy: "system:memory-extract",
|
|
651
|
+
workingDir,
|
|
652
|
+
assignedTo: targetAgent,
|
|
653
|
+
approvalPolicy: "rw_allow",
|
|
654
|
+
});
|
|
655
|
+
const pushed = hub.pushIssueAssignment(extractIssueId, targetAgent);
|
|
656
|
+
if (!pushed) {
|
|
657
|
+
log.warn(`extract-memory: pushIssueAssignment 失败,agent ${targetAgent} 可能不在线 (issue ${extractIssueId})`);
|
|
658
|
+
}
|
|
659
|
+
hub.notifyIssueChanged(extractIssueId, sourceIssue.group_id, "created");
|
|
660
|
+
log.info(`extract-memory: source=${req.params.id} → extract issue=${extractIssueId} → agent=${targetAgent}`);
|
|
661
|
+
res.status(201).json({ extractIssueId, agentName: targetAgent, pushed });
|
|
662
|
+
});
|
|
663
|
+
}
|