ework-web 0.10.108 → 0.10.110

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.108",
3
+ "version": "0.10.110",
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",
@@ -36,7 +36,7 @@ interface RouterDaemonRow {
36
36
 
37
37
  let routerCache: { at: number; rows: RouterDaemonRow[] } | null = null;
38
38
 
39
- async function routerDaemons(): Promise<RouterDaemonRow[]> {
39
+ export async function routerDaemons(): Promise<RouterDaemonRow[]> {
40
40
  if (routerCache && Date.now() - routerCache.at < 15_000) return routerCache.rows;
41
41
  try {
42
42
  const cfg = await loadConfig();
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { homedir } from "os";
8
8
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
9
9
  import type { Config } from "./config";
10
10
  import { setConfig, deleteConfig, getConfigAll, initDB, getDB } from "./db";
11
- import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint, getRunningSessionsForProject, resolveSessionUid } from "./coordination";
11
+ import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint, getRunningSessionsForProject, resolveSessionUid, routerDaemons } from "./coordination";
12
12
  import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
13
13
  import type { MysqlTargetOpts } from "./db-admin";
14
14
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
@@ -402,7 +402,7 @@ const REPO_AI_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai$/;
402
402
  const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
403
403
  const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
404
404
  const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
405
- const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
405
+ const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on|reset-session)$/;
406
406
  const REPO_ISSUE_MODEL_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/model$/;
407
407
  const REPO_ISSUE_RUNTIME_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/runtime$/;
408
408
  const REPO_ISSUE_STATUS_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/ai-status$/;
@@ -611,7 +611,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
611
611
  const projectOff = cfgKv[`dispatchOff:${owner}/${repo}`] === "1";
612
612
  const aiStatus = issue?.ai_status ?? "";
613
613
  const issueOff = aiStatus === "dispatch_off" || aiStatus === "halted";
614
- return json({ dispatchOff: globalOff || projectOff || issueOff, aiStatus });
614
+ const sessionResetMs = Number(cfgKv[`sessionReset:${owner}/${repo}#${number}`]) || null;
615
+ return json({ dispatchOff: globalOff || projectOff || issueOff, aiStatus, sessionResetMs });
615
616
  }
616
617
  if (url.pathname === "/api/v1/wake-logins" && req.method === "GET") {
617
618
  const owner = url.searchParams.get("owner") ?? "";
@@ -757,6 +758,23 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
757
758
  const limit = Math.min(5000, Math.max(1, Number(url.searchParams.get("limit")) || 30));
758
759
  const daemonEp = url.searchParams.get("daemon");
759
760
  const daemonIdParam = url.searchParams.get("daemon_id");
761
+ // daemon_id is only a hint: ids churn across daemon reinstalls and the
762
+ // {{d_*}} mirror tables exist only in split-DB mysql deployments, so a
763
+ // stale hint must fall back to scanning daemons that hold the session.
764
+ const tryAllDaemons = async (): Promise<Response | null> => {
765
+ if (!/^ses_[0-9A-Za-z]{8,}/.test(sid)) return null;
766
+ let registry: Array<{ endpoint?: string }> = [];
767
+ try { registry = await routerDaemons(); } catch { registry = []; }
768
+ for (const d of registry) {
769
+ if (!d.endpoint) continue;
770
+ try {
771
+ const c = new RemoteOpencodeClient(d.endpoint);
772
+ const { html: body } = await buildSessionView(c, sid, desc, cfg.collapseLines, limit, all);
773
+ return html(body);
774
+ } catch { /* daemon does not hold this session — try next */ }
775
+ }
776
+ return null;
777
+ };
760
778
  if (!daemonEp && daemonIdParam) {
761
779
  const id = Number(daemonIdParam);
762
780
  if (Number.isFinite(id) && id > 0) {
@@ -775,6 +793,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
775
793
  } catch (e) {
776
794
  const status = e instanceof OpencodeError ? e.status : 500;
777
795
  if (status === 404) {
796
+ const scanned = await tryAllDaemons();
797
+ if (scanned) return scanned;
778
798
  return html(errorPage(
779
799
  "会话不在该 daemon 上",
780
800
  `会话 ${sid} 在 daemon ${effectiveId} (${ep}) 上找不到 (HTTP ${status})。\n\n` +
@@ -785,6 +805,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
785
805
  }
786
806
  throw e;
787
807
  }
808
+ } else {
809
+ const scanned = await tryAllDaemons();
810
+ if (scanned) return scanned;
788
811
  }
789
812
  }
790
813
  }
@@ -1911,6 +1934,13 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1911
1934
  const issue = await getIssueWithMeta(project.id, number);
1912
1935
  if (!issue) return json({ error: "issue not found" }, 404);
1913
1936
  const statusMap: Record<string, string> = { halt: "halted", resume: "", "dispatch-off": "dispatch_off", "dispatch-on": "" };
1937
+ if (action === "reset-session") {
1938
+ if (!(await canAdminProject(project.id, ctx.user))) {
1939
+ return json({ error: "admin role required" }, 403);
1940
+ }
1941
+ await setConfig(`sessionReset:${owner}/${repo}#${number}`, String(Date.now()));
1942
+ return json({ ok: true, reset: true });
1943
+ }
1914
1944
  const newStatus = statusMap[action] ?? "";
1915
1945
  const oldStatus = issue.ai_status ?? "";
1916
1946
  await updateIssueAiStatus(issue.id, newStatus);
@@ -214,6 +214,9 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
214
214
  ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 恢复接单</button>`
215
215
  : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="关闭此 issue 的自动接单">🔕 暂停接单</button>`
216
216
  : "";
217
+ const resetBtnHtml = showActions
218
+ ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/reset-session" data-action-confirm="重置 AI 会话?下次触发将从全新会话开始(历史评论保留)" title="重置 AI 会话(下次触发开新会话)">🔄 重置会话</button>`
219
+ : "";
217
220
  const runtimeSelectHtml = props.runtimeSelect && showActions
218
221
  ? `<span class="model-select-wrap"><select class="model-select" id="issueRuntimeSelect" title="此 issue 的运行时(新会话生效)">
219
222
  <option value="" ${props.runtimeSelect.current === "" ? "selected" : ""}>默认运行时</option>
@@ -251,7 +254,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
251
254
  <div class="meta-status">
252
255
  <span class="state-badge ${stateClass}">${stateLabel}</span>
253
256
  ${aiBadgeHtml}
254
- ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${runtimeSelectHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
257
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${resetBtnHtml}${runtimeSelectHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
255
258
  const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
256
259
  if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
257
260
  if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);