ework-web 0.10.65 → 0.10.66

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.65",
3
+ "version": "0.10.66",
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",
package/src/index.ts CHANGED
@@ -2189,7 +2189,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2189
2189
  const project = await getProject(owner, repo);
2190
2190
  if (!project) return html(errorPage("404", "项目不存在"), 404);
2191
2191
  if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
2192
- const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
2192
+ const dispatchCfg = await getConfigAll();
2193
+ const projectDispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
2194
+ const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login, projectDispatchOff);
2193
2195
  return html(body);
2194
2196
  } catch (e) {
2195
2197
  const status = e instanceof StoreError ? e.status : 500;
@@ -18,6 +18,7 @@ export interface LayoutProps {
18
18
  labels?: { id: number; name: string; color: string }[];
19
19
  canEditLabels?: boolean;
20
20
  aiStatus?: string;
21
+ projectDispatchOff?: boolean;
21
22
  viewerLogin?: string;
22
23
  viewerIsAdmin?: boolean;
23
24
  customActions?: IssueAction[];
@@ -192,9 +193,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
192
193
  : `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
193
194
  : "";
194
195
  const dispatchBtnHtml = showActions
195
- ? props.aiStatus === "dispatch_off"
196
- ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
197
- : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
196
+ ? props.projectDispatchOff
197
+ ? `<span class="action-btn dispatch-btn" style="opacity:.5;cursor:not-allowed" title="项目已关闭自动接单">🔕 项目不接单</span>`
198
+ : props.aiStatus === "dispatch_off"
199
+ ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
200
+ : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
198
201
  : "";
199
202
  return `<!doctype html>
200
203
  <html lang="zh">
@@ -91,7 +91,8 @@ export async function buildIssueThread(
91
91
  owner: string,
92
92
  repo: string,
93
93
  number: number,
94
- viewerLogin?: string
94
+ viewerLogin?: string,
95
+ projectDispatchOff?: boolean
95
96
  ): Promise<{ html: string }> {
96
97
  const project = await getProject(owner, repo);
97
98
  if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
@@ -150,6 +151,7 @@ export async function buildIssueThread(
150
151
  labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
151
152
  canEditLabels: cfg.writesEnabled !== false,
152
153
  aiStatus: issue.ai_status ?? "",
154
+ projectDispatchOff: projectDispatchOff ?? false,
153
155
  viewerLogin,
154
156
  viewerIsAdmin,
155
157
  customActions,
package/src/webhooks.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  // the full retry trail rather than an opaque final status.
17
17
 
18
18
  import { createHmac, randomUUID } from "node:crypto";
19
- import { getDB, getConfigAll } from "./db";
19
+ import { getDB } from "./db";
20
20
  import { log } from "./logger";
21
21
  import { loadConfig } from "./config";
22
22
  import {
@@ -28,7 +28,6 @@ import {
28
28
  resolveModel,
29
29
  listLabelsForIssue,
30
30
  getUserByLogin,
31
- canWriteProject,
32
31
  type IssueRow,
33
32
  type ProjectRow,
34
33
  type CommentRow,
@@ -301,6 +300,7 @@ interface PayloadComment {
301
300
  created_at: string;
302
301
  updated_at: string;
303
302
  user: PayloadUser;
303
+ author_kind?: string;
304
304
  }
305
305
 
306
306
  interface IssueEventPayload {
@@ -636,25 +636,11 @@ export async function emitIssueEvent(
636
636
  const project = await getProjectById(projectId);
637
637
  if (!project) return;
638
638
  const issue = await getIssueById(issueId);
639
- if (!issue) return;
640
-
641
- if (action === "opened") {
642
- const cfg = await getConfigAll();
643
- const scopeKey = `${project.owner}/${project.name}`;
644
- if (cfg["dispatchEnabled"] === "false") {
645
- log.info(`webhook: global dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
646
- return;
647
- }
648
- if (cfg[`dispatchOff:${scopeKey}`] === "1") {
649
- log.info(`webhook: project dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
650
- return;
651
- }
652
- if ((issue as IssueRow).ai_status === "dispatch_off") {
653
- log.info(`webhook: issue dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
654
- return;
655
- }
656
- }
639
+ if (!issue) { log.warn(`emitIssueEvent: issue ${issueId} not found — silent skip prevented`); return; }
657
640
 
641
+ // Web is a dumb message bus — always fan out. Wake policy is enforced
642
+ // downstream (daemon handleEvent). Web only suppresses for subscription-
643
+ // level concerns (none currently).
658
644
  const commentCount = await countCommentsSafe(issueId);
659
645
  // Resolve model: project override > global default. Empty string = no
660
646
  // override (daemon omits --model, lets opencode pick). globalDefault
@@ -689,48 +675,16 @@ export async function emitCommentEvent(
689
675
  if (!issue) return;
690
676
  const comment = await getCommentByIdSafe(commentId);
691
677
  if (!comment) return;
692
- const cfg = await getConfigAll();
693
- const scopeKey = `${project.owner}/${project.name}`;
694
- // Issue-level dispatch_off is a per-issue explicit close — block comments entirely (same as halted).
695
- if ((issue as IssueRow).ai_status === "dispatch_off") {
696
- log.info(`webhook: issue dispatch disabled — skipping comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
697
- return;
698
- }
678
+ // Web is a dumb message bus — always fan out. Wake policy is enforced
679
+ // downstream (daemon handleEvent checks author_kind, ai_status, env config).
699
680
  const authorUser = await getUserByLogin(comment.author);
700
- const dispatchOff = cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1";
701
- if (dispatchOff && authorUser && authorUser.kind !== "human") {
702
- log.info(`webhook: dispatch disabled — non-human comment blocked (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
703
- return;
704
- }
705
- if (dispatchOff) {
706
- log.info(`webhook: dispatch disabled but human comment waking AI (author=${comment.author}) for ${scopeKey}#${issue.number}`);
707
- }
708
- const appCfg = await loadConfig();
709
- const cfgList = (k: string, d: string) =>
710
- (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
711
- const noWake = cfgList("noWakeLogins", appCfg.daemonBotLogin);
712
- const okLogins = cfgList("wakeLogins", "");
713
- const okKinds = cfgList("wakeKinds", "human");
714
- const author = comment.author;
715
- const denied =
716
- noWake.includes(author) ? "deny-list" :
717
- okLogins.includes(author) ? null :
718
- (authorUser && !okKinds.includes(authorUser.kind)) ? `kind=${authorUser.kind}` :
719
- null;
720
- if (denied) {
721
- log.info(`webhook: comment wake denied (author=${author} reason=${denied}) for ${scopeKey}#${issue.number}`);
722
- return;
723
- }
724
- if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {
725
- log.info(`webhook: comment wake denied (author=${comment.author} not project member) for ${scopeKey}#${issue.number}`);
726
- return;
727
- }
728
681
  const commentCount = await countCommentsSafe(issueId);
729
682
  const globalDefault = (await loadConfig()).defaultModel;
730
683
  const model = resolveModel(project.model, globalDefault);
731
684
  const labels = await toPayloadLabels(issueId);
732
685
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
733
686
  (payload as CommentEventPayload).event_id = randomUUID();
687
+ (payload as CommentEventPayload).comment.author_kind = authorUser?.kind ?? "human";
734
688
  const rawBody = JSON.stringify(payload);
735
689
  await fanOut(projectId, "issue_comment", rawBody);
736
690
  } catch (e) {