@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,373 @@
|
|
|
1
|
+
import { nowBeijing, shiftBeijing } from "../../shared/time.js";
|
|
2
|
+
import { safeJsonParse } from "../../shared/parse.js";
|
|
3
|
+
import { buildUpdate } from "./build-update.js";
|
|
4
|
+
export const issueMethods = {
|
|
5
|
+
createIssue(issue) {
|
|
6
|
+
const now = nowBeijing();
|
|
7
|
+
this.db.prepare(`
|
|
8
|
+
INSERT INTO issues (id, group_id, title, description, priority, created_by, working_dir, type, slash_command, approval_policy, assigned_to, created_at, repo_url, repo_branch)
|
|
9
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10
|
+
`).run(issue.id, issue.groupId, issue.title, issue.description || "", issue.priority || "medium", issue.createdBy, issue.workingDir || null, issue.type || "task", issue.slashCommand || null, issue.approvalPolicy || "rw_allow", issue.assignedTo || null, now, issue.repoUrl || null, issue.repoBranch || null);
|
|
11
|
+
this.db.prepare(`
|
|
12
|
+
INSERT INTO issue_events (issue_id, event_type, agent_name, content, created_at)
|
|
13
|
+
VALUES (?, 'created', ?, '', ?)
|
|
14
|
+
`).run(issue.id, issue.createdBy, now);
|
|
15
|
+
},
|
|
16
|
+
getIssueById(id) {
|
|
17
|
+
return this.db.prepare("SELECT * FROM issues WHERE id = ?").get(id);
|
|
18
|
+
},
|
|
19
|
+
listIssuesByGroup(groupId, status, type) {
|
|
20
|
+
let sql = "SELECT * FROM issues WHERE group_id = ?";
|
|
21
|
+
const params = [groupId];
|
|
22
|
+
if (status) {
|
|
23
|
+
sql += " AND status = ?";
|
|
24
|
+
params.push(status);
|
|
25
|
+
}
|
|
26
|
+
if (type) {
|
|
27
|
+
sql += " AND type = ?";
|
|
28
|
+
params.push(type);
|
|
29
|
+
}
|
|
30
|
+
sql += " ORDER BY CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'failed' THEN 2 WHEN 'cancelled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END, created_at DESC";
|
|
31
|
+
return this.db.prepare(sql).all(...params);
|
|
32
|
+
},
|
|
33
|
+
listAllIssues(status) {
|
|
34
|
+
let sql = "SELECT * FROM issues";
|
|
35
|
+
const params = [];
|
|
36
|
+
if (status) {
|
|
37
|
+
sql += " WHERE status = ?";
|
|
38
|
+
params.push(status);
|
|
39
|
+
}
|
|
40
|
+
sql += " ORDER BY CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'failed' THEN 2 WHEN 'cancelled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END, created_at DESC";
|
|
41
|
+
return this.db.prepare(sql).all(...params);
|
|
42
|
+
},
|
|
43
|
+
/**
|
|
44
|
+
* 分页拉取 issue。看板每列独立分页(默认首屏 50 条),避免 completed/cancelled
|
|
45
|
+
* 累积过多时一次性把全表拉到前端。total 是不带 limit/offset 的全量计数,
|
|
46
|
+
* 给列头展示真实总数;items 是当前页。排序与 listAllIssues 保持一致。
|
|
47
|
+
*/
|
|
48
|
+
listIssuesPage(opts) {
|
|
49
|
+
const orderSql = " ORDER BY CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'failed' THEN 2 WHEN 'cancelled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END, created_at DESC";
|
|
50
|
+
const where = opts.status ? " WHERE status = ?" : "";
|
|
51
|
+
const params = opts.status ? [opts.status] : [];
|
|
52
|
+
const total = this.db.prepare(`SELECT COUNT(*) as n FROM issues${where}`).get(...params).n;
|
|
53
|
+
const items = this.db
|
|
54
|
+
.prepare(`SELECT * FROM issues${where}${orderSql} LIMIT ? OFFSET ?`)
|
|
55
|
+
.all(...params, opts.limit, opts.offset);
|
|
56
|
+
return { items, total };
|
|
57
|
+
},
|
|
58
|
+
updateIssueStatus(id, status, extra) {
|
|
59
|
+
const now = nowBeijing();
|
|
60
|
+
const built = buildUpdate({
|
|
61
|
+
table: "issues",
|
|
62
|
+
sets: {
|
|
63
|
+
status,
|
|
64
|
+
assigned_to: extra?.assignedTo,
|
|
65
|
+
result: extra?.result,
|
|
66
|
+
error_message: extra?.errorMessage,
|
|
67
|
+
artifacts: extra?.artifacts !== undefined ? JSON.stringify(extra.artifacts) : undefined,
|
|
68
|
+
session_id: extra?.sessionId,
|
|
69
|
+
cli_tool: extra?.cliTool,
|
|
70
|
+
usage: extra?.usage,
|
|
71
|
+
model: extra?.model,
|
|
72
|
+
},
|
|
73
|
+
where: "id = ?",
|
|
74
|
+
whereParams: [id],
|
|
75
|
+
updatedAt: false,
|
|
76
|
+
extraSets: [
|
|
77
|
+
{ sql: "updated_at = ?", params: [now] },
|
|
78
|
+
// started_at 只在第一次进 in_progress 时写,后续 progress 事件不覆盖——
|
|
79
|
+
// 否则最后一次 in_progress(带 result)会把 started_at 推到接近完成时间,
|
|
80
|
+
// 导致总耗时显示成 0。COALESCE 保留已有值,paused → in_progress 续跑时
|
|
81
|
+
// 也保留首次开始时间(总耗时含 paused 段,反映"从开始到结束"的真实时长)。
|
|
82
|
+
...(status === "in_progress" ? [{ sql: "started_at = COALESCE(started_at, ?)", params: [now] }] : []),
|
|
83
|
+
...(status === "completed" || status === "failed" || status === "cancelled"
|
|
84
|
+
? [{ sql: "completed_at = ?", params: [now] }]
|
|
85
|
+
: []),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
if (built)
|
|
89
|
+
this.db.prepare(built.sql).run(...built.params);
|
|
90
|
+
// E2ED auto-sync hook: when an issue reaches terminal state, notify
|
|
91
|
+
// registered listeners so e2ed can advance requirement status.
|
|
92
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
93
|
+
this._onIssueTerminal?.(id);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
updateIssueWorkingDir(id, workingDir) {
|
|
97
|
+
this.db.prepare("UPDATE issues SET working_dir = ?, updated_at = ? WHERE id = ?").run(workingDir, nowBeijing(), id);
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* 覆盖式写入 issues.latest_todos_json。每次 worker 解析到 TodoWrite tool_use
|
|
101
|
+
* 都调一次(全量替换);是否额外落 issue_event 由调用方(ws-hub)按内容 hash
|
|
102
|
+
* 去重决定,这里只管快照列。
|
|
103
|
+
*/
|
|
104
|
+
updateIssueTodos(id, todos) {
|
|
105
|
+
this.db.prepare("UPDATE issues SET latest_todos_json = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(todos), nowBeijing(), id);
|
|
106
|
+
},
|
|
107
|
+
/**
|
|
108
|
+
* 反查最新绑定到某个 sessionId 的 issue。
|
|
109
|
+
* session_id 由 migration 013 加入,无 session_id 列的旧 DB 走不到这里。
|
|
110
|
+
*/
|
|
111
|
+
getLatestIssueBySessionId(sessionId) {
|
|
112
|
+
return this.db.prepare("SELECT * FROM issues WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1").get(sessionId);
|
|
113
|
+
},
|
|
114
|
+
/**
|
|
115
|
+
* 查该 (cliTool, groupId) 下最新的 issue。Debug Sessions 视图用:
|
|
116
|
+
* worker SessionStore 跟 issues.session_id 是两条独立路径(SessionStore
|
|
117
|
+
* 只在 chat 路径更新,issue 执行不写 SessionStore),所以反查
|
|
118
|
+
* session_id 经常落空。改用 (cliTool, groupId) 取最新一条 issue,展示
|
|
119
|
+
* 「上次 claude/codex 在这个群里跑了多少 token」更贴近用户预期。
|
|
120
|
+
*/
|
|
121
|
+
getLatestIssueByCliTool(cliTool, groupId) {
|
|
122
|
+
return this.db.prepare("SELECT * FROM issues WHERE cli_tool = ? AND group_id = ? ORDER BY updated_at DESC LIMIT 1").get(cliTool, groupId);
|
|
123
|
+
},
|
|
124
|
+
/** Atomically claim the next unassigned issue for an executor agent. */
|
|
125
|
+
claimNextIssue(agentName) {
|
|
126
|
+
const issue = this.db.prepare(`
|
|
127
|
+
SELECT i.* FROM issues i
|
|
128
|
+
JOIN groups g ON g.id = i.group_id
|
|
129
|
+
WHERE i.status = 'open' AND i.assigned_to IS NULL AND i.type = 'task'
|
|
130
|
+
AND g.archived_at IS NULL
|
|
131
|
+
ORDER BY
|
|
132
|
+
CASE i.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
|
|
133
|
+
i.created_at ASC
|
|
134
|
+
LIMIT 1
|
|
135
|
+
`).get();
|
|
136
|
+
if (!issue)
|
|
137
|
+
return undefined;
|
|
138
|
+
// Atomic update: only claim if still unassigned
|
|
139
|
+
const now = nowBeijing();
|
|
140
|
+
const result = this.db.prepare("UPDATE issues SET assigned_to = ?, status = 'in_progress', started_at = ?, updated_at = ? WHERE id = ? AND assigned_to IS NULL").run(agentName, now, now, issue.id);
|
|
141
|
+
if (result.changes === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
this.db.prepare(`
|
|
144
|
+
INSERT INTO issue_events (issue_id, event_type, agent_name, content, created_at)
|
|
145
|
+
VALUES (?, 'assigned', ?, ?, ?)
|
|
146
|
+
`).run(issue.id, agentName, `Claimed by ${agentName}`, now);
|
|
147
|
+
return this.getIssueById(issue.id);
|
|
148
|
+
},
|
|
149
|
+
addIssueEvent(event) {
|
|
150
|
+
this.db.prepare(`
|
|
151
|
+
INSERT INTO issue_events (issue_id, event_type, agent_name, content, metadata, created_at)
|
|
152
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
153
|
+
`).run(event.issueId, event.eventType, event.agentName, event.content || "", JSON.stringify(event.metadata || {}), nowBeijing());
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* Post a comment/message on an issue, optionally replying to a specific
|
|
157
|
+
* event (message quoting). Returns the new event ID.
|
|
158
|
+
*/
|
|
159
|
+
addIssueComment(issueId, agentName, content, replyToId) {
|
|
160
|
+
const result = this.db.prepare(`
|
|
161
|
+
INSERT INTO issue_events (issue_id, event_type, agent_name, content, metadata, reply_to_id, created_at)
|
|
162
|
+
VALUES (?, 'comment', ?, ?, '{}', ?, ?)
|
|
163
|
+
`).run(issueId, agentName, content, replyToId ?? null, nowBeijing());
|
|
164
|
+
return Number(result.lastInsertRowid);
|
|
165
|
+
},
|
|
166
|
+
/**
|
|
167
|
+
* Get all comment messages for an issue, with reply-to resolution (quoted
|
|
168
|
+
* message content + author embedded in the result).
|
|
169
|
+
*/
|
|
170
|
+
getIssueMessages(issueId) {
|
|
171
|
+
const rows = this.db.prepare(`
|
|
172
|
+
SELECT * FROM issue_events
|
|
173
|
+
WHERE issue_id = ? AND event_type = 'comment'
|
|
174
|
+
ORDER BY created_at ASC
|
|
175
|
+
`).all(issueId);
|
|
176
|
+
return rows.map((r) => {
|
|
177
|
+
const msg = {
|
|
178
|
+
id: r.id,
|
|
179
|
+
event_type: r.event_type,
|
|
180
|
+
agent_name: r.agent_name,
|
|
181
|
+
content: r.content,
|
|
182
|
+
created_at: r.created_at,
|
|
183
|
+
metadata: r.metadata,
|
|
184
|
+
reply_to_id: r.reply_to_id,
|
|
185
|
+
};
|
|
186
|
+
if (r.reply_to_id != null) {
|
|
187
|
+
const quoted = this.db.prepare("SELECT id, agent_name, content, created_at FROM issue_events WHERE id = ?").get(r.reply_to_id);
|
|
188
|
+
msg.quoted = quoted ?? null;
|
|
189
|
+
}
|
|
190
|
+
return msg;
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
// 拉取 issue 的事件流。
|
|
194
|
+
//
|
|
195
|
+
// 旧实现是 SELECT * ORDER BY created_at ASC LIMIT 200,在 issue 跑久了
|
|
196
|
+
// events 累积超过 200 条时(典型如 worker 流式喷了大量 [status:thinking]
|
|
197
|
+
// progress chunk)会把**最新**的事件截掉 —— 用户的追加指令(appended)
|
|
198
|
+
// 即便已经入库、worker 已消费,也永远拉不回来,前端 reload 多少次都看不到。
|
|
199
|
+
// 参见 issue 2284adfa 的复现。
|
|
200
|
+
//
|
|
201
|
+
// 现在:把事件拆成两类分别处理:
|
|
202
|
+
// - 非 progress(created/assigned/appended/approval_request/completed/failed/...)
|
|
203
|
+
// 是用户关心的关键节点,**全部保留**,不受 limit 影响。
|
|
204
|
+
// - progress(worker 流式输出)条数最多,保留**最早 headKeep 条 + 最新
|
|
205
|
+
// tailKeep 条**,中间被省略的部分用 event_type='progress_truncated' 的
|
|
206
|
+
// 虚拟事件标注,前端渲染成「已省略 N 条早期进展」chip。
|
|
207
|
+
getIssueEvents(issueId, headKeep = 5, tailKeep = 300) {
|
|
208
|
+
const nonProgress = this.db.prepare("SELECT * FROM issue_events WHERE issue_id = ? AND event_type != 'progress' ORDER BY created_at ASC, id ASC").all(issueId);
|
|
209
|
+
// 拉全部 progress 在 JS 层过滤纯 status-only 噪声。典型 /plan 任务里
|
|
210
|
+
// 60% 的 progress chunk 是 [status:thinking]Working/Running/Done[/status:thinking]
|
|
211
|
+
// 重复几百次 —— hoistStatus 只取 bucket 内最后一个 status 渲染 pill,
|
|
212
|
+
// 中间的全是冗余。过滤掉它们让 head/tail 截断预算花在有意义的 exec/result/text
|
|
213
|
+
// 上,大幅减少「已省略 N 条早期进展」的 N。保留最后一条 status-only
|
|
214
|
+
// 事件,确保 hoistStatus 能取到最终状态(Done/Answered)而不是过时的 Running。
|
|
215
|
+
const allProgress = this.db.prepare("SELECT * FROM issue_events WHERE issue_id = ? AND event_type = 'progress' ORDER BY id ASC").all(issueId);
|
|
216
|
+
const STATUS_OPEN = "[status:thinking]";
|
|
217
|
+
const STATUS_CLOSE = "[/status:thinking]";
|
|
218
|
+
const isStatusOnly = (c) => {
|
|
219
|
+
if (!c.startsWith(STATUS_OPEN))
|
|
220
|
+
return false;
|
|
221
|
+
const closeIdx = c.indexOf(STATUS_CLOSE, STATUS_OPEN.length);
|
|
222
|
+
if (closeIdx === -1)
|
|
223
|
+
return false;
|
|
224
|
+
return c.slice(closeIdx + STATUS_CLOSE.length).trim() === "";
|
|
225
|
+
};
|
|
226
|
+
let lastStatusId = -1;
|
|
227
|
+
for (let i = allProgress.length - 1; i >= 0; i--) {
|
|
228
|
+
if (isStatusOnly(allProgress[i].content || "")) {
|
|
229
|
+
lastStatusId = allProgress[i].id;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const filtered = allProgress.filter(ev => !isStatusOnly(ev.content || "") || ev.id === lastStatusId);
|
|
234
|
+
const progressCount = filtered.length;
|
|
235
|
+
// 过滤后的有意义事件数不超过 head+tail,直接全返回,不需要 marker。
|
|
236
|
+
if (progressCount <= headKeep + tailKeep) {
|
|
237
|
+
return mergeIssueEvents([...nonProgress, ...filtered]);
|
|
238
|
+
}
|
|
239
|
+
const head = filtered.slice(0, headKeep);
|
|
240
|
+
const tail = filtered.slice(filtered.length - tailKeep);
|
|
241
|
+
// 截断后 tail 的开头可能落在某条 [tool-result:exec] 上 —— 它配对的
|
|
242
|
+
// [tool:exec] 在被丢掉的中间段,Dashboard 拿到这条孤儿 result 会渲染成
|
|
243
|
+
// "(unknown)" 命令卡片(整段子代理输出挂在一条无名的命令下)。从 tail
|
|
244
|
+
// 头部开始丢弃 result chunk,直到遇到第一条 [tool:exec](之后 tail 内部
|
|
245
|
+
// FIFO 配对就能正常工作)。status-thinking / 纯文本等非 result chunk
|
|
246
|
+
// 不影响配对,保留。被丢的 result 计入 omitted,marker 仍提示"已省略"。
|
|
247
|
+
//
|
|
248
|
+
// 用 startsWith 而不是 includes:executor 每条 progress chunk 都以单个
|
|
249
|
+
// tag 开头([tool:exec] / [tool-result:exec] / [status:thinking] / 纯文本),
|
|
250
|
+
// 起始位置 0 的 tag 一定是真 tag。而 includes 会被 result 内容里出现的
|
|
251
|
+
// 字面量 "[tool:exec]"(比如 grep 命令把源码里的 tag 字符串匹配出来)
|
|
252
|
+
// 误判成 exec chunk,导致孤儿 result 没被丢掉。
|
|
253
|
+
let seenExec = false;
|
|
254
|
+
const trimmedTail = [];
|
|
255
|
+
for (const ev of tail) {
|
|
256
|
+
const c = ev.content || "";
|
|
257
|
+
if (!seenExec && c.startsWith("[tool:exec]"))
|
|
258
|
+
seenExec = true;
|
|
259
|
+
if (!seenExec && c.startsWith("[tool-result:exec]"))
|
|
260
|
+
continue;
|
|
261
|
+
trimmedTail.push(ev);
|
|
262
|
+
}
|
|
263
|
+
const omitted = progressCount - head.length - trimmedTail.length;
|
|
264
|
+
// marker 插在 head 最后一条之后、tail 第一条之前。
|
|
265
|
+
// created_at 取 head 末尾 +1ms,确保排序落在 head 和 tail 之间。
|
|
266
|
+
const markerTime = head.length > 0
|
|
267
|
+
? shiftBeijing(head[head.length - 1].created_at, 1)
|
|
268
|
+
: (trimmedTail[0]?.created_at ?? nowBeijing());
|
|
269
|
+
const marker = {
|
|
270
|
+
id: -1,
|
|
271
|
+
issue_id: issueId,
|
|
272
|
+
event_type: "progress_truncated",
|
|
273
|
+
agent_name: "",
|
|
274
|
+
content: "",
|
|
275
|
+
metadata: JSON.stringify({ omitted }),
|
|
276
|
+
created_at: markerTime,
|
|
277
|
+
reply_to_id: null,
|
|
278
|
+
};
|
|
279
|
+
return mergeIssueEvents([...nonProgress, ...head, marker, ...trimmedTail]);
|
|
280
|
+
},
|
|
281
|
+
/** Get a single issue event by its ID. */
|
|
282
|
+
getIssueEventById(eventId) {
|
|
283
|
+
return this.db.prepare("SELECT * FROM issue_events WHERE id = ?").get(eventId);
|
|
284
|
+
},
|
|
285
|
+
/** Get all issue events for a group (across all issues in that group) */
|
|
286
|
+
getIssueEventsByGroup(groupId, limit = 500) {
|
|
287
|
+
return this.db.prepare("SELECT ie.* FROM issue_events ie JOIN issues i ON ie.issue_id = i.id WHERE i.group_id = ? ORDER BY ie.created_at ASC LIMIT ?").all(groupId, limit);
|
|
288
|
+
},
|
|
289
|
+
/**
|
|
290
|
+
* Approvals piggy-back on issue_events (event_type='approval_request') —
|
|
291
|
+
* their lifecycle ("pending" → "accepted"/"denied") lives inside the JSON
|
|
292
|
+
* metadata column. Finding one requires a scan + JSON parse since approvalId
|
|
293
|
+
* isn't indexed; the per-issue event count is small (capped at ~200) so this
|
|
294
|
+
* is fine in practice.
|
|
295
|
+
*/
|
|
296
|
+
findApprovalEvent(issueId, approvalId) {
|
|
297
|
+
const rows = this.db.prepare("SELECT * FROM issue_events WHERE issue_id = ? AND event_type = 'approval_request' ORDER BY created_at DESC").all(issueId);
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
try {
|
|
300
|
+
const meta = JSON.parse(row.metadata || "{}");
|
|
301
|
+
if (meta.approvalId === approvalId)
|
|
302
|
+
return row;
|
|
303
|
+
}
|
|
304
|
+
catch { /* malformed metadata — skip */ }
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
},
|
|
308
|
+
/** Mark an approval event as resolved. Returns true if the row was updated.
|
|
309
|
+
* When `feedback` is provided and status is `denied`, it is persisted in
|
|
310
|
+
* metadata so the dashboard can render the rejection reason on the
|
|
311
|
+
* resolved card. */
|
|
312
|
+
updateApprovalStatus(eventId, status, resolvedBy, feedback) {
|
|
313
|
+
const row = this.db.prepare("SELECT metadata FROM issue_events WHERE id = ?").get(eventId);
|
|
314
|
+
if (!row)
|
|
315
|
+
return false;
|
|
316
|
+
const meta = safeJsonParse(row.metadata, {});
|
|
317
|
+
meta.status = status;
|
|
318
|
+
meta.resolvedBy = resolvedBy;
|
|
319
|
+
meta.resolvedAt = nowBeijing();
|
|
320
|
+
if (status === "denied" && feedback)
|
|
321
|
+
meta.feedback = feedback;
|
|
322
|
+
const result = this.db.prepare("UPDATE issue_events SET metadata = ? WHERE id = ?").run(JSON.stringify(meta), eventId);
|
|
323
|
+
return result.changes > 0;
|
|
324
|
+
},
|
|
325
|
+
deleteIssue(id) {
|
|
326
|
+
this.db.prepare("DELETE FROM issue_events WHERE issue_id = ?").run(id);
|
|
327
|
+
this.db.prepare("DELETE FROM issues WHERE id = ?").run(id);
|
|
328
|
+
},
|
|
329
|
+
updateIssuePriority(id, priority) {
|
|
330
|
+
const built = buildUpdate({
|
|
331
|
+
table: "issues",
|
|
332
|
+
sets: { priority },
|
|
333
|
+
where: "id = ?",
|
|
334
|
+
whereParams: [id],
|
|
335
|
+
updatedAt: "beijing",
|
|
336
|
+
});
|
|
337
|
+
if (built)
|
|
338
|
+
this.db.prepare(built.sql).run(...built.params);
|
|
339
|
+
},
|
|
340
|
+
// 同时支持 title / description 的部分更新。两个字段都不传时返回 false。
|
|
341
|
+
// 标题在调用方已经做过非空校验,这里只负责落库。
|
|
342
|
+
updateIssueContent(id, fields) {
|
|
343
|
+
const built = buildUpdate({
|
|
344
|
+
table: "issues",
|
|
345
|
+
sets: {
|
|
346
|
+
title: fields.title,
|
|
347
|
+
description: fields.description,
|
|
348
|
+
slash_command: fields.slashCommand,
|
|
349
|
+
approval_policy: fields.approvalPolicy,
|
|
350
|
+
},
|
|
351
|
+
where: "id = ?",
|
|
352
|
+
whereParams: [id],
|
|
353
|
+
updatedAt: "beijing",
|
|
354
|
+
});
|
|
355
|
+
if (!built)
|
|
356
|
+
return false;
|
|
357
|
+
const result = this.db.prepare(built.sql).run(...built.params);
|
|
358
|
+
return result.changes > 0;
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* Sort events by created_at then id. Pulled out of getIssueEvents so the
|
|
363
|
+
* head/tail/all paths can share the merge logic without a class-level helper.
|
|
364
|
+
*/
|
|
365
|
+
function mergeIssueEvents(rows) {
|
|
366
|
+
return rows.sort((a, b) => {
|
|
367
|
+
const ta = Date.parse(a.created_at);
|
|
368
|
+
const tb = Date.parse(b.created_at);
|
|
369
|
+
if (ta !== tb)
|
|
370
|
+
return ta - tb;
|
|
371
|
+
return a.id - b.id;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links — 链接主表 / 出现记录 / 标签 / 来源群 / 巡检 run-log CRUD。
|
|
3
|
+
*
|
|
4
|
+
* 采集路径(inline hook):
|
|
5
|
+
* collectLinksFromText → extractUrls + normalizeUrl → dedup by url_norm
|
|
6
|
+
* 命中已有 link: addLinkOccurrence + touchLinkLastSeen + (可选)addLinkSourceGroup
|
|
7
|
+
* 新 link:createLink + addLinkOccurrence + addLinkSourceGroup
|
|
8
|
+
*
|
|
9
|
+
* 分类路径(link-patrol scheduled task + 巡检员 agent):
|
|
10
|
+
* listUnclassifiedLinks → handler 拼 prompt → pushIssueAssignment
|
|
11
|
+
* → agent 完成 issue → handleLinkPatrolIssueTerminal 解析 result → updateLinkClassification
|
|
12
|
+
*
|
|
13
|
+
* Methods attach via Object.assign(见 internal.ts)。
|
|
14
|
+
*/
|
|
15
|
+
import { nowBeijing } from "../../shared/time.js";
|
|
16
|
+
import { buildUpdate } from "./build-update.js";
|
|
17
|
+
// ─── Methods ──────────────────────────────────────────────────────────────
|
|
18
|
+
export const linkMethods = {
|
|
19
|
+
// ─── Link 主表 ────────────────────────────────────────────────────────
|
|
20
|
+
/** INSERT OR IGNORE,命中 UNIQUE(url_norm) 时返回 undefined。 */
|
|
21
|
+
createLink(input) {
|
|
22
|
+
const now = nowBeijing();
|
|
23
|
+
this.db.prepare(`
|
|
24
|
+
INSERT OR IGNORE INTO links (id, url_norm, url_raw, title, category, summary, host, created_at, updated_at, last_seen_at)
|
|
25
|
+
VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?)
|
|
26
|
+
`).run(input.id, input.urlNorm, input.urlRaw, input.host, now, now, now);
|
|
27
|
+
},
|
|
28
|
+
getLinkByUrlNorm(urlNorm) {
|
|
29
|
+
return this.db.prepare("SELECT * FROM links WHERE url_norm = ?").get(urlNorm);
|
|
30
|
+
},
|
|
31
|
+
getLink(id) {
|
|
32
|
+
return this.db.prepare("SELECT * FROM links WHERE id = ?").get(id);
|
|
33
|
+
},
|
|
34
|
+
/** 更新 last_seen_at + updated_at。同 link 多次出现时累加 occurrence 后调。 */
|
|
35
|
+
touchLinkLastSeen(id) {
|
|
36
|
+
const now = nowBeijing();
|
|
37
|
+
this.db.prepare("UPDATE links SET last_seen_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
38
|
+
},
|
|
39
|
+
/** 巡检员写分类结果。tags 为空数组时清空标签。 */
|
|
40
|
+
updateLinkClassification(id, fields) {
|
|
41
|
+
// 1. UPDATE links 主表(category / title / summary)
|
|
42
|
+
const built = buildUpdate({
|
|
43
|
+
table: "links",
|
|
44
|
+
sets: {
|
|
45
|
+
category: fields.category,
|
|
46
|
+
title: fields.title,
|
|
47
|
+
summary: fields.summary,
|
|
48
|
+
},
|
|
49
|
+
where: "id = ?",
|
|
50
|
+
whereParams: [id],
|
|
51
|
+
updatedAt: "beijing",
|
|
52
|
+
});
|
|
53
|
+
if (built)
|
|
54
|
+
this.db.prepare(built.sql).run(...built.params);
|
|
55
|
+
// 2. tags 多对多:传了就重写(先 DELETE 再 INSERT)
|
|
56
|
+
if (Array.isArray(fields.tags)) {
|
|
57
|
+
this.db.prepare("DELETE FROM link_tags WHERE link_id = ?").run(id);
|
|
58
|
+
if (fields.tags.length > 0) {
|
|
59
|
+
const stmt = this.db.prepare("INSERT OR IGNORE INTO link_tags (link_id, tag) VALUES (?, ?)");
|
|
60
|
+
for (const tag of fields.tags) {
|
|
61
|
+
if (typeof tag === "string" && tag.trim())
|
|
62
|
+
stmt.run(id, tag.trim());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
/** 列表过滤。tag 过滤走 link_tags JOIN;search 在 url_raw / title / host 上 LIKE。 */
|
|
69
|
+
listLinks(filter = {}) {
|
|
70
|
+
const { category, tag, search, groupId, host, limit = 50, offset = 0, } = filter;
|
|
71
|
+
const where = [];
|
|
72
|
+
const params = [];
|
|
73
|
+
if (category) {
|
|
74
|
+
where.push("category = ?");
|
|
75
|
+
params.push(category);
|
|
76
|
+
}
|
|
77
|
+
if (host) {
|
|
78
|
+
where.push("host = ?");
|
|
79
|
+
params.push(host);
|
|
80
|
+
}
|
|
81
|
+
if (search) {
|
|
82
|
+
where.push("(url_raw LIKE ? OR title LIKE ? OR host LIKE ?)");
|
|
83
|
+
const kw = `%${search}%`;
|
|
84
|
+
params.push(kw, kw, kw);
|
|
85
|
+
}
|
|
86
|
+
if (tag) {
|
|
87
|
+
where.push("id IN (SELECT link_id FROM link_tags WHERE tag = ?)");
|
|
88
|
+
params.push(tag);
|
|
89
|
+
}
|
|
90
|
+
if (groupId) {
|
|
91
|
+
where.push("id IN (SELECT link_id FROM link_source_groups WHERE group_id = ?)");
|
|
92
|
+
params.push(groupId);
|
|
93
|
+
}
|
|
94
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
95
|
+
params.push(limit, offset);
|
|
96
|
+
return this.db.prepare(`SELECT * FROM links ${whereClause} ORDER BY last_seen_at DESC LIMIT ? OFFSET ?`).all(...params);
|
|
97
|
+
},
|
|
98
|
+
countLinks(filter = {}) {
|
|
99
|
+
const { category, tag, search, groupId, host } = filter;
|
|
100
|
+
const where = [];
|
|
101
|
+
const params = [];
|
|
102
|
+
if (category) {
|
|
103
|
+
where.push("category = ?");
|
|
104
|
+
params.push(category);
|
|
105
|
+
}
|
|
106
|
+
if (host) {
|
|
107
|
+
where.push("host = ?");
|
|
108
|
+
params.push(host);
|
|
109
|
+
}
|
|
110
|
+
if (search) {
|
|
111
|
+
where.push("(url_raw LIKE ? OR title LIKE ? OR host LIKE ?)");
|
|
112
|
+
const kw = `%${search}%`;
|
|
113
|
+
params.push(kw, kw, kw);
|
|
114
|
+
}
|
|
115
|
+
if (tag) {
|
|
116
|
+
where.push("id IN (SELECT link_id FROM link_tags WHERE tag = ?)");
|
|
117
|
+
params.push(tag);
|
|
118
|
+
}
|
|
119
|
+
if (groupId) {
|
|
120
|
+
where.push("id IN (SELECT link_id FROM link_source_groups WHERE group_id = ?)");
|
|
121
|
+
params.push(groupId);
|
|
122
|
+
}
|
|
123
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
124
|
+
const row = this.db.prepare(`SELECT COUNT(*) as n FROM links ${whereClause}`).get(...params);
|
|
125
|
+
return row?.n ?? 0;
|
|
126
|
+
},
|
|
127
|
+
/** 取未分类链接(category IS NULL)按 last_seen_at desc,scheduler handler 用。 */
|
|
128
|
+
listUnclassifiedLinks(limit = 20) {
|
|
129
|
+
return this.db.prepare(`
|
|
130
|
+
SELECT l.*, (
|
|
131
|
+
SELECT context_snippet FROM link_occurrences
|
|
132
|
+
WHERE link_id = l.id
|
|
133
|
+
ORDER BY occurred_at ASC
|
|
134
|
+
LIMIT 1
|
|
135
|
+
) AS first_context
|
|
136
|
+
FROM links l
|
|
137
|
+
WHERE l.category IS NULL
|
|
138
|
+
ORDER BY l.last_seen_at DESC
|
|
139
|
+
LIMIT ?
|
|
140
|
+
`).all(limit);
|
|
141
|
+
},
|
|
142
|
+
// ─── Tags ─────────────────────────────────────────────────────────────
|
|
143
|
+
listTagsForLink(linkId) {
|
|
144
|
+
const rows = this.db.prepare("SELECT tag FROM link_tags WHERE link_id = ? ORDER BY tag").all(linkId);
|
|
145
|
+
return rows.map((r) => r.tag);
|
|
146
|
+
},
|
|
147
|
+
// ─── Occurrences ──────────────────────────────────────────────────────
|
|
148
|
+
addLinkOccurrence(linkId, input) {
|
|
149
|
+
const id = (`occ_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`);
|
|
150
|
+
this.db.prepare(`
|
|
151
|
+
INSERT INTO link_occurrences (id, link_id, source_type, source_id, source_group_id, source_sender, context_snippet, occurred_at)
|
|
152
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
153
|
+
`).run(id, linkId, input.sourceType, input.sourceId ?? null, input.sourceGroupId ?? null, input.sourceSender ?? null, input.contextSnippet ?? null, nowBeijing());
|
|
154
|
+
},
|
|
155
|
+
listOccurrencesForLink(linkId, limit = 50) {
|
|
156
|
+
return this.db.prepare("SELECT * FROM link_occurrences WHERE link_id = ? ORDER BY occurred_at DESC LIMIT ?").all(linkId, limit);
|
|
157
|
+
},
|
|
158
|
+
// ─── Source groups(多对多)────────────────────────────────────────────
|
|
159
|
+
addLinkSourceGroup(linkId, groupId) {
|
|
160
|
+
this.db.prepare("INSERT OR IGNORE INTO link_source_groups (link_id, group_id) VALUES (?, ?)").run(linkId, groupId);
|
|
161
|
+
},
|
|
162
|
+
listSourceGroupsForLink(linkId) {
|
|
163
|
+
const rows = this.db.prepare("SELECT group_id FROM link_source_groups WHERE link_id = ?").all(linkId);
|
|
164
|
+
return rows.map((r) => r.group_id);
|
|
165
|
+
},
|
|
166
|
+
// ─── Patrol runs / logs ───────────────────────────────────────────────
|
|
167
|
+
createLinkPatrolRun(input) {
|
|
168
|
+
const now = input.startedAt ?? nowBeijing();
|
|
169
|
+
this.db.prepare(`
|
|
170
|
+
INSERT INTO link_patrol_runs
|
|
171
|
+
(run_id, patrol_group_id, patrol_issue_id, started_at, finished_at, candidates_scanned, candidates_classified, status, note)
|
|
172
|
+
VALUES (?, ?, ?, ?, NULL, ?, 0, ?, NULL)
|
|
173
|
+
`).run(input.runId, input.patrolGroupId, input.patrolIssueId ?? null, now, input.candidatesScanned ?? 0, input.status ?? "dispatched");
|
|
174
|
+
},
|
|
175
|
+
finishLinkPatrolRun(runId, status, opts) {
|
|
176
|
+
const built = buildUpdate({
|
|
177
|
+
table: "link_patrol_runs",
|
|
178
|
+
sets: {
|
|
179
|
+
candidates_classified: opts?.classified,
|
|
180
|
+
note: opts?.note,
|
|
181
|
+
},
|
|
182
|
+
where: "run_id = ?",
|
|
183
|
+
whereParams: [runId],
|
|
184
|
+
updatedAt: false,
|
|
185
|
+
extraSets: [
|
|
186
|
+
{ column: "finished_at", value: nowBeijing() },
|
|
187
|
+
{ column: "status", value: status },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
if (built)
|
|
191
|
+
this.db.prepare(built.sql).run(...built.params);
|
|
192
|
+
},
|
|
193
|
+
getLinkPatrolRunByIssueId(patrolIssueId) {
|
|
194
|
+
return this.db.prepare("SELECT * FROM link_patrol_runs WHERE patrol_issue_id = ? ORDER BY started_at DESC LIMIT 1").get(patrolIssueId);
|
|
195
|
+
},
|
|
196
|
+
getLinkPatrolRun(runId) {
|
|
197
|
+
return this.db.prepare("SELECT * FROM link_patrol_runs WHERE run_id = ?").get(runId);
|
|
198
|
+
},
|
|
199
|
+
listLinkPatrolRuns(opts) {
|
|
200
|
+
const limit = Math.min(opts?.limit ?? 50, 500);
|
|
201
|
+
const where = [];
|
|
202
|
+
const params = [];
|
|
203
|
+
if (opts?.patrolGroupId) {
|
|
204
|
+
where.push("patrol_group_id = ?");
|
|
205
|
+
params.push(opts.patrolGroupId);
|
|
206
|
+
}
|
|
207
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
208
|
+
params.push(limit);
|
|
209
|
+
return this.db.prepare(`SELECT * FROM link_patrol_runs ${whereClause} ORDER BY started_at DESC LIMIT ?`).all(...params);
|
|
210
|
+
},
|
|
211
|
+
insertLinkPatrolLog(input) {
|
|
212
|
+
this.db.prepare(`
|
|
213
|
+
INSERT INTO link_patrol_logs
|
|
214
|
+
(id, run_id, link_id, category, tags, title, rationale, raw, created_at)
|
|
215
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
216
|
+
`).run(input.id, input.runId, input.linkId ?? null, input.category, input.tags ? JSON.stringify(input.tags) : null, input.title ?? null, input.rationale ?? null, input.raw ?? null, nowBeijing());
|
|
217
|
+
},
|
|
218
|
+
listLinkPatrolLogsForRun(runId) {
|
|
219
|
+
return this.db.prepare("SELECT * FROM link_patrol_logs WHERE run_id = ? ORDER BY created_at ASC").all(runId);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* master_node — 本机 master 的身份行(单行表)。
|
|
3
|
+
*
|
|
4
|
+
* 由 OPC bootstrap 在 master 启动时调用 `upsertMasterNode` 写入,
|
|
5
|
+
* 包含 masterId(8 字符 base36,永远稳定)、hostname(显示用)、role 等。
|
|
6
|
+
*/
|
|
7
|
+
export const masterNodeMethods = {
|
|
8
|
+
getMasterNode() {
|
|
9
|
+
return this.db.prepare("SELECT * FROM master_node LIMIT 1").get();
|
|
10
|
+
},
|
|
11
|
+
/**
|
|
12
|
+
* 写入或覆盖本机 master 身份行。表是单行的(LIMIT 1),
|
|
13
|
+
* 调用方应保证一个进程只调一次(首次启动写入,之后只读)。
|
|
14
|
+
*/
|
|
15
|
+
upsertMasterNode(input) {
|
|
16
|
+
const existing = this.db.prepare("SELECT id FROM master_node LIMIT 1").get();
|
|
17
|
+
if (existing) {
|
|
18
|
+
this.db.prepare(`
|
|
19
|
+
UPDATE master_node SET
|
|
20
|
+
hostname = ?,
|
|
21
|
+
role = ?,
|
|
22
|
+
display_name = COALESCE(?, display_name),
|
|
23
|
+
endpoint = COALESCE(?, endpoint),
|
|
24
|
+
team_name = COALESCE(?, team_name),
|
|
25
|
+
updated_at = datetime('now')
|
|
26
|
+
WHERE id = ?
|
|
27
|
+
`).run(input.hostname, input.role, input.displayName ?? null, input.endpoint ?? null, input.teamName ?? null, existing.id);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.db.prepare(`
|
|
31
|
+
INSERT INTO master_node (id, hostname, role, display_name, endpoint, team_name)
|
|
32
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
33
|
+
`).run(input.id, input.hostname, input.role, input.displayName ?? null, input.endpoint ?? null, input.teamName ?? null);
|
|
34
|
+
},
|
|
35
|
+
updateMasterRole(role) {
|
|
36
|
+
this.db.prepare("UPDATE master_node SET role = ?, updated_at = datetime('now') WHERE 1=1").run(role);
|
|
37
|
+
},
|
|
38
|
+
/** 本机 master 的 hostname —— 用于 agents 复合键查询时注入 hostname。 */
|
|
39
|
+
getLocalHostname() {
|
|
40
|
+
const row = this.db.prepare("SELECT hostname FROM master_node LIMIT 1").get();
|
|
41
|
+
return row?.hostname;
|
|
42
|
+
},
|
|
43
|
+
};
|