ework-web 0.10.20 → 0.10.22

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.20",
3
+ "version": "0.10.22",
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",
@@ -68,3 +68,38 @@ export async function listAllDaemons(): Promise<DaemonDetail[]> {
68
68
  return [];
69
69
  }
70
70
  }
71
+
72
+ export interface SessionDaemonInfo {
73
+ daemonId: number;
74
+ displayName: string;
75
+ endpoint: string;
76
+ }
77
+
78
+ export async function getSessionDaemonMap(): Promise<Map<string, SessionDaemonInfo>> {
79
+ try {
80
+ const rows = await getDB().all<{
81
+ opencode_session_id: string;
82
+ daemon_id: number;
83
+ display_name: string | null;
84
+ internal_endpoint: string | null;
85
+ }>(
86
+ `SELECT s.opencode_session_id, d.id AS daemon_id, d.display_name, d.internal_endpoint
87
+ FROM {{d_op_sessions}} s
88
+ JOIN {{d_issues}} i ON i.uid = s.issue_id
89
+ LEFT JOIN {{d_daemons}} d ON d.id = i.owner_daemon_id
90
+ WHERE s.opencode_session_id IS NOT NULL AND s.opencode_session_id != ''`,
91
+ );
92
+ const map = new Map<string, SessionDaemonInfo>();
93
+ for (const r of rows) {
94
+ map.set(r.opencode_session_id, {
95
+ daemonId: r.daemon_id,
96
+ displayName: r.display_name ?? `daemon-${r.daemon_id}`,
97
+ endpoint: r.internal_endpoint ?? "",
98
+ });
99
+ }
100
+ return map;
101
+ } catch (e) {
102
+ log.info(`coordination: session-daemon map failed (${e instanceof Error ? e.message : String(e)})`);
103
+ return new Map();
104
+ }
105
+ }
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, initDB, getDB } from "./db";
9
- import { getActiveDaemons, listAllDaemons } from "./coordination";
9
+ import { getActiveDaemons, listAllDaemons, getSessionDaemonMap } 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";
@@ -68,6 +68,7 @@ import {
68
68
  archiveLabel,
69
69
  deleteLabel,
70
70
  setIssueLabels,
71
+ listAllProjectIds,
71
72
  type ProjectRole,
72
73
  type UserRow,
73
74
  } from "./store";
@@ -127,6 +128,8 @@ async function refreshOpencodeClient(): Promise<void> {
127
128
  await refreshOpencodeClient();
128
129
  setInterval(refreshOpencodeClient, 10_000);
129
130
 
131
+ void autoWireAllProjects(`http://${cfg.host}:${cfg.port}`);
132
+
130
133
  async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
131
134
  if (!cfg.autowireActive) {
132
135
  log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
@@ -160,6 +163,24 @@ async function autoWireDaemon(projectId: number, origin: string): Promise<void>
160
163
  }
161
164
  }
162
165
 
166
+ let backfillStarted = false;
167
+ async function autoWireAllProjects(origin: string): Promise<void> {
168
+ if (backfillStarted) return;
169
+ backfillStarted = true;
170
+ if (!cfg.autowireActive || (!cfg.daemonBotLogin.trim() && !cfg.daemonWebhookUrl.trim())) return;
171
+ try {
172
+ const ids = await listAllProjectIds();
173
+ let wired = 0;
174
+ for (const id of ids) {
175
+ await autoWireDaemon(id, origin);
176
+ wired++;
177
+ }
178
+ if (wired > 0) log.info("autoWireAllProjects: backfilled", { count: wired });
179
+ } catch (e) {
180
+ log.warn("autoWireAllProjects failed", { err: e as Error });
181
+ }
182
+ }
183
+
163
184
  const SEC_HEADERS: Record<string, string> = {
164
185
  "content-security-policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
165
186
  "x-content-type-options": "nosniff",
@@ -561,7 +582,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
561
582
  const form = await req.formData().catch(() => new FormData());
562
583
  const f: Record<string, string | undefined> = {};
563
584
  for (const [k, v] of form.entries()) f[k] = typeof v === "string" ? v : undefined;
564
- const r = await handleCreateProject(f);
585
+ const r = await handleCreateProject(f, cfg.defaultModel);
565
586
  if (r.projectId) {
566
587
  await ensureProjectBootstrapAdmin(r.projectId, ctx.user!.login);
567
588
  await autoWireDaemon(r.projectId, url.origin);
@@ -589,7 +610,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
589
610
  if (url.pathname.match(SESSIONS_RE)) {
590
611
  const q = url.searchParams.get("q")?.trim() ?? "";
591
612
  try {
592
- const { html: body } = await buildSessionList(opencode, q);
613
+ const daemonMap = await getSessionDaemonMap();
614
+ const { html: body } = await buildSessionList(opencode, q, daemonMap);
593
615
  return html(body);
594
616
  } catch (e) {
595
617
  return html(errorPage("加载失败", errMsg(e)), e instanceof OpencodeError ? e.status : 502);
@@ -973,6 +995,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
973
995
  return json({ ok: true });
974
996
  }
975
997
 
998
+ if (req.method === "POST" && url.pathname === "/api/daemons/wire-all") {
999
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1000
+ if (!rateLimit(`daemons-wire:${ip}`, 5, 5 / 3600)) return json({ error: "rate limited" }, 429);
1001
+ try {
1002
+ const ids = await listAllProjectIds();
1003
+ for (const id of ids) await autoWireDaemon(id, url.origin);
1004
+ return json({ ok: true, count: ids.length });
1005
+ } catch (e) {
1006
+ return json({ ok: false, error: errMsg(e) });
1007
+ }
1008
+ }
1009
+
976
1010
  if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
977
1011
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
978
1012
  if (!rateLimit(`daemons-deploy:${ip}`, 10, 10 / 3600)) return json({ error: "rate limited" }, 429);
@@ -1931,10 +1965,7 @@ function parseState(s: string | null): "open" | "closed" | "all" {
1931
1965
  async function createProjectSafe(owner: string, name: string) {
1932
1966
  let project = await getProject(owner, name);
1933
1967
  if (project) return project;
1934
- // Auto-create project on first issue POST. Owner/name were already validated by the
1935
- // URL regex shape; allow creation here so `/<owner>/<new-repo>/issues` (POST) bootstraps
1936
- // a project in one step. Use createIssue's tx for atomicity.
1937
- project = await createProject(owner, name, "");
1968
+ project = await createProject(owner, name, "", cfg.defaultModel);
1938
1969
  return project;
1939
1970
  }
1940
1971
 
package/src/opencode.ts CHANGED
@@ -29,6 +29,7 @@ export interface SessionListItem {
29
29
  directory?: string;
30
30
  peakTokens?: number;
31
31
  msgCount?: number;
32
+ daemon?: { displayName: string; endpoint: string };
32
33
  }
33
34
 
34
35
  export interface SessionInfo {
package/src/store.ts CHANGED
@@ -171,7 +171,12 @@ export async function listProjectsWithCounts(): Promise<ProjectWithCounts[]> {
171
171
  );
172
172
  }
173
173
 
174
- export async function createProject(owner: string, name: string, description: string): Promise<ProjectRow> {
174
+ export async function listAllProjectIds(): Promise<number[]> {
175
+ const rows = await getDB().all<{ id: number }>("SELECT id FROM {{projects}} ORDER BY id");
176
+ return rows.map((r) => r.id);
177
+ }
178
+
179
+ export async function createProject(owner: string, name: string, description: string, model?: string): Promise<ProjectRow> {
175
180
  owner = owner.trim();
176
181
  name = name.trim();
177
182
  if (!/^[A-Za-z0-9_.-]+$/.test(owner)) throw new StoreError(400, "owner 含非法字符");
@@ -180,8 +185,8 @@ export async function createProject(owner: string, name: string, description: st
180
185
  const ts = now();
181
186
  const db = getDB();
182
187
  const info = await db.run(
183
- "INSERT INTO {{projects}} (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
184
- [owner, name, description ?? "", ts, ts]
188
+ "INSERT INTO {{projects}} (owner, name, description, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
189
+ [owner, name, description ?? "", model ?? "", ts, ts]
185
190
  );
186
191
  return (await getProjectById(info.insertId))!;
187
192
  }
package/src/views/home.ts CHANGED
@@ -92,14 +92,15 @@ ${tabNavHTML("projects")}
92
92
  }
93
93
 
94
94
  export async function handleCreateProject(
95
- form: Record<string, string | undefined>
95
+ form: Record<string, string | undefined>,
96
+ defaultModel: string,
96
97
  ): Promise<{ location: string; error?: string; projectId?: number }> {
97
98
  const owner = (form.owner ?? "").trim();
98
99
  const name = (form.name ?? "").trim();
99
100
  const description = (form.description ?? "").trim();
100
101
  if (!owner || !name) return { location: "/projects", error: "owner 和 name 必填" };
101
102
  try {
102
- const p = await createProject(owner, name, description);
103
+ const p = await createProject(owner, name, description, defaultModel);
103
104
  return { location: `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`, projectId: p.id };
104
105
  } catch (e) {
105
106
  return {
@@ -1,4 +1,5 @@
1
1
  import type { OpencodeClientInterface, SessionListItem, SessionExport, SessionMessage, MessagePart, ToolState } from "../opencode";
2
+ import type { SessionDaemonInfo } from "../coordination";
2
3
  import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
3
4
  import { renderMarkdown, linkifySessionIDs, linkifyAbsPaths } from "../render/markdown";
4
5
  import { BUILD_ID } from "../build";
@@ -8,8 +9,19 @@ import { homedir } from "os";
8
9
 
9
10
  const LIST_LIMIT = 100;
10
11
 
11
- export async function buildSessionList(client: OpencodeClientInterface, q: string): Promise<{ html: string }> {
12
+ export async function buildSessionList(
13
+ client: OpencodeClientInterface,
14
+ q: string,
15
+ daemonMap?: Map<string, SessionDaemonInfo>,
16
+ ): Promise<{ html: string }> {
12
17
  let sessions = await client.listSessions(LIST_LIMIT);
18
+ if (daemonMap) {
19
+ sessions = sessions.map((s) => {
20
+ const di = daemonMap.get(s.id);
21
+ if (di) return { ...s, daemon: { displayName: di.displayName, endpoint: di.endpoint } };
22
+ return s;
23
+ });
24
+ }
13
25
  const needle = q.trim().toLowerCase();
14
26
  if (needle) {
15
27
  sessions = sessions.filter((s) => s.title.toLowerCase().includes(needle) || s.id.toLowerCase().includes(needle));
@@ -32,6 +44,7 @@ export async function buildSessionList(client: OpencodeClientInterface, q: strin
32
44
  .srow .st{font-weight:600;color:var(--text);font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
33
45
  .srow .sm{display:flex;gap:1rem;color:var(--text-muted);font-size:12px;margin-top:.25rem;flex-wrap:wrap}
34
46
  .srow .sid{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
47
+ .sd-badge{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:0 .3rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
35
48
  .empty{padding:2rem;text-align:center;color:var(--text-muted)}
36
49
  </style></head><body>
37
50
  <header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · OpenCode 会话 ${sessions.length}</span></header>
@@ -50,6 +63,7 @@ function sessionRow(s: SessionListItem): string {
50
63
  const badges = [
51
64
  s.peakTokens ? `<span>🧮 峰值 ${kfmt(s.peakTokens)}</span>` : "",
52
65
  s.msgCount ? `<span>💬 ${s.msgCount}</span>` : "",
66
+ s.daemon ? `<span class="sd-badge" title="${escapeAttr(s.daemon.endpoint)}">🖥️ ${escapeHtml(s.daemon.displayName)} ${escapeHtml(s.daemon.endpoint)}</span>` : "",
53
67
  ].join("");
54
68
  return `<a class="srow" href="${escapeAttr(href)}">
55
69
  <div class="st">${escapeHtml(s.title)}</div>