ework-web 0.10.61 → 0.10.63

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.61",
3
+ "version": "0.10.63",
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",
@@ -118,3 +118,39 @@ export async function resolveDaemonEndpoint(daemonId: number): Promise<string |
118
118
  return null;
119
119
  }
120
120
  }
121
+
122
+ export interface RunningSessionInfo {
123
+ issueNumber: string;
124
+ sessionId: string;
125
+ daemonId: number;
126
+ }
127
+
128
+ /**
129
+ * Query daemon DB for sessions with state='running' belonging to a specific project scope.
130
+ * Uses REAL daemon session state instead of the display-only ai_status field.
131
+ */
132
+ export async function getRunningSessionsForProject(scopeKey: string): Promise<RunningSessionInfo[]> {
133
+ try {
134
+ const rows = await getDB().all<{
135
+ tracker_issue_id: string;
136
+ opencode_session_id: string;
137
+ daemon_id: number;
138
+ }>(
139
+ `SELECT i.tracker_issue_id, s.opencode_session_id, i.owner_daemon_id AS daemon_id
140
+ FROM {{d_op_sessions}} s
141
+ JOIN {{d_issues}} i ON i.uid = s.issue_id
142
+ WHERE s.state = 'running'
143
+ AND i.tracker_scope_key = ?
144
+ AND s.opencode_session_id IS NOT NULL`,
145
+ [scopeKey],
146
+ );
147
+ return rows.map((r) => ({
148
+ issueNumber: r.tracker_issue_id,
149
+ sessionId: r.opencode_session_id,
150
+ daemonId: r.daemon_id,
151
+ }));
152
+ } catch (e) {
153
+ log.info(`coordination: running-sessions query failed (${e instanceof Error ? e.message : String(e)})`);
154
+ return [];
155
+ }
156
+ }
package/src/giteaApi.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // bg audit) plus a few cheap stubs (/version, /user, /repos/:o/:r) that
13
13
  // Gitea clients commonly hit during connection bootstrap.
14
14
 
15
- import { getDB } from "./db";
15
+ import { getDB, getConfigAll } from "./db";
16
16
  import {
17
17
  StoreError,
18
18
  getProject,
@@ -32,6 +32,8 @@ import {
32
32
  listReactionsFor,
33
33
  canWriteProject,
34
34
  canReadProject,
35
+ ensureUser,
36
+ getUserByLogin,
35
37
  type UserRow,
36
38
  } from "./store";
37
39
  import {
@@ -95,6 +97,12 @@ function asContent(v: unknown): string | undefined {
95
97
  return trimmed;
96
98
  }
97
99
 
100
+ async function canSudo(u: UserRow): Promise<boolean> {
101
+ const cfg = await getConfigAll();
102
+ const allow = (cfg["sudoLogins"] ?? "").split(",").map((s: string) => s.trim()).filter(Boolean);
103
+ return allow.includes(u.login);
104
+ }
105
+
98
106
  export async function handleGiteaApi(
99
107
  req: Request,
100
108
  url: URL,
@@ -103,8 +111,30 @@ export async function handleGiteaApi(
103
111
  const path = url.pathname;
104
112
  if (!path.startsWith("/api/v1/")) return null;
105
113
  const origin = new URL(req.url).origin;
106
- const user = ctx.user;
107
- if (!user) return giteaError(401, "requires authentication");
114
+ const caller = ctx.user;
115
+ if (!caller) return giteaError(401, "requires authentication");
116
+
117
+ // ─── Sudo: Gitea convention for impersonation ───
118
+ // Allows bridge/integration processes to post on behalf of real users.
119
+ // Permission gated by config KV `sudoLogins` whitelist (not is_admin — bridges
120
+ // should not be admins). Sudo-Kind header controls kind for first-creation
121
+ // only; existing users' kind is never changed via sudo.
122
+ const sudoLoginRaw = req.headers.get("Sudo") ?? url.searchParams.get("sudo");
123
+ const sudoLogin = sudoLoginRaw?.trim() || null;
124
+ let user: UserRow = caller;
125
+ if (sudoLogin && sudoLogin !== caller.login) {
126
+ if (!(await canSudo(caller))) return giteaError(403, "sudo requires permission");
127
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(sudoLogin)) return giteaError(400, "invalid sudo login");
128
+ const sudoKindRaw = req.headers.get("Sudo-Kind")?.trim();
129
+ const existing = await getUserByLogin(sudoLogin);
130
+ if (existing) {
131
+ user = existing;
132
+ } else {
133
+ const kind = sudoKindRaw === "bot" || sudoKindRaw === "system" ? sudoKindRaw : "human";
134
+ user = await ensureUser(sudoLogin, kind);
135
+ }
136
+ if (!user.is_active) return giteaError(403, "sudo target inactive");
137
+ }
108
138
 
109
139
  if (ROUTES.version.test(path) && req.method === "GET") {
110
140
  return { status: 200, body: { version: "1.22.0" } };
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "os";
6
6
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
7
7
  import type { Config } from "./config";
8
8
  import { setConfig, deleteConfig, getConfigAll, initDB, getDB } from "./db";
9
- import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint } from "./coordination";
9
+ import { getActiveDaemons, listAllDaemons, getSessionDaemonMap, resolveDaemonEndpoint, getRunningSessionsForProject } from "./coordination";
10
10
  import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql, generateMysqlDDL } from "./db-admin";
11
11
  import type { MysqlTargetOpts } from "./db-admin";
12
12
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
@@ -1311,6 +1311,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1311
1311
  return json({ ok: true });
1312
1312
  }
1313
1313
 
1314
+ if (url.pathname === "/api/sudo-policy" && req.method === "GET") {
1315
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1316
+ const cfg = await getConfigAll();
1317
+ const list = (k: string, d: string) => (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
1318
+ return json({ sudoLogins: list("sudoLogins", "") });
1319
+ }
1320
+
1321
+ if (url.pathname === "/api/sudo-policy" && req.method === "POST") {
1322
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1323
+ const payload = await req.json().catch(() => ({} as Record<string, unknown>));
1324
+ const join = (v: unknown) => Array.isArray(v) ? v.filter((s) => typeof s === "string").join(",") : (typeof v === "string" ? v : "");
1325
+ const sl = join((payload as { sudoLogins?: unknown }).sudoLogins);
1326
+ if (sl) await setConfig("sudoLogins", sl); else await deleteConfig("sudoLogins");
1327
+ return json({ ok: true });
1328
+ }
1329
+
1314
1330
  if (url.pathname === "/api/router/daemons" && req.method === "GET") {
1315
1331
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1316
1332
  try {
@@ -1978,12 +1994,17 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1978
1994
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1979
1995
  }
1980
1996
  const issues = await listIssues(project.id, { state: "all" });
1981
- const processing = issues.filter((it) => it.ai_status === "processing");
1982
- for (const it of processing) {
1983
- await updateIssueAiStatus(it.id, "halted");
1984
- void emitStatusChanged(project.id, it.number, "processing", "halted", ctx.user!.login, url.origin);
1997
+ const running = await getRunningSessionsForProject(`${owner}/${repo}`);
1998
+ const issueIds = new Set<number>();
1999
+ for (const r of running) {
2000
+ const it = issues.find((i) => String(i.number) === r.issueNumber);
2001
+ if (it) issueIds.add(it.id);
2002
+ }
2003
+ for (const id of issueIds) {
2004
+ await updateIssueAiStatus(id, "halted");
2005
+ void emitStatusChanged(project.id, id, "processing", "halted", ctx.user!.login, url.origin);
1985
2006
  }
1986
- return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已停止 ${processing.length} 个运行中AI会话`)}`, 303);
2007
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`已停止 ${issueIds.size} 个运行中AI会话`)}`, 303);
1987
2008
  }
1988
2009
 
1989
2010
  const settingsDispatchMatch = url.pathname.match(SETTINGS_DISPATCH_RE);
package/src/webhooks.ts CHANGED
@@ -756,7 +756,7 @@ export async function emitStatusChanged(
756
756
  const project = await getProjectById(projectId);
757
757
  if (!project) return;
758
758
  const issue = await getIssueById(issueId);
759
- if (!issue) return;
759
+ if (!issue) { log.warn(`emitStatusChanged: issue ${issueId} not found (project=${projectId}) — silent skip prevented`); return; }
760
760
  const commentCount = await countCommentsSafe(issueId);
761
761
  const repo = buildRepository(project, origin);
762
762
  const sender = buildUser(actor, origin);