ework-web 0.10.43 → 0.10.45
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/package.json +1 -1
- package/src/config.ts +2 -0
- package/src/db.ts +16 -0
- package/src/index.ts +69 -0
- package/src/render/layout.ts +40 -0
- package/src/schema-mysql.sql +1 -0
- package/src/schema.sql +2 -0
- package/src/static/daemon-mgr.js +30 -1
- package/src/store.ts +10 -0
- package/src/views/issueThread.ts +2 -0
- package/src/webhooks.ts +55 -1
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -82,6 +82,7 @@ export const configSchema = z.object({
|
|
|
82
82
|
internalAuthHook: z.string().default(""),
|
|
83
83
|
userAuthHook: z.string().default(""),
|
|
84
84
|
loginPage: z.string().default(""),
|
|
85
|
+
statusHook: z.string().default(""),
|
|
85
86
|
// Default "provider/model" string passed to `opencode run --model <X>`.
|
|
86
87
|
// Empty = let opencode pick per its own opencode.json + env. ework-daemon
|
|
87
88
|
// pushes this (or the per-project override) on every spawn to defend
|
|
@@ -192,6 +193,7 @@ export async function loadConfig(): Promise<Config> {
|
|
|
192
193
|
internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
|
|
193
194
|
userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
|
|
194
195
|
loginPage: process.env.WORK_LOGIN_PAGE ?? "",
|
|
196
|
+
statusHook: process.env.WORK_STATUS_HOOK ?? "",
|
|
195
197
|
defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
|
|
196
198
|
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
197
199
|
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
package/src/db.ts
CHANGED
|
@@ -128,6 +128,9 @@ function migrateIssuesTable(db: Database): void {
|
|
|
128
128
|
if (!have.has("closed_at")) {
|
|
129
129
|
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN closed_at TEXT"));
|
|
130
130
|
}
|
|
131
|
+
if (!have.has("ai_status")) {
|
|
132
|
+
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status TEXT NOT NULL DEFAULT ''"));
|
|
133
|
+
}
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
function migrateLabelsTable(db: Database): void {
|
|
@@ -354,6 +357,18 @@ async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
|
|
|
354
357
|
}
|
|
355
358
|
}
|
|
356
359
|
|
|
360
|
+
async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
|
|
361
|
+
const [cols] = await pool.query(
|
|
362
|
+
applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'ai_status'")
|
|
363
|
+
);
|
|
364
|
+
if (Array.isArray(cols) && cols.length > 0) return;
|
|
365
|
+
try {
|
|
366
|
+
await pool.query(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status VARCHAR(32) NOT NULL DEFAULT ''"));
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.warn("[db] MySQL issues ai_status column add failed:", (e as Error).message);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
357
372
|
class MysqlDriver implements AsyncDatabase {
|
|
358
373
|
readonly dialect = "mysql" as const;
|
|
359
374
|
private readonly pool: Pool;
|
|
@@ -398,6 +413,7 @@ class MysqlDriver implements AsyncDatabase {
|
|
|
398
413
|
}
|
|
399
414
|
await migrateMysqlSurrogateId(pool);
|
|
400
415
|
await migrateMysqlProjectsVisibility(pool);
|
|
416
|
+
await migrateMysqlIssuesAiStatus(pool);
|
|
401
417
|
}
|
|
402
418
|
return new MysqlDriver(pool);
|
|
403
419
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
createProject,
|
|
38
38
|
postComment,
|
|
39
39
|
setIssueState,
|
|
40
|
+
updateIssueAiStatus,
|
|
40
41
|
createAttachment,
|
|
41
42
|
getAttachment,
|
|
42
43
|
verifyUserPassword,
|
|
@@ -86,6 +87,7 @@ import {
|
|
|
86
87
|
import {
|
|
87
88
|
emitIssueEvent,
|
|
88
89
|
emitCommentEvent,
|
|
90
|
+
emitStatusChanged,
|
|
89
91
|
emitPingEvent,
|
|
90
92
|
listWebhooks,
|
|
91
93
|
createWebhook,
|
|
@@ -374,6 +376,7 @@ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
|
374
376
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
375
377
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
376
378
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
379
|
+
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume)$/;
|
|
377
380
|
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
378
381
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
379
382
|
const SESSIONS_RE = /^\/sessions$/;
|
|
@@ -564,6 +567,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
564
567
|
// /api/* routes since those would 404 on /api/v1/* paths. The shim reuses
|
|
565
568
|
// the existing ctx.user (cookie or PAT auth already resolved above).
|
|
566
569
|
if (url.pathname.startsWith("/api/v1/")) {
|
|
570
|
+
const evStatus = url.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/status$/);
|
|
571
|
+
if (evStatus && req.method === "POST") {
|
|
572
|
+
const [, owner, repo, numStr] = evStatus;
|
|
573
|
+
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
574
|
+
try {
|
|
575
|
+
const project = await getProject(owner, repo);
|
|
576
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
577
|
+
const issue = await getIssueWithMeta(project.id, Number(numStr));
|
|
578
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
579
|
+
const body = (await req.json().catch(() => ({}))) as { status?: string; detail?: string };
|
|
580
|
+
const newStatus = typeof body.status === "string" ? body.status : "";
|
|
581
|
+
const oldStatus = issue.ai_status ?? "";
|
|
582
|
+
if (newStatus === oldStatus) return json({ ok: true, status: newStatus, unchanged: true });
|
|
583
|
+
await updateIssueAiStatus(issue.id, newStatus);
|
|
584
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user?.login ?? "daemon", url.origin, body.detail);
|
|
585
|
+
return json({ ok: true, status: newStatus });
|
|
586
|
+
} catch (e) {
|
|
587
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
567
590
|
const result = await handleGiteaApi(req, url, { user: ctx.user });
|
|
568
591
|
if (result) {
|
|
569
592
|
if (result.body === null) {
|
|
@@ -1023,8 +1046,34 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1023
1046
|
|
|
1024
1047
|
const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
|
|
1025
1048
|
const daemonRestartRe = /^\/api\/daemons\/(\d+)\/restart$/;
|
|
1049
|
+
const daemonPauseRe = /^\/api\/daemons\/(\d+)\/pause$/;
|
|
1050
|
+
const daemonResumeRe = /^\/api\/daemons\/(\d+)\/resume$/;
|
|
1026
1051
|
const daemonRemoveRe = /^\/api\/daemons\/(\d+)\/remove$/;
|
|
1027
1052
|
|
|
1053
|
+
if (req.method === "POST" && (daemonPauseRe.test(url.pathname) || daemonResumeRe.test(url.pathname))) {
|
|
1054
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1055
|
+
const isPause = daemonPauseRe.test(url.pathname);
|
|
1056
|
+
const match = url.pathname.match(isPause ? daemonPauseRe : daemonResumeRe);
|
|
1057
|
+
const id = match ? Number(match[1]) : NaN;
|
|
1058
|
+
if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
|
|
1059
|
+
const rows = await getDB().all<{ endpoint: string }>(
|
|
1060
|
+
`SELECT internal_endpoint AS endpoint FROM {{d_daemons}} WHERE id = ?`, [id]
|
|
1061
|
+
);
|
|
1062
|
+
if (rows.length === 0) return json({ ok: false, error: "daemon not found" }, 404);
|
|
1063
|
+
const ep = rows[0]!.endpoint;
|
|
1064
|
+
const proto = ep.startsWith("http") ? "" : "http://";
|
|
1065
|
+
try {
|
|
1066
|
+
const upstream = await fetch(`${proto}${ep}/api/admin/${isPause ? "pause" : "resume"}`, { method: "POST" });
|
|
1067
|
+
if (!upstream.ok) {
|
|
1068
|
+
const body = await upstream.text().catch(() => "");
|
|
1069
|
+
return json({ ok: false, error: `daemon returned ${upstream.status}: ${body.slice(0, 200)}` }, 502);
|
|
1070
|
+
}
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
return json({ ok: false, error: `cannot reach daemon: ${errMsg(e)}` }, 502);
|
|
1073
|
+
}
|
|
1074
|
+
return json({ ok: true, paused: isPause });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1028
1077
|
if (req.method === "POST" && daemonRestartRe.test(url.pathname)) {
|
|
1029
1078
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1030
1079
|
const match = url.pathname.match(daemonRestartRe);
|
|
@@ -1613,6 +1662,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1613
1662
|
}
|
|
1614
1663
|
}
|
|
1615
1664
|
|
|
1665
|
+
const hp = url.pathname.match(REPO_ISSUE_HALT_RE);
|
|
1666
|
+
if (hp) {
|
|
1667
|
+
const [, owner, repo, numStr, action] = hp;
|
|
1668
|
+
if (!(owner && repo && numStr && action)) return json({ error: "bad path" }, 400);
|
|
1669
|
+
const number = Number(numStr);
|
|
1670
|
+
try {
|
|
1671
|
+
const project = await getProject(owner, repo);
|
|
1672
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1673
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
1674
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1675
|
+
const newStatus = action === "halt" ? "halted" : "";
|
|
1676
|
+
const oldStatus = issue.ai_status ?? "";
|
|
1677
|
+
await updateIssueAiStatus(issue.id, newStatus);
|
|
1678
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
|
|
1679
|
+
return json({ ok: true, status: newStatus });
|
|
1680
|
+
} catch (e) {
|
|
1681
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1616
1685
|
const ci = url.pathname.match(REPO_LIST_RE);
|
|
1617
1686
|
if (ci) {
|
|
1618
1687
|
const [, owner, repo] = ci;
|
package/src/render/layout.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface LayoutProps {
|
|
|
16
16
|
ttsEnabled?: boolean;
|
|
17
17
|
labels?: { id: number; name: string; color: string }[];
|
|
18
18
|
canEditLabels?: boolean;
|
|
19
|
+
aiStatus?: string;
|
|
20
|
+
statusHook?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export const THEME_CSS = `
|
|
@@ -128,6 +130,14 @@ header.topbar .num{opacity:.7}
|
|
|
128
130
|
.lp-name{flex:1;overflow-wrap:anywhere}
|
|
129
131
|
.lp-scope{font-size:10px;color:var(--text-muted)}
|
|
130
132
|
.lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center}
|
|
133
|
+
.ai-badge{font-size:12px;padding:.1rem .5rem;border-radius:10px;font-weight:600}
|
|
134
|
+
.ai-processing{background:#0969da;color:#fff;animation:ai-pulse 2s ease-in-out infinite}
|
|
135
|
+
.ai-halted{background:#bf8700;color:#fff}
|
|
136
|
+
.ai-completed{background:#1a7f37;color:#fff}
|
|
137
|
+
.ai-failed{background:#cf222e;color:#fff}
|
|
138
|
+
@keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
139
|
+
.halt-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#cf222e;cursor:pointer;font-weight:600}
|
|
140
|
+
.halt-btn:hover{background:#cf222e;color:#fff}
|
|
131
141
|
`;
|
|
132
142
|
|
|
133
143
|
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
@@ -146,6 +156,22 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
146
156
|
const labelPickerBtn = props.canEditLabels
|
|
147
157
|
? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
|
|
148
158
|
: "";
|
|
159
|
+
const aiBadgeHtml = (() => {
|
|
160
|
+
const s = props.aiStatus ?? "";
|
|
161
|
+
if (!s) return "";
|
|
162
|
+
const map: Record<string, { cls: string; label: string }> = {
|
|
163
|
+
processing: { cls: "ai-processing", label: "⚙️ 处理中" },
|
|
164
|
+
halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
|
|
165
|
+
completed: { cls: "ai-completed", label: "✓ 已完成" },
|
|
166
|
+
failed: { cls: "ai-failed", label: "✗ 失败" },
|
|
167
|
+
};
|
|
168
|
+
const m = map[s];
|
|
169
|
+
if (!m) return "";
|
|
170
|
+
return `<span class="ai-badge ${m.cls}">${m.label}</span>`;
|
|
171
|
+
})();
|
|
172
|
+
const haltBtnHtml = props.writesEnabled !== false && props.aiStatus === "processing"
|
|
173
|
+
? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
|
|
174
|
+
: "";
|
|
149
175
|
return `<!doctype html>
|
|
150
176
|
<html lang="zh">
|
|
151
177
|
<head>
|
|
@@ -167,6 +193,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
167
193
|
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
168
194
|
<div class="meta-status">
|
|
169
195
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
196
|
+
${aiBadgeHtml}${haltBtnHtml}
|
|
170
197
|
${labelsHtml}${labelPickerBtn}
|
|
171
198
|
<span class="count" id="count">…</span>
|
|
172
199
|
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
@@ -198,6 +225,19 @@ ${props.writesEnabled !== false
|
|
|
198
225
|
<script src="/static/app.js?v=${BUILD_ID}" defer></script>
|
|
199
226
|
${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
|
|
200
227
|
<script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
|
|
228
|
+
${haltBtnHtml ? `<script>
|
|
229
|
+
(function(){
|
|
230
|
+
var btn=document.getElementById("haltBtn");
|
|
231
|
+
if(!btn)return;
|
|
232
|
+
btn.addEventListener("click",function(){
|
|
233
|
+
if(!confirm("确认停止 AI 处理?"))return;
|
|
234
|
+
btn.disabled=true;btn.textContent="⏳ 停止中…";
|
|
235
|
+
fetch(window.location.pathname+"/halt",{method:"POST"}).then(function(r){return r.json()}).then(function(d){
|
|
236
|
+
if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent="⏹ 停止"}
|
|
237
|
+
}).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent="⏹ 停止"})
|
|
238
|
+
});
|
|
239
|
+
})();
|
|
240
|
+
</script>` : ""}
|
|
201
241
|
</body>
|
|
202
242
|
</html>`;
|
|
203
243
|
}
|
package/src/schema-mysql.sql
CHANGED
|
@@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
53
53
|
created_at VARCHAR(40) NOT NULL,
|
|
54
54
|
updated_at VARCHAR(40) NOT NULL,
|
|
55
55
|
closed_at VARCHAR(40) DEFAULT NULL,
|
|
56
|
+
ai_status VARCHAR(32) NOT NULL DEFAULT '',
|
|
56
57
|
UNIQUE (project_id, number),
|
|
57
58
|
CONSTRAINT {{fk_issues_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
58
59
|
CONSTRAINT {{fk_issues_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
|
package/src/schema.sql
CHANGED
|
@@ -63,6 +63,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
63
63
|
-- NULL when open; stamped on close, cleared on reopen. Backfill-safe because
|
|
64
64
|
-- migrateIssuesTable ADDs the column idempotently for legacy DBs.
|
|
65
65
|
closed_at TEXT,
|
|
66
|
+
-- AI processing status: '' (none) | 'processing' | 'halted' | 'completed' | 'failed'
|
|
67
|
+
ai_status TEXT NOT NULL DEFAULT '',
|
|
66
68
|
UNIQUE (project_id, number)
|
|
67
69
|
);
|
|
68
70
|
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
package/src/static/daemon-mgr.js
CHANGED
|
@@ -55,7 +55,11 @@
|
|
|
55
55
|
var stRaw = String(d.status || 'unknown').toLowerCase();
|
|
56
56
|
var actions = '';
|
|
57
57
|
if (stRaw === 'active') {
|
|
58
|
-
actions = '<button type="button" class="
|
|
58
|
+
actions = '<button type="button" class="pause" data-id="' + d.id + '">暂停</button>' +
|
|
59
|
+
'<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
|
|
60
|
+
} else if (stRaw === 'drained' || stRaw === 'paused') {
|
|
61
|
+
actions = '<button type="button" class="resume" data-id="' + d.id + '">恢复</button>' +
|
|
62
|
+
'<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
|
|
59
63
|
} else {
|
|
60
64
|
actions = '<button type="button" class="restart" data-id="' + d.id + '">重启</button>' +
|
|
61
65
|
'<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
|
|
@@ -81,6 +85,8 @@
|
|
|
81
85
|
});
|
|
82
86
|
};
|
|
83
87
|
bindBtn('button.stop', stopDaemon);
|
|
88
|
+
bindBtn('button.pause', pauseDaemon);
|
|
89
|
+
bindBtn('button.resume', resumeDaemon);
|
|
84
90
|
bindBtn('button.restart', restartDaemon);
|
|
85
91
|
bindBtn('button.remove', removeDaemon);
|
|
86
92
|
}
|
|
@@ -138,6 +144,29 @@
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
async function pauseDaemon(id) {
|
|
148
|
+
if (!confirm('确认暂停 daemon #' + id + '?\n暂停后不再接收新任务,已进行中的会话不受影响。')) return;
|
|
149
|
+
setResult('正在暂停 daemon #' + id + '…', 'loading');
|
|
150
|
+
try {
|
|
151
|
+
var res = await fetch('/api/daemons/' + id + '/pause', { method: 'POST' });
|
|
152
|
+
if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
|
|
153
|
+
var data = await res.json();
|
|
154
|
+
if (data.ok) { setResult('✓ daemon #' + id + ' 已暂停', 'ok'); await loadDaemons(); }
|
|
155
|
+
else setResult('✗ ' + (data.error || '未知错误'), 'err');
|
|
156
|
+
} catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function resumeDaemon(id) {
|
|
160
|
+
setResult('正在恢复 daemon #' + id + '…', 'loading');
|
|
161
|
+
try {
|
|
162
|
+
var res = await fetch('/api/daemons/' + id + '/resume', { method: 'POST' });
|
|
163
|
+
if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
|
|
164
|
+
var data = await res.json();
|
|
165
|
+
if (data.ok) { setResult('✓ daemon #' + id + ' 已恢复', 'ok'); await loadDaemons(); }
|
|
166
|
+
else setResult('✗ ' + (data.error || '未知错误'), 'err');
|
|
167
|
+
} catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
|
|
168
|
+
}
|
|
169
|
+
|
|
141
170
|
async function stopDaemon(id) {
|
|
142
171
|
if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
|
|
143
172
|
setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
|
package/src/store.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface IssueRow {
|
|
|
73
73
|
created_at: string;
|
|
74
74
|
updated_at: string;
|
|
75
75
|
closed_at: string | null;
|
|
76
|
+
ai_status: string;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export interface IssueWithMeta extends IssueRow {
|
|
@@ -498,6 +499,15 @@ export async function setIssueState(
|
|
|
498
499
|
});
|
|
499
500
|
}
|
|
500
501
|
|
|
502
|
+
export async function updateIssueAiStatus(issueId: number, status: string): Promise<void> {
|
|
503
|
+
await getDB().run("UPDATE {{issues}} SET ai_status = ? WHERE id = ?", [status, issueId]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function getIssueAiStatus(issueId: number): Promise<string> {
|
|
507
|
+
const row = await getDB().get<{ ai_status: string }>("SELECT ai_status FROM {{issues}} WHERE id = ?", [issueId]);
|
|
508
|
+
return row?.ai_status ?? "";
|
|
509
|
+
}
|
|
510
|
+
|
|
501
511
|
export interface IssuePatch {
|
|
502
512
|
title?: string;
|
|
503
513
|
body?: string;
|
package/src/views/issueThread.ts
CHANGED
|
@@ -130,6 +130,8 @@ export async function buildIssueThread(
|
|
|
130
130
|
ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
|
|
131
131
|
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
|
|
132
132
|
canEditLabels: cfg.writesEnabled !== false,
|
|
133
|
+
aiStatus: issue.ai_status ?? "",
|
|
134
|
+
statusHook: cfg.statusHook ?? "",
|
|
133
135
|
},
|
|
134
136
|
safeJsonEmbed(payload),
|
|
135
137
|
displayViews.map((v) => renderCommentCard(v, cfg)).join("")
|
package/src/webhooks.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type CommentRow,
|
|
33
33
|
} from "./store";
|
|
34
34
|
|
|
35
|
-
export type WebhookEventName = "issues" | "issue_comment";
|
|
35
|
+
export type WebhookEventName = "issues" | "issue_comment" | "status_changed";
|
|
36
36
|
export type IssueAction = "opened" | "closed" | "reopened";
|
|
37
37
|
|
|
38
38
|
export interface WebhookRow {
|
|
@@ -287,6 +287,7 @@ interface PayloadComment {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
interface IssueEventPayload {
|
|
290
|
+
event_id?: string;
|
|
290
291
|
action: IssueAction;
|
|
291
292
|
issue: PayloadIssue;
|
|
292
293
|
repository: PayloadRepository;
|
|
@@ -297,6 +298,7 @@ interface IssueEventPayload {
|
|
|
297
298
|
}
|
|
298
299
|
|
|
299
300
|
interface CommentEventPayload {
|
|
301
|
+
event_id?: string;
|
|
300
302
|
action: "created";
|
|
301
303
|
issue: PayloadIssue;
|
|
302
304
|
comment: PayloadComment;
|
|
@@ -304,6 +306,16 @@ interface CommentEventPayload {
|
|
|
304
306
|
sender: PayloadUser;
|
|
305
307
|
}
|
|
306
308
|
|
|
309
|
+
interface StatusChangedPayload {
|
|
310
|
+
event_id: string;
|
|
311
|
+
action: "status_changed";
|
|
312
|
+
issue: PayloadIssue;
|
|
313
|
+
repository: PayloadRepository;
|
|
314
|
+
sender: PayloadUser;
|
|
315
|
+
status: { from: string; to: string };
|
|
316
|
+
detail?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
307
319
|
function buildUser(login: string, origin: string): PayloadUser {
|
|
308
320
|
return {
|
|
309
321
|
id: 1,
|
|
@@ -615,6 +627,7 @@ export async function emitIssueEvent(
|
|
|
615
627
|
const model = resolveModel(project.model, globalDefault);
|
|
616
628
|
const labels = await toPayloadLabels(issueId);
|
|
617
629
|
const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
|
|
630
|
+
(payload as IssueEventPayload).event_id = randomUUID();
|
|
618
631
|
const rawBody = JSON.stringify(payload);
|
|
619
632
|
await fanOut(projectId, "issues", rawBody);
|
|
620
633
|
} catch (e) {
|
|
@@ -645,6 +658,7 @@ export async function emitCommentEvent(
|
|
|
645
658
|
const model = resolveModel(project.model, globalDefault);
|
|
646
659
|
const labels = await toPayloadLabels(issueId);
|
|
647
660
|
const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
|
|
661
|
+
(payload as CommentEventPayload).event_id = randomUUID();
|
|
648
662
|
const rawBody = JSON.stringify(payload);
|
|
649
663
|
await fanOut(projectId, "issue_comment", rawBody);
|
|
650
664
|
} catch (e) {
|
|
@@ -657,6 +671,46 @@ export async function emitCommentEvent(
|
|
|
657
671
|
}
|
|
658
672
|
}
|
|
659
673
|
|
|
674
|
+
export async function emitStatusChanged(
|
|
675
|
+
projectId: number,
|
|
676
|
+
issueId: number,
|
|
677
|
+
from: string,
|
|
678
|
+
to: string,
|
|
679
|
+
actor: string,
|
|
680
|
+
origin: string,
|
|
681
|
+
detail?: string
|
|
682
|
+
): Promise<void> {
|
|
683
|
+
try {
|
|
684
|
+
const project = await getProjectById(projectId);
|
|
685
|
+
if (!project) return;
|
|
686
|
+
const issue = await getIssueById(issueId);
|
|
687
|
+
if (!issue) return;
|
|
688
|
+
const commentCount = await countCommentsSafe(issueId);
|
|
689
|
+
const repo = buildRepository(project, origin);
|
|
690
|
+
const sender = buildUser(actor, origin);
|
|
691
|
+
const issuePayload = buildIssue(issue, project, commentCount, origin);
|
|
692
|
+
const payload: StatusChangedPayload = {
|
|
693
|
+
event_id: randomUUID(),
|
|
694
|
+
action: "status_changed",
|
|
695
|
+
issue: issuePayload,
|
|
696
|
+
repository: repo,
|
|
697
|
+
sender,
|
|
698
|
+
status: { from, to },
|
|
699
|
+
detail,
|
|
700
|
+
};
|
|
701
|
+
const rawBody = JSON.stringify(payload);
|
|
702
|
+
await fanOut(projectId, "status_changed", rawBody);
|
|
703
|
+
} catch (e) {
|
|
704
|
+
log.error("webhook: emitStatusChanged failed", {
|
|
705
|
+
err: e as Error,
|
|
706
|
+
projectId,
|
|
707
|
+
issueId,
|
|
708
|
+
from,
|
|
709
|
+
to,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
660
714
|
export async function emitPingEvent(projectId: number, origin: string): Promise<void> {
|
|
661
715
|
// Synthetic ping: send a small `ping` event (no issue context). Useful for
|
|
662
716
|
// verifying webhook configuration without triggering a real write.
|