dsh-github-flow 0.5.0 → 0.7.0

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.
Files changed (103) hide show
  1. package/README.en.md +112 -17
  2. package/README.md +115 -17
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +81 -22
  5. package/docs/images/1.png +0 -0
  6. package/lib/api/route-helpers.d.ts +19 -0
  7. package/lib/api/route-helpers.js +54 -0
  8. package/lib/api/routes-actions.js +214 -9
  9. package/lib/api/routes-lifecycle.js +8 -1
  10. package/lib/api/routes-overview.d.ts +26 -0
  11. package/lib/api/routes-overview.js +396 -36
  12. package/lib/api/routes-proxy.js +8 -1
  13. package/lib/api/routes.d.ts +11 -1
  14. package/lib/api/routes.js +15 -2
  15. package/lib/cache.d.ts +56 -4
  16. package/lib/cache.js +77 -7
  17. package/lib/client.d.ts +8 -3
  18. package/lib/client.js +5542 -1391
  19. package/lib/destructive.d.ts +59 -0
  20. package/lib/destructive.js +285 -0
  21. package/lib/executor/args-guard.d.ts +17 -0
  22. package/lib/executor/args-guard.js +23 -0
  23. package/lib/executor/error-classifier.d.ts +55 -0
  24. package/lib/executor/error-classifier.js +199 -0
  25. package/lib/executor/failure-anchors.d.ts +52 -0
  26. package/lib/executor/failure-anchors.js +116 -0
  27. package/lib/executor/git-parser.d.ts +124 -0
  28. package/lib/executor/git-parser.js +397 -0
  29. package/lib/executor/pr-review.d.ts +95 -0
  30. package/lib/executor/pr-review.js +175 -0
  31. package/lib/executor/run-log.d.ts +36 -0
  32. package/lib/executor/run-log.js +78 -0
  33. package/lib/executor.d.ts +277 -6
  34. package/lib/executor.js +1265 -94
  35. package/lib/github-service.d.ts +1 -1
  36. package/lib/main.d.ts +92 -18
  37. package/lib/main.js +186 -95
  38. package/lib/renderers/common.d.ts +0 -3
  39. package/lib/renderers/common.js +8 -44
  40. package/lib/renderers/markdown.d.ts +1 -0
  41. package/lib/renderers/markdown.js +1 -0
  42. package/lib/renderers/pr.js +26 -1
  43. package/lib/renderers/task.d.ts +9 -0
  44. package/lib/renderers/task.js +120 -0
  45. package/lib/review-rules.d.ts +60 -0
  46. package/lib/review-rules.js +204 -0
  47. package/lib/shared/state-badges.d.ts +42 -0
  48. package/lib/shared/state-badges.js +102 -0
  49. package/lib/task-primitives.d.ts +109 -0
  50. package/lib/task-primitives.js +194 -0
  51. package/lib/tool-tiers.d.ts +57 -0
  52. package/lib/tool-tiers.js +168 -0
  53. package/lib/tools/agent-task.js +4 -4
  54. package/lib/tools/api.js +18 -6
  55. package/lib/tools/cache.js +42 -40
  56. package/lib/tools/codespace.js +4 -12
  57. package/lib/tools/discussion.js +4 -4
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +72 -18
  61. package/lib/tools/image-utils.d.ts +27 -0
  62. package/lib/tools/image-utils.js +73 -3
  63. package/lib/tools/issue.js +16 -28
  64. package/lib/tools/label.js +4 -15
  65. package/lib/tools/pr.d.ts +47 -0
  66. package/lib/tools/pr.js +170 -33
  67. package/lib/tools/project.js +4 -13
  68. package/lib/tools/release.js +11 -22
  69. package/lib/tools/repo.js +4 -4
  70. package/lib/tools/ruleset.js +9 -6
  71. package/lib/tools/run.js +76 -24
  72. package/lib/tools/secret.js +11 -17
  73. package/lib/tools/skill.js +18 -22
  74. package/lib/tools/status.js +4 -4
  75. package/lib/tools/task.d.ts +219 -0
  76. package/lib/tools/task.js +457 -0
  77. package/lib/types.d.ts +514 -1
  78. package/lib/ui/help-modal.d.ts +6 -0
  79. package/lib/ui/help-modal.js +124 -16
  80. package/lib/ui/issue-modal.d.ts +14 -0
  81. package/lib/ui/issue-modal.js +370 -0
  82. package/lib/ui/markdown-lite.d.ts +66 -0
  83. package/lib/ui/markdown-lite.js +312 -0
  84. package/lib/ui/network-banner.d.ts +12 -0
  85. package/lib/ui/network-banner.js +35 -0
  86. package/lib/ui/pr-modal.d.ts +17 -0
  87. package/lib/ui/pr-modal.js +555 -0
  88. package/lib/ui/repo-badges.d.ts +43 -0
  89. package/lib/ui/repo-badges.js +82 -0
  90. package/lib/ui/repo-selector.d.ts +14 -0
  91. package/lib/ui/repo-selector.js +112 -0
  92. package/lib/ui/run-modal.d.ts +26 -0
  93. package/lib/ui/run-modal.js +486 -0
  94. package/lib/ui/storage.d.ts +47 -0
  95. package/lib/ui/storage.js +139 -0
  96. package/lib/ui/styles.d.ts +1 -1
  97. package/lib/ui/styles.js +1330 -44
  98. package/lib/ui/sync-settings-modal.js +1 -1
  99. package/lib/ui/utils.js +13 -1
  100. package/lib/ui/workspace-panel.js +226 -29
  101. package/lib/version.d.ts +1 -1
  102. package/lib/version.js +1 -1
  103. package/package.json +9 -2
@@ -8,4 +8,30 @@ import { TtlCache } from '../cache.js';
8
8
  import type { UpdateChecker } from '../updater.js';
9
9
  import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
10
10
  import { type RouteDeps } from './route-helpers.js';
11
+ /**
12
+ * 右侧栏单仓库 PR / Issue 的展示条数上限。
13
+ *
14
+ * 为什么从 10 提到 20:加上 `--state all` 后列表里混入了 closed/merged,
15
+ * 而 `gh pr list` 的排序依据是 **number 降序**(实测 30 条验证:14763、14752、
16
+ * 14751 …… 严格递减),不是 updatedAt。取 10 条时实测 cli/cli 只剩 5 条非 OPEN,
17
+ * 用户切到「Merged」可能只有 2-3 条,筛选体验形同虚设。
18
+ * 多拉 10 条的代价可忽略(同一个 gh 进程,仅 JSON 体积略增),
19
+ * 但绝不能改成「每个状态各查一次」——那会把 3 个 gh 子进程变成 6~9 个,
20
+ * 右侧栏首屏本就受 Promise.all 8 秒超时约束。
21
+ */
22
+ /**
23
+ * 列表默认每页条数。
24
+ *
25
+ * 为什么是 15 而不是 20:与右侧栏「Git 提交」「未提交变更」两个已有分页的默认页大小对齐,
26
+ * 用户下滑即可继续加载(`prLimit/issueLimit/runLimit` 查询参数),所以首屏不必再靠
27
+ * 「多拉一点」来换取筛选体验。20 条时代首屏 JSON 实测可达 28,608 字符,直接撞上
28
+ * executor 的 24,000 字符输出上限 → 被截断 → JSON 解析被跳过 → 面板空列表且无报错。
29
+ */
30
+ export declare const RIGHTBAR_LIST_LIMIT = 15;
31
+ /**
32
+ * 缓存键:必须把三个分页参数算进去。
33
+ * 只用 cwd 当键的话,「先加载 15 条」的缓存会喂给「请求 30 条」的那次刷新,
34
+ * 表现是点了「加载更多」列表不变——而且因为命中的是缓存,连请求日志都没有。
35
+ */
36
+ export declare function overviewCacheKey(cwd: string, prLimit: number, issueLimit: number, runLimit: number): string;
11
37
  export declare function registerOverviewRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>, checker: UpdateChecker): void;
@@ -1,7 +1,57 @@
1
1
  import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
2
2
  import { resolveIssueCategories, calculateIssueStats, basenameSafe, sendJson } from './route-helpers.js';
3
+ import { describeDeterministicError } from '../executor/error-classifier.js';
3
4
  /** 探测单个工作区时在途的 gh 进程数上限;libuv 线程池默认 4,留出余量给其他请求 */
4
5
  const WORKSPACE_PROBE_CONCURRENCY = 3;
6
+ /**
7
+ * 右侧栏单仓库 PR / Issue 的展示条数上限。
8
+ *
9
+ * 为什么从 10 提到 20:加上 `--state all` 后列表里混入了 closed/merged,
10
+ * 而 `gh pr list` 的排序依据是 **number 降序**(实测 30 条验证:14763、14752、
11
+ * 14751 …… 严格递减),不是 updatedAt。取 10 条时实测 cli/cli 只剩 5 条非 OPEN,
12
+ * 用户切到「Merged」可能只有 2-3 条,筛选体验形同虚设。
13
+ * 多拉 10 条的代价可忽略(同一个 gh 进程,仅 JSON 体积略增),
14
+ * 但绝不能改成「每个状态各查一次」——那会把 3 个 gh 子进程变成 6~9 个,
15
+ * 右侧栏首屏本就受 Promise.all 8 秒超时约束。
16
+ */
17
+ /**
18
+ * 列表默认每页条数。
19
+ *
20
+ * 为什么是 15 而不是 20:与右侧栏「Git 提交」「未提交变更」两个已有分页的默认页大小对齐,
21
+ * 用户下滑即可继续加载(`prLimit/issueLimit/runLimit` 查询参数),所以首屏不必再靠
22
+ * 「多拉一点」来换取筛选体验。20 条时代首屏 JSON 实测可达 28,608 字符,直接撞上
23
+ * executor 的 24,000 字符输出上限 → 被截断 → JSON 解析被跳过 → 面板空列表且无报错。
24
+ */
25
+ export const RIGHTBAR_LIST_LIMIT = 15;
26
+ /** 单页上限:防止一次请求把几万条拉进内存(gh 自身也没有更细的分页) */
27
+ const LIST_LIMIT_MAX = 100;
28
+ /**
29
+ * 列表类查询的输出上限(字符)。
30
+ *
31
+ * 为什么不能沿用默认的 24,000:那个阈值是按「喂给模型上下文」调的。面板列表是
32
+ * **程序化消费**——前端只渲染首屏,永不整段进模型上下文。用 24KB 截 15 条 PR 的结果是
33
+ * 截断后 JSON 解析被跳过、`data` 变 undefined,表现为「这个仓库拉不到 PR」。
34
+ * 与 pr diff / issue 详情放宽到 2MB 是同一个理由。
35
+ */
36
+ const LIST_OUTPUT_CAP = 1_048_576;
37
+ /**
38
+ * 解析分页参数:非法/缺失回落默认页大小,超过上限则截到上限。
39
+ * 上限存在是因为面板可能被脚本按 `prLimit=999999` 调用。
40
+ */
41
+ function parseListLimit(raw) {
42
+ const n = Number(raw);
43
+ if (!Number.isFinite(n) || n <= 0)
44
+ return RIGHTBAR_LIST_LIMIT;
45
+ return Math.min(Math.floor(n), LIST_LIMIT_MAX);
46
+ }
47
+ /**
48
+ * 缓存键:必须把三个分页参数算进去。
49
+ * 只用 cwd 当键的话,「先加载 15 条」的缓存会喂给「请求 30 条」的那次刷新,
50
+ * 表现是点了「加载更多」列表不变——而且因为命中的是缓存,连请求日志都没有。
51
+ */
52
+ export function overviewCacheKey(cwd, prLimit, issueLimit, runLimit) {
53
+ return `${cwd}|${prLimit}|${issueLimit}|${runLimit}`;
54
+ }
5
55
  export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, checker) {
6
56
  const { bindRoute } = deps;
7
57
  // 1. 获取全局与当前仓库的 GitHub 概览
@@ -18,6 +68,10 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
18
68
  let cwd = url.searchParams.get('cwd');
19
69
  const sessionId = url.searchParams.get('sessionId');
20
70
  const force = url.searchParams.get('force') === 'true';
71
+ // 分页参数:默认 15,非法值回落默认,超过上限截断(见 parseListLimit)
72
+ const prLimit = parseListLimit(url.searchParams.get('prLimit'));
73
+ const issueLimit = parseListLimit(url.searchParams.get('issueLimit'));
74
+ const runLimit = parseListLimit(url.searchParams.get('runLimit'));
21
75
  // 会话 ID → 物理路径,以及「最近活跃工作区」兜底,统一由 workspace 模块提供
22
76
  if (!cwd && sessionId) {
23
77
  cwd = findWorkspacePathBySession(sessionId) || null;
@@ -29,7 +83,24 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
29
83
  cwd = process.cwd();
30
84
  }
31
85
  const now = Date.now();
32
- const cachedData = cacheMap.get(cwd, now);
86
+ // 先行检查当前工作区是否真实包含 .git,无 .git 铁证判定为未关联仓库
87
+ const isGitRepo = hasGitDir(cwd);
88
+ // 缓存失效判定:比对 remote 配置指纹,而不是比对解析出的仓库身份。
89
+ //
90
+ // 为什么换:身份比对把「解析器的正确性」变成了「缓存有效性的前置条件」。
91
+ // 多 remote 布局下(如 upstream 段写在 origin 段之前),取「第一个
92
+ // github.com 匹配」会拿到上游仓库,与缓存里的 fork 身份恒失配,于是
93
+ // 每次请求都作废缓存、重拉 6 个 gh 子进程。指纹只回答「配置变没变」,
94
+ // 不依赖任何语义理解,对该类偏差免疫。
95
+ //
96
+ // 指纹读取是纯本地 readFileSync(几 KB),与 hasGitDir 同级开销,零子进程。
97
+ const remoteFingerprint = isGitRepo ? executor.getRemoteFingerprint(cwd) : null;
98
+ // 缓存键带上分页参数:否则「15 条」的缓存会喂给「请求 30 条」的刷新,
99
+ // 表现为点了加载更多但列表不变(且连请求日志都没有,因为命中的是缓存)。
100
+ const cacheKey = overviewCacheKey(cwd, prLimit, issueLimit, runLimit);
101
+ // 指纹失效必须针对**同一个键**,否则「remote url 变了」永远作废不掉缓存条目
102
+ cacheMap.invalidateIfFingerprintChanged(cacheKey, remoteFingerprint);
103
+ const cachedData = cacheMap.get(cacheKey, now);
33
104
  if (cachedData && !force) {
34
105
  sendJson(res, 200, {
35
106
  ok: true,
@@ -40,9 +111,12 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
40
111
  return;
41
112
  }
42
113
  const auth = await executor.checkAuth(cwd);
43
- // 先行检查当前工作区是否真实包含 .git,无 .git 铁证判定为未关联仓库
44
- const isGitRepo = hasGitDir(cwd);
45
114
  const repo = isGitRepo ? await executor.getRepoMetadata(cwd) : null;
115
+ if (repo) {
116
+ // 推导当前工作区可用的关联仓库(如 origin/Fork 与 upstream/Parent),
117
+ // 仅在存在多个关联仓库或 Fork 场景下供 UI 渲染手动切换控件。
118
+ repo.associatedRepos = executor.getAssociatedRepos(cwd, repo, auth?.user);
119
+ }
46
120
  // 若当前工作区未关联 Git 仓库,直接返回纯净空状态,坚决不拉取远端数据
47
121
  if (!isGitRepo || !repo) {
48
122
  const emptyData = {
@@ -55,36 +129,134 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
55
129
  lastUpdated: new Date().toISOString(),
56
130
  updateInfo: checker.getQuickStatus(),
57
131
  };
58
- cacheMap.set(cwd, emptyData, now);
132
+ cacheMap.set(cacheKey, emptyData, now, remoteFingerprint);
59
133
  sendJson(res, 200, { ok: true, data: emptyData, fromCache: false, cwd });
60
134
  return;
61
135
  }
62
136
  let pullRequests = [];
137
+ /** 仓库总量(GraphQL 统计;失败为 null,前端回落已加载条数) */
138
+ let repoCounts = null;
63
139
  let issues = [];
64
140
  let runs = [];
141
+ // 确定性降级说明(非错误):在 if 块内累积、在块外随响应一起落缓存,
142
+ // 因此必须在请求作用域声明,不能藏在分支里。
143
+ const deterministicNotices = [];
65
144
  // 面板增强信息载体(git 状态/Release 锚定/分支 PR——全部可选字段,
66
145
  // 进缓存与快照时旧消费方零破坏,渲染层 ?. 兜底)
67
146
  const freshExtras = {};
68
147
  if (repo && auth.loggedIn) {
69
- // 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs
70
- const [prsRes, issuesRes, runsRes] = await Promise.all([
71
- executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
72
- executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
73
- executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
148
+ /*
149
+ * 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs。
150
+ *
151
+ * 必须显式传 `--state all`:gh 的默认值是 open,不传就只剩未关闭记录。
152
+ * 本仓库实测 `pr list` 默认返回 []、`--state all` 才返回 [#6 CLOSED]——
153
+ * 右侧栏「只显示已打开」的根因就在这里,靠前端加筛选器救不回来,
154
+ * 因为数据在到达浏览器前就被 gh 过滤掉了。
155
+ * 与全局面板(gh search,phase29 已移除 --state=open)保持同一口径。
156
+ */
157
+ // 仓库标识(owner/name)。只接受该形态,避免把任意字符串拼进 GraphQL / REST 路径。
158
+ const repoNwo = repo?.nameWithOwner || '';
159
+ const repoNwoValid = /^[\w.-]+\/[\w.-]+$/.test(repoNwo);
160
+ const [prsRes, issuesRes, runsRes, totalsRes, runsTotalRes] = await Promise.all([
161
+ executor.run([
162
+ 'pr', 'list', '--state', 'all',
163
+ // 列表只渲染简要信息(编号、标题、分支、状态、合并冲突与 CI),无需拉取 author 等未消费对象
164
+ '--json', 'number,title,state,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt',
165
+ '-L', String(prLimit),
166
+ ], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
167
+ executor.run([
168
+ 'issue', 'list', '--state', 'all',
169
+ '--json', 'number,title,state,author,labels,assignees,url,updatedAt',
170
+ '-L', String(issueLimit),
171
+ ], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
172
+ executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', String(runLimit)], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
173
+ /*
174
+ * 总量统计(与列表分页彻底解耦)。
175
+ *
176
+ * 为什么必须单独查:列表是「按页拉」的,`pullRequests.length` 只是**已加载条数**,
177
+ * 拿它当总数会在用户下滑时从 15 跳到 30、再跳到 45——看起来像总数在变,是明显的 bug。
178
+ * 真正的总数只能靠 GitHub 的计数接口拿。
179
+ *
180
+ * 为什么用 GraphQL `search.issueCount` 而不是 `repository.pullRequests.totalCount`:
181
+ * `Repository.issues` 在 GitHub 语义里**包含 PR**(共用 issue 空间),直接取会把 PR 算进 Issue;
182
+ * `search` 加 `is:pr` / `is:issue` 限定符才能精确区分,且一次请求可以并发拿全 7 个口径。
183
+ */
184
+ (() => {
185
+ const nwo = repoNwo;
186
+ // 只接受 owner/name 形态,避免把任意字符串拼进 GraphQL 查询
187
+ if (!repoNwoValid)
188
+ return Promise.resolve({ ok: false });
189
+ const scope = `repo:${nwo}`;
190
+ // GraphQL 字符串字面量必须用双引号;这里整段 query 作为**单个 argv 元素**传给
191
+ // execFile(不经过 shell),所以双引号无需再转义,写了反斜杠反而会让 GitHub 解析失败。
192
+ // 注意只产出「带引号的查询值」,`query:` 前缀由调用点写——否则会拼成 query:query:"..."
193
+ const q = (extra) => `"${scope} ${extra}"`;
194
+ const query = `query{` +
195
+ `prAll:search(query:${q('is:pr')},type:ISSUE){issueCount}` +
196
+ `prOpen:search(query:${q('is:pr is:open')},type:ISSUE){issueCount}` +
197
+ `prMerged:search(query:${q('is:pr is:merged')},type:ISSUE){issueCount}` +
198
+ `prClosed:search(query:${q('is:pr is:closed')},type:ISSUE){issueCount}` +
199
+ `issAll:search(query:${q('is:issue')},type:ISSUE){issueCount}` +
200
+ `issOpen:search(query:${q('is:issue is:open')},type:ISSUE){issueCount}` +
201
+ `issClosed:search(query:${q('is:issue is:closed')},type:ISSUE){issueCount}` +
202
+ `}`;
203
+ return executor.run(['api', 'graphql', '-f', `query=${query}`], { cwd, timeoutMs: 8_000 });
204
+ })(),
205
+ /*
206
+ * Actions 运行总数。
207
+ *
208
+ * 为什么单独走 REST:GitHub GraphQL **没有** workflow runs 连接
209
+ * (`Repository` 上不存在 `workflowRuns` 字段),拿不到 totalCount;
210
+ * REST `GET /repos/{owner}/{repo}/actions/runs?per_page=1` 的响应体里带
211
+ * `total_count`(实测该仓 1146),per_page=1 只为把响应压到最小。
212
+ */
213
+ (() => {
214
+ if (!repoNwoValid)
215
+ return Promise.resolve({ ok: false });
216
+ return executor.run(['api', `repos/${repoNwo}/actions/runs?per_page=1`], {
217
+ cwd,
218
+ timeoutMs: 8_000,
219
+ });
220
+ })(),
74
221
  ]);
75
- // 核心数据拉取故障检测:若拉取核心业务数据遭遇失败(网络超时/断网/接口报错),
76
- // 绝对不能把失败伪装成「0 个 PR、0 个 Issue」并假装成功返回!
77
- const failures = [];
78
- if (!prsRes.ok)
79
- failures.push(`PR 获取失败: ${prsRes.error || '网络或接口异常'}`);
80
- if (!issuesRes.ok)
81
- failures.push(`Issue 获取失败: ${issuesRes.error || '网络或接口异常'}`);
82
- if (!runsRes.ok)
83
- failures.push(`Actions 获取失败: ${runsRes.error || '网络或接口异常'}`);
84
- if (failures.length > 0) {
85
- const failureReason = failures.join(';');
86
- // 若此前内存有缓存,保全已有缓存内容,附加 networkError 与 partialFailure
87
- const fallbackCached = cacheMap.getFallback(cwd) || cacheMap.get(cwd, now);
222
+ // 核心数据拉取故障检测:把失败按语义拆成两堆。
223
+ //
224
+ // 为什么必须拆:`gh` 对所有失败都返回非零退出,但两类失败的性质完全不同——
225
+ // · 确定性(仓库关了 Issues / 仓库不存在 / 无权限):重试一万次也还是失败,
226
+ // 正确处置是「该字段降级为空列表 + 一句说明」,绝不能伪装成网络故障;
227
+ // · 传输层(超时 / 断连 / 代理阻断):值得回退历史快照并允许重试。
228
+ // 旧实现把两者混为一谈,导致「仓库关了 Issues」弹出「网络连接失败」横幅,
229
+ // 还被客户端重试器当成可恢复抖动反复空烧。
230
+ const transientFailures = [];
231
+ const collectFailure = (res, label) => {
232
+ if (res.ok) {
233
+ // 截断不是成功。`executor.run` 在超限时会跳过 JSON.parse(data 变 undefined)
234
+ // 但仍返回 ok:true —— 旧实现因此把它当「仓库没有 PR」,面板显示空列表且零报错。
235
+ // 这条曾经让一个 579 个 PR 的仓库看起来「无法拉取 PR」,必须显式透出。
236
+ if (res.truncated) {
237
+ deterministicNotices.push(`${label}: 返回数据过大已被截断,请到 GitHub 查看完整列表`);
238
+ }
239
+ return;
240
+ }
241
+ const msg = res.error || '接口异常';
242
+ // errorKind 由 GhExecutor 统一分类(单一事实源),这里只做分流
243
+ if (res.errorKind === 'deterministic') {
244
+ deterministicNotices.push(`${label}: ${describeDeterministicError(msg) || msg}`);
245
+ }
246
+ else {
247
+ transientFailures.push(`${label}: ${msg}`);
248
+ }
249
+ };
250
+ collectFailure(prsRes, 'PR 获取失败');
251
+ collectFailure(issuesRes, 'Issue 获取失败');
252
+ collectFailure(runsRes, 'Actions 获取失败');
253
+ if (transientFailures.length > 0) {
254
+ const failureReason = transientFailures.join(';');
255
+ // 若此前内存有缓存,保全已有缓存内容,附加 networkError 与 partialFailure。
256
+ // repo 必须用本次新鲜解析值覆盖:列表数据旧一点可以接受,仓库身份旧了就是错的
257
+ // (典型场景:用户把 origin 从上游换成自己的 fork 后,issue list 永久失败,
258
+ // 失败分支从不写缓存,旧身份被 freeze 在 fallback 里永远出不来)。
259
+ const fallbackCached = cacheMap.getFallback(cacheKey) || cacheMap.get(cacheKey, now);
88
260
  if (fallbackCached && (fallbackCached.pullRequests.length > 0 || fallbackCached.issues.length > 0 || fallbackCached.runs.length > 0)) {
89
261
  sendJson(res, 200, {
90
262
  ok: true,
@@ -93,6 +265,7 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
93
265
  error: failureReason,
94
266
  data: {
95
267
  ...fallbackCached,
268
+ repo: repo || fallbackCached.repo,
96
269
  networkError: true,
97
270
  networkErrorReason: failureReason,
98
271
  auth: { ...fallbackCached.auth, networkError: true, networkErrorReason: failureReason },
@@ -163,8 +336,66 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
163
336
  .catch(() => null);
164
337
  // 5. 分支列表(纯本地毫秒级):供右侧栏分支选择器 0ms 秒开,消除独立请求延迟与 404 风险
165
338
  freshExtras.branches = await executor.getGitBranches(cwd).catch(() => null);
339
+ /*
340
+ * 解析「仓库总量」。
341
+ *
342
+ * 与列表分页彻底解耦:列表按页拉,`pullRequests.length` 只是已加载条数;
343
+ * 总数来自 GraphQL `search.issueCount`(第 4 路)与 REST `actions/runs.total_count`(第 5 路)。
344
+ * 任何解析失败都返回 null → 前端回落到「已加载条数」,绝不因计数失败影响列表本身。
345
+ */
346
+ repoCounts = (() => {
347
+ const body = totalsRes?.ok ? totalsRes.data : null;
348
+ const root = body && typeof body === 'object' ? body.data : null;
349
+ if (!root || typeof root !== 'object')
350
+ return null;
351
+ const num = (key) => {
352
+ const v = root[key];
353
+ return v && typeof v.issueCount === 'number' ? v.issueCount : undefined;
354
+ };
355
+ // Actions 总数来自 REST(GraphQL 没有 workflow runs 连接),响应体里是 total_count
356
+ const runsBody = runsTotalRes?.ok ? runsTotalRes.data : null;
357
+ const runTotal = runsBody && typeof runsBody.total_count === 'number' ? runsBody.total_count : undefined;
358
+ const prTotal = num('prAll');
359
+ // PR/Issue 统计失败但 Actions 拿到时,仍要下发 runTotal(两者互相独立)
360
+ if (prTotal === undefined)
361
+ return runTotal === undefined ? null : { runTotal };
362
+ return {
363
+ prTotal,
364
+ prOpenTotal: num('prOpen'),
365
+ prMergedTotal: num('prMerged'),
366
+ prClosedTotal: num('prClosed'),
367
+ issueTotal: num('issAll'),
368
+ issueOpenTotal: num('issOpen'),
369
+ issueClosedTotal: num('issClosed'),
370
+ ...(runTotal !== undefined ? { runTotal } : {}),
371
+ };
372
+ })();
166
373
  }
167
374
  const issueStats = calculateIssueStats(issues);
375
+ /*
376
+ * 「还有没有更多」判定。
377
+ *
378
+ * 有真实总数时用精确比较(loaded < total)——这是「还有更多」的定义本身;
379
+ * 拿不到总数才回落到「本次是否拉满」的启发式。
380
+ *
381
+ * 为什么必须精确:`>= prLimit` 在边界上会误报。例如 loaded=total 时,
382
+ * `loaded >= prLimit` 仍为真,页脚一直显示,用户点下去却一条都拉不到。
383
+ *
384
+ * 为什么还要叠加 `!*Saturated`:`parseListLimit` 把请求夹在 LIST_LIMIT_MAX(100) 内。
385
+ * 一旦客户端已经把 limit 顶到这个上限,再点「加载更多」只会拿到同样的 100 条
386
+ * (limit 115 → 夹回 100),而 loaded(100) < total(323) 又恒成立 →
387
+ * 页脚永远显示、永远点不动,这是比误报更糟的死循环。
388
+ * 此时必须停止下发 truncated,由前端改显「已加载 X / 共 Y,更早记录请到 GitHub」。
389
+ */
390
+ const prLimitSaturated = prLimit >= LIST_LIMIT_MAX;
391
+ const issueLimitSaturated = issueLimit >= LIST_LIMIT_MAX;
392
+ const runLimitSaturated = runLimit >= LIST_LIMIT_MAX;
393
+ const prsTruncated = !prLimitSaturated &&
394
+ (repoCounts?.prTotal !== undefined ? pullRequests.length < repoCounts.prTotal : pullRequests.length >= prLimit);
395
+ const issuesTruncated = !issueLimitSaturated &&
396
+ (repoCounts?.issueTotal !== undefined ? issues.length < repoCounts.issueTotal : issues.length >= issueLimit);
397
+ const runsTruncated = !runLimitSaturated &&
398
+ (repoCounts?.runTotal !== undefined ? runs.length < repoCounts.runTotal : runs.length >= runLimit);
168
399
  const freshData = {
169
400
  auth,
170
401
  repo: repo || undefined,
@@ -174,9 +405,18 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
174
405
  runs,
175
406
  lastUpdated: new Date().toISOString(),
176
407
  updateInfo: checker.getQuickStatus(),
408
+ // 确定性降级说明随数据一起落缓存:它是仓库的稳定属性(Issues 关着就一直关着),
409
+ // 每次请求重新声明没有意义,也不该让它冒泡成 networkError。
410
+ ...(deterministicNotices.length > 0 ? { notices: deterministicNotices } : {}),
411
+ // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
412
+ ...(prsTruncated ? { prsTruncated: true } : {}),
413
+ ...(issuesTruncated ? { issuesTruncated: true } : {}),
414
+ ...(runsTruncated ? { runsTruncated: true } : {}),
415
+ // 仓库总量(与分页无关的真实总数);查询失败时整组缺席,前端回落已加载条数
416
+ ...(repoCounts || {}),
177
417
  ...freshExtras,
178
418
  };
179
- cacheMap.set(cwd, freshData, now);
419
+ cacheMap.set(cacheKey, freshData, now, remoteFingerprint);
180
420
  sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
181
421
  }
182
422
  catch (err) {
@@ -196,7 +436,13 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
196
436
  // 全局驾驶舱要 spawn 十几次 gh(认证 + 仓库 + PR + Issue + 每个工作区探测),
197
437
  // 实测冷启动曾达 18.9 秒。缓存是这里唯一的解药:面板每次挂载都会来取一次。
198
438
  const now = Date.now();
199
- const cachedGlobal = globalCache.get('global', now);
439
+ // 全局面板同样支持分页:默认 15,与右侧栏以及 Git/未提交两条列表同口径。
440
+ // 缓存键必须带分页参数,否则「15 条」的缓存会喂给「请求 30 条」的刷新。
441
+ const url = new URL(req.url || '', 'http://127.0.0.1');
442
+ const prLimit = parseListLimit(url.searchParams.get('prLimit'));
443
+ const issueLimit = parseListLimit(url.searchParams.get('issueLimit'));
444
+ const globalCacheKey = `global|${prLimit}|${issueLimit}`;
445
+ const cachedGlobal = globalCache.get(globalCacheKey, now);
200
446
  if (cachedGlobal) {
201
447
  sendJson(res, 200, {
202
448
  ok: true,
@@ -209,10 +455,18 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
209
455
  const auth = await executor.checkAuth();
210
456
  let userRepos = [];
211
457
  let myPrs = [];
458
+ /** 账号级总量(GraphQL 统计;失败为 null) */
459
+ let accountCounts = null;
212
460
  let myIssues = [];
213
461
  let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
214
462
  let workspaceMatrix = [];
215
463
  let workspaceError;
464
+ /** PR 检索是否触及 limit(命中即说明可能还有更早的 PR 未返回) */
465
+ let prsTruncated = false;
466
+ /** Issues 三路检索是否任一路触及 limit(去重合并后无法从长度反推) */
467
+ let issuesTruncated = false;
468
+ // 全局驾驶舱的确定性降级说明(同单仓库口径:在分支内累积、块外落缓存)
469
+ const globalNotices = [];
216
470
  if (auth.loggedIn) {
217
471
  // 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 三维 Issue 检索(名下仓库用户提单 + 指派待办 + 我创建的)
218
472
  // 为什么三路并发搜索:
@@ -220,28 +474,95 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
220
474
  // - --assignee=@me: 捕获团队成员或自己指派给当前账号的待办任务 (含跨组织/第三方项目)
221
475
  // - --author=@me: 捕获当前账号亲自发起的任务与向外部提起的议题
222
476
  // 3 个轻量进程与 repo/prs 同步 spawn,经 OS 并发调度实测仅耗时 3 秒出头,完全满足 8 秒超时门限。
223
- const issueFields = 'number,title,repository,author,assignees,labels,url,updatedAt';
477
+ // 为什么不再限定 --state=open:
478
+ // 实测当前账号 7 个 PR 全为 merged/closed、5 个 Issue 全为 closed,
479
+ // `--state=open` 恒返回空数组(退出码仍为 0,属于"正确但无用")。
480
+ // 用户看到的就是一片空白,且无法区分「确实没有」与「请求失败」。
481
+ // 改为拉全量并在前端做状态筛选:数据先可见,再谈收敛。
482
+ // 同时 --json 必须补上 state 字段——不下发的话前端根本无从筛选。
483
+ const issueFields = 'number,title,state,repository,author,assignees,labels,url,updatedAt';
484
+ /**
485
+ * PR 检索上限。提到常量是因为「命中 limit」这个事实要被下游用来判断截断,
486
+ * 散落的字面量会让两处逻辑(查询与判断)出现漂移。
487
+ */
224
488
  const queryResults = await Promise.allSettled([
225
489
  executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
226
- executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt,isDraft'], { timeoutMs: 8_000 }),
227
- executor.run(['search', 'issues', '--owner=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
228
- executor.run(['search', 'issues', '--assignee=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
229
- executor.run(['search', 'issues', '--author=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
490
+ executor.run(['search', 'prs', '--author=@me', '--limit', String(prLimit), '--json', 'number,title,state,repository,url,updatedAt,isDraft'], { timeoutMs: 8_000 }),
491
+ executor.run(['search', 'issues', '--owner=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
492
+ executor.run(['search', 'issues', '--assignee=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
493
+ executor.run(['search', 'issues', '--author=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
494
+ /*
495
+ * 账号级 PR 总量(与列表分页解耦)。
496
+ *
497
+ * 为什么必须单独查:`myPrs.length` 只是已加载条数,拿它当总数会在下滑时不断变大。
498
+ * PR 侧口径与列表完全一致(都是 `is:pr author:@me`),所以这个总数可以精确比较。
499
+ *
500
+ * 为什么**不**给 Issue 也配一个总数:列表是 owner/assignee/author 三路的并集,
501
+ * 而唯一能给出不重复总数的查询是 `is:issue involves:@me`——它不含「我仓库里
502
+ * 别人提的 issue」(实测差集确实存在,不是索引延迟)。拿它当总数会**小于**已加载条数,
503
+ * 三路分别计数相加又会因重叠而虚高。既然拿不到正确口径,就不下发假总数,
504
+ * 由前端老实显示「已加载 N」。
505
+ */
506
+ executor.run([
507
+ 'api', 'graphql', '-f',
508
+ 'query=' +
509
+ `query{` +
510
+ `prAll:search(query:"is:pr author:@me",type:ISSUE){issueCount}` +
511
+ `prOpen:search(query:"is:pr author:@me is:open",type:ISSUE){issueCount}` +
512
+ `prMerged:search(query:"is:pr author:@me is:merged",type:ISSUE){issueCount}` +
513
+ `prClosed:search(query:"is:pr author:@me is:closed",type:ISSUE){issueCount}` +
514
+ `}`,
515
+ ], { timeoutMs: 10_000 }),
230
516
  ]);
231
517
  const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
232
518
  const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
233
519
  const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
234
520
  const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
235
521
  const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
236
- // 检测全局拉取是否发生网络故障,绝不把网络失败当作「0 仓库、0 PR」伪造成功
522
+ const totalsRes = queryResults[5].status === 'fulfilled' ? queryResults[5].value : { ok: false };
523
+ /*
524
+ * 解析账号级总量。失败时返回 null → 前端回落「已加载条数」,
525
+ * 绝不因计数失败影响列表本身。
526
+ */
527
+ accountCounts = (() => {
528
+ const body = totalsRes?.ok ? totalsRes.data : null;
529
+ const root = body && typeof body === 'object' ? body.data : null;
530
+ if (!root || typeof root !== 'object')
531
+ return null;
532
+ const num = (key) => {
533
+ const v = root[key];
534
+ return v && typeof v.issueCount === 'number' ? v.issueCount : undefined;
535
+ };
536
+ const prTotal = num('prAll');
537
+ if (prTotal === undefined)
538
+ return null;
539
+ return {
540
+ prTotal,
541
+ prOpenTotal: num('prOpen'),
542
+ prMergedTotal: num('prMerged'),
543
+ prClosedTotal: num('prClosed'),
544
+ };
545
+ })();
546
+ // 检测全局拉取是否发生网络故障,绝不把网络失败当作「0 仓库、0 PR」伪造成功。
547
+ // 与单仓库概览同一口径:确定性失败(账号级搜索本就不存在确定性场景,
548
+ // 但仓库列表拉取可能命中 404/403)不进入网络故障分支,只作为 notice 透出。
237
549
  const globalFailures = [];
238
- if (!reposRes.ok)
239
- globalFailures.push(`仓库列表拉取失败: ${reposRes.error || '网络或接口异常'}`);
240
- if (!prsRes.ok)
241
- globalFailures.push(`个人 PR 拉取失败: ${prsRes.error || '网络或接口异常'}`);
550
+ const collectGlobalFailure = (res, label) => {
551
+ if (res.ok)
552
+ return;
553
+ const msg = res.error || '接口异常';
554
+ if (res.errorKind === 'deterministic') {
555
+ globalNotices.push(`${label}: ${describeDeterministicError(msg) || msg}`);
556
+ }
557
+ else {
558
+ globalFailures.push(`${label}: ${msg}`);
559
+ }
560
+ };
561
+ collectGlobalFailure(reposRes, '仓库列表拉取失败');
562
+ collectGlobalFailure(prsRes, '个人 PR 拉取失败');
242
563
  if (globalFailures.length > 0) {
243
564
  const globalNetReason = globalFailures.join(';');
244
- const fallbackGlobal = globalCache.getFallback('global') || globalCache.get('global', now);
565
+ const fallbackGlobal = globalCache.getFallback(globalCacheKey) || globalCache.get(globalCacheKey, now);
245
566
  if (fallbackGlobal) {
246
567
  sendJson(res, 200, {
247
568
  ok: true,
@@ -272,6 +593,21 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
272
593
  }
273
594
  if (prsRes.ok && Array.isArray(prsRes.data)) {
274
595
  myPrs = prsRes.data;
596
+ /*
597
+ * PR「还有更多」判定。
598
+ *
599
+ * 优先用账号级真实总数做精确比较——`accountCounts.prTotal` 与列表查询
600
+ * 是**同一个口径**(都是 `is:pr author:@me`),所以 `loaded < total` 就是定义。
601
+ * 拿不到总数才回落到「本次是否拉满」的启发式。
602
+ *
603
+ * 为什么精确比较重要:`>= prLimit` 在边界会误报——用户点够次数后页脚一直显示
604
+ * 「加载更多」,点下去却一条都拉不到。
605
+ */
606
+ prsTruncated =
607
+ // 顶到单次上限后「再加载」不可能有新结果(parseListLimit 会把更大的 limit 夹回 100),
608
+ // 必须停止下发 truncated,否则 loaded < total 恒成立 → 页脚永远显示、点不动
609
+ prLimit < LIST_LIMIT_MAX &&
610
+ (accountCounts?.prTotal !== undefined ? myPrs.length < accountCounts.prTotal : myPrs.length >= prLimit);
275
611
  }
276
612
  // 多源 Issues 聚合、按 URL 唯一去重并排序
277
613
  const issueMap = new Map();
@@ -287,6 +623,24 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
287
623
  collectIssues(ownerIssuesRes);
288
624
  collectIssues(assignedIssuesRes);
289
625
  collectIssues(authorIssuesRes);
626
+ /*
627
+ * Issues 的「还有更多」判定:三路检索只要有一路拉满,就说明可能还有更早的记录。
628
+ * 三路结果会被去重合并,所以不能拿 myIssues.length 反推(去重后天然变小)。
629
+ *
630
+ * 这里**故意不用** accountCounts.issueTotal 做精确比较:那个总数用的是
631
+ * `is:issue involves:@me`(author/assignee/mention/commenter 的超集),
632
+ * 而列表是三路(owner/assignee/author)的并集,前者恒 ≥ 后者。
633
+ * 拿它比较会得到 `loaded < total` 永远成立 → 页脚永远显示、点了却拉不到东西。
634
+ */
635
+ for (const r of [ownerIssuesRes, assignedIssuesRes, authorIssuesRes]) {
636
+ // 已顶到单次上限时不再喊「还有更多」:再点也只会拿到同样的数据(死循环)
637
+ if (issueLimit >= LIST_LIMIT_MAX)
638
+ break;
639
+ if (r.ok && Array.isArray(r.data) && r.data.length >= issueLimit) {
640
+ issuesTruncated = true;
641
+ break;
642
+ }
643
+ }
290
644
  const rawList = Array.from(issueMap.values());
291
645
  // 统一按最后更新时间降序排列
292
646
  rawList.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
@@ -343,8 +697,14 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
343
697
  lastUpdated: new Date().toISOString(),
344
698
  updateInfo: checker.getQuickStatus(),
345
699
  ...(workspaceError ? { workspaceError } : {}),
700
+ // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
701
+ ...(prsTruncated ? { prsTruncated: true } : {}),
702
+ ...(issuesTruncated ? { issuesTruncated: true } : {}),
703
+ // 账号级总量(与分页无关);计数查询失败时整组缺席,前端回落已加载条数
704
+ ...(accountCounts || {}),
705
+ ...(globalNotices.length > 0 ? { notices: globalNotices } : {}),
346
706
  };
347
- globalCache.set('global', globalData, now);
707
+ globalCache.set(globalCacheKey, globalData, now);
348
708
  sendJson(res, 200, { ok: true, data: globalData, fromCache: false });
349
709
  }
350
710
  catch (err) {
@@ -1,4 +1,4 @@
1
- import { sendJson, readJsonBody } from './route-helpers.js';
1
+ import { sendJson, readJsonBody, guardLocalWrite } from './route-helpers.js';
2
2
  export function registerProxyRoutes(deps, checker, proxyManager) {
3
3
  const { bindRoute } = deps;
4
4
  // 1. 代理配置:GET 读取状态 / POST 保存并立即生效(写 proxy.json + 重应用 dispatcher,零插件重载)
@@ -18,6 +18,9 @@ export function registerProxyRoutes(deps, checker, proxyManager) {
18
18
  sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
19
19
  return;
20
20
  }
21
+ // 跨站门禁:代理配置写入会改变插件全部外联流量的走向。
22
+ if (!guardLocalWrite(req, res, { requireJson: true }))
23
+ return;
21
24
  if (!proxyManager) {
22
25
  sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
23
26
  return;
@@ -47,6 +50,10 @@ export function registerProxyRoutes(deps, checker, proxyManager) {
47
50
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
48
51
  return;
49
52
  }
53
+ // 无请求体,只校验 Origin。保持「每个 POST 端点都有跨站门禁」这条不变量,
54
+ // 否则下一个人加端点时又会漏。
55
+ if (!guardLocalWrite(req, res))
56
+ return;
50
57
  if (!proxyManager) {
51
58
  sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
52
59
  return;