ework-web 0.10.100 → 0.10.102
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 +53 -7
- package/src/index.ts +22 -6
- package/src/render/layout.ts +2 -2
- package/src/static/app.js +10 -0
package/package.json
CHANGED
package/src/coordination.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getDB } from "./db";
|
|
2
2
|
import { log } from "./logger";
|
|
3
|
+
import { loadConfig } from "./config";
|
|
3
4
|
|
|
4
5
|
export interface DaemonInfo {
|
|
5
6
|
id: number;
|
|
@@ -19,6 +20,50 @@ export interface DaemonDetail {
|
|
|
19
20
|
|
|
20
21
|
const HEARTBEAT_STALE_MS = 120_000;
|
|
21
22
|
|
|
23
|
+
// Split-UID deployments keep the daemon registry in the daemon's own SQLite
|
|
24
|
+
// DB, unreachable (and table-absent) from the web process — the \{\{d_*\}\}
|
|
25
|
+
// queries then fail and every lookup silently degrades to "no daemons".
|
|
26
|
+
// Fall back to the router's registry API, which holds the same rows and is
|
|
27
|
+
// the natural source of truth when web and daemon share no database.
|
|
28
|
+
interface RouterDaemonRow {
|
|
29
|
+
id: number;
|
|
30
|
+
displayName: string;
|
|
31
|
+
endpoint: string;
|
|
32
|
+
capacity: number;
|
|
33
|
+
lastHeartbeat: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let routerCache: { at: number; rows: RouterDaemonRow[] } | null = null;
|
|
38
|
+
|
|
39
|
+
async function routerDaemons(): Promise<RouterDaemonRow[]> {
|
|
40
|
+
if (routerCache && Date.now() - routerCache.at < 15_000) return routerCache.rows;
|
|
41
|
+
try {
|
|
42
|
+
const cfg = await loadConfig();
|
|
43
|
+
if (!cfg.daemonWebhookUrl) return [];
|
|
44
|
+
const res = await fetch(`${cfg.daemonWebhookUrl.replace(/\/$/, "")}/api/daemons`, {
|
|
45
|
+
signal: AbortSignal.timeout(5000),
|
|
46
|
+
});
|
|
47
|
+
const data = (await res.json()) as { daemons?: Array<Partial<RouterDaemonRow>> };
|
|
48
|
+
const rows: RouterDaemonRow[] = (data.daemons ?? [])
|
|
49
|
+
.filter((d): d is Partial<RouterDaemonRow> & { id: number; endpoint: string } =>
|
|
50
|
+
typeof d.id === "number" && typeof d.endpoint === "string")
|
|
51
|
+
.map((d) => ({
|
|
52
|
+
id: d.id,
|
|
53
|
+
displayName: typeof d.displayName === "string" ? d.displayName : `daemon-${d.id}`,
|
|
54
|
+
endpoint: d.endpoint,
|
|
55
|
+
capacity: typeof d.capacity === "number" ? d.capacity : 0,
|
|
56
|
+
lastHeartbeat: typeof d.lastHeartbeat === "string" ? d.lastHeartbeat : "",
|
|
57
|
+
status: typeof d.status === "string" ? d.status : "unknown",
|
|
58
|
+
}));
|
|
59
|
+
routerCache = { at: Date.now(), rows };
|
|
60
|
+
return rows;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
log.info(`coordination: router registry fallback failed (${e instanceof Error ? e.message : String(e)})`);
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export async function getActiveDaemons(): Promise<DaemonInfo[]> {
|
|
23
68
|
const stale = new Date(Date.now() - HEARTBEAT_STALE_MS);
|
|
24
69
|
const staleStr = stale.toISOString().slice(0, 19).replace("T", " ");
|
|
@@ -130,19 +175,20 @@ export async function getSessionDaemonMap(): Promise<Map<string, SessionDaemonIn
|
|
|
130
175
|
|
|
131
176
|
export async function resolveDaemonEndpoint(daemonId: number): Promise<string | null> {
|
|
132
177
|
try {
|
|
133
|
-
const
|
|
134
|
-
|
|
178
|
+
const local = await getDB().all<{ internal_endpoint: string }>(
|
|
179
|
+
"SELECT internal_endpoint FROM {{d_daemons}} WHERE id = ? LIMIT 1",
|
|
135
180
|
[daemonId],
|
|
136
181
|
);
|
|
137
|
-
const ep =
|
|
138
|
-
if (
|
|
139
|
-
return ep;
|
|
182
|
+
const ep = local[0]?.internal_endpoint;
|
|
183
|
+
if (ep) return ep;
|
|
140
184
|
} catch (e) {
|
|
141
|
-
log.info(`coordination:
|
|
142
|
-
return null;
|
|
185
|
+
log.info(`coordination: local daemon lookup failed (${e instanceof Error ? e.message : String(e)})`);
|
|
143
186
|
}
|
|
187
|
+
const row = (await routerDaemons()).find((d) => d.id === daemonId);
|
|
188
|
+
return row ? row.endpoint : null;
|
|
144
189
|
}
|
|
145
190
|
|
|
191
|
+
|
|
146
192
|
export interface RunningSessionInfo {
|
|
147
193
|
issueNumber: string;
|
|
148
194
|
sessionId: string;
|
package/src/index.ts
CHANGED
|
@@ -208,7 +208,6 @@ const SEC_HEADERS: Record<string, string> = {
|
|
|
208
208
|
"x-content-type-options": "nosniff",
|
|
209
209
|
"x-frame-options": "DENY",
|
|
210
210
|
"referrer-policy": "same-origin",
|
|
211
|
-
"permissions-policy": "()",
|
|
212
211
|
};
|
|
213
212
|
|
|
214
213
|
function buildCsp(cfg: Config): string {
|
|
@@ -1828,11 +1827,19 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1828
1827
|
const [, owner, repo, numStr] = cp;
|
|
1829
1828
|
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
1830
1829
|
const number = Number(numStr);
|
|
1830
|
+
const isFormSubmit = (req.headers.get("content-type") || "").includes("application/x-www-form-urlencoded");
|
|
1831
1831
|
try {
|
|
1832
|
-
const payload =
|
|
1833
|
-
|
|
1832
|
+
const payload = isFormSubmit
|
|
1833
|
+
? ((await req.formData().catch(() => new FormData())) as FormData)
|
|
1834
|
+
: ((await req.json().catch(() => ({}))) as { body?: unknown; close?: unknown; reopen?: unknown });
|
|
1835
|
+
const body = typeof (payload as FormData).get === "function"
|
|
1836
|
+
? String((payload as FormData).get("body") ?? "")
|
|
1837
|
+
: typeof (payload as { body?: unknown }).body === "string"
|
|
1838
|
+
? ((payload as { body: string }).body)
|
|
1839
|
+
: "";
|
|
1834
1840
|
const hasBody = body.trim().length > 0;
|
|
1835
|
-
const
|
|
1841
|
+
const jsonPayload = isFormSubmit ? null : (payload as { close?: unknown; reopen?: unknown });
|
|
1842
|
+
const wantsStateChange = (jsonPayload?.close ?? jsonPayload?.reopen) === true;
|
|
1836
1843
|
if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
|
|
1837
1844
|
if (body.length > 65536) return json({ error: "body too long" }, 413);
|
|
1838
1845
|
const project = await getProject(owner, repo);
|
|
@@ -1855,11 +1862,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1855
1862
|
}
|
|
1856
1863
|
let closed = false;
|
|
1857
1864
|
let reopened = false;
|
|
1858
|
-
if (
|
|
1865
|
+
if (jsonPayload?.close === true) {
|
|
1859
1866
|
await setIssueState(issue.id, "closed");
|
|
1860
1867
|
closed = true;
|
|
1861
1868
|
void emitIssueEvent(project.id, issue.id, "closed", url.origin);
|
|
1862
|
-
} else if (
|
|
1869
|
+
} else if (jsonPayload?.reopen === true) {
|
|
1863
1870
|
await setIssueState(issue.id, "open");
|
|
1864
1871
|
reopened = true;
|
|
1865
1872
|
void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
|
|
@@ -1867,8 +1874,17 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1867
1874
|
if (view) {
|
|
1868
1875
|
void emitCommentEvent(project.id, issue.id, view.id, url.origin);
|
|
1869
1876
|
}
|
|
1877
|
+
if (isFormSubmit) {
|
|
1878
|
+
return new Response(null, {
|
|
1879
|
+
status: 303,
|
|
1880
|
+
headers: { location: `/${owner}/${repo}/issues/${number}${view ? `#comment-${view.id}` : ""}` },
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1870
1883
|
return json({ comment: view, closed, reopened });
|
|
1871
1884
|
} catch (e) {
|
|
1885
|
+
if (isFormSubmit) {
|
|
1886
|
+
return new Response(null, { status: 303, headers: { location: `/${owner}/${repo}/issues/${number}` } });
|
|
1887
|
+
}
|
|
1872
1888
|
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1873
1889
|
}
|
|
1874
1890
|
}
|
package/src/render/layout.ts
CHANGED
|
@@ -263,8 +263,8 @@ ${props.descriptionHtml.trim() ? `<div class="desc-wrap">
|
|
|
263
263
|
${props.descriptionCollapsed ? `<button type="button" class="desc-toggle" id="descToggle">显示详情 ▾</button>` : ""}
|
|
264
264
|
</div>` : ""}
|
|
265
265
|
${props.writesEnabled !== false
|
|
266
|
-
? `<form id="composer" class="composer">
|
|
267
|
-
<textarea id="composerInput" rows="5" placeholder="写评论…(Ctrl/⌘+Enter 发送,以 ${escapeHtml(op)} 身份;可粘贴/选择图片或任意文件上传)"></textarea>
|
|
266
|
+
? `<form id="composer" class="composer" method="post" action="/api/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues/${props.issueNumber}/comment">
|
|
267
|
+
<textarea id="composerInput" name="body" rows="5" placeholder="写评论…(Ctrl/⌘+Enter 发送,以 ${escapeHtml(op)} 身份;可粘贴/选择图片或任意文件上传)"></textarea>
|
|
268
268
|
<div class="submit-col">
|
|
269
269
|
<button type="button" id="composerClose" class="btn-close" data-action="${toggleAction}" title="${toggleTitle}">${toggleLabel}</button>
|
|
270
270
|
<label class="upload-btn" title="上传图片/附件/文件">📎<input type="file" id="composerFile" multiple></label>
|
package/src/static/app.js
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
const initialEl = document.getElementById("initial-data");
|
|
7
7
|
const P = initialEl ? JSON.parse(initialEl.textContent) : null;
|
|
8
8
|
if (!P) return;
|
|
9
|
+
const earlyForm = document.getElementById("composer");
|
|
10
|
+
if (earlyForm) {
|
|
11
|
+
earlyForm.addEventListener(
|
|
12
|
+
"submit",
|
|
13
|
+
(e) => {
|
|
14
|
+
if (!e.defaultPrevented) e.preventDefault();
|
|
15
|
+
},
|
|
16
|
+
{ capture: true }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
9
19
|
const MAX_DOM = 300;
|
|
10
20
|
const POLL_MS = 5000;
|
|
11
21
|
const NEW_FADE_MS = 4500;
|