ework-web 0.10.48 → 0.10.50
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/index.ts +85 -4
- package/src/render/layout.ts +24 -1
- package/src/webhooks.ts +19 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn } from "child_process";
|
|
|
5
5
|
import { homedir } from "os";
|
|
6
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
7
7
|
import type { Config } from "./config";
|
|
8
|
-
import { setConfig, initDB, getDB } from "./db";
|
|
8
|
+
import { setConfig, deleteConfig, getConfigAll, initDB, getDB } from "./db";
|
|
9
9
|
import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint } from "./coordination";
|
|
10
10
|
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
|
|
11
11
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
@@ -378,7 +378,7 @@ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
|
378
378
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
379
379
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
380
380
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
381
|
-
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume)$/;
|
|
381
|
+
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
|
|
382
382
|
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
383
383
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
384
384
|
const SESSIONS_RE = /^\/sessions$/;
|
|
@@ -1076,6 +1076,37 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1076
1076
|
return json({ ok: true, paused: isPause });
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
|
+
const daemonCapacityRe = /^\/api\/daemons\/(\d+)\/capacity$/;
|
|
1080
|
+
|
|
1081
|
+
if (req.method === "POST" && daemonCapacityRe.test(url.pathname)) {
|
|
1082
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1083
|
+
const match = url.pathname.match(daemonCapacityRe);
|
|
1084
|
+
const id = match ? Number(match[1]) : NaN;
|
|
1085
|
+
if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
|
|
1086
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
1087
|
+
const value = (payload as { value?: unknown })?.value;
|
|
1088
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
|
|
1089
|
+
return json({ error: "value must be a positive number" }, 400);
|
|
1090
|
+
}
|
|
1091
|
+
await getDB().run(`UPDATE {{d_daemons}} SET capacity = ? WHERE id = ?`, [Math.floor(value), id]);
|
|
1092
|
+
const rows = await getDB().all<{ endpoint: string }>(
|
|
1093
|
+
`SELECT internal_endpoint AS endpoint FROM {{d_daemons}} WHERE id = ?`, [id]
|
|
1094
|
+
);
|
|
1095
|
+
if (rows.length > 0) {
|
|
1096
|
+
const ep = rows[0]!.endpoint;
|
|
1097
|
+
const proto = ep.startsWith("http") ? "" : "http://";
|
|
1098
|
+
try {
|
|
1099
|
+
await fetch(`${proto}${ep}/api/admin/max-concurrent`, {
|
|
1100
|
+
method: "POST",
|
|
1101
|
+
headers: { "Content-Type": "application/json" },
|
|
1102
|
+
body: JSON.stringify({ value: Math.floor(value) }),
|
|
1103
|
+
signal: AbortSignal.timeout(3000),
|
|
1104
|
+
});
|
|
1105
|
+
} catch { /* daemon will pick up via heartbeat DB sync */ }
|
|
1106
|
+
}
|
|
1107
|
+
return json({ ok: true, capacity: Math.floor(value) });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1079
1110
|
if (req.method === "POST" && daemonRestartRe.test(url.pathname)) {
|
|
1080
1111
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1081
1112
|
const match = url.pathname.match(daemonRestartRe);
|
|
@@ -1199,6 +1230,53 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1199
1230
|
});
|
|
1200
1231
|
}
|
|
1201
1232
|
|
|
1233
|
+
// ─── Dispatch switch (global / project) ───
|
|
1234
|
+
|
|
1235
|
+
const projectDispatchRe = /^\/api\/([^/]+)\/([^/]+)\/dispatch$/;
|
|
1236
|
+
|
|
1237
|
+
if (req.method === "GET" && url.pathname === "/api/dispatch") {
|
|
1238
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1239
|
+
const cfg = await getConfigAll();
|
|
1240
|
+
const globalEnabled = cfg["dispatchEnabled"] !== "false";
|
|
1241
|
+
const projectOverrides: Record<string, boolean> = {};
|
|
1242
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
1243
|
+
if (k.startsWith("dispatchOff:") && v === "1") {
|
|
1244
|
+
projectOverrides[k.slice("dispatchOff:".length)] = false;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return json({ globalEnabled, projectOverrides });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (req.method === "POST" && url.pathname === "/api/dispatch") {
|
|
1251
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1252
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
1253
|
+
const enabled = !(payload && typeof payload === "object" && (payload as { enabled?: unknown }).enabled === false);
|
|
1254
|
+
if (enabled) {
|
|
1255
|
+
await deleteConfig("dispatchEnabled");
|
|
1256
|
+
} else {
|
|
1257
|
+
await setConfig("dispatchEnabled", "false");
|
|
1258
|
+
}
|
|
1259
|
+
return json({ ok: true, globalEnabled: enabled });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (req.method === "POST" && projectDispatchRe.test(url.pathname)) {
|
|
1263
|
+
const m = url.pathname.match(projectDispatchRe)!;
|
|
1264
|
+
const owner = m[1]!;
|
|
1265
|
+
const repo = m[2]!;
|
|
1266
|
+
const project = await getProject(owner, repo);
|
|
1267
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1268
|
+
if (!(await canWriteProject(project.id, ctx.user))) return json({ error: "insufficient permissions" }, 403);
|
|
1269
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
1270
|
+
const enabled = !(payload && typeof payload === "object" && (payload as { enabled?: unknown }).enabled === false);
|
|
1271
|
+
const key = `dispatchOff:${owner}/${repo}`;
|
|
1272
|
+
if (enabled) {
|
|
1273
|
+
await deleteConfig(key);
|
|
1274
|
+
} else {
|
|
1275
|
+
await setConfig(key, "1");
|
|
1276
|
+
}
|
|
1277
|
+
return json({ ok: true, enabled });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1202
1280
|
if (url.pathname === "/api/router/daemons" && req.method === "GET") {
|
|
1203
1281
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1204
1282
|
try {
|
|
@@ -1674,10 +1752,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1674
1752
|
if (!project) return json({ error: "project not found" }, 404);
|
|
1675
1753
|
const issue = await getIssueWithMeta(project.id, number);
|
|
1676
1754
|
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1677
|
-
const
|
|
1755
|
+
const statusMap: Record<string, string> = { halt: "halted", resume: "", "dispatch-off": "dispatch_off", "dispatch-on": "" };
|
|
1756
|
+
const newStatus = statusMap[action] ?? "";
|
|
1678
1757
|
const oldStatus = issue.ai_status ?? "";
|
|
1679
1758
|
await updateIssueAiStatus(issue.id, newStatus);
|
|
1680
|
-
|
|
1759
|
+
if (action === "halt" || action === "resume") {
|
|
1760
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
|
|
1761
|
+
}
|
|
1681
1762
|
return json({ ok: true, status: newStatus });
|
|
1682
1763
|
} catch (e) {
|
|
1683
1764
|
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
package/src/render/layout.ts
CHANGED
|
@@ -132,11 +132,14 @@ header.topbar .num{opacity:.7}
|
|
|
132
132
|
.ai-badge{font-size:12px;padding:.1rem .5rem;border-radius:10px;font-weight:600}
|
|
133
133
|
.ai-processing{background:#0969da;color:#fff;animation:ai-pulse 2s ease-in-out infinite}
|
|
134
134
|
.ai-halted{background:#bf8700;color:#fff}
|
|
135
|
+
.ai-dispatch-off{background:#6f7781;color:#fff}
|
|
135
136
|
.ai-completed{background:#1a7f37;color:#fff}
|
|
136
137
|
.ai-failed{background:#cf222e;color:#fff}
|
|
137
138
|
@keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
138
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}
|
|
139
140
|
.halt-btn:hover{background:#cf222e;color:#fff}
|
|
141
|
+
.dispatch-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#6f7781;cursor:pointer;font-weight:600}
|
|
142
|
+
.dispatch-btn:hover{background:#6f7781;color:#fff}
|
|
140
143
|
`;
|
|
141
144
|
|
|
142
145
|
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
@@ -161,6 +164,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
161
164
|
const map: Record<string, { cls: string; label: string }> = {
|
|
162
165
|
processing: { cls: "ai-processing", label: "⚙️ 处理中" },
|
|
163
166
|
halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
|
|
167
|
+
dispatch_off: { cls: "ai-dispatch-off", label: "🔕 不接单" },
|
|
164
168
|
completed: { cls: "ai-completed", label: "✓ 已完成" },
|
|
165
169
|
failed: { cls: "ai-failed", label: "✗ 失败" },
|
|
166
170
|
};
|
|
@@ -171,6 +175,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
171
175
|
const haltBtnHtml = props.writesEnabled !== false && props.aiStatus !== "halted"
|
|
172
176
|
? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
|
|
173
177
|
: "";
|
|
178
|
+
const dispatchBtnHtml = props.writesEnabled !== false
|
|
179
|
+
? props.aiStatus === "dispatch_off"
|
|
180
|
+
? `<button type="button" id="dispatchBtn" class="dispatch-btn" title="允许自动接单">🔔 接单</button>`
|
|
181
|
+
: `<button type="button" id="dispatchBtn" class="dispatch-btn" title="设为不自动接单">🔕 不接单</button>`
|
|
182
|
+
: "";
|
|
174
183
|
return `<!doctype html>
|
|
175
184
|
<html lang="zh">
|
|
176
185
|
<head>
|
|
@@ -192,7 +201,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
192
201
|
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
193
202
|
<div class="meta-status">
|
|
194
203
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
195
|
-
${aiBadgeHtml}${haltBtnHtml}
|
|
204
|
+
${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}
|
|
196
205
|
${labelsHtml}${labelPickerBtn}
|
|
197
206
|
<span class="count" id="count">…</span>
|
|
198
207
|
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
@@ -237,6 +246,19 @@ ${haltBtnHtml ? `<script>
|
|
|
237
246
|
});
|
|
238
247
|
})();
|
|
239
248
|
</script>` : ""}
|
|
249
|
+
${dispatchBtnHtml ? `<script>
|
|
250
|
+
(function(){
|
|
251
|
+
var btn=document.getElementById("dispatchBtn");
|
|
252
|
+
if(!btn)return;
|
|
253
|
+
btn.addEventListener("click",function(){
|
|
254
|
+
var off=btn.textContent.indexOf("不接单")>=0;
|
|
255
|
+
btn.disabled=true;btn.textContent="⏳ …";
|
|
256
|
+
fetch(window.location.pathname+"/"+(off?"dispatch-off":"dispatch-on"),{method:"POST"}).then(function(r){return r.json()}).then(function(d){
|
|
257
|
+
if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"}
|
|
258
|
+
}).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"})
|
|
259
|
+
});
|
|
260
|
+
})();
|
|
261
|
+
</script>` : ""}
|
|
240
262
|
</body>
|
|
241
263
|
</html>`;
|
|
242
264
|
}
|
|
@@ -256,6 +278,7 @@ export function aiStatusBadge(status: string | undefined): string {
|
|
|
256
278
|
const map: Record<string, string> = {
|
|
257
279
|
processing: '<span class="ai-status-list" style="color:var(--accent)">⚙️ 处理中</span>',
|
|
258
280
|
halted: '<span class="ai-status-list" style="color:#bf8700">⏸️ 已暂停</span>',
|
|
281
|
+
dispatch_off: '<span class="ai-status-list" style="color:#6f7781">🔕 不接单</span>',
|
|
259
282
|
completed: '<span class="ai-status-list" style="color:var(--green)">✓ 已完成</span>',
|
|
260
283
|
failed: '<span class="ai-status-list" style="color:#cf222e">✗ 失败</span>',
|
|
261
284
|
};
|
package/src/webhooks.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the full retry trail rather than an opaque final status.
|
|
17
17
|
|
|
18
18
|
import { createHmac, randomUUID } from "node:crypto";
|
|
19
|
-
import { getDB } from "./db";
|
|
19
|
+
import { getDB, getConfigAll } from "./db";
|
|
20
20
|
import { log } from "./logger";
|
|
21
21
|
import { loadConfig } from "./config";
|
|
22
22
|
import {
|
|
@@ -635,6 +635,24 @@ export async function emitIssueEvent(
|
|
|
635
635
|
if (!project) return;
|
|
636
636
|
const issue = await getIssueById(issueId);
|
|
637
637
|
if (!issue) return;
|
|
638
|
+
|
|
639
|
+
if (action === "opened") {
|
|
640
|
+
const cfg = await getConfigAll();
|
|
641
|
+
const scopeKey = `${project.owner}/${project.name}`;
|
|
642
|
+
if (cfg["dispatchEnabled"] === "false") {
|
|
643
|
+
log.info(`webhook: global dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (cfg[`dispatchOff:${scopeKey}`] === "1") {
|
|
647
|
+
log.info(`webhook: project dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if ((issue as IssueRow).ai_status === "dispatch_off") {
|
|
651
|
+
log.info(`webhook: issue dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
638
656
|
const commentCount = await countCommentsSafe(issueId);
|
|
639
657
|
// Resolve model: project override > global default. Empty string = no
|
|
640
658
|
// override (daemon omits --model, lets opencode pick). globalDefault
|