mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-08 07:58:38 -10:00
feat(serve): backfill session PR bindings and refresh their merge state (#9729)
* feat(serve): backfill session PR bindings and refresh their merge state Legacy sessions predate the PR-binding feature, so the sidebar had no way to answer 'which session produced PR N'. An on-demand route scans every trusted workspace's persisted sessions, resolves PR numbers from the worktree slug/branch convention and from transcript gitBranch x gh headRefName intersections (the dominant source in practice), and writes the existing .pr.json sidecars. Bound PRs now carry a state snapshot (open/merged/closed) that a 5-minute daemon sweep advances via a slim gh pr list --state all query, and the sidebar badge dims merged PRs while the tooltip names merged/closed ones. * fix(serve): keep the PR-state refresh out of the serve fast-path closure run-qwen-serve is a pre-listen bundle root whose static closure must stay free of the SessionService chain (glob et al.). Loading session-pr-refresh statically pulled that chain in; a dynamic import() of the core barrel from inside the refresh module was worse — it made the barrel's full namespace live and poisoned the shared chunk for every static barrel importer (ACP agent included). Load the whole refresh module through a dynamic import at timer start instead, guarded by a generation counter against dispose races. * test(serve): cover the refresh env switch and GitDialog state binding process-env-guard scans serve sources for process.env reads; register QWEN_SESSION_PR_REFRESH_MINUTES as a documented process-scoped switch. GitDialog now sends state 'open' with the binding, so the dialog tests assert it. * fix(serve): close review holes in session PR state refresh and backfill Review round 1 findings on the PR-state feature. The refresh interval resolver validated minutes but not the converted milliseconds: values above setInterval's 32-bit max clamp to 1 ms (a "monthly" interval becomes a continuous sweep hot loop), sub-minute values degenerate the same way, and a blank env value parsed as 0 and silently disabled the sweep. Fall back to the default for all three; keep 0 as the disable. parsePrNumberFromWorktree accepted `pr-0` (a legal user slug) and bound number 0, which isValidSessionPr rejects — invalidating the whole sidecar and permanently dropping prior bindings on the next upsert. Gate both slug and branch conventions on n > 0. Live-only sessions (bound before their first transcript flush) bypassed both the sidecar-wins state merge and the sweep, freezing their PR state at bind time. All three listing paths now build the live-only insertion through a shared helper that best-effort reads the sidecar with the same sidecar-wins merge rule, and the sweep enumerates `.pr.json` sidecars directly (new SessionService.listSessionIdsWithPrSidecar) instead of walking transcripts — discovering unflushed sessions and removing the pagination loop entirely. Backfill bound every resolved number and let upsertSessionPr evict past the cap, so sessions with more than SESSION_PR_LIST_LIMIT PRs re-bound the evicted numbers forever, rotating the badge's latest entry each run. Bind only the cap's tail (excess counted as overLimit), making repeat runs converge to bound: 0. Both the sweep and backfill write phases also isolate per-sidecar failures instead of aborting the rest. * fix(serve): correct PR binding map, sweep isolation, and fast-path state - Backfill branch-to-PR mapping is first-write-wins on the newest-first gh list, so a reused head branch binds the newest PR, not the oldest. - The over-cap slice reserves a slot for the convention (pr-<N>) number instead of evicting it first. - The refresh sweep re-checks project membership for sidecar-discovered sessions, so sanitized-cwd collisions cannot cross-rewrite sidecars. - The live-only list fast path merges the PR sidecar like the persisted paths, rendering the sweep-refreshed state instead of bind-time state. - The invalid-pr 400 message now names the state constraint; the permission-based sweep test gets the repo's win32/root guard. * fix(serve): keep convention PRs bound and sweep past malformed heads - The over-cap slice wrote the convention (pr-<N>) number first, making it the sidecar's oldest entry; the next run binding any new number evicted it and silently unbound the session's own PR. Bind it last so it stays the newest entry and growth evicts a branch mapping instead. - The branch-to-PR map was first-write-wins, correct only while the slim list kept gh's newest-first order (updatedAt is not fetched, so the parser sort is a no-op). The highest number now wins a reused head branch regardless of arrival order. - A transcript head without a string cwd made the sidecar belongs-check throw ERR_INVALID_ARG_TYPE outside its fail-open path, aborting the whole workspace's refresh sweep on every tick. Treat it as inconclusive like a missing transcript. - Pin the reviewed behaviors: one batched gh fetch per workspace, merged-head binding, the cap slice's exact survivors, a second backfill run introducing a new candidate, the accepted fail-open under a real sanitizeCwd collision, and the live-only fast path's explicit runtimeBaseDir sidecar read. * fix(serve): keep convention PRs bound through cap evictions and state re-binds Backfill tracked bound numbers in a start-of-run snapshot, so capped writes that evicted the convention number (left in the oldest slot by pre-fix runs or foreign entries) dropped it while counting it alreadyBound. Bind the convention number last on every run and rebuild the bound set from each persisted list so an evicted number is re-bound in the same run. Also include `state` in the bridge's same-binding early return, memoize a failed git-remote lookup per workspace, and add witnesses: convention retention across accumulating/overflowing runs, draft→open normalization, corrupt/unreadable sidecar skip arms, already-bound sidecar stability, and workspace effectiveEnv forwarding. * fix(serve): plan backfill writes around unresolvable bindings, pin state gates * fix(serve): finalize the backfill cap plan inside the mutation queue * fix(serve): guard backfill writes against concurrent evictions and deletions The in-queue planner rebuilt additions from the urls map, but the URL-resolution loop skips numbers the snapshot already held, so a planned number evicted concurrently at the cap was re-added without a URL; the invalid entry failed isValidSessionPr and voided the whole sidecar. Gate additions on a resolved URL and let the next run re-bind the displaced number instead. The planner also wrote into sessions deleted or archive-moved between the snapshot read and the queued write — removeSessionFiles unlinks sidecars outside the mutation queue — resurrecting orphan sidecars the refresh sweep would then service forever. Abort the queued write when the scanned transcript is gone. Witnesses: cap eviction of a planned number, deletion over null and non-empty snapshots, archive-mid-run. * fix(serve): never bind sessions through the default branch name Backfill's transcript-branch→PR mapping keyed on bare branch names, so a session run on the repository's default branch bound to whatever PR carried that head branch — including fork PRs opened from the fork's default branch, which gh reports without owner qualification. Skip the default branch (git symbolic-ref refs/remotes/origin/HEAD) when building the mapping. Also: the refresh sweep's `updated` counter now sums actual rewrites (updateSessionPrStates returns the changed-entry count) instead of every pending binding present on the gh page; the git PATH-shim test is skipped on win32, where cmd.exe/PATHEXT never executes the extensionless shim; and startSessionPrRefreshTimer gains tests for its trust guard, disabled short-circuit, re-entrancy guard, and dispose. * fix(serve): notify catalog on PR binding writes, sanitize remote lookup, guard refresh import * fix(serve): invalidate eviction-only backfill writes, fail closed on unknown default branch (#9729) * fix(serve): report gh degradation on backfill, pin remaining PR-state gaps (#9729) * test(cli): complete SubagentManager mock for AgentTool init (#9729) * fix(serve): key PR state refresh by repository and harden sidecar writes (#9729) Address the round-13/14 Critical review findings on the session PR state feature: - updateSessionPrStates applies a fetched state only when the fetched PR's url matches the binding's url, so a workspace PR can no longer stamp its state onto a binding pointing at another repository's same-numbered PR (a wrong terminal state was permanent — merged entries leave the sweep). - The refresh sweep re-checks sidecar liveness at the write commit step via assertCanCommit, so a session deleted or archived mid-sweep no longer gets its sidecar resurrected at the stale path. - collectTranscriptBranches parses each transcript line as JSON and reads only the top-level gitBranch, so nested structured values (tool-call arguments/results, MCP payloads) can no longer inject a branch the session never ran on. - Backfill syncs the hydrated bridge entry through a new overwrite-capable bridge.setSessionPrs after a capped plan evicts bindings, so the summary merge can no longer resurrect evicted numbers from a stale live entry until daemon restart. * fix(serve): stop cross-repo PR state carry and backfill re-bind eviction (#9729) Address the round-15 Critical review findings: - upsertSessionPr and the bridge's updateSessionMetadata carry an existing entry's state onto a re-bind only when the binding target is the same PR: urls are compared in canonical form (host/path case, trailing slash, query, and fragment never change which PR a url names), so a different repository's same-numbered PR no longer inherits a state the refresh sweep can never heal. - The backfill planner treats an entry committed after the run's snapshot read as foreign, so a client re-bind of a snapshot-held planned number can no longer be trimmed out of the plan and evicted by the capped rewrite the daemon just confirmed; slots recompute around it and the live-entry sync publishes the surviving binding. * fix: compare session PR urls canonically in sweep and backfill planner (#9729) * test(core): repair telemetry-swap config mock for skill restore on resume (#9729) #10016 added a skill-restore step to GeminiClient.initialize() on resumed sessions that calls config.getToolRegistry(); this file's minimal config mock never implemented it, so every resume-path test failed. Stub the registry with no skill tool so the restore step is a no-op. * fix: repair backfill scan mtime ties, live-entry sync race, and cap double-billing (#9729) * fix(serve): run PR-state sidecar commits under the generation guard and archive lane (#9729) The refresh sweep and POST /sessions/backfill-prs snapshot a workspace runtime from the registry and then await sidecar scans, gh, and queued writes. Unlike every REST/ACP binding writer, neither asserted the runtime's generation guard, so a trust/env replacement or removal that landed mid-run left the retired generation running gh with its stale env, committing sidecars, and notifying its obsolete bridge. Both now assert the guard before gh, inside each atomic commit, and before the cache invalidation + catalog bump; a closed generation aborts the run instead of being miscounted as a write error. The existence check that narrowed R7-3/R14-2 ran in the planner before the temp-file write and rename, so archive/delete could still move the transcript and sidecar in between and the rename recreated an orphan .pr.json. Each commit (and, for backfill, the live-entry sync that follows it) now runs under the session's shared SessionArchiveCoordinator lane — the lane archive/delete take exclusively across their renames — so the two never interleave: an archive in flight defers the session to the next run, an archive attempted during a commit is refused, and a draining daemon stops the sweep. The daemon hands the app-wide coordinator to the route directly and to the timer through a per-tick lookup, since the serve app is built after the timer starts. --------- Co-authored-by: qwen-code-ci-bot <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: qwen-code-autofix[bot] <qwen-code-autofix[bot]@users.noreply.github.com>
This commit is contained in:
35 files changed
+6534
-134
No files matched your search
@@ -20,10 +20,11 @@ Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答"哪个会话
|
||||
`DaemonSessionSummary` 与 `BridgeSessionSummary`(镜像,需同步)增加:
|
||||
|
||||
```json
|
||||
"prs": [{ "number": 9517, "url": "https://github.com/owner/repo/pull/9517" }]
|
||||
"prs": [{ "number": 9517, "url": "https://github.com/owner/repo/pull/9517", "state": "open" }]
|
||||
```
|
||||
|
||||
- 一个会话可能创建多个 PR(stacked PR、连续修复),`prs` 按绑定时间排序(最后一个 = 最新),上限 10 个(超出丢弃最旧)。同号重复绑定刷新 url 并移到最新位。
|
||||
- `state`:可选快照,取值 `open` / `merged` / `closed`;写入端(GitDialog)记 `open`,回填记 gh 查询时刻的值,刷新定时器负责 open→merged/closed 迁移。badge 对 merged 弱化显示。
|
||||
- `number`:正整数;`url`:http(s) URL(badge/tooltip 直接作为链接目标渲染,拒绝 `javascript:` 等 scheme——route、bridge、SDK 校验器、sidecar 校验四层统一要求)。
|
||||
- 字段可选、可缺省;不提供"清除"语义。
|
||||
- 写入 API 保持单条:`updateSessionMetadata(sessionId, { pr: {number, url} })` 每次绑定一个,daemon 负责 upsert 进列表;读取/事件/响应均为完整 `prs` 数组。
|
||||
@@ -42,7 +43,7 @@ Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答"哪个会话
|
||||
|
||||
- 新 core 服务 `packages/core/src/services/session-pr-service.ts`:`SessionPr` 接口、数组 schema 校验(`{prs: [...]}`,容忍 ENOENT/JSON 损坏)、`readSessionPrs` / `writeSessionPrs` / `upsertSessionPr`(按 number 去重、移到最新、cap 10)。
|
||||
- `SessionService` 增加 `getPrSessionPathForArchiveState` 路径助手;归档/取消归档移动 sidecar、删除会话时清理(与 worktree sidecar 一一对应)。
|
||||
- `session-list.ts` 的 `enrichPrSidecars` 回填 persisted summary 的 `prs`;live 会话的 entry.prs 只含本 daemon 生命周期内的绑定,回填时与 sidecar 历史按 number 合并(live 的 url 优先,live-only 的排最后)。
|
||||
- `session-list.ts` 的 `enrichPrSidecars` 回填 persisted summary 的 `prs`;live 会话的 entry.prs 只含本 daemon 生命周期内的绑定,回填时与 sidecar 历史按 number 合并(live 的 url 优先,live-only 的排最后;`state` 以 sidecar 为准——定时器只刷 sidecar,live entry 停在绑定时刻)。
|
||||
|
||||
### 展示与搜索(web-shell 侧栏)
|
||||
|
||||
@@ -52,6 +53,27 @@ Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答"哪个会话
|
||||
- SSE 消费侧:web-shell 不直接消费 `session_metadata_updated` 更新 store;bridge 的 `markSessionCatalogChanged()` 触发 catalog revision bump,侧栏 live-state 轮询(2s 周期)发现后自动 refetch——badge 在绑定后 ~2s 内出现(与改名等其他客户端变更的传播机制一致)。
|
||||
- i18n:新增 `sidebar.sessionPr` / `sidebar.sessionPrMultiple` 两个 key(EN/ZH)。
|
||||
|
||||
### 存量回填(按需)
|
||||
|
||||
新增 daemon 路由 `POST /sessions/backfill-prs`(进程级、按需触发,启动不自动扫描),实现见 `packages/cli/src/serve/routes/session-pr-backfill.ts`:
|
||||
|
||||
- 遍历 registry 中所有 trusted workspace runtime;每个 workspace 扫描 persisted 会话(active + archived),候选分支 = worktree sidecar branch ∪ transcript 各记录的 `gitBranch`(正则提取,distinct 上限 64),解析双源:
|
||||
1. **约定**:slug `pr-<N>` / branch `worktree-pr-<N>` 直接给出 PR 号(零网络);
|
||||
2. **gh 批量**:每 workspace 一次 `fetchGitHubPullRequests({state:'all', limit:500, slim:true})`,把候选分支与 headRefName 交集映射到 number + url。实测存量里 `pr-<N>` slug 与 worktree branch 几乎零命中(PR 基本不从 worktree 分支提交),`gitBranch` 是主力来源(主 workspace 342 会话命中 272)。
|
||||
- `slim` 只取 number/url/headRefName:带 CI rollup 的全字段查询在 `--state all --limit 500` 下触发 GitHub GraphQL 504;slim 约 4s/60KB。
|
||||
- URL 优先取 gh 映射;gh 不可用时由 git remote web URL 推导 `<repo>/pull/<N>`(支持 https / scp 风格 ssh / `ssh://` 与 enterprise host,仅约定号);解析不到号的会话原样跳过。
|
||||
- 已知副作用:曾 checkout/review 他人 PR 分支的会话也会被绑定(多 PR 列表可容纳,搜索语义上属于"相关会话")。
|
||||
- 已绑定同一 number 的会话跳过(不刷新 createdAt),重复调用幂等;写入复用 `upsertSessionPr`。
|
||||
- 响应按 workspace 聚合 `scanned/bound/alreadyBound/unresolved`,并以 `ghAvailable` 标记 gh 查询是否成功(`false` 表示分支映射本轮被跳过,`bound: 0` 是降级结果而非真空);untrusted workspace 跳过。
|
||||
|
||||
### 合入状态快照 + 定时刷新
|
||||
|
||||
- **快照来源**:slim 查询增取 gh `state`(OPEN/MERGED/CLOSED → `open`/`merged`/`closed`);回填写入当时值,GitDialog 新建记 `open`。
|
||||
- **定时器**:daemon 启动后挂独立低频任务(不挂列表轮询热路径),默认 **5 分钟**一轮,环境变量 `QWEN_SESSION_PR_REFRESH_MINUTES` 可调(`0` = 关闭);`unref()` 不阻碍进程退出,首轮延迟启动避开 boot。
|
||||
- **每轮**:遍历 trusted workspace → 读 `.pr.json` sidecar 挑出非 merged 绑定 → 无目标直接跳过(零 gh 调用)→ 有目标发一次 slim `gh pr list --state all --limit 500` → `updateSessionPrStates` 原地回写 state(**不重排顺序、不刷新 createdAt**,与 upsert 共享同一路径写入队列避免竞态)→ badge 经现有 2s 轮询自动更新。
|
||||
- **成本**:只对含未合入绑定的 workspace 发查询(每 ~4s);gh 不可用时静默跳过该轮。
|
||||
- **明确不做**:不自动发现新 PR(agent shell 里 `gh pr create` 的发现需重扫 transcript,全量 6 分钟不适合进定时器;GitDialog 创建路径自绑定已覆盖主流)。
|
||||
|
||||
## 关键决策
|
||||
|
||||
- **绑定时机 = GitDialog 创建 PR 成功时**。Agent 在 shell 里自行 `gh pr create` 的路径无法拦截,MVP 不覆盖;用户主力流程是 GitDialog。
|
||||
@@ -71,14 +93,14 @@ Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答"哪个会话
|
||||
| core | `packages/core/src/services/session-pr-service.ts`(新增)+ SessionService 路径助手/归档移动/删除清理 |
|
||||
| daemon 路由 | `packages/cli/src/serve/routes/session.ts`(两个 PATCH 路由校验 + sidecar 写入)、`acp-http/dispatch.ts`(ACP `session/update_metadata` 的 sidecar 写入) |
|
||||
| daemon 列表 | `packages/cli/src/serve/server/session-list.ts`(enrichPrSidecars) |
|
||||
| daemon 回填 | `packages/cli/src/serve/routes/session-pr-backfill.ts`(`POST /sessions/backfill-prs`,存量会话按需回填) |
|
||||
| web-shell | `GitDialog.tsx`(回写)、`WebShellSidebar.tsx`(badge + 搜索)、`SessionDetailsTooltip.tsx`(PR 行)、locale 文件 |
|
||||
| 测试 | 上述各层的 collocated 单测 |
|
||||
|
||||
## 范围边界(明确不做)
|
||||
|
||||
- 服务端分页过滤(20+ 会话规模客户端搜索足够;`sourceType/sourceId` 过滤管道是将来扩展的样板)。
|
||||
- 历史会话迁移;CLI `--worktree=#<pr>` 的 `pr-<n>` slug 由搜索匹配 slug 顺带覆盖。
|
||||
- 纯 branch 会话 branch 信息重启丢失的独立 bug,另行处理。
|
||||
- 无 worktree sidecar 的纯 branch 会话:branch 未持久化,回填无可靠来源,不覆盖。
|
||||
- Agent shell 内 `gh pr create` 的自动发现。
|
||||
|
||||
## 开放问题
|
||||
|
||||
@@ -27672,6 +27672,42 @@ describe('createAcpSessionBridge', () => {
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('replaces this-daemon-lifetime bindings on setSessionPrs', async () => {
|
||||
// The backfill cap trim evicts persisted bindings; the hydrated live
|
||||
// entry must follow the sidecar (unlike seed, which defers to
|
||||
// existing bindings) or the summary merge resurrects the evicted
|
||||
// numbers. Unknown session ids are no-ops, not throws.
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: { number: 1, url: 'https://github.com/o/r/pull/1' },
|
||||
});
|
||||
expect(() =>
|
||||
bridge.setSessionPrs?.('no-such-session', [
|
||||
{ number: 9, url: 'https://github.com/o/r/pull/9' },
|
||||
]),
|
||||
).not.toThrow();
|
||||
bridge.setSessionPrs?.(session.sessionId, [
|
||||
{
|
||||
number: 3,
|
||||
url: 'https://github.com/o/r/pull/3',
|
||||
state: 'merged',
|
||||
},
|
||||
{ number: 4, url: 'https://github.com/o/r/pull/4' },
|
||||
]);
|
||||
|
||||
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual([
|
||||
{ number: 3, url: 'https://github.com/o/r/pull/3', state: 'merged' },
|
||||
{ number: 4, url: 'https://github.com/o/r/pull/4' },
|
||||
]);
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('logs a stderr audit record when a pr binding is added', async () => {
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
@@ -27763,6 +27799,188 @@ describe('createAcpSessionBridge', () => {
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('republishes when the same binding returns with a new state', async () => {
|
||||
// The same-binding early return must compare state too: the sidecar
|
||||
// write the caller makes after this returns would otherwise disagree
|
||||
// with the live entry, the event stream, and the response.
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const before = bridge.getSessionCatalogVersion().revision;
|
||||
|
||||
const events: BridgeEvent[] = [];
|
||||
const sub = bridge.subscribeEvents(session.sessionId);
|
||||
const drain = (async () => {
|
||||
for await (const ev of sub) events.push(ev);
|
||||
})();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open',
|
||||
},
|
||||
});
|
||||
const effective = bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
});
|
||||
|
||||
expect(effective.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual(
|
||||
effective.prs,
|
||||
);
|
||||
expect(bridge.getSessionCatalogVersion().revision).toBe(before + 2);
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const prEvents = events.filter(
|
||||
(e) =>
|
||||
e.type === 'session_metadata_updated' &&
|
||||
(e.data as { prs?: unknown }).prs !== undefined,
|
||||
);
|
||||
// Both binds publish: subscribers must see the open->merged
|
||||
// transition, not only the final live entry.
|
||||
expect(prEvents).toHaveLength(2);
|
||||
expect(
|
||||
(prEvents[1]?.data as { prs: Array<{ state?: string }> }).prs?.[0]
|
||||
?.state,
|
||||
).toBe('merged');
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await drain;
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('preserves the known state on a stateless re-bind', async () => {
|
||||
// Mirrors the sidecar's `bound.state ?? known?.state`: SDK/ACP
|
||||
// clients omit state on re-bind, and the live entry must not reset.
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
});
|
||||
const effective = bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517?v=2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(effective.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517?v=2',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual(
|
||||
effective.prs,
|
||||
);
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('does not carry state across a cross-repository re-bind', async () => {
|
||||
// Twin of the sidecar's url-gated carry: a same-numbered PR of
|
||||
// another repository is a different PR and must not inherit the
|
||||
// previous binding's state.
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/repo-a/o/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
});
|
||||
const effective = bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/repo-b/o/pull/9517',
|
||||
},
|
||||
});
|
||||
|
||||
expect(effective.prs).toEqual([
|
||||
{ number: 9517, url: 'https://github.com/repo-b/o/pull/9517' },
|
||||
]);
|
||||
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual(
|
||||
effective.prs,
|
||||
);
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('rejects a pr state outside the enum and names the constraint', async () => {
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
// 'draft' is a likely mistake: gh reports it, but the sidecar and
|
||||
// the badge only know open/merged/closed.
|
||||
expect(() =>
|
||||
bridge.updateSessionMetadata(session.sessionId, {
|
||||
pr: {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'draft',
|
||||
} as { number: number; url: string },
|
||||
}),
|
||||
).toThrow(/`state` of `open`, `merged`, or `closed`/);
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('propagates state through seedSessionPrs', async () => {
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
bridge.seedSessionPrs?.(session.sessionId, [
|
||||
{
|
||||
number: 9500,
|
||||
url: 'https://github.com/o/r/pull/9500',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual([
|
||||
{
|
||||
number: 9500,
|
||||
url: 'https://github.com/o/r/pull/9500',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
|
||||
await bridge.closeSession(session.sessionId);
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('bumps the catalog revision when a pr is bound', async () => {
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
TURN_RESULT_CODE_TEXT_TRUNCATED,
|
||||
TURN_RESULT_TEXT_MAX_CHARS,
|
||||
TrustGateError,
|
||||
canonicalSessionPrUrl,
|
||||
normalizeTurnResultError,
|
||||
normalizeSnapshotPayload,
|
||||
ShellExecutionService,
|
||||
@@ -10283,11 +10284,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
// The url is interpolated into the stderr audit line — control
|
||||
// characters would let a client forge log lines (the displayName
|
||||
// branch rejects them for the same reason).
|
||||
hasControlCharacter((pr as SessionPrInfo).url)
|
||||
hasControlCharacter((pr as SessionPrInfo).url) ||
|
||||
((pr as SessionPrInfo).state !== undefined &&
|
||||
(pr as SessionPrInfo).state !== 'open' &&
|
||||
(pr as SessionPrInfo).state !== 'merged' &&
|
||||
(pr as SessionPrInfo).state !== 'closed')
|
||||
) {
|
||||
throw new InvalidSessionMetadataError(
|
||||
'pr',
|
||||
`must be an object with a positive integer \`number\` and an http(s) \`url\` of at most ${SESSION_PR_URL_MAX_LENGTH} characters, without control characters`,
|
||||
`must be an object with a positive integer \`number\` and an http(s) \`url\` of at most ${SESSION_PR_URL_MAX_LENGTH} characters, without control characters, and an optional \`state\` of \`open\`, \`merged\`, or \`closed\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10362,13 +10367,36 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const bound = metadata.pr;
|
||||
const existing = entry.prs ?? [];
|
||||
const latest = existing[existing.length - 1];
|
||||
if (latest?.number === bound.number && latest.url === bound.url) {
|
||||
if (
|
||||
latest?.number === bound.number &&
|
||||
latest.url === bound.url &&
|
||||
// A re-bind carrying a new state is a change: the live entry,
|
||||
// the metadata event, and the catalog revision must all see it.
|
||||
(bound.state === undefined || bound.state === latest.state)
|
||||
) {
|
||||
// Same binding repeated — no change, no event.
|
||||
} else {
|
||||
// Re-binding a number refreshes it and moves it to latest.
|
||||
// Re-binding a number refreshes it and moves it to latest; an
|
||||
// omitted state preserves the known one (mirrors the sidecar) —
|
||||
// only for the same PR: a different repository's same-numbered
|
||||
// PR is a different PR and must not inherit its state.
|
||||
const known = existing.find(
|
||||
(p) =>
|
||||
p.number === bound.number &&
|
||||
canonicalSessionPrUrl(p.url) === canonicalSessionPrUrl(bound.url),
|
||||
);
|
||||
entry.prs = [
|
||||
...existing.filter((p) => p.number !== bound.number),
|
||||
{ number: bound.number, url: bound.url },
|
||||
{
|
||||
number: bound.number,
|
||||
url: bound.url,
|
||||
...((bound.state ?? known?.state)
|
||||
? {
|
||||
state: (bound.state ??
|
||||
known?.state) as SessionPrInfo['state'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
].slice(-SESSION_PR_LIST_LIMIT);
|
||||
markSessionCatalogChanged();
|
||||
writeStderrLine(
|
||||
@@ -10409,7 +10437,23 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry || (entry.prs && entry.prs.length > 0)) return;
|
||||
entry.prs = prs
|
||||
.map(({ number, url }) => ({ number, url }))
|
||||
.map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}))
|
||||
.slice(-SESSION_PR_LIST_LIMIT);
|
||||
},
|
||||
|
||||
setSessionPrs(sessionId, prs) {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) return;
|
||||
entry.prs = prs
|
||||
.map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}))
|
||||
.slice(-SESSION_PR_LIST_LIMIT);
|
||||
},
|
||||
|
||||
|
||||
@@ -768,6 +768,8 @@ export interface BridgeSessionGoal {
|
||||
export interface SessionPrInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
/** Snapshot of the PR's state at last bind/refresh; optional. */
|
||||
state?: 'open' | 'merged' | 'closed';
|
||||
}
|
||||
|
||||
export interface SessionMetadataUpdate {
|
||||
@@ -1564,6 +1566,17 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
|
||||
*/
|
||||
seedSessionPrs?(sessionId: string, prs: SessionPrInfo[]): void;
|
||||
|
||||
/**
|
||||
* Replace the in-memory PR binding list of a live session with the
|
||||
* persisted sidecar contents after a rewrite that can evict bindings
|
||||
* (the backfill cap trim). Unlike {@link seedSessionPrs}, overwrites an
|
||||
* entry that already holds bindings, so the summary merge cannot
|
||||
* resurrect evicted numbers from a stale entry. No-op when the entry is
|
||||
* unknown (session not live). Callers own sidecar I/O; the bridge stays
|
||||
* storage-agnostic. Optional so lightweight fakes may omit it.
|
||||
*/
|
||||
setSessionPrs?(sessionId: string, prs: SessionPrInfo[]): void;
|
||||
|
||||
/**
|
||||
* List the structured artifacts registered for a live session. Throws
|
||||
* `SessionNotFoundError` when the id is unknown.
|
||||
|
||||
@@ -3051,6 +3051,7 @@ export class AcpDispatcher {
|
||||
typeof boundPr['number'] === 'number' &&
|
||||
typeof boundPr['url'] === 'string'
|
||||
) {
|
||||
const boundState = boundPr['state'];
|
||||
const persistedPrs = (
|
||||
await upsertSessionPr(
|
||||
service.getPrSessionPathForArchiveState(
|
||||
@@ -3060,9 +3061,18 @@ export class AcpDispatcher {
|
||||
{
|
||||
number: boundPr['number'],
|
||||
url: boundPr['url'],
|
||||
...(boundState === 'open' ||
|
||||
boundState === 'merged' ||
|
||||
boundState === 'closed'
|
||||
? { state: boundState }
|
||||
: {}),
|
||||
},
|
||||
)
|
||||
).map(({ number, url }) => ({ number, url }));
|
||||
).map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}));
|
||||
// Reply with the authoritative persisted list, mirroring the
|
||||
// REST metadata routes.
|
||||
result = { ...result, prs: persistedPrs };
|
||||
|
||||
@@ -5629,7 +5629,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
||||
params: {
|
||||
sessionId,
|
||||
metadata: {
|
||||
pr: { number: 9101, url: 'https://github.com/o/r/pull/9101' },
|
||||
pr: {
|
||||
number: 9101,
|
||||
url: 'https://github.com/o/r/pull/9101',
|
||||
state: 'merged',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -5650,10 +5654,21 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
||||
'id' in frame &&
|
||||
(frame as { id: unknown }).id === 471,
|
||||
) as
|
||||
| { result?: { prs?: Array<{ number: number; url: string }> } }
|
||||
| {
|
||||
result?: {
|
||||
prs?: Array<{ number: number; url: string; state?: string }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(reply?.result?.prs?.map((entry) => entry.number)).toEqual([
|
||||
9100, 9101,
|
||||
// The reply echoes the persisted state: the dispatch persistence arm
|
||||
// must carry `state` into the sidecar write, not drop it.
|
||||
expect(reply?.result?.prs).toEqual([
|
||||
{ number: 9100, url: 'https://github.com/o/r/pull/9100' },
|
||||
{
|
||||
number: 9101,
|
||||
url: 'https://github.com/o/r/pull/9101',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
// The entry must be re-hydrated from the sidecar BEFORE the mutation
|
||||
// so the `session_metadata_updated` event payload is complete too.
|
||||
@@ -5665,6 +5680,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
||||
).toEqual([9100]);
|
||||
const persisted = await readSessionPrs(sidecarPath);
|
||||
expect(persisted?.map((entry) => entry.number)).toEqual([9100, 9101]);
|
||||
expect(persisted?.find((entry) => entry.number === 9101)?.state).toBe(
|
||||
'merged',
|
||||
);
|
||||
} finally {
|
||||
registry.dispose();
|
||||
rememberLane.dispose();
|
||||
|
||||
@@ -267,6 +267,16 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
accesses: { whole: 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/server/session-pr-refresh.ts',
|
||||
{
|
||||
reason:
|
||||
'The PR-state refresh interval (QWEN_SESSION_PR_REFRESH_MINUTES) is a ' +
|
||||
'process-scoped operator switch; embedded callers may omit the ' +
|
||||
'environment argument.',
|
||||
accesses: { whole: 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/live/live-host-coordinator.ts',
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Application, RequestHandler } from 'express';
|
||||
import {
|
||||
SESSION_PR_LIST_LIMIT,
|
||||
canonicalSessionPrUrl,
|
||||
fetchGitHubPullRequests,
|
||||
getDefaultBranch,
|
||||
gitEnv,
|
||||
readSessionPrs,
|
||||
readWorktreeSession,
|
||||
replaceSessionPrs,
|
||||
type SessionArchiveState,
|
||||
type SessionPr,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SendBridgeError } from '../server/error-response.js';
|
||||
import { DaemonDrainingError } from '../server/session-archive.js';
|
||||
import { invalidateWorkspaceSessionListCache } from '../server/session-list.js';
|
||||
import type { SessionPrArchiveLane } from '../server/session-pr-refresh.js';
|
||||
import { createWorkspaceRuntimeSessionService } from '../workspace-runtime-storage.js';
|
||||
import {
|
||||
WorkspaceGenerationClosedError,
|
||||
type WorkspaceRegistry,
|
||||
type WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
|
||||
export interface SessionPrBackfillOptions {
|
||||
/**
|
||||
* Serialises each session's sidecar commit with archive/delete of that
|
||||
* session (see {@link SessionPrArchiveLane}). The daemon always passes the
|
||||
* app-wide coordinator; only tests omit it.
|
||||
*/
|
||||
archiveCoordinator?: SessionPrArchiveLane;
|
||||
}
|
||||
|
||||
// `--worktree=#<N>` launches persist slug `pr-<N>` with branch
|
||||
// `worktree-pr-<N>` (see worktreeStartup / worktreeBranchForSlug); the
|
||||
// sidecars survive restarts, so they are the zero-network backfill source.
|
||||
const SLUG_PR_PATTERN = /^pr-(\d{1,9})$/;
|
||||
const BRANCH_PR_PATTERN = /^worktree-pr-(\d{1,9})$/;
|
||||
|
||||
/**
|
||||
* Extracts the PR number a worktree sidecar's slug/branch convention names.
|
||||
* The slug wins: a custom-renamed branch under a `pr-<N>` slug still refers
|
||||
* to PR N, while a custom slug keeps a conventional branch matchable.
|
||||
*/
|
||||
export function parsePrNumberFromWorktree(
|
||||
slug?: string,
|
||||
branch?: string,
|
||||
): number | undefined {
|
||||
const slugMatch = SLUG_PR_PATTERN.exec(slug ?? '');
|
||||
if (slugMatch) {
|
||||
const number = Number(slugMatch[1]);
|
||||
// `pr-0` is a legal user slug, but 0 is not a PR number — binding it
|
||||
// would invalidate the whole sidecar (isValidSessionPr rejects it).
|
||||
return number > 0 ? number : undefined;
|
||||
}
|
||||
const branchMatch = BRANCH_PR_PATTERN.exec(branch ?? '');
|
||||
if (branchMatch) {
|
||||
const number = Number(branchMatch[1]);
|
||||
return number > 0 ? number : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a git remote URL (https / ssh / scp-style) to the repository's
|
||||
* web URL, used to build `<repo>/pull/<N>` when `gh` is unavailable.
|
||||
*/
|
||||
export function normalizeRemoteToWebUrl(remote: string): string | undefined {
|
||||
const trimmed = remote.trim();
|
||||
if (!trimmed) return undefined;
|
||||
let input = trimmed;
|
||||
// An ssh:// remote's explicit port is the SSH port, almost never the web
|
||||
// port — ssh-derived URLs drop it (scp-style remotes cannot carry one).
|
||||
// An https remote's port IS the web port and must survive.
|
||||
let sshDerived = false;
|
||||
if (input.startsWith('git@')) {
|
||||
input = `https://${input.slice('git@'.length).replace(':', '/')}`;
|
||||
sshDerived = true;
|
||||
} else if (input.startsWith('ssh://')) {
|
||||
input = `https://${input.slice('ssh://'.length)}`;
|
||||
sshDerived = true;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
|
||||
const pathname = url.pathname.replace(/\.git\/?$/, '');
|
||||
if (!pathname || pathname === '/') return undefined;
|
||||
const host = sshDerived ? url.hostname : url.host;
|
||||
return `${url.protocol}//${host}${pathname}`.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function getRemoteWebUrl(cwd: string): string | undefined {
|
||||
try {
|
||||
const remote = execSync('git remote get-url origin', {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
// Sanitize like every sibling git/gh call in this path: without an
|
||||
// env option the spawn inherits the daemon's raw process.env, and an
|
||||
// inherited GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_* would resolve origin
|
||||
// against a different repository despite the cwd.
|
||||
env: gitEnv(),
|
||||
}).trim();
|
||||
return normalizeRemoteToWebUrl(remote);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionPrBackfillWorkspaceResult {
|
||||
workspaceCwd: string;
|
||||
/** Persisted sessions scanned (active + archived). */
|
||||
scanned: number;
|
||||
/** New PR bindings written by this run (a session may bind several). */
|
||||
bound: number;
|
||||
/** Sidecar writes persisted, including eviction-only rewrites. */
|
||||
written: number;
|
||||
/** Resolved bindings that already existed in the sidecar. */
|
||||
alreadyBound: number;
|
||||
/** Resolved numbers skipped because they exceed the sidecar cap. */
|
||||
overLimit: number;
|
||||
/** Convention numbers whose URL could not be resolved. */
|
||||
unresolved: number;
|
||||
/**
|
||||
* Whether the workspace's `gh pr list` succeeded. False means transcript-
|
||||
* branch mappings were skipped this run, so a zero `bound` count is a
|
||||
* degraded run, not a genuinely empty one.
|
||||
*/
|
||||
ghAvailable?: boolean;
|
||||
/** Sidecar writes that failed; the affected session keeps its bindings. */
|
||||
writeErrors?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BackfillCandidate {
|
||||
sessionId: string;
|
||||
archiveState: SessionArchiveState;
|
||||
/** Transcript path the candidate was scanned from, in this archive state. */
|
||||
transcriptPath: string;
|
||||
/** PR number named by the worktree slug/branch convention, if any. */
|
||||
conventionNumber: number | undefined;
|
||||
/** Worktree branch plus every `gitBranch` seen in the transcript. */
|
||||
branches: readonly string[];
|
||||
}
|
||||
|
||||
// Transcript records carry the branch the session was on; the set is small
|
||||
// per session and only ever compared against PR head branches.
|
||||
const MAX_DISTINCT_BRANCHES = 64;
|
||||
|
||||
async function collectTranscriptBranches(
|
||||
filePath: string,
|
||||
): Promise<readonly string[]> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const branches = new Set<string>();
|
||||
// Structured per-line parse: a `gitBranch` key nested anywhere inside a
|
||||
// record (tool-call arguments/results, MCP payloads) must not be taken
|
||||
// for the record's own top-level branch — a text regex cannot tell them
|
||||
// apart once the record is JSON-stringified.
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line) continue;
|
||||
let record: unknown;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record === null || typeof record !== 'object') continue;
|
||||
const branch = (record as Record<string, unknown>)['gitBranch'];
|
||||
if (typeof branch === 'string' && branch.length > 0) {
|
||||
branches.add(branch);
|
||||
if (branches.size >= MAX_DISTINCT_BRANCHES) break;
|
||||
}
|
||||
}
|
||||
return [...branches];
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills PR bindings onto a workspace's persisted sessions. Sources, in
|
||||
* priority order: the worktree slug/branch convention (names the number
|
||||
* without any network); and one batched `gh pr list --state all` per
|
||||
* workspace mapping head branches — the worktree branch and every
|
||||
* `gitBranch` recorded in the session's transcript — to PR numbers and URLs.
|
||||
* The URL comes from `gh` when available, else from the git remote web URL
|
||||
* (convention numbers only). A session may bind several PRs.
|
||||
*/
|
||||
export async function backfillWorkspaceSessionPrs(
|
||||
runtime: WorkspaceRuntime,
|
||||
fetchPullRequests: typeof fetchGitHubPullRequests = fetchGitHubPullRequests,
|
||||
options: SessionPrBackfillOptions = {},
|
||||
): Promise<SessionPrBackfillWorkspaceResult> {
|
||||
// The route snapshots this runtime from the registry and then awaits
|
||||
// scans, `gh`, and queued writes; a trust/env replacement or removal
|
||||
// closes the generation guard meanwhile, and a retired generation must
|
||||
// not run `gh` with its stale env or commit sidecars — the same guard
|
||||
// every REST/ACP binding writer asserts around `upsertSessionPr`.
|
||||
const assertGenerationOpen = (): void =>
|
||||
runtime.generationGuard?.assertOpen();
|
||||
const result: SessionPrBackfillWorkspaceResult = {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
scanned: 0,
|
||||
bound: 0,
|
||||
written: 0,
|
||||
alreadyBound: 0,
|
||||
overLimit: 0,
|
||||
unresolved: 0,
|
||||
};
|
||||
const sessionService = createWorkspaceRuntimeSessionService(runtime);
|
||||
const candidates: BackfillCandidate[] = [];
|
||||
for (const archiveState of ['active', 'archived'] as const) {
|
||||
// Tie-safe exhaustive enumeration (see listAllProjectSessionIds): the
|
||||
// paged listSessions mtime cursor silently skips sessions tied with a
|
||||
// page's last entry, which an all-sessions sweep must never do.
|
||||
const sessionIds =
|
||||
await sessionService.listAllProjectSessionIds(archiveState);
|
||||
for (const sessionId of sessionIds) {
|
||||
result.scanned += 1;
|
||||
const dir = path.dirname(
|
||||
sessionService.getWorktreeSessionPathForArchiveState(
|
||||
sessionId,
|
||||
archiveState,
|
||||
),
|
||||
);
|
||||
let worktree: Awaited<ReturnType<typeof readWorktreeSession>>;
|
||||
try {
|
||||
worktree = await readWorktreeSession(
|
||||
path.join(dir, `${sessionId}.worktree.json`),
|
||||
);
|
||||
} catch {
|
||||
worktree = null;
|
||||
}
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const branches = [
|
||||
...(worktree ? [worktree.worktreeBranch] : []),
|
||||
...(await collectTranscriptBranches(transcriptPath)),
|
||||
];
|
||||
const conventionNumber = worktree
|
||||
? parsePrNumberFromWorktree(worktree.slug, worktree.worktreeBranch)
|
||||
: undefined;
|
||||
if (branches.length === 0 && conventionNumber === undefined) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
sessionId,
|
||||
archiveState,
|
||||
transcriptPath,
|
||||
conventionNumber,
|
||||
branches,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return result;
|
||||
|
||||
assertGenerationOpen();
|
||||
const numberToUrl = new Map<number, string>();
|
||||
const numberToState = new Map<number, 'open' | 'merged' | 'closed'>();
|
||||
const branchToNumber = new Map<string, number>();
|
||||
const prs = await fetchPullRequests(
|
||||
runtime.workspaceCwd,
|
||||
runtime.env.effectiveEnv,
|
||||
{ state: 'all', limit: 500, slim: true },
|
||||
);
|
||||
result.ghAvailable = prs.kind === 'ok';
|
||||
if (prs.kind === 'ok') {
|
||||
// A session run on the repository's default branch cannot be attributed
|
||||
// to a PR by branch name: fork PRs opened from the fork's default branch
|
||||
// carry that same bare name as their headRefName (gh does not qualify it
|
||||
// by owner), so mapping it would bind every such session to an unrelated
|
||||
// contributor's PR — the highest-numbered one.
|
||||
let defaultBranch: string | undefined;
|
||||
// Fail closed: when the default branch is undeterminable (no
|
||||
// refs/remotes/origin/HEAD — git init + remote add without a clone, or
|
||||
// `git remote set-head origin -d`), the exclusion below degenerates to
|
||||
// `headRefName !== undefined` and fork PRs carrying the bare default-
|
||||
// branch name would map again — the misattribution this guard exists
|
||||
// to prevent. Skip head-branch mapping for the whole run instead;
|
||||
// convention/slug bindings do not read branchToNumber and survive.
|
||||
let headBranchMapping = false;
|
||||
if (candidates.some((candidate) => candidate.branches.length > 0)) {
|
||||
// Local ref read only — no credentials needed; getDefaultBranch
|
||||
// sanitizes the ambient env through gitEnv internally, the same
|
||||
// stripping getRemoteWebUrl applies to its git spawn.
|
||||
const defaultRef = await getDefaultBranch(runtime.workspaceCwd);
|
||||
if (defaultRef) {
|
||||
defaultBranch = defaultRef.slice(defaultRef.indexOf('/') + 1);
|
||||
headBranchMapping = true;
|
||||
}
|
||||
}
|
||||
for (const pr of prs.pullRequests) {
|
||||
numberToUrl.set(pr.number, pr.url);
|
||||
// The sidecar snapshot has no 'draft' variant — a draft is still open.
|
||||
numberToState.set(pr.number, pr.state === 'draft' ? 'open' : pr.state);
|
||||
if (!headBranchMapping) continue;
|
||||
// Highest-number-wins a reused head branch: PR numbers are assigned
|
||||
// monotonically, so this picks the newest PR regardless of arrival
|
||||
// order — the slim field set omits updatedAt, so no sort order is
|
||||
// guaranteed to survive parsing.
|
||||
const mapped = branchToNumber.get(pr.headRefName);
|
||||
if (
|
||||
pr.headRefName &&
|
||||
pr.headRefName !== defaultBranch &&
|
||||
(mapped === undefined || pr.number > mapped)
|
||||
) {
|
||||
branchToNumber.set(pr.headRefName, pr.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remoteWebUrl: string | undefined;
|
||||
let remoteResolved = false;
|
||||
for (const candidate of candidates) {
|
||||
let numbers: number[] = [];
|
||||
if (candidate.conventionNumber !== undefined) {
|
||||
numbers.push(candidate.conventionNumber);
|
||||
}
|
||||
for (const branch of candidate.branches) {
|
||||
const mapped = branchToNumber.get(branch);
|
||||
if (mapped !== undefined && !numbers.includes(mapped)) {
|
||||
numbers.push(mapped);
|
||||
}
|
||||
}
|
||||
if (numbers.length === 0) continue;
|
||||
// The convention number is planned last so a fresh write makes it the
|
||||
// sidecar's newest entry: capped lists evict from the oldest end, which
|
||||
// must stay branch mappings, not the session's own PR.
|
||||
if (candidate.conventionNumber !== undefined) {
|
||||
numbers = [...numbers.slice(1), candidate.conventionNumber];
|
||||
}
|
||||
const prPath = sessionService.getPrSessionPathForArchiveState(
|
||||
candidate.sessionId,
|
||||
candidate.archiveState,
|
||||
);
|
||||
// Captured before the snapshot read: an entry committed while this run
|
||||
// is in flight is newer than the plan and must not be trimmed by it.
|
||||
const snapshotAt = new Date().toISOString();
|
||||
let existing: Awaited<ReturnType<typeof readSessionPrs>>;
|
||||
try {
|
||||
existing = await readSessionPrs(prPath);
|
||||
} catch {
|
||||
existing = null;
|
||||
}
|
||||
const existingNumbers = new Set((existing ?? []).map((pr) => pr.number));
|
||||
const urls = new Map<number, string>();
|
||||
const states = new Map<number, SessionPr['state']>();
|
||||
for (const number of numbers) {
|
||||
if (existingNumbers.has(number)) continue;
|
||||
let url = numberToUrl.get(number);
|
||||
if (url === undefined && number === candidate.conventionNumber) {
|
||||
// Cache the miss too: an unresolvable remote must cost one
|
||||
// blocking git spawn per workspace, not one per candidate.
|
||||
if (!remoteResolved) {
|
||||
remoteWebUrl = getRemoteWebUrl(runtime.workspaceCwd);
|
||||
remoteResolved = true;
|
||||
}
|
||||
if (remoteWebUrl !== undefined) url = `${remoteWebUrl}/pull/${number}`;
|
||||
}
|
||||
if (url === undefined) {
|
||||
result.unresolved += 1;
|
||||
continue;
|
||||
}
|
||||
urls.set(number, url);
|
||||
const state = numberToState.get(number);
|
||||
if (state !== undefined) states.set(number, state);
|
||||
}
|
||||
// The cap is shared with entries this run did not resolve and cannot
|
||||
// re-resolve (dialog-created bindings, PRs that fell out of the gh
|
||||
// window): they take their slots first, and the resolved numbers are
|
||||
// trimmed around them, counting the displaced in overLimit. The plan is
|
||||
// finalized inside the mutation queue, against the freshest list, so a
|
||||
// binding that lands between the snapshot read and this write is never
|
||||
// dropped and the slots are recomputed around it; sequential capped
|
||||
// upserts instead cascaded evictions through the list.
|
||||
const droppable = new Set(
|
||||
numbers.filter(
|
||||
(number) => existingNumbers.has(number) || urls.has(number),
|
||||
),
|
||||
);
|
||||
const createdAt = new Date().toISOString();
|
||||
let added = 0;
|
||||
// A closed generation is a whole-run condition, not a per-session write
|
||||
// failure: surface it to the route (which reports the workspace as
|
||||
// failed) instead of miscounting it in writeErrors.
|
||||
assertGenerationOpen();
|
||||
const commit = async (): Promise<void> => {
|
||||
const persisted = await replaceSessionPrs(prPath, (fresh) => {
|
||||
assertGenerationOpen();
|
||||
// Under the archive lane no archive/delete rename can interleave
|
||||
// with this write; the existence check still covers a transcript
|
||||
// removed by a path that takes no lane, so the plan never
|
||||
// resurrects a sidecar for a session gone from this archive state.
|
||||
if (!existsSync(candidate.transcriptPath)) return null;
|
||||
const freshNumbers = new Set(fresh.map((entry) => entry.number));
|
||||
// Only entries seen in the snapshot are subject to this plan; newer
|
||||
// ones are bindings this run never planned for and must keep. A
|
||||
// re-bind of a snapshot-held number commits with a fresh createdAt,
|
||||
// so it is no longer the entry this run planned for.
|
||||
const plannedFor = (entry: SessionPr): boolean => {
|
||||
// Same-PR identity is number + canonical url, as in
|
||||
// upsertSessionPr: a binding to another repository's
|
||||
// same-numbered PR is foreign to this plan and keeps its slot;
|
||||
// trimming it would let a later run flip it to this repo's PR.
|
||||
const resolved = numberToUrl.get(entry.number);
|
||||
const samePr =
|
||||
resolved === undefined ||
|
||||
canonicalSessionPrUrl(entry.url) ===
|
||||
canonicalSessionPrUrl(resolved);
|
||||
return (
|
||||
droppable.has(entry.number) &&
|
||||
existingNumbers.has(entry.number) &&
|
||||
entry.createdAt < snapshotAt &&
|
||||
samePr
|
||||
);
|
||||
};
|
||||
const foreignEntries = fresh.filter((entry) => !plannedFor(entry));
|
||||
// Fresh-foreign numbers already hold their slots; billing them
|
||||
// again as plan members trims a snapshot binding even though the
|
||||
// cap is never exceeded.
|
||||
const foreignNumbers = new Set(
|
||||
foreignEntries.map((entry) => entry.number),
|
||||
);
|
||||
const foreignCount = foreignEntries.length;
|
||||
const slots = Math.max(0, SESSION_PR_LIST_LIMIT - foreignCount);
|
||||
let plan = numbers.filter(
|
||||
(number) => droppable.has(number) && !foreignNumbers.has(number),
|
||||
);
|
||||
if (plan.length > slots) {
|
||||
result.overLimit += plan.length - slots;
|
||||
const convention = candidate.conventionNumber;
|
||||
if (slots === 0) {
|
||||
plan = [];
|
||||
} else if (
|
||||
convention !== undefined &&
|
||||
plan[plan.length - 1] === convention
|
||||
) {
|
||||
// Keep the pr-<N> slug's PR and displace the oldest branch-
|
||||
// mapped numbers instead.
|
||||
const branchSlots = slots - 1;
|
||||
plan = [
|
||||
...(branchSlots > 0 ? plan.slice(0, -1).slice(-branchSlots) : []),
|
||||
convention,
|
||||
];
|
||||
} else {
|
||||
plan = plan.slice(-slots);
|
||||
}
|
||||
}
|
||||
const planSet = new Set(plan);
|
||||
result.alreadyBound += plan.filter((number) =>
|
||||
freshNumbers.has(number),
|
||||
).length;
|
||||
const kept = fresh.filter(
|
||||
(entry) => planSet.has(entry.number) || !plannedFor(entry),
|
||||
);
|
||||
const additions: SessionPr[] = [];
|
||||
for (const number of plan) {
|
||||
if (freshNumbers.has(number)) continue;
|
||||
const url = urls.get(number);
|
||||
// Snapshot-held numbers were skipped by the URL loop, so one
|
||||
// evicted concurrently has no URL here; re-adding it url-less
|
||||
// would fail isValidSessionPr and void the whole sidecar. Skip
|
||||
// it and let the next run re-bind it.
|
||||
if (url === undefined) continue;
|
||||
const state = states.get(number);
|
||||
additions.push({
|
||||
number,
|
||||
url,
|
||||
createdAt,
|
||||
...(state !== undefined ? { state } : {}),
|
||||
});
|
||||
}
|
||||
added = additions.length;
|
||||
const next = [...kept, ...additions];
|
||||
return next.length === fresh.length &&
|
||||
next.every((entry, index) => entry === fresh[index])
|
||||
? null
|
||||
: next;
|
||||
});
|
||||
if (persisted !== null) {
|
||||
result.bound += added;
|
||||
result.written += 1;
|
||||
// Every other binding writer keeps the hydrated bridge entry in
|
||||
// step with the sidecar; a capped plan can evict numbers, and the
|
||||
// stale entry would resurrect them in the summary merge until a
|
||||
// daemon restart. The sync runs inside the mutation queue against
|
||||
// the freshest list, so a bind queued between the rewrite's commit
|
||||
// and the sync keeps its slot instead of being clobbered by the
|
||||
// rewrite-time snapshot. No-op when the session is not live.
|
||||
await replaceSessionPrs(prPath, (fresh) => {
|
||||
// Never publish into a retired generation's bridge.
|
||||
assertGenerationOpen();
|
||||
runtime.bridge.setSessionPrs?.(
|
||||
candidate.sessionId,
|
||||
fresh.map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
})),
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
// The shared lane spans the rewrite AND the live-entry sync: archive
|
||||
// and delete take the exclusive lane across their renames, so while
|
||||
// this holds the session neither can move the transcript or sidecar
|
||||
// out from under the atomic write, and the sync publishes a list the
|
||||
// archive move cannot have split.
|
||||
await (options.archiveCoordinator
|
||||
? options.archiveCoordinator.runSharedMany(
|
||||
[candidate.sessionId],
|
||||
commit,
|
||||
)
|
||||
: commit());
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof WorkspaceGenerationClosedError ||
|
||||
error instanceof DaemonDrainingError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
// One unwritable (or concurrently archiving) sidecar must not abort
|
||||
// the whole workspace; the next run re-plans it.
|
||||
result.writeErrors = (result.writeErrors ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerSessionPrBackfillRoutes(
|
||||
app: Application,
|
||||
deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
sendBridgeError: SendBridgeError;
|
||||
mutate: (opts?: { strict?: boolean }) => RequestHandler;
|
||||
archiveCoordinator?: SessionPrArchiveLane;
|
||||
},
|
||||
): void {
|
||||
app.post('/sessions/backfill-prs', deps.mutate(), async (_req, res) => {
|
||||
const route = 'POST /sessions/backfill-prs';
|
||||
try {
|
||||
const workspaces: SessionPrBackfillWorkspaceResult[] = [];
|
||||
for (const runtime of deps.workspaceRegistry.listAll()) {
|
||||
if (!runtime.trusted) {
|
||||
workspaces.push({
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
scanned: 0,
|
||||
bound: 0,
|
||||
written: 0,
|
||||
alreadyBound: 0,
|
||||
overLimit: 0,
|
||||
unresolved: 0,
|
||||
error: 'untrusted workspace skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await backfillWorkspaceSessionPrs(runtime, undefined, {
|
||||
archiveCoordinator: deps.archiveCoordinator,
|
||||
});
|
||||
// Same pairing as every other catalog mutation in this feature:
|
||||
// the sidebar refetch is catalog-version-gated, so a persisted
|
||||
// rewrite — new bindings or an eviction-only plan — stays
|
||||
// invisible until the cache scope is dropped and the revision
|
||||
// advances. Gate on writes, not additions: a capped plan can
|
||||
// evict an entry while adding none.
|
||||
if (result.written > 0) {
|
||||
// A generation retired after its last commit must not notify
|
||||
// its obsolete bridge; the successor owns the catalog now.
|
||||
runtime.generationGuard?.assertOpen();
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
archiveStates: ['active', 'archived'],
|
||||
});
|
||||
runtime.bridge.markSessionCatalogChanged();
|
||||
}
|
||||
workspaces.push(result);
|
||||
} catch (error) {
|
||||
workspaces.push({
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
scanned: 0,
|
||||
bound: 0,
|
||||
written: 0,
|
||||
alreadyBound: 0,
|
||||
overLimit: 0,
|
||||
unresolved: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.status(200).json({
|
||||
v: 1,
|
||||
workspaces,
|
||||
scanned: workspaces.reduce((sum, w) => sum + w.scanned, 0),
|
||||
bound: workspaces.reduce((sum, w) => sum + w.bound, 0),
|
||||
});
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2355,12 +2355,16 @@ export function registerSessionRoutes(
|
||||
const parseSessionPrBody = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): { number: number; url: string } | null | undefined => {
|
||||
):
|
||||
| { number: number; url: string; state?: 'open' | 'merged' | 'closed' }
|
||||
| null
|
||||
| undefined => {
|
||||
const raw: unknown = safeBody(req)['pr'];
|
||||
if (raw === undefined) return undefined;
|
||||
const candidate = raw as Record<string, unknown> | null;
|
||||
const number = candidate?.['number'];
|
||||
const url = candidate?.['url'];
|
||||
const state = candidate?.['state'];
|
||||
if (
|
||||
candidate === null ||
|
||||
typeof candidate !== 'object' ||
|
||||
@@ -2372,16 +2376,20 @@ export function registerSessionRoutes(
|
||||
!/^https?:\/\//i.test(url) ||
|
||||
// The url lands in the bridge's stderr audit line — control
|
||||
// characters would let a caller forge log lines.
|
||||
hasControlCharacter(url)
|
||||
hasControlCharacter(url) ||
|
||||
(state !== undefined &&
|
||||
state !== 'open' &&
|
||||
state !== 'merged' &&
|
||||
state !== 'closed')
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: `\`pr\` must be an object with a positive integer \`number\` and an http(s) \`url\` of at most ${SESSION_PR_URL_MAX_LENGTH} characters, without control characters`,
|
||||
error: `\`pr\` must be an object with a positive integer \`number\` and an http(s) \`url\` of at most ${SESSION_PR_URL_MAX_LENGTH} characters, without control characters, and an optional \`state\` of \`open\`, \`merged\`, or \`closed\``,
|
||||
code: 'invalid_metadata',
|
||||
field: 'pr',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { number, url };
|
||||
return { number, url, ...(state ? { state } : {}) };
|
||||
};
|
||||
|
||||
const serializeSessionErrors = (
|
||||
@@ -5780,7 +5788,11 @@ export function registerSessionRoutes(
|
||||
service.getPrSessionPathForArchiveState(sessionId, 'active'),
|
||||
pr,
|
||||
)
|
||||
).map(({ number, url }) => ({ number, url }));
|
||||
).map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}));
|
||||
effective = { ...effective, prs: persistedPrs };
|
||||
}
|
||||
} finally {
|
||||
@@ -5892,7 +5904,11 @@ export function registerSessionRoutes(
|
||||
await runWithWorkspaceRuntimeStorage(runtime, async () => {
|
||||
let effective: {
|
||||
displayName?: string;
|
||||
prs?: Array<{ number: number; url: string }>;
|
||||
prs?: Array<{
|
||||
number: number;
|
||||
url: string;
|
||||
state?: 'open' | 'merged' | 'closed';
|
||||
}>;
|
||||
};
|
||||
const service = createWorkspaceRuntimeSessionService(runtime);
|
||||
try {
|
||||
@@ -5939,7 +5955,11 @@ export function registerSessionRoutes(
|
||||
),
|
||||
pr,
|
||||
)
|
||||
).map(({ number, url }) => ({ number, url }));
|
||||
).map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}));
|
||||
assertRuntimeGenerationOpen?.();
|
||||
effective = { ...effective, prs: persistedPrs };
|
||||
}
|
||||
@@ -5965,9 +5985,10 @@ export function registerSessionRoutes(
|
||||
pr,
|
||||
);
|
||||
assertRuntimeGenerationOpen?.();
|
||||
effective.prs = persisted.map(({ number, url }) => ({
|
||||
effective.prs = persisted.map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}));
|
||||
}
|
||||
if (displayName !== undefined) {
|
||||
@@ -6513,12 +6534,14 @@ export function registerSessionRoutes(
|
||||
},
|
||||
),
|
||||
)
|
||||
: Promise.resolve(
|
||||
listLiveWorkspaceSessionsForResponse(
|
||||
runtime.bridge,
|
||||
key,
|
||||
options,
|
||||
),
|
||||
: listLiveWorkspaceSessionsForResponse(
|
||||
runtime.bridge,
|
||||
key,
|
||||
options,
|
||||
{
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const result =
|
||||
liveRuntime && deps.conversationRuntimeActivity
|
||||
|
||||
@@ -10422,6 +10422,58 @@ describe('runQwenServe runtime startup failures', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate the refresh chunk vanishing under a running daemon (an in-place
|
||||
// upgrade replacing dist/): the module factory throws, so the first
|
||||
// health-triggered runtime build's dynamic import rejects.
|
||||
vi.mock('./server/session-pr-refresh.js', () => {
|
||||
throw new Error(
|
||||
'Cannot find module session-pr-refresh (simulated chunk replacement)',
|
||||
);
|
||||
});
|
||||
|
||||
describe('session-pr-refresh degraded load on the serve fast path', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (tmpDir) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades to no PR-state sweep instead of leaking an unhandled rejection', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-pr-refresh-degrade-')),
|
||||
);
|
||||
// The serve fast path installs no process-level unhandledRejection
|
||||
// handler before the runtime builds, so record them here: without the
|
||||
// import's .catch the rejection escapes and Node's default is to exit.
|
||||
const rejections: unknown[] = [];
|
||||
const recordRejection = (reason: unknown): void => {
|
||||
rejections.push(reason);
|
||||
};
|
||||
process.on('unhandledRejection', recordRejection);
|
||||
let handle: RunHandle | undefined;
|
||||
try {
|
||||
({ handle } = await startDeferredDaemon(tmpDir));
|
||||
const health = await fetch(`${handle.url}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
// The first health schedules the runtime build; the refresh module
|
||||
// import fires inside it and rejects on the next turns.
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(rejections).toEqual([]);
|
||||
// The intended degradation is "no PR-state sweep" — the daemon keeps
|
||||
// serving everything else.
|
||||
const healthAfter = await fetch(`${handle.url}/health`);
|
||||
expect(healthAfter.status).toBe(200);
|
||||
} finally {
|
||||
process.off('unhandledRejection', recordRejection);
|
||||
await handle?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runQwenServe Web Shell signals on RunHandle', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from './workspace-registry.js';
|
||||
import type { SessionArchiveCoordinator } from './server/session-archive.js';
|
||||
import type {
|
||||
DaemonTrustPolicySnapshot,
|
||||
DaemonWorkspaceTrustDecision,
|
||||
@@ -4116,6 +4117,12 @@ async function runQwenServeImpl(
|
||||
// bucket plus the window-scoped event-loop histogram it resets each seal.
|
||||
// Torn down together with the event-loop monitor on runtime restart/stop.
|
||||
let daemonMetricsSampler: { dispose(): void } | undefined;
|
||||
// Low-frequency sweep refreshing bound-PR state snapshots (open → merged).
|
||||
// The refresh module loads via dynamic import (see start site) because it
|
||||
// pulls the SessionService chain, which must stay out of the pre-listen
|
||||
// static closure; the generation guards dispose-vs-async-start races.
|
||||
let sessionPrRefreshTimer: { dispose(): void } | undefined;
|
||||
let sessionPrRefreshGeneration = 0;
|
||||
let runtimeStartupError: string | undefined;
|
||||
let runtimeStarting: Promise<void> | undefined;
|
||||
let markRuntimeReady!: () => void;
|
||||
@@ -4135,6 +4142,10 @@ async function runQwenServeImpl(
|
||||
daemonEventLoopMonitor = undefined;
|
||||
const metricsSampler = daemonMetricsSampler;
|
||||
daemonMetricsSampler = undefined;
|
||||
const prRefreshTimer = sessionPrRefreshTimer;
|
||||
sessionPrRefreshTimer = undefined;
|
||||
sessionPrRefreshGeneration += 1;
|
||||
prRefreshTimer?.dispose();
|
||||
try {
|
||||
eventLoopMonitor?.dispose();
|
||||
} catch (err) {
|
||||
@@ -6149,6 +6160,43 @@ async function runQwenServeImpl(
|
||||
},
|
||||
};
|
||||
|
||||
// Same lifecycle as the metrics sampler above: retire any prior timer
|
||||
// before starting a new one (buildRuntime re-entry), unref'd inside.
|
||||
// Dynamic import on purpose: session-pr-refresh statically pulls the
|
||||
// SessionService chain (glob et al.), which the serve fast-path bundle
|
||||
// closure check forbids in this pre-listen root's static closure.
|
||||
sessionPrRefreshTimer?.dispose();
|
||||
const refreshGeneration = ++sessionPrRefreshGeneration;
|
||||
void import('./server/session-pr-refresh.js')
|
||||
.then((mod) => {
|
||||
if (refreshGeneration !== sessionPrRefreshGeneration) return;
|
||||
sessionPrRefreshTimer = mod.startSessionPrRefreshTimer({
|
||||
workspaceRegistry,
|
||||
// The coordinator lives on the serve app (createServeApp below),
|
||||
// which is built after this timer starts; read it per tick like
|
||||
// the metrics sampler reads `acpHandle`.
|
||||
getArchiveCoordinator: () =>
|
||||
(
|
||||
app.locals as {
|
||||
sessionArchiveCoordinator?: SessionArchiveCoordinator;
|
||||
}
|
||||
).sessionArchiveCoordinator,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
// Degrade to "no PR-state sweep" instead of leaking an unhandled
|
||||
// rejection: the serve fast path installs no process-level
|
||||
// unhandledRejection handler before this runs, and Node's default
|
||||
// for one is to exit — a failed chunk load (e.g. an in-place
|
||||
// upgrade replacing dist/ under the running daemon) would take
|
||||
// down every runtime, session, and connection the daemon serves.
|
||||
daemonLog.warn(
|
||||
`session-pr-refresh load failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Factory for dynamically creating workspace runtimes (POST /workspaces).
|
||||
interface WorkspaceRuntimeBuildOptions {
|
||||
readonly provenance?: WorkspaceRuntimeProvenance;
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
PromptDeadlineExceededError,
|
||||
resolvePromptDeadlineMs,
|
||||
} from './server.js';
|
||||
import { invalidateWorkspaceSessionListCache } from './server/session-list.js';
|
||||
import {
|
||||
invalidateWorkspaceSessionListCache,
|
||||
listLiveWorkspaceSessionsForResponse,
|
||||
} from './server/session-list.js';
|
||||
import type { ChannelWorkerSnapshot } from './channel-worker-supervisor.js';
|
||||
import {
|
||||
ChannelWorkerControlError,
|
||||
@@ -15779,6 +15782,411 @@ describe('createServeApp', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the sidecar state over the live bind-time state on merge', async () => {
|
||||
// The refresh timer rewrites `state` in the sidecar while the live
|
||||
// entry keeps the bind-time snapshot — otherwise a live session's PR
|
||||
// would never show as merged until daemon restart.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544a005';
|
||||
await writeStoredSession({
|
||||
sessionId: id,
|
||||
cwd: WS_BOUND,
|
||||
timestamp: '2026-05-17T12:00:00.000Z',
|
||||
prompt: 'stored prompt',
|
||||
mtime: new Date('2026-05-17T12:00:05.000Z'),
|
||||
});
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
// The TTL cache would otherwise keep serving a scan taken before this
|
||||
// test's sidecar write.
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: new Storage(WS_BOUND).getRuntimeBaseDir(),
|
||||
workspaceCwd: WS_BOUND,
|
||||
archiveStates: ['active'],
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(bridge, WS_BOUND);
|
||||
|
||||
const merged = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(merged?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the PR sidecar for live-only sessions so refreshed state wins', async () => {
|
||||
// The bind route persists the sidecar before the session's first
|
||||
// flush. Until then the row is live-only, and its bind-time state
|
||||
// must not shadow the sidecar's refreshed state.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b001';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: new Storage(WS_BOUND).getRuntimeBaseDir(),
|
||||
workspaceCwd: WS_BOUND,
|
||||
archiveStates: ['active'],
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(bridge, WS_BOUND);
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the PR sidecar for live-only sessions on the organized path', async () => {
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b002';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'closed',
|
||||
});
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: new Storage(WS_BOUND).getRuntimeBaseDir(),
|
||||
workspaceCwd: WS_BOUND,
|
||||
archiveStates: ['active'],
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(bridge, WS_BOUND, {
|
||||
view: 'organized',
|
||||
group: 'all',
|
||||
});
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'closed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the PR sidecar for live-only sessions on the metadata path', async () => {
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b003';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: new Storage(WS_BOUND).getRuntimeBaseDir(),
|
||||
workspaceCwd: WS_BOUND,
|
||||
archiveStates: ['active'],
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
sourceType: 'scheduled_task',
|
||||
sourceId: 'task-live',
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(bridge, WS_BOUND, {
|
||||
sourceType: 'scheduled_task',
|
||||
sourceId: 'task-live',
|
||||
});
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the PR sidecar for live-only sessions on the live-only fast path', async () => {
|
||||
// Secondary workspaces without persisted transcripts take the
|
||||
// live-only fast path; it must render the sidecar's refreshed state
|
||||
// exactly like the persisted paths.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b004';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listLiveWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
WS_BOUND,
|
||||
);
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the PR sidecar for a live-only session whose bridge entry has no prs', async () => {
|
||||
// Bridge entries are re-created without `prs` on daemon restart,
|
||||
// close/reload, and archive/restore; only metadata updates re-seed
|
||||
// them. The live-only fast path must still read the persisted sidecar
|
||||
// for such rows — like the sibling live-only paths — or the badge
|
||||
// vanishes on an unflushed workspace until a re-bind.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b008';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listLiveWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
WS_BOUND,
|
||||
);
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('degrades a live-only row to bind-time prs when its sidecar is unreadable', async () => {
|
||||
// readSessionPrs rethrows non-ENOENT I/O errors (EISDIR here); the
|
||||
// live-only enrichment must swallow them and render the bridge's
|
||||
// bind-time prs instead of failing the whole listing request.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b009';
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(id, 'active');
|
||||
await fsp.rm(sidecarPath, { recursive: true, force: true });
|
||||
await fsp.mkdir(sidecarPath, { recursive: true });
|
||||
try {
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listLiveWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
WS_BOUND,
|
||||
);
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await fsp.rm(sidecarPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the live-only PR sidecar from an explicit runtime base dir', async () => {
|
||||
// The route passes `{ runtimeBaseDir: runtime.sessionRuntimeBaseDir }`;
|
||||
// for a managed runtime with a distinct pinned base dir the read must
|
||||
// honor it instead of the ambient default.
|
||||
const id = '550e8400-e29b-41d4-a716-44665544b007';
|
||||
const altBaseDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-serve-alt-runtime-'),
|
||||
);
|
||||
try {
|
||||
const sidecarPath = Storage.runWithResolvedRuntimeBaseDir(
|
||||
altBaseDir,
|
||||
() =>
|
||||
new SessionService(WS_BOUND).getPrSessionPathForArchiveState(
|
||||
id,
|
||||
'active',
|
||||
),
|
||||
);
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
});
|
||||
const bridge = fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: id,
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
prs: [
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'open' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listLiveWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
WS_BOUND,
|
||||
undefined,
|
||||
{ runtimeBaseDir: altBaseDir },
|
||||
);
|
||||
|
||||
const live = result.sessions.find((s) => s.sessionId === id);
|
||||
expect(live?.prs).toEqual([
|
||||
{
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
state: 'merged',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await fsp.rm(altBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('survives PR sidecars on the organized listing path', async () => {
|
||||
const id = '550e8400-e29b-41d4-a716-446655440005';
|
||||
await writeStoredSession({
|
||||
@@ -18337,6 +18745,47 @@ describe('createServeApp', () => {
|
||||
listSessionsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders sidecar prs for a live-only standalone row under its canonical id', async () => {
|
||||
// A standalone session can enter the bridge under a non-canonical
|
||||
// id spelling while its PR sidecar is stored under the canonical
|
||||
// id; the live-only metadata row must be keyed canonically AND
|
||||
// read the sidecar under the canonical id.
|
||||
const canonicalId = standaloneId(12);
|
||||
const service = new SessionService(WS_BOUND);
|
||||
const sidecarPath = service.getPrSessionPathForArchiveState(
|
||||
canonicalId,
|
||||
'active',
|
||||
);
|
||||
await fsp.rm(sidecarPath, { force: true });
|
||||
await upsertSessionPr(sidecarPath, {
|
||||
number: 9729,
|
||||
url: 'https://github.com/o/r/pull/9729',
|
||||
});
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(
|
||||
fakeBridge({
|
||||
listImpl: () => [
|
||||
{
|
||||
sessionId: canonicalId.toUpperCase(),
|
||||
workspaceCwd: WS_BOUND,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
sourceType: 'standalone',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
WS_BOUND,
|
||||
{ conversationKind: 'standalone-top-level', size: 10 },
|
||||
);
|
||||
|
||||
const row = result.sessions.find(
|
||||
(session) => session.sessionId === canonicalId,
|
||||
);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.prs?.map((pr) => pr.number)).toEqual([9729]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parentSessionId filter', () => {
|
||||
@@ -26526,6 +26975,27 @@ describe('createServeApp', () => {
|
||||
expect(bridge.updateMetadataCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400 message names the state constraint for an invalid pr state', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
const res = await auth(
|
||||
request(app).patch(
|
||||
'/session/550e8400-e29b-41d4-a716-446655440321/metadata',
|
||||
),
|
||||
).send({
|
||||
pr: {
|
||||
number: 1,
|
||||
url: 'https://github.com/o/r/pull/1',
|
||||
state: 'draft',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_metadata');
|
||||
expect(res.body.field).toBe('pr');
|
||||
expect(res.body.error).toContain('`state`');
|
||||
expect(bridge.updateMetadataCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400 invalid_metadata when the pr url exceeds the length cap', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, {
|
||||
|
||||
@@ -122,6 +122,7 @@ import {
|
||||
} from './routes/workspace-trust.js';
|
||||
import { registerPermissionRoutes } from './routes/permission.js';
|
||||
import { registerSessionRoutes } from './routes/session.js';
|
||||
import { registerSessionPrBackfillRoutes } from './routes/session-pr-backfill.js';
|
||||
import { createRequestedSessionIdAdmission } from './session-id-admission.js';
|
||||
import {
|
||||
registerScheduledTasksRoutes,
|
||||
@@ -2169,6 +2170,12 @@ export function createServeApp(
|
||||
sendBridgeError,
|
||||
mutate,
|
||||
});
|
||||
registerSessionPrBackfillRoutes(app, {
|
||||
workspaceRegistry,
|
||||
sendBridgeError,
|
||||
mutate,
|
||||
archiveCoordinator,
|
||||
});
|
||||
|
||||
// Workspace memory + agents CRUD routes.
|
||||
mountWorkspaceMemoryRoutes(app, {
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
readSessionPrs,
|
||||
type SessionArchiveState,
|
||||
type SessionGroupPresetColor,
|
||||
type SessionPr,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SessionPrInfo } from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
import type {
|
||||
AcpSessionBridge,
|
||||
BridgeSessionSummary,
|
||||
@@ -455,7 +457,7 @@ async function enrichPrSidecars(
|
||||
if (sidecar) {
|
||||
bySessionId.set(sessionId, {
|
||||
...summary,
|
||||
prs: sidecar.map(({ number, url }) => ({ number, url })),
|
||||
prs: sidecarToPrInfos(sidecar),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -522,17 +524,84 @@ function mergeLiveSessionSummary(
|
||||
// number (live url wins, live-only bindings sort latest) instead of letting
|
||||
// the spread overwrite the history.
|
||||
if (existing.prs || live.prs) {
|
||||
const livePrs = live.prs ?? [];
|
||||
merged.prs = [
|
||||
...(existing.prs ?? []).filter(
|
||||
(p) => !livePrs.some((l) => l.number === p.number),
|
||||
),
|
||||
...livePrs,
|
||||
];
|
||||
merged.prs = mergeSummaryPrs(existing.prs, live.prs);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function sidecarToPrInfos(sidecar: readonly SessionPr[]): SessionPrInfo[] {
|
||||
return sidecar.map(({ number, url, state }) => ({
|
||||
number,
|
||||
url,
|
||||
...(state ? { state } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges persisted (sidecar-enriched) PR bindings with a live entry's for
|
||||
* summary rendering: dedupe by number (live url wins, live-only bindings
|
||||
* sort latest). For `state` the persisted sidecar wins: the refresh timer
|
||||
* rewrites it there while the live entry is frozen at bind-time.
|
||||
*/
|
||||
function mergeSummaryPrs(
|
||||
persistedPrs: readonly SessionPrInfo[] | undefined,
|
||||
livePrs: readonly SessionPrInfo[] | undefined,
|
||||
): SessionPrInfo[] {
|
||||
const live = livePrs ?? [];
|
||||
const persistedByNumber = new Map(
|
||||
(persistedPrs ?? []).map((p) => [p.number, p]),
|
||||
);
|
||||
return [
|
||||
...(persistedPrs ?? []).filter(
|
||||
(p) => !live.some((l) => l.number === p.number),
|
||||
),
|
||||
...live.map((l) => {
|
||||
const persisted = persistedByNumber.get(l.number);
|
||||
return persisted?.state !== undefined && persisted.state !== l.state
|
||||
? { ...l, state: persisted.state }
|
||||
: l;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the first-page insertion for a session that is live but has no
|
||||
* persisted record yet. The bind route persists the PR sidecar before the
|
||||
* session's first flush, so best-effort read it: the row then renders the
|
||||
* sidecar's refreshed `state` instead of the live entry's bind-time
|
||||
* snapshot, matching {@link mergeLiveSessionSummary}.
|
||||
*/
|
||||
async function liveOnlySummary(
|
||||
live: BridgeSessionSummary,
|
||||
sessionService: SessionService,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BridgeSessionSummary> {
|
||||
const summary: BridgeSessionSummary = {
|
||||
...live,
|
||||
createdAt: live.createdAt,
|
||||
clientCount: live.clientCount,
|
||||
hasActivePrompt: live.hasActivePrompt,
|
||||
isArchived: false,
|
||||
};
|
||||
let sidecar: Awaited<ReturnType<typeof readSessionPrs>>;
|
||||
try {
|
||||
const sidecarPath = sessionService.getPrSessionPathForArchiveState(
|
||||
live.sessionId,
|
||||
'active',
|
||||
);
|
||||
sidecar = signal
|
||||
? await readSessionPrs(sidecarPath, { signal })
|
||||
: await readSessionPrs(sidecarPath);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
sidecar = null;
|
||||
}
|
||||
if (sidecar) {
|
||||
summary.prs = mergeSummaryPrs(sidecarToPrInfos(sidecar), live.prs);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function clonePersistedSummary(
|
||||
session: Readonly<BridgeSessionSummary>,
|
||||
): BridgeSessionSummary {
|
||||
@@ -907,13 +976,7 @@ async function listOrganizedWorkspaceSessionsForResponse(
|
||||
bySessionId.set(
|
||||
live.sessionId,
|
||||
applyOrganization(
|
||||
{
|
||||
...live,
|
||||
createdAt: live.createdAt,
|
||||
clientCount: live.clientCount,
|
||||
hasActivePrompt: live.hasActivePrompt,
|
||||
isArchived: false,
|
||||
},
|
||||
await liveOnlySummary(live, sessionService, readOptions.signal),
|
||||
organization,
|
||||
),
|
||||
);
|
||||
@@ -1099,13 +1162,14 @@ async function listWorkspaceSessionsByMetadataForResponse(
|
||||
})
|
||||
: sessionService.sessionExists(sessionId)))
|
||||
) {
|
||||
bySessionId.set(sessionId, {
|
||||
...canonicalLive,
|
||||
createdAt: canonicalLive.createdAt,
|
||||
clientCount: canonicalLive.clientCount,
|
||||
hasActivePrompt: canonicalLive.hasActivePrompt,
|
||||
isArchived: false,
|
||||
});
|
||||
bySessionId.set(
|
||||
sessionId,
|
||||
await liveOnlySummary(
|
||||
canonicalLive,
|
||||
sessionService,
|
||||
readOptions.signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1331,13 +1395,10 @@ async function listWorkspaceSessionsForResponseInRuntime(
|
||||
})
|
||||
: sessionService.sessionExists(live.sessionId))))
|
||||
) {
|
||||
bySessionId.set(live.sessionId, {
|
||||
...live,
|
||||
createdAt: live.createdAt,
|
||||
clientCount: live.clientCount,
|
||||
hasActivePrompt: live.hasActivePrompt,
|
||||
isArchived: false,
|
||||
});
|
||||
bySessionId.set(
|
||||
live.sessionId,
|
||||
await liveOnlySummary(live, sessionService, readOptions.signal),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,48 +1415,70 @@ async function listWorkspaceSessionsForResponseInRuntime(
|
||||
return { sessions, nextCursor };
|
||||
}
|
||||
|
||||
export function listLiveWorkspaceSessionsForResponse(
|
||||
export async function listLiveWorkspaceSessionsForResponse(
|
||||
bridge: AcpSessionBridge,
|
||||
workspaceCwd: string,
|
||||
options?: Pick<ListWorkspaceSessionsOptions, 'cursor' | 'size'>,
|
||||
): ListWorkspaceSessionsResult {
|
||||
const rawSize = options?.size;
|
||||
const requestedSize =
|
||||
typeof rawSize === 'number' && Number.isSafeInteger(rawSize)
|
||||
? rawSize
|
||||
: DEFAULT_SESSION_PAGE_SIZE;
|
||||
const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE);
|
||||
const cursorKey =
|
||||
options?.cursor !== undefined
|
||||
? parseLiveSessionCursor(options.cursor)
|
||||
: undefined;
|
||||
const sessions = bridge
|
||||
.listWorkspaceSessions(workspaceCwd)
|
||||
.sort((a, b) =>
|
||||
compareLiveSessionCursorKeys(
|
||||
getLiveSessionCursorKey(a),
|
||||
getLiveSessionCursorKey(b),
|
||||
readOptions: { runtimeBaseDir?: string; signal?: AbortSignal } = {},
|
||||
): Promise<ListWorkspaceSessionsResult> {
|
||||
const runtimeBaseDir = new Storage(
|
||||
workspaceCwd,
|
||||
readOptions.runtimeBaseDir,
|
||||
).getRuntimeBaseDir();
|
||||
return Storage.runWithResolvedRuntimeBaseDir(runtimeBaseDir, async () => {
|
||||
const rawSize = options?.size;
|
||||
const requestedSize =
|
||||
typeof rawSize === 'number' && Number.isSafeInteger(rawSize)
|
||||
? rawSize
|
||||
: DEFAULT_SESSION_PAGE_SIZE;
|
||||
const pageSize = Math.min(
|
||||
Math.max(requestedSize, 1),
|
||||
MAX_SESSION_PAGE_SIZE,
|
||||
);
|
||||
const cursorKey =
|
||||
options?.cursor !== undefined
|
||||
? parseLiveSessionCursor(options.cursor)
|
||||
: undefined;
|
||||
const sessions = bridge
|
||||
.listWorkspaceSessions(workspaceCwd)
|
||||
.sort((a, b) =>
|
||||
compareLiveSessionCursorKeys(
|
||||
getLiveSessionCursorKey(a),
|
||||
getLiveSessionCursorKey(b),
|
||||
),
|
||||
);
|
||||
const afterCursor =
|
||||
cursorKey === undefined
|
||||
? sessions
|
||||
: sessions.filter(
|
||||
(session) =>
|
||||
compareLiveSessionCursorKeys(
|
||||
cursorKey,
|
||||
getLiveSessionCursorKey(session),
|
||||
) < 0,
|
||||
);
|
||||
const page = afterCursor.slice(0, pageSize);
|
||||
// The bind route persists the PR sidecar before the session's first
|
||||
// flush, and a bridge entry re-created after a restart/close/reload
|
||||
// carries no `prs`, so every live-only row must read the sidecar like
|
||||
// the sibling live-only paths do — unconditionally.
|
||||
const sessionService = new SessionService(workspaceCwd);
|
||||
const enriched = await Promise.all(
|
||||
page.map((summary) =>
|
||||
liveOnlySummary(summary, sessionService, readOptions.signal),
|
||||
),
|
||||
);
|
||||
const afterCursor =
|
||||
cursorKey === undefined
|
||||
? sessions
|
||||
: sessions.filter(
|
||||
(session) =>
|
||||
compareLiveSessionCursorKeys(
|
||||
cursorKey,
|
||||
getLiveSessionCursorKey(session),
|
||||
) < 0,
|
||||
);
|
||||
const page = afterCursor.slice(0, pageSize);
|
||||
const nextCursor =
|
||||
page.length < afterCursor.length
|
||||
? encodeLiveSessionCursor(getLiveSessionCursorKey(page[page.length - 1]!))
|
||||
: undefined;
|
||||
return {
|
||||
sessions: page,
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
const nextCursor =
|
||||
page.length < afterCursor.length
|
||||
? encodeLiveSessionCursor(
|
||||
getLiveSessionCursorKey(page[page.length - 1]!),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
sessions: enriched,
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// This module must stay OUT of the serve pre-listen static closure: it
|
||||
// pulls the SessionService chain (glob et al.) via the core barrel, which
|
||||
// the fast-path bundle closure check forbids before listen. `run-qwen-serve`
|
||||
// therefore loads it through a dynamic import(); keep every import here
|
||||
// static — a dynamic import() of the barrel from inside would make the
|
||||
// barrel's full namespace live and poison the shared chunk for every
|
||||
// static barrel importer (ACP agent included).
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
fetchGitHubPullRequests,
|
||||
readSessionPrs,
|
||||
updateSessionPrStates,
|
||||
type SessionPrState,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { createWorkspaceRuntimeSessionService } from '../workspace-runtime-storage.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
import {
|
||||
DaemonDrainingError,
|
||||
type SessionArchiveCoordinator,
|
||||
} from './session-archive.js';
|
||||
import { invalidateWorkspaceSessionListCache } from './session-list.js';
|
||||
|
||||
export const DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const FIRST_RUN_DELAY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The slice of {@link SessionArchiveCoordinator} the sweep needs: the
|
||||
* per-session shared lane that archive/delete take exclusively, so a sidecar
|
||||
* commit and an archive move of the same session never interleave.
|
||||
*/
|
||||
export type SessionPrArchiveLane = Pick<
|
||||
SessionArchiveCoordinator,
|
||||
'runSharedMany'
|
||||
>;
|
||||
|
||||
export interface SessionPrRefreshOptions {
|
||||
/**
|
||||
* Serialises each sidecar commit with archive/delete of the same session.
|
||||
* Omitted only by callers that own no coordinator (tests); the daemon
|
||||
* always passes the app-wide one.
|
||||
*/
|
||||
archiveCoordinator?: SessionPrArchiveLane;
|
||||
}
|
||||
|
||||
/**
|
||||
* `QWEN_SESSION_PR_REFRESH_MINUTES`: refresh interval in minutes; `0`
|
||||
* disables the sweep. Missing, blank, invalid, sub-minute, and
|
||||
* timer-overflowing values fall back to the default.
|
||||
*/
|
||||
export function resolveSessionPrRefreshIntervalMs(
|
||||
env: Readonly<Record<string, string | undefined>>,
|
||||
): number | undefined {
|
||||
const raw = env['QWEN_SESSION_PR_REFRESH_MINUTES'];
|
||||
// Blank means "unset" in templated env files; Number('') is 0 and would
|
||||
// silently disable the sweep.
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
const minutes = Number(raw);
|
||||
if (!Number.isFinite(minutes) || minutes < 0) {
|
||||
return DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
if (minutes === 0) return undefined;
|
||||
// Sub-minute values degenerate into a near-continuous sweep, and once the
|
||||
// converted ms exceeds setInterval's 32-bit max Node clamps the delay to
|
||||
// 1 ms — turning a "longer" interval into a hot loop.
|
||||
if (minutes < 1) return DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS;
|
||||
const ms = minutes * 60_000;
|
||||
return ms <= 2 ** 31 - 1 ? ms : DEFAULT_SESSION_PR_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export interface SessionPrRefreshResult {
|
||||
/** Sidecars read (sessions with at least one binding). */
|
||||
scanned: number;
|
||||
/** Bindings whose state was rewritten (open → merged/closed). */
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the persisted `state` snapshot of one workspace's PR bindings.
|
||||
* Only merged is terminal (closed PRs can reopen), so workspaces whose
|
||||
* bindings are all merged cost no `gh` call at all. One slim
|
||||
* `gh pr list --state all` per workspace per sweep; rewritten in place
|
||||
* (order and createdAt preserved).
|
||||
*/
|
||||
export async function refreshWorkspaceSessionPrStates(
|
||||
runtime: WorkspaceRuntime,
|
||||
fetchPullRequests: typeof fetchGitHubPullRequests = fetchGitHubPullRequests,
|
||||
options: SessionPrRefreshOptions = {},
|
||||
): Promise<SessionPrRefreshResult> {
|
||||
// The runtime was snapshotted from the registry before this sweep awaited
|
||||
// anything; a trust/env replacement or removal closes its generation
|
||||
// guard while the sweep is in flight, and a retired generation must not
|
||||
// run `gh` with its stale env, commit sidecars, or notify its obsolete
|
||||
// bridge — the same guard every REST/ACP binding writer asserts.
|
||||
const assertGenerationOpen = (): void =>
|
||||
runtime.generationGuard?.assertOpen();
|
||||
const sessionService = createWorkspaceRuntimeSessionService(runtime);
|
||||
const pendingNumbers: Array<{
|
||||
sessionId: string;
|
||||
prPath: string;
|
||||
numbers: number[];
|
||||
}> = [];
|
||||
let scanned = 0;
|
||||
for (const archiveState of ['active', 'archived'] as const) {
|
||||
// Sidecar-driven, not transcript-driven: a binding persisted before the
|
||||
// session's first flush has no transcript yet, and its state must not
|
||||
// stay frozen at bind time.
|
||||
for (const sessionId of sessionService.listSessionIdsWithPrSidecar(
|
||||
archiveState,
|
||||
)) {
|
||||
// The sidecar enumeration (unlike listSessions) sees foreign sessions
|
||||
// whose sanitized cwds collide onto this chats dir — never rewrite
|
||||
// another project's bindings.
|
||||
if (
|
||||
!(await sessionService.sessionPrSidecarBelongsToCurrentProject(
|
||||
sessionId,
|
||||
archiveState,
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const prPath = sessionService.getPrSessionPathForArchiveState(
|
||||
sessionId,
|
||||
archiveState,
|
||||
);
|
||||
let prs: Awaited<ReturnType<typeof readSessionPrs>>;
|
||||
try {
|
||||
prs = await readSessionPrs(prPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!prs) continue;
|
||||
scanned += 1;
|
||||
const numbers = prs
|
||||
// Only merged is terminal: closed PRs can be reopened, so they
|
||||
// keep participating in the sweep.
|
||||
.filter((p) => p.state !== 'merged')
|
||||
.map((p) => p.number);
|
||||
if (numbers.length > 0) {
|
||||
pendingNumbers.push({ sessionId, prPath, numbers });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingNumbers.length === 0) return { scanned, updated: 0 };
|
||||
|
||||
assertGenerationOpen();
|
||||
const result = await fetchPullRequests(
|
||||
runtime.workspaceCwd,
|
||||
runtime.env.effectiveEnv,
|
||||
{ state: 'all', limit: 500, slim: true },
|
||||
);
|
||||
if (result.kind !== 'ok') return { scanned, updated: 0 };
|
||||
// The url rides along with the state: the map is keyed by number, but a
|
||||
// binding may point at another repository whose same-numbered PR must
|
||||
// never supply this workspace's state.
|
||||
const numberToFetch = new Map<
|
||||
number,
|
||||
{ state: SessionPrState; url: string }
|
||||
>();
|
||||
for (const pr of result.pullRequests) {
|
||||
// The sidecar snapshot has no 'draft' variant — a draft is still open.
|
||||
numberToFetch.set(pr.number, {
|
||||
state: pr.state === 'draft' ? 'open' : pr.state,
|
||||
url: pr.url,
|
||||
});
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const target of pendingNumbers) {
|
||||
const states = new Map<number, { state: SessionPrState; url: string }>();
|
||||
for (const number of target.numbers) {
|
||||
const fetched = numberToFetch.get(number);
|
||||
// Only a number ABSENT from gh's page is skipped (out of the limit
|
||||
// window); a present one is authoritative — including an 'open' that
|
||||
// supersedes a stale 'closed' after a reopen.
|
||||
if (fetched !== undefined) states.set(number, fetched);
|
||||
}
|
||||
if (states.size === 0) continue;
|
||||
const commit = (): Promise<number> =>
|
||||
updateSessionPrStates(target.prPath, states, {
|
||||
assertCanCommit: () => {
|
||||
assertGenerationOpen();
|
||||
// Belt and braces under the archive lane: a sidecar that vanished
|
||||
// between the queued read and this commit step (a delete that
|
||||
// took no lane) must not be resurrected by the write.
|
||||
if (!existsSync(target.prPath)) {
|
||||
throw new Error(
|
||||
`session PR sidecar vanished during refresh: ${target.prPath}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
// The archive lane is what makes the existence check above sufficient:
|
||||
// archive/delete hold the session's exclusive lane across their
|
||||
// renames, so while this commit holds the shared lane neither can
|
||||
// move the transcript or sidecar out from under the atomic write.
|
||||
updated += await (options.archiveCoordinator
|
||||
? options.archiveCoordinator.runSharedMany([target.sessionId], commit)
|
||||
: commit());
|
||||
} catch (error) {
|
||||
// A draining daemon accepts no further session maintenance — stop the
|
||||
// sweep instead of failing every remaining target one by one.
|
||||
if (error instanceof DaemonDrainingError) throw error;
|
||||
// One unwritable (or archiving) sidecar must not starve the rest of
|
||||
// the sweep; the next tick picks it up.
|
||||
}
|
||||
}
|
||||
if (updated > 0) {
|
||||
// Same pairing as every other binding write in this feature: the
|
||||
// sidebar refetch is catalog-version-gated and the live-state payload
|
||||
// carries no `prs`, so a silent sidecar rewrite would leave stale
|
||||
// badges until an unrelated catalog change or a reload. Never notify a
|
||||
// retired generation's bridge: its successor owns the catalog now.
|
||||
assertGenerationOpen();
|
||||
invalidateWorkspaceSessionListCache({
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
archiveStates: ['active', 'archived'],
|
||||
});
|
||||
runtime.bridge.markSessionCatalogChanged();
|
||||
}
|
||||
return { scanned, updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-frequency daemon sweep that keeps bound PR states fresh. Runs off the
|
||||
* session-list polling path (its own timer), unref'd so it never keeps the
|
||||
* process alive, and the first run is delayed to stay out of boot's way.
|
||||
* Returns undefined when disabled via `QWEN_SESSION_PR_REFRESH_MINUTES=0`.
|
||||
*/
|
||||
export function startSessionPrRefreshTimer(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
env?: Readonly<Record<string, string | undefined>>;
|
||||
/**
|
||||
* Resolved per tick (not at start) because the daemon parks the
|
||||
* coordinator on the serve app, which is built after this timer starts.
|
||||
*/
|
||||
getArchiveCoordinator?: () => SessionPrArchiveLane | undefined;
|
||||
}): { dispose(): void } | undefined {
|
||||
const intervalMs = resolveSessionPrRefreshIntervalMs(deps.env ?? process.env);
|
||||
if (intervalMs === undefined) return undefined;
|
||||
let running = false;
|
||||
const tick = async (): Promise<void> => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const archiveCoordinator = deps.getArchiveCoordinator?.();
|
||||
for (const runtime of deps.workspaceRegistry.listAll()) {
|
||||
if (!runtime.trusted) continue;
|
||||
try {
|
||||
await refreshWorkspaceSessionPrStates(runtime, undefined, {
|
||||
archiveCoordinator,
|
||||
});
|
||||
} catch (error) {
|
||||
// A draining daemon rejects every workspace the same way.
|
||||
if (error instanceof DaemonDrainingError) return;
|
||||
// A single workspace's failure (including a generation retired
|
||||
// mid-sweep) must not starve the rest.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
const first = setTimeout(() => void tick(), FIRST_RUN_DELAY_MS);
|
||||
first.unref();
|
||||
const timer = setInterval(() => void tick(), intervalMs);
|
||||
timer.unref();
|
||||
return {
|
||||
dispose(): void {
|
||||
clearTimeout(first);
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -482,6 +482,7 @@ describe('AppContainer State Management', () => {
|
||||
addChangeListener: vi.fn(),
|
||||
loadSubagent: vi.fn(),
|
||||
createSubagent: vi.fn(),
|
||||
getAvailableModelGrades: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
|
||||
mockSubagentManager as SubagentManager,
|
||||
@@ -510,6 +511,23 @@ describe('AppContainer State Management', () => {
|
||||
} as InitializationResult;
|
||||
});
|
||||
|
||||
// AgentTool's constructor fires refreshSubagents() as a floating promise;
|
||||
// a SubagentManager mock missing any method it touches rejects unhandled
|
||||
// and fails the whole vitest run, not just this file.
|
||||
it('keeps the SubagentManager mock complete for AgentTool init', async () => {
|
||||
const rejections: unknown[] = [];
|
||||
const onRejection = (reason: unknown) => rejections.push(reason);
|
||||
process.on('unhandledRejection', onRejection);
|
||||
try {
|
||||
await mockConfig.initialize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(rejections).toEqual([]);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onRejection);
|
||||
await mockConfig.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
describe('speculative tool results', () => {
|
||||
it('renders error envelopes as failed tools', () => {
|
||||
expect(
|
||||
|
||||
@@ -89,6 +89,9 @@ function makeEnv() {
|
||||
const config: any = {
|
||||
getSessionId: () => sessionId,
|
||||
getResumedSessionData: () => resumedData,
|
||||
// initialize() on a resumed session asks the skill tool to restore its
|
||||
// loaded skills via config.getToolRegistry().getTool(...).
|
||||
getToolRegistry: () => ({ getTool: () => undefined }),
|
||||
swap(id: string, data?: ResumedSessionData) {
|
||||
sessionId = id;
|
||||
resumedData = data;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -12,11 +13,24 @@ import {
|
||||
SESSION_PR_LIST_LIMIT,
|
||||
mergeSessionPrLists,
|
||||
readSessionPrs,
|
||||
replaceSessionPrs,
|
||||
updateSessionPrStates,
|
||||
upsertSessionPr,
|
||||
writeSessionPrs,
|
||||
type SessionPr,
|
||||
type SessionPrState,
|
||||
} from './session-pr-service.js';
|
||||
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
readFile: vi.fn<typeof import('node:fs/promises').readFile>(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
fsMocks.readFile.mockImplementation(actual.readFile);
|
||||
return { ...actual, readFile: fsMocks.readFile };
|
||||
});
|
||||
|
||||
const entry = (number: number): SessionPr => ({
|
||||
number,
|
||||
url: `https://github.com/owner/repo/pull/${number}`,
|
||||
@@ -27,6 +41,7 @@ let tmpDir: string;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fsMocks.readFile.mockClear();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-pr-test-'));
|
||||
filePath = path.join(tmpDir, 'test.pr.json');
|
||||
});
|
||||
@@ -184,6 +199,225 @@ describe('upsertSessionPr failure handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertSessionPr state', () => {
|
||||
it('persists an explicit state', async () => {
|
||||
const prs = await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: entry(100).url,
|
||||
state: 'open',
|
||||
});
|
||||
expect(prs[0]?.state).toBe('open');
|
||||
expect(await readSessionPrs(filePath)).toEqual(prs);
|
||||
});
|
||||
|
||||
it('preserves the known state on a stateless re-bind', async () => {
|
||||
await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: entry(100).url,
|
||||
state: 'merged',
|
||||
});
|
||||
const prs = await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: entry(100).url,
|
||||
});
|
||||
expect(prs).toHaveLength(1);
|
||||
expect(prs[0]?.state).toBe('merged');
|
||||
});
|
||||
|
||||
it('does not carry state onto a same-numbered PR of another repository', async () => {
|
||||
await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: 'https://github.com/repo-a/owner/pull/100',
|
||||
state: 'merged',
|
||||
});
|
||||
const prs = await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: 'https://github.com/repo-b/owner/pull/100',
|
||||
});
|
||||
expect(prs).toHaveLength(1);
|
||||
expect(prs[0]?.url).toBe('https://github.com/repo-b/owner/pull/100');
|
||||
expect(prs[0]?.state).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries state when the re-bind spells the same PR differently', async () => {
|
||||
await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: 'https://github.com/Owner/Repo/pull/100/',
|
||||
state: 'merged',
|
||||
});
|
||||
const prs = await upsertSessionPr(filePath, {
|
||||
number: 100,
|
||||
url: 'https://github.com/owner/repo/pull/100?v=2',
|
||||
});
|
||||
expect(prs[0]?.state).toBe('merged');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSessionPrStates', () => {
|
||||
const fetched = (
|
||||
number: number,
|
||||
state: SessionPrState,
|
||||
url: string = entry(number).url,
|
||||
): ReadonlyMap<number, { state: SessionPrState; url: string }> =>
|
||||
new Map([[number, { state, url }]]);
|
||||
|
||||
it('rewrites states in place without touching order or createdAt', async () => {
|
||||
await writeSessionPrs(filePath, [
|
||||
{ ...entry(100), state: 'open' },
|
||||
{ ...entry(101), state: 'open' },
|
||||
]);
|
||||
const updated = await updateSessionPrStates(
|
||||
filePath,
|
||||
fetched(100, 'merged'),
|
||||
);
|
||||
expect(updated).toBe(1);
|
||||
const persisted = await readSessionPrs(filePath);
|
||||
expect(persisted?.map((p) => p.number)).toEqual([100, 101]);
|
||||
expect(persisted?.[0]?.state).toBe('merged');
|
||||
expect(persisted?.[0]?.createdAt).toBe(entry(100).createdAt);
|
||||
expect(persisted?.[1]?.state).toBe('open');
|
||||
});
|
||||
|
||||
it('returns 0 without writing when nothing changes', async () => {
|
||||
await writeSessionPrs(filePath, [{ ...entry(100), state: 'merged' }]);
|
||||
const before = await fs.readFile(filePath, 'utf-8');
|
||||
expect(await updateSessionPrStates(filePath, fetched(100, 'merged'))).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await fs.readFile(filePath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('returns 0 when the sidecar is absent', async () => {
|
||||
expect(await updateSessionPrStates(filePath, fetched(100, 'merged'))).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes against a concurrent upsert on the same sidecar', async () => {
|
||||
await writeSessionPrs(filePath, [{ ...entry(100), state: 'open' }]);
|
||||
const [updated, prs] = await Promise.all([
|
||||
updateSessionPrStates(filePath, fetched(100, 'merged')),
|
||||
upsertSessionPr(filePath, { number: 101, url: entry(101).url }),
|
||||
]);
|
||||
expect(updated).toBe(1);
|
||||
expect(prs?.map((p) => p.number)).toEqual([100, 101]);
|
||||
// Whichever ran second read the first's write — nothing was clobbered.
|
||||
const persisted = await readSessionPrs(filePath);
|
||||
expect(persisted?.find((p) => p.number === 100)?.state).toBe('merged');
|
||||
expect(persisted?.find((p) => p.number === 101)).toBeDefined();
|
||||
});
|
||||
|
||||
it('applies a fetched state only when its url matches the entry', async () => {
|
||||
// The map is keyed by number, but the metadata route accepts any
|
||||
// http(s) url: a binding that points at another repository must never
|
||||
// pick up this repo's same-numbered PR state (and a wrong terminal
|
||||
// state would be permanent — merged entries leave the sweep).
|
||||
await writeSessionPrs(filePath, [{ ...entry(100), state: 'open' }]);
|
||||
const updated = await updateSessionPrStates(
|
||||
filePath,
|
||||
fetched(
|
||||
100,
|
||||
'merged',
|
||||
'https://github.com/other-org/other-repo/pull/100',
|
||||
),
|
||||
);
|
||||
expect(updated).toBe(0);
|
||||
const persisted = await readSessionPrs(filePath);
|
||||
expect(persisted?.[0]?.state).toBe('open');
|
||||
});
|
||||
|
||||
it('refreshes a binding whose url is canonical-equivalent to the fetched one', async () => {
|
||||
// Binding urls preserve the user's remote casing (backfill's remote
|
||||
// fallback) or carry dialog spelling variants, while gh returns the
|
||||
// server-canonical spelling; GitHub repo paths are case-insensitive,
|
||||
// so a byte-unequal comparison skips the entry on every sweep — it
|
||||
// can never reach the terminal merged state.
|
||||
await writeSessionPrs(filePath, [
|
||||
{
|
||||
...entry(100),
|
||||
url: 'https://github.com/OWNER/REPO/pull/100/?v=2',
|
||||
state: 'open',
|
||||
},
|
||||
]);
|
||||
const updated = await updateSessionPrStates(
|
||||
filePath,
|
||||
fetched(100, 'merged'),
|
||||
);
|
||||
expect(updated).toBe(1);
|
||||
const persisted = await readSessionPrs(filePath);
|
||||
expect(persisted?.[0]?.state).toBe('merged');
|
||||
});
|
||||
|
||||
it('does not resurrect a sidecar deleted between the queued read and the write commit', async () => {
|
||||
// Deletion and archive moves unlink the sidecar outside the mutation
|
||||
// queue. Force the race deterministically: the queued read captures the
|
||||
// contents, the deletion lands the moment the read resolves, and only
|
||||
// the commit-step guard can still stop the stale write.
|
||||
await writeSessionPrs(filePath, [{ ...entry(100), state: 'open' }]);
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
fsMocks.readFile.mockImplementationOnce(async () => {
|
||||
fsSync.unlinkSync(filePath);
|
||||
return raw;
|
||||
});
|
||||
await expect(
|
||||
updateSessionPrStates(filePath, fetched(100, 'merged'), {
|
||||
assertCanCommit: () => {
|
||||
if (!fsSync.existsSync(filePath)) {
|
||||
throw new Error('sidecar vanished during refresh');
|
||||
}
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('sidecar vanished during refresh');
|
||||
expect(fsSync.existsSync(filePath)).toBe(false);
|
||||
// The rejected write must not wedge the queue for later mutations.
|
||||
const recovered = await upsertSessionPr(filePath, {
|
||||
number: 101,
|
||||
url: entry(101).url,
|
||||
});
|
||||
expect(recovered.map((p) => p.number)).toEqual([101]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSessionPrs', () => {
|
||||
it('runs the planner against the freshest list inside the queue', async () => {
|
||||
// The planner must see the result of mutations queued ahead of it —
|
||||
// planning from a stale outer read would clobber a binding that lands
|
||||
// between the caller's read and write.
|
||||
await writeSessionPrs(filePath, [entry(100)]);
|
||||
const seen: number[][] = [];
|
||||
const upsert = upsertSessionPr(filePath, {
|
||||
number: 101,
|
||||
url: entry(101).url,
|
||||
});
|
||||
const replace = replaceSessionPrs(filePath, (existing) => {
|
||||
seen.push(existing.map((p) => p.number));
|
||||
return [...existing, entry(102)];
|
||||
});
|
||||
await Promise.all([upsert, replace]);
|
||||
expect(seen).toEqual([[100, 101]]);
|
||||
expect((await readSessionPrs(filePath))?.map((p) => p.number)).toEqual([
|
||||
100, 101, 102,
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the file untouched when the planner returns null', async () => {
|
||||
await writeSessionPrs(filePath, [entry(100)]);
|
||||
const before = await fs.readFile(filePath, 'utf-8');
|
||||
expect(await replaceSessionPrs(filePath, () => null)).toBeNull();
|
||||
expect(await fs.readFile(filePath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('persists the planner result and returns it', async () => {
|
||||
await writeSessionPrs(filePath, [entry(100)]);
|
||||
const persisted = await replaceSessionPrs(filePath, (existing) =>
|
||||
existing.filter((p) => p.number !== 100),
|
||||
);
|
||||
expect(persisted).toEqual([]);
|
||||
// An empty list reads back as null (isValidSessionPrList rejects it).
|
||||
expect(await readSessionPrs(filePath)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSessionPrLists', () => {
|
||||
const at = (number: number, createdAt: string, url?: string): SessionPr => ({
|
||||
number,
|
||||
|
||||
@@ -23,8 +23,12 @@ export interface SessionPr {
|
||||
number: number;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
/** Snapshot at last write/refresh; refreshed by the daemon timer. */
|
||||
state?: SessionPrState;
|
||||
}
|
||||
|
||||
export type SessionPrState = 'open' | 'merged' | 'closed';
|
||||
|
||||
/** Bound on the persisted PR list; oldest bindings are dropped beyond it. */
|
||||
export const SESSION_PR_LIST_LIMIT = 10;
|
||||
|
||||
@@ -61,7 +65,11 @@ function isValidSessionPr(value: unknown): value is SessionPr {
|
||||
// The url is interpolated into a stderr audit line by the bridge —
|
||||
// control characters would forge log lines.
|
||||
!hasControlCharacter(v['url']) &&
|
||||
typeof v['createdAt'] === 'string'
|
||||
typeof v['createdAt'] === 'string' &&
|
||||
(v['state'] === undefined ||
|
||||
v['state'] === 'open' ||
|
||||
v['state'] === 'merged' ||
|
||||
v['state'] === 'closed')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,40 +150,138 @@ export function mergeSessionPrLists(
|
||||
.slice(-SESSION_PR_LIST_LIMIT);
|
||||
}
|
||||
|
||||
// Serializes read-modify-write cycles per sidecar path: concurrent bindings
|
||||
// Serializes read-modify-write cycles per sidecar path: concurrent mutations
|
||||
// for the same session must not interleave (read [] → read [] → write [A] →
|
||||
// write [B] would silently drop A). A failed predecessor must not block
|
||||
// later bindings.
|
||||
const upsertQueue = new Map<string, Promise<unknown>>();
|
||||
// later mutations.
|
||||
const mutationQueue = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Insert or refresh a binding (matched by PR number) and persist the list,
|
||||
* keeping at most {@link SESSION_PR_LIST_LIMIT} latest entries. A re-bound
|
||||
* number moves to the end (latest) with a fresh createdAt.
|
||||
*/
|
||||
export function upsertSessionPr(
|
||||
function enqueuePrMutation<T>(
|
||||
filePath: string,
|
||||
pr: { number: number; url: string },
|
||||
): Promise<SessionPr[]> {
|
||||
const run = async (): Promise<SessionPr[]> => {
|
||||
const existing = (await readSessionPrs(filePath)) ?? [];
|
||||
const rest = existing.filter((entry) => entry.number !== pr.number);
|
||||
const next = [
|
||||
...rest,
|
||||
{ number: pr.number, url: pr.url, createdAt: new Date().toISOString() },
|
||||
].slice(-SESSION_PR_LIST_LIMIT);
|
||||
await writeSessionPrs(filePath, next);
|
||||
return next;
|
||||
};
|
||||
const previous = upsertQueue.get(filePath) ?? Promise.resolve();
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = mutationQueue.get(filePath) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(run);
|
||||
upsertQueue.set(filePath, next);
|
||||
mutationQueue.set(filePath, next);
|
||||
// The cleanup chain must absorb `next`'s rejection too — a derived
|
||||
// finally/catch promise would otherwise reject unhandled whenever the
|
||||
// queued write fails, even though every caller awaits `next` itself.
|
||||
const cleanup = (): void => {
|
||||
if (upsertQueue.get(filePath) === next) upsertQueue.delete(filePath);
|
||||
if (mutationQueue.get(filePath) === next) mutationQueue.delete(filePath);
|
||||
};
|
||||
void next.then(cleanup, cleanup);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a binding url for same-target comparison: host/path
|
||||
* case, trailing slashes, query, and fragment never change which PR a url
|
||||
* names (GitHub hosts and repo paths are case-insensitive; query variants
|
||||
* are cache-busters), while the repository path does — a same-numbered PR
|
||||
* of a different repository is a different PR.
|
||||
*/
|
||||
export function canonicalSessionPrUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}${parsed.pathname}`
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return url.toLowerCase().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or refresh a binding (matched by PR number) and persist the list,
|
||||
* keeping at most {@link SESSION_PR_LIST_LIMIT} latest entries. A re-bound
|
||||
* number moves to the end (latest) with a fresh createdAt. An omitted
|
||||
* `state` preserves the existing entry's state when the re-bind targets the
|
||||
* same PR (same canonical url) — a different repository's same-numbered PR
|
||||
* is a different PR and must not inherit its state.
|
||||
*/
|
||||
export function upsertSessionPr(
|
||||
filePath: string,
|
||||
pr: { number: number; url: string; state?: SessionPrState },
|
||||
): Promise<SessionPr[]> {
|
||||
return enqueuePrMutation(filePath, async () => {
|
||||
const existing = (await readSessionPrs(filePath)) ?? [];
|
||||
const known = existing.find(
|
||||
(entry) =>
|
||||
entry.number === pr.number &&
|
||||
canonicalSessionPrUrl(entry.url) === canonicalSessionPrUrl(pr.url),
|
||||
);
|
||||
const rest = existing.filter((entry) => entry.number !== pr.number);
|
||||
const next = [
|
||||
...rest,
|
||||
{
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
createdAt: new Date().toISOString(),
|
||||
...((pr.state ?? known?.state)
|
||||
? { state: (pr.state ?? known?.state) as SessionPrState }
|
||||
: {}),
|
||||
},
|
||||
].slice(-SESSION_PR_LIST_LIMIT);
|
||||
await writeSessionPrs(filePath, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites bound PR states in place — order and createdAt are preserved, so
|
||||
* a refresh sweep never reshuffles the badge's "latest" entry. A fetched
|
||||
* state applies only when its url matches the entry's: the map is keyed by
|
||||
* number, but a binding may point at any repository, and a same-numbered PR
|
||||
* of a different repo is a different PR. Returns the number of entries
|
||||
* rewritten; 0 when the sidecar is absent/invalid or nothing changed (no
|
||||
* write then). `assertCanCommit` runs inside the mutation queue right
|
||||
* before the irreversible write commit; a throw aborts the write.
|
||||
*/
|
||||
export function updateSessionPrStates(
|
||||
filePath: string,
|
||||
states: ReadonlyMap<number, { state: SessionPrState; url: string }>,
|
||||
options: { assertCanCommit?: () => void } = {},
|
||||
): Promise<number> {
|
||||
return enqueuePrMutation(filePath, async () => {
|
||||
const existing = await readSessionPrs(filePath);
|
||||
if (!existing) return 0;
|
||||
let changed = 0;
|
||||
const next = existing.map((entry) => {
|
||||
const fetched = states.get(entry.number);
|
||||
if (
|
||||
fetched === undefined ||
|
||||
canonicalSessionPrUrl(fetched.url) !==
|
||||
canonicalSessionPrUrl(entry.url) ||
|
||||
fetched.state === entry.state
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
changed += 1;
|
||||
return { ...entry, state: fetched.state };
|
||||
});
|
||||
if (changed === 0) return 0;
|
||||
await writeSessionPrs(filePath, next, options);
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the sidecar with a precomputed list atomically with respect to
|
||||
* concurrent mutations: the planner runs inside the mutation queue against
|
||||
* the freshest list, so a plan-then-write cycle cannot clobber a binding
|
||||
* that lands between the caller's read and write. The planner returns the
|
||||
* replacement list, or null to leave the file untouched. Resolves with the
|
||||
* persisted list, or null when nothing changed.
|
||||
*/
|
||||
export function replaceSessionPrs(
|
||||
filePath: string,
|
||||
plan: (existing: SessionPr[]) => SessionPr[] | null,
|
||||
): Promise<SessionPr[] | null> {
|
||||
return enqueuePrMutation(filePath, async () => {
|
||||
const existing = (await readSessionPrs(filePath)) ?? [];
|
||||
const next = plan(existing);
|
||||
if (next === null) return null;
|
||||
await writeSessionPrs(filePath, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -380,6 +380,7 @@ export const SESSION_TITLE_MAX_LENGTH = 200;
|
||||
* (32-36 hex characters, optionally with hyphens).
|
||||
*/
|
||||
const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/;
|
||||
const PR_SIDECAR_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.pr\.json$/;
|
||||
/** Maximum number of lines to scan when looking for the first prompt text. */
|
||||
const MAX_PROMPT_SCAN_LINES = 10;
|
||||
/**
|
||||
@@ -749,6 +750,58 @@ export class SessionService {
|
||||
return this.getPrSessionPathForState(sessionId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists session ids that have a PR sidecar in the given archive state's
|
||||
* chats dir. Unlike {@link listSessions} (transcript-driven), this also
|
||||
* sees sessions whose binding sidecar was written before their first
|
||||
* transcript flush.
|
||||
*/
|
||||
listSessionIdsWithPrSidecar(archiveState: SessionArchiveState): string[] {
|
||||
const chatsDir = this.getChatsDirForState(archiveState);
|
||||
let fileNames: string[];
|
||||
try {
|
||||
fileNames = fs.readdirSync(chatsDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
return fileNames
|
||||
.filter((name) => PR_SIDECAR_FILE_PATTERN.test(name))
|
||||
.map((name) => name.slice(0, -'.pr.json'.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-membership check for sidecar-driven callers (the PR refresh
|
||||
* sweep) that enumerate chats-dir files directly. That enumeration —
|
||||
* unlike {@link listSessions} — also sees sessions of other projects
|
||||
* whose sanitized cwds collide onto the same chats dir, so the
|
||||
* transcript head must pass the same rule listSessions applies when it
|
||||
* exists. A missing transcript is inconclusive: the sidecar may predate
|
||||
* the session's first flush, so the binding must stay refreshable.
|
||||
*/
|
||||
async sessionPrSidecarBelongsToCurrentProject(
|
||||
sessionId: string,
|
||||
archiveState: SessionArchiveState,
|
||||
): Promise<boolean> {
|
||||
let records: ChatRecord[];
|
||||
try {
|
||||
records = await jsonl.readLines<ChatRecord>(
|
||||
this.getSessionFilePath(sessionId, archiveState),
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
this.warn(
|
||||
`sessionPrSidecarBelongsToCurrentProject: failed to read ${sessionId}: ${error}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const head = records[0];
|
||||
if (!head || typeof head.cwd !== 'string') return true;
|
||||
return this.sessionBelongsToCurrentProject(sessionId, head.cwd);
|
||||
}
|
||||
|
||||
private async readProjectSessionHead(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
@@ -2074,6 +2127,50 @@ export class SessionService {
|
||||
return { count, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerates every persisted session id of this project for one archive
|
||||
* state by reading the chats dir directly. Unlike {@link listSessions}
|
||||
* there is no mtime cursor and no page size: an exhaustive sweep paged
|
||||
* by the strict `mtime < cursor` filter would silently skip sessions
|
||||
* that share an mtime with a page's last entry, on every run. Same
|
||||
* disk-walk shape as {@link countSessionsInState} — first-record read
|
||||
* for project membership only, no title/prompt hydration.
|
||||
*/
|
||||
async listAllProjectSessionIds(
|
||||
archiveState: SessionArchiveState,
|
||||
): Promise<string[]> {
|
||||
const chatsDir = this.getChatsDirForState(archiveState);
|
||||
let fileNames: string[];
|
||||
try {
|
||||
fileNames = fs.readdirSync(chatsDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
const sessionIds: string[] = [];
|
||||
for (const name of fileNames) {
|
||||
if (!SESSION_FILE_PATTERN.test(name)) continue;
|
||||
const filePath = path.join(chatsDir, name);
|
||||
try {
|
||||
const records = await jsonl.readLines<ChatRecord>(filePath, 1);
|
||||
if (records.length === 0) continue;
|
||||
const firstRecord = records[0]!;
|
||||
if (
|
||||
!(await this.sessionBelongsToCurrentProject(
|
||||
firstRecord.sessionId,
|
||||
firstRecord.cwd,
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sessionIds.push(firstRecord.sessionId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return sessionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all records from a session file.
|
||||
*/
|
||||
|
||||
@@ -169,6 +169,23 @@ describe('parseGhPrList', () => {
|
||||
'unexpected gh output',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the gh state field to merged/closed, falling back to isDraft', () => {
|
||||
const result = parseGhPrList(
|
||||
JSON.stringify([
|
||||
ghPrEntry({ number: 1, state: 'MERGED' }),
|
||||
ghPrEntry({ number: 2, state: 'CLOSED' }),
|
||||
ghPrEntry({ number: 3, state: 'OPEN' }),
|
||||
ghPrEntry({ number: 4, state: 'OPEN', isDraft: true }),
|
||||
]),
|
||||
);
|
||||
expect(result.map((pr) => pr.state)).toEqual([
|
||||
'merged',
|
||||
'closed',
|
||||
'open',
|
||||
'draft',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGitHubPullRequests', () => {
|
||||
@@ -219,6 +236,37 @@ describe('fetchGitHubPullRequests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses slim fields and the requested state/limit when asked', async () => {
|
||||
fs.mkdirSync(path.join(dir, '.git'));
|
||||
mockGhSuccess([ghPrEntry({ state: 'MERGED' })]);
|
||||
|
||||
const result = await fetchGitHubPullRequests(dir, undefined, {
|
||||
state: 'all',
|
||||
limit: 500,
|
||||
slim: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'ok',
|
||||
pullRequests: [expect.objectContaining({ number: 1, state: 'merged' })],
|
||||
});
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'500',
|
||||
'--json',
|
||||
'number,url,headRefName,state',
|
||||
],
|
||||
expect.objectContaining({ cwd: dir }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns cli_unavailable when gh is not installed', async () => {
|
||||
fs.mkdirSync(path.join(dir, '.git'));
|
||||
mockGhError(
|
||||
|
||||
@@ -26,7 +26,9 @@ export const GITHUB_PR_LIST_LIMIT = 30;
|
||||
const GH_PR_LIST_FIELDS =
|
||||
'number,title,url,author,headRefName,isDraft,reviewDecision,statusCheckRollup,updatedAt';
|
||||
|
||||
export type GitHubPullRequestState = 'open' | 'draft';
|
||||
const GH_PR_LIST_FIELDS_SLIM = 'number,url,headRefName,state';
|
||||
|
||||
export type GitHubPullRequestState = 'open' | 'draft' | 'merged' | 'closed';
|
||||
|
||||
export type GitHubPullRequestReviewDecision =
|
||||
| 'approved'
|
||||
@@ -131,6 +133,7 @@ interface GhPrListEntry {
|
||||
author?: { login?: string } | null;
|
||||
headRefName?: string;
|
||||
isDraft?: boolean;
|
||||
state?: string;
|
||||
reviewDecision?: string | null;
|
||||
statusCheckRollup?: Array<GhCheckRun | GhStatusContext>;
|
||||
updatedAt?: string;
|
||||
@@ -139,13 +142,27 @@ interface GhPrListEntry {
|
||||
function mapEntry(entry: GhPrListEntry): GitHubPullRequest | null {
|
||||
if (typeof entry.number !== 'number') return null;
|
||||
const parsed = Date.parse(entry.updatedAt ?? '');
|
||||
// `state` is only requested by slim consumers; the panel derives open/draft
|
||||
// from isDraft because its field set predates it.
|
||||
let state: GitHubPullRequestState;
|
||||
switch (entry.state?.toUpperCase()) {
|
||||
case 'MERGED':
|
||||
state = 'merged';
|
||||
break;
|
||||
case 'CLOSED':
|
||||
state = 'closed';
|
||||
break;
|
||||
default:
|
||||
state = entry.isDraft ? 'draft' : 'open';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
number: entry.number,
|
||||
title: entry.title ?? '',
|
||||
url: entry.url ?? '',
|
||||
author: entry.author?.login ?? '',
|
||||
headRefName: entry.headRefName ?? '',
|
||||
state: entry.isDraft ? 'draft' : 'open',
|
||||
state,
|
||||
reviewDecision: mapReviewDecision(entry.reviewDecision),
|
||||
checks: aggregateChecks(entry.statusCheckRollup),
|
||||
updatedAt: Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000),
|
||||
@@ -164,10 +181,28 @@ export function parseGhPrList(stdout: string): GitHubPullRequest[] {
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export interface FetchGitHubPullRequestsOptions {
|
||||
/** Bound on the returned list; defaults to {@link GITHUB_PR_LIST_LIMIT}. */
|
||||
limit?: number;
|
||||
/** `open` (default, glanceable panel) or `all` (merged/closed heads too). */
|
||||
state?: 'open' | 'all';
|
||||
/**
|
||||
* Request only number/url/headRefName. The panel's CI rollup field makes
|
||||
* large `--state all` queries hit GitHub GraphQL server timeouts (504);
|
||||
* branch-mapping consumers like PR backfill don't need it.
|
||||
*/
|
||||
slim?: boolean;
|
||||
}
|
||||
|
||||
function runGhPrList(
|
||||
gitRoot: string,
|
||||
env?: Readonly<Record<string, string | undefined>>,
|
||||
options: FetchGitHubPullRequestsOptions = {},
|
||||
): Promise<string> {
|
||||
const limit = Math.min(
|
||||
Math.max(options.limit ?? GITHUB_PR_LIST_LIMIT, 1),
|
||||
1000,
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'gh',
|
||||
@@ -175,11 +210,11 @@ function runGhPrList(
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
options.state ?? 'open',
|
||||
'--limit',
|
||||
String(GITHUB_PR_LIST_LIMIT),
|
||||
String(limit),
|
||||
'--json',
|
||||
GH_PR_LIST_FIELDS,
|
||||
options.slim ? GH_PR_LIST_FIELDS_SLIM : GH_PR_LIST_FIELDS,
|
||||
],
|
||||
{
|
||||
cwd: gitRoot,
|
||||
@@ -228,13 +263,14 @@ function ghErrorMessage(
|
||||
export async function fetchGitHubPullRequests(
|
||||
cwd: string,
|
||||
env?: Readonly<Record<string, string | undefined>>,
|
||||
options?: FetchGitHubPullRequestsOptions,
|
||||
): Promise<FetchGitHubPullRequestsResult> {
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
if (!gitRoot) return { kind: 'not_a_repo' };
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = await runGhPrList(gitRoot, env);
|
||||
stdout = await runGhPrList(gitRoot, env, options);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { kind: 'cli_unavailable' };
|
||||
|
||||
@@ -36,6 +36,10 @@ export function isDaemonSessionPrInfo(
|
||||
/^https?:\/\//i.test(v['url']) &&
|
||||
// The daemon interpolates the url into a stderr audit line — control
|
||||
// characters would forge log lines downstream of this gate.
|
||||
!hasControlCharacter(v['url'])
|
||||
!hasControlCharacter(v['url']) &&
|
||||
(v['state'] === undefined ||
|
||||
v['state'] === 'open' ||
|
||||
v['state'] === 'merged' ||
|
||||
v['state'] === 'closed')
|
||||
);
|
||||
}
|
||||
@@ -1039,6 +1039,8 @@ export interface DaemonBranchInfo {
|
||||
export interface DaemonSessionPrInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
/** Snapshot of the PR's state at last bind/refresh; optional. */
|
||||
state?: 'open' | 'merged' | 'closed';
|
||||
}
|
||||
|
||||
/** Returned from `POST /session`. */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isDaemonSessionPrInfo } from '../../src/daemon/session-pr.js';
|
||||
|
||||
describe('isDaemonSessionPrInfo', () => {
|
||||
const valid = {
|
||||
number: 9517,
|
||||
url: 'https://github.com/o/r/pull/9517',
|
||||
};
|
||||
|
||||
it('accepts a binding with no state and each enum state', () => {
|
||||
expect(isDaemonSessionPrInfo(valid)).toBe(true);
|
||||
for (const state of ['open', 'merged', 'closed'] as const) {
|
||||
expect(isDaemonSessionPrInfo({ ...valid, state })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a state outside the enum', () => {
|
||||
// A dropped or inverted state clause would change what
|
||||
// DaemonClient's `body.prs.filter(isDaemonSessionPrInfo)` and the
|
||||
// events.ts `prs` payload check accept — bindings would silently
|
||||
// vanish for SDK consumers.
|
||||
expect(isDaemonSessionPrInfo({ ...valid, state: 'draft' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed numbers and urls', () => {
|
||||
expect(isDaemonSessionPrInfo({ ...valid, number: 0 })).toBe(false);
|
||||
expect(isDaemonSessionPrInfo({ ...valid, number: 1.5 })).toBe(false);
|
||||
expect(
|
||||
isDaemonSessionPrInfo({ ...valid, url: 'javascript:alert(1)' }),
|
||||
).toBe(false);
|
||||
expect(isDaemonSessionPrInfo(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -31,3 +31,25 @@
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Merged PRs are settled history — dim the badge so open work stands out. */
|
||||
.sessionPrBadgeMerged {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground, #6b7280) 10%,
|
||||
transparent
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground, #6b7280) 75%,
|
||||
var(--foreground)
|
||||
);
|
||||
}
|
||||
|
||||
.sessionPrBadgeMerged:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground, #6b7280) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { DaemonSessionPrInfo } from '@qwen-code/sdk/daemon';
|
||||
import { I18nProvider } from '../i18n';
|
||||
import { SessionPrBadge } from './SessionPrBadge';
|
||||
import styles from './SessionPrBadge.module.css';
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function renderBadge(prs: DaemonSessionPrInfo[]): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<SessionPrBadge prs={prs} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
const pr = (
|
||||
number: number,
|
||||
state?: DaemonSessionPrInfo['state'],
|
||||
): DaemonSessionPrInfo => ({
|
||||
number,
|
||||
url: `https://github.com/o/r/pull/${number}`,
|
||||
...(state ? { state } : {}),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('SessionPrBadge', () => {
|
||||
it('dims the badge when the latest bound PR is merged', () => {
|
||||
const container = renderBadge([pr(9517, 'merged')]);
|
||||
const badge = container.querySelector('a');
|
||||
expect(badge?.classList.contains(styles.sessionPrBadgeMerged)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the accent style for open or stateless bindings', () => {
|
||||
for (const state of ['open', undefined] as const) {
|
||||
const container = renderBadge([pr(9517, state)]);
|
||||
const badge = container.querySelector('a');
|
||||
expect(badge?.classList.contains(styles.sessionPrBadgeMerged)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,11 @@ export function SessionPrBadge({ prs, tabIndex }: SessionPrBadgeProps) {
|
||||
: t('sidebar.sessionPr', { number: latest.number });
|
||||
return (
|
||||
<a
|
||||
className={styles.sessionPrBadge}
|
||||
className={
|
||||
latest.state === 'merged'
|
||||
? `${styles.sessionPrBadge} ${styles.sessionPrBadgeMerged}`
|
||||
: styles.sessionPrBadge
|
||||
}
|
||||
href={latest.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -927,7 +927,11 @@ describe('GitDialog', () => {
|
||||
await flush();
|
||||
|
||||
expect(updateSessionMetadata).toHaveBeenCalledWith('sess-1', {
|
||||
pr: { number: 99, url: 'https://github.com/o/r/pull/99' },
|
||||
pr: {
|
||||
number: 99,
|
||||
url: 'https://github.com/o/r/pull/99',
|
||||
state: 'open',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1005,7 +1009,11 @@ describe('GitDialog', () => {
|
||||
await flush();
|
||||
|
||||
expect(updateSessionMetadata).toHaveBeenCalledWith('sess-1', {
|
||||
pr: { number: 99, url: 'https://github.com/o/r/pull/99' },
|
||||
pr: {
|
||||
number: 99,
|
||||
url: 'https://github.com/o/r/pull/99',
|
||||
state: 'open',
|
||||
},
|
||||
});
|
||||
// The binding failure is a warning only — the created PR status stays.
|
||||
expect(document.body.textContent).toContain('#99');
|
||||
@@ -1107,7 +1115,11 @@ describe('GitDialog', () => {
|
||||
await flush();
|
||||
|
||||
expect(updateSessionMetadata).toHaveBeenCalledWith('sess-fresh', {
|
||||
pr: { number: 99, url: 'https://github.com/o/r/pull/99' },
|
||||
pr: {
|
||||
number: 99,
|
||||
url: 'https://github.com/o/r/pull/99',
|
||||
state: 'open',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -548,7 +548,13 @@ export function GitDialog({
|
||||
// workspace is rejected by the daemon route's workspace-conflict
|
||||
// check and surfaces here as a warning only.
|
||||
if (typeof result.number === 'number' && result.url) {
|
||||
const pr = { number: result.number, url: result.url };
|
||||
// Newly created → the snapshot starts open; the daemon's refresh
|
||||
// timer advances it to merged/closed.
|
||||
const pr = {
|
||||
number: result.number,
|
||||
url: result.url,
|
||||
state: 'open' as const,
|
||||
};
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) {
|
||||
ws.updateSessionMetadata(sid, { pr }).catch((err: unknown) => {
|
||||
|
||||
@@ -134,6 +134,58 @@ describe('SessionDetailsTooltip', () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('marks merged and closed pull requests with a state suffix', async () => {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<SessionDetailsTooltip
|
||||
session={{
|
||||
sessionId: 'session-1',
|
||||
workspaceCwd: '/work/qwen-code',
|
||||
clientCount: 1,
|
||||
prs: [
|
||||
{
|
||||
number: 9500,
|
||||
url: 'https://github.com/o/r/pull/9500',
|
||||
state: 'merged',
|
||||
},
|
||||
{
|
||||
number: 9501,
|
||||
url: 'https://github.com/o/r/pull/9501',
|
||||
state: 'closed',
|
||||
},
|
||||
{ number: 9502, url: 'https://github.com/o/r/pull/9502' },
|
||||
],
|
||||
}}
|
||||
label="Fix CI"
|
||||
time=""
|
||||
completedUnread={false}
|
||||
>
|
||||
<button type="button">Fix CI</button>
|
||||
</SessionDetailsTooltip>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await openDetails(container);
|
||||
|
||||
const details = document.querySelector('[role="dialog"]');
|
||||
const byNumber = (number: number) =>
|
||||
details?.querySelector(`a[href="https://github.com/o/r/pull/${number}"]`);
|
||||
// An open (or state-less) binding renders without a suffix; swapped or
|
||||
// deleted label branches are the exact regression this pins.
|
||||
expect(byNumber(9502)?.textContent).toBe('Pull Request #9502');
|
||||
expect(byNumber(9501)?.textContent).toBe('Pull Request #9501 · Closed');
|
||||
expect(byNumber(9500)?.textContent).toBe('Pull Request #9500 · Merged');
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it('does not reopen after a row action opens its menu', async () => {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement('div');
|
||||
|
||||
@@ -168,6 +168,13 @@ export function SessionDetailsTooltip({
|
||||
}}
|
||||
>
|
||||
{t('sidebar.sessionPr', { number: pr.number })}
|
||||
{pr.state === 'merged' || pr.state === 'closed'
|
||||
? ` · ${
|
||||
pr.state === 'merged'
|
||||
? t('sidebar.sessionPrStateMerged')
|
||||
: t('sidebar.sessionPrStateClosed')
|
||||
}`
|
||||
: ''}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1402,6 +1402,8 @@ const EN: Messages = {
|
||||
'sidebar.sessionPr': (v) => `Pull Request #${v?.number ?? ''}`,
|
||||
'sidebar.sessionPrMultiple': (v) =>
|
||||
`Pull Request #${v?.number ?? ''} (${v?.count ?? 0} total)`,
|
||||
'sidebar.sessionPrStateMerged': 'Merged',
|
||||
'sidebar.sessionPrStateClosed': 'Closed',
|
||||
'sidebar.userInputNeeded': 'User input needed',
|
||||
'sidebar.userInputNeededShort': 'Input',
|
||||
'sidebar.completedUnread': 'Finished',
|
||||
@@ -4454,6 +4456,8 @@ const ZH: Messages = {
|
||||
'sidebar.sessionPr': (v) => `合并请求 #${v?.number ?? ''}`,
|
||||
'sidebar.sessionPrMultiple': (v) =>
|
||||
`合并请求 #${v?.number ?? ''}(共 ${v?.count ?? 0} 个)`,
|
||||
'sidebar.sessionPrStateMerged': '已合入',
|
||||
'sidebar.sessionPrStateClosed': '已关闭',
|
||||
'sidebar.userInputNeeded': '需要用户输入',
|
||||
'sidebar.userInputNeededShort': '需输入',
|
||||
'sidebar.completedUnread': '刚完成',
|
||||
|
||||
Reference in new issue
Block a user