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 +1 -1
- package/src/coordination.ts +35 -0
- package/src/index.ts +38 -7
- package/src/opencode.ts +1 -0
- package/src/store.ts +8 -3
- package/src/views/home.ts +3 -2
- package/src/views/sessionLog.ts +15 -1
package/package.json
CHANGED
package/src/coordination.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
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
|
|
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 {
|
package/src/views/sessionLog.ts
CHANGED
|
@@ -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(
|
|
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>
|