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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.48",
3
+ "version": "0.10.50",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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 newStatus = action === "halt" ? "halted" : "";
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
- void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
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);
@@ -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