feat(web-shell): show a Channel event timeline (#16106)

* feat(web-shell): show a Channel event timeline

Channels reported what they did only through stderr, the publication audit,
and the inbound task store, none of which the daemon could read. An operator
wondering why a GitHub comment got no reply had to read the daemon log.

Every Channel now appends one bounded journal per instance: the inbound
messages it accepted, the ones it skipped and the machine reason for each
skip, publication outcomes, inbound task transitions, and poll results. The
journal holds routing decisions and identifiers only, never message text; it
rotates at 1 MiB to a single backup, and a write failure is swallowed into one
stderr line so diagnostics cannot break message handling.

The daemon serves the journal through GET /workspace/channels/events and its
workspace-qualified form, with limit, type, channel, and since filters, a
32-file and 2 MiB read bound, and the existing trust and client checks. The
channel parameter repeats once per instance name because a Channel name may
legally contain a comma.

The Web Shell gains a Channel events page and sidebar entry that render the
cross-Channel timeline for the resolved workspace, filtered by Channel and
event type and refreshed every five seconds, gated by channel_management.

Docs: the design in English and Chinese, the serve protocol, and the Channel
user guide. Channel tests isolate QWEN_HOME so journals never land in a real
home directory.

* fix(web-shell): scope Channel events to the selected workspace

Review found four correctness gaps in the event timeline.

The page read the provider's daemon-wide primary cwd, so a Channel started in
a registered secondary workspace could never be inspected. It now offers the
same workspace selection as the management page and passes the selected cwd
to both hooks; changing the workspace resets the Channel filter.

The poll loop captured the adapter's detail before the poll ran, so the first
success had no counters and every later one carried the previous poll's
counters against the current timestamp. The field is now cleared before the
poll and consumed after it settles, on both the success and failure paths.

The 32-file read cap was applied before the Channel filter, which made an
explicitly requested, quieter Channel unreachable once busier peers had newer
files. Candidate files are now narrowed by the requested identities first.

Channel identities were truncated to 80 code points while the management API
accepts 255 UTF-8 bytes, so longer legal names never matched their own filter
and names sharing a prefix became indistinguishable. The identity now keeps
its full legal length; only the UI shortens it for display.

Each fix has a regression test that was mutation-checked against the previous
behavior. Design (both languages) and the Channel user guide now describe the
workspace selection, the poll-detail timing, the filter-before-cap ordering,
and the identity limit.

* fix(web-shell): mask Channel events cached for another query

The workspace selector changes the query without remounting the hook, and the
underlying resource keeps its previous data while a request is in flight or
after it fails. Selecting a second workspace therefore showed the first
workspace's rows under the new selector until the new request succeeded, and
kept them when it failed — including their Channel names in the filter list.

Each snapshot is now tagged with the query that produced it (workspace,
Channel, types, limit) and mismatched data is masked, the same way the Channel
management hook already does. Two hook-level regressions drive the real hook
and resource with a pending and a rejected second-workspace request; both fail
without the tag.

* fix(web-shell): keep Channel event reads off the daemon's critical path

A verification round measured the journal read at the documented cap: one
page poll took ~860 ms of synchronous I/O, an unrelated /health request waited
~790 ms behind it, and four concurrent reads serialized to 3.4 s. The route
runs on the daemon that also serves sessions, so a diagnostic page must not be
able to stall it.

The read is now asynchronous and chunked (64 KiB awaits) under tighter bounds:
256 KiB per journal file, 4 MiB per request, 32 files, freshest first. On the
same 32-file / 32 MiB fixture the read is ~175 ms, a bystander /health stays at
6 ms p50 against a 2 ms baseline, and four concurrent reads finish in ~630 ms.

Two reporting gaps go with it. Anything the bounds clip — dropped files or an
exhausted byte budget — now sets `truncated`, so the page cannot show a
timeline that silently omits whole Channels. And a scope directory that cannot
be read propagates instead of returning an empty list, so the route answers
5xx rather than claiming the workspace's Channels did nothing; a missing
directory still means "nothing written yet".

The page also stops polling while its tab is hidden and reloads when it
becomes visible, so a forgotten tab no longer reads journals every 5 s.

Tests pin the two behaviors the wire depended on but the suite did not:
reading events that survive only in the rotated file, and clamping a limit
above the maximum. Both new mutations are killed, as are the truncation,
unreadable-directory, and hidden-tab guards; a bystander-timer test pins that
the read yields to the event loop.

* fix(web-shell): report a clipped journal tail as truncated

The per-file read window introduced with the asynchronous read is 256 KiB,
but a journal grows to 1 MiB before it rotates. `readTail` skipped the head of
a taller file without telling anyone, so a single busy Channel lost its own
older events while the response still claimed `truncated: false` — the exact
completeness contract the previous commit set out to fix. A 1 MiB journal of
1020 records answered 255 events with `truncated: false` at `limit=500`.

`readTail` now reports whether it started mid-file and the reader folds that
into `truncated`. The request budget is also spent in whole tail windows
instead of a final partial one, so its flag keeps the precise meaning "whole
journals were left out".

Three tests close the gaps this exposed: a journal taller than the tail window
must report truncation even when the returned rows fit the limit; the budget
fixture now uses twenty full-window journals whose combined rows stay under
the record limit, so only the budget can explain the flag; and a journal file
that cannot be read must fail loudly, like the directory case already does.
All four previously surviving mutants — tail flag, budget flag, budget debit,
swallowed file-read errors — are now killed.

* fix(web-shell): bound Channel event read retention

The events read parsed and retained every matching record across all
journals before applying `limit`, so an all-Channels poll on a busy
workspace held far more than the rows it returns. Trim the accumulator to
the newest `limit` once it passes four times that. A dropped record is
older than every kept one and the sort is stable, so the answer does not
change: a 60-scenario differential run against the previous behaviour
produced byte-identical output. Trimming counts as hitting the record
limit, so it sets `truncated` too.

Closes the remaining non-blocking review items as well. The provider
workspace action declares the full query type it already forwards, so the
interface no longer suggests that path cannot filter by Channel. The new
UI strings use the established 频道 label rather than introducing a second
word for Channel beside the existing sidebar entry; the design docs keep
渠道, which is what every other zh-CN design uses. The docs now state the
page's own request size (200) apart from the route default (100), and the
`truncated` comments describe every reason it can be set instead of only
the record limit. The NUL query-key separator is documented as
collision-free: instance names reject control characters, journal names
are sanitized, and a path cannot contain NUL.

* fix(channels): read a journal that fits the budget and keep aligned records

Reserving a full 256 KiB window per journal left a small journal unread
when the remainder was under one window, even when it covered that
journal many times over — coverage lost in exchange for a flag that was
already truthful. Reserve what the journal actually needs instead, using
the size the enumeration already stats, so the budget flag still means
"whole journals were left out" and a 200-byte journal is no longer skipped
beside seventeen full ones.

A tail window that landed exactly on a line boundary also dropped one
complete record, because the first line was discarded whenever the window
started mid-file. Read one leading byte and drop that line only when the
byte is not a newline. Folding the byte into the first chunk keeps the read
to one awaited call per chunk, which is what lets the daemon serve other
requests between them — now pinned by a test that compares the event-loop
turns a tall read takes against a short one over the same six journals. A
synchronous chunk read fails it.

Also records why an unparseable instant cannot satisfy `since` while an
unfiltered read still returns it, and pins both halves.

* fix(channels): collapse repeated reason-filter skips into a counted row

A real end-to-end run — the actual GithubAdapter over HTTP against a local
GitHub-compatible API, a real agent turn, the page in a browser — exposed an
amplification the seeded-journal runs could not. The poll cursor does not
advance while an inbound task is recoverable, so a task that failed and is
still retryable makes the provider return the same notifications on every
poll, and filtered notifications are skipped before any dedupe. Each poll
therefore re-journaled the whole batch: with a 2 s poll and 300 filtered
notifications the journal rotated and pushed the accepted/posted rows out of
the page's 200-row window.

Journal at most 20 reason_filter rows per poll and collapse the remainder into
one row carrying detail.filtered, which the page renders as a count in both
languages. The budget is per poll, so a genuinely new batch is still described
in full. Measured against the real adapter: 15 polls served 4,500 notifications
and wrote 315 rows instead of 4,500, without rotating. Against a stub that
honours both `since` and GitHub's read-state semantics, the redelivery stops
once the batch is marked read and a new batch gets a fresh budget.

The design docs record the mechanism precisely, including that an in-flight
turn cannot cause it because the poll loop awaits pollOnce.

* fix(web-shell): keep one Channel event polling loop across visibility restores

Restoring tab visibility while a journal request was still pending started a
second run. Clearing the timer handle cannot cancel an in-flight run, so both
runs settled, both called schedule(), and only the last timer handle was kept —
the orphan kept polling forever. Ordinary tab switches on a slow response
therefore multiplied journal reads permanently: one overlapping hide/show cycle
left two requests per interval, three cycles left four.

Guard the cycle with an in-flight flag so a restore during a pending request
lets that request finish and reschedule instead of starting a second loop. Both
read paths are time-bounded (the SDK's 30 s fetch default and the action
timeout), so the guard cannot stall polling. The regression test holds every
request pending, overlaps three hide/show cycles, and asserts one request per
interval afterwards; removing the guard reproduces the reported four calls.
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-11-03 13:42:18 +08:00
1 parent 71119c6fa0
commit 02b8b0108c
41 files changed
+4022 -23

No files matched your search

@@ -0,0 +1,288 @@
# Channel event timeline in the Web Shell
[English](2026-11-02-channel-events-timeline.md) | [简体中文](2026-11-02-channel-events-timeline.zh-CN.md)
Status: implemented. Date: 2026-11-02.
## Problem
A Channel answers a lot of questions only through stderr. When the GitHub
Channel does not reply to a comment, an operator cannot tell from the Web Shell
whether the notification was filtered by `reasonFilter`, dropped because the
comment did not mention the bot, rejected by a sender gate, deduplicated, or
answered but published as a suppressed comment. The Channel management page
shows configuration and a runtime badge (`stopped` / `starting` / `connected` /
`partial` / `error`); it shows nothing about what the Channel actually did.
Everything that could answer those questions exists in scattered form:
`preflight` rejections and poll failures go to stderr, publication outcomes go
to `github-audit.jsonl`, inbound task state goes to `github-inbound-tasks.json`,
and the notification reason filter only writes a debug payload. None of it is
readable by the daemon, so no page can show it.
## Goals
- Record what a Channel did, per Channel, in one bounded, append-only journal.
- Expose the journal over a workspace-scoped, read-only daemon route.
- Show a cross-Channel timeline as a standalone Web Shell page, with filters
for Channel and event type.
## Non-goals
- No message bodies in the journal. Operators see routing decisions and
outcomes, not conversation text.
- No live push (SSE). The page polls; a Channel worker writes the journal in a
separate process from the daemon.
- No new capability bit. The page is gated by `channel_management`, which is
what makes the journal exist in the first place.
- No retention beyond one rotated file. The journal is diagnostic, not an
audit trail with a compliance requirement; `github-audit.jsonl` stays the
publication record.
## Current state
- `packages/channels/base/src/PollingChannelBase.ts` polls, backs off, and
writes poll failures to stderr only.
- `packages/channels/base/src/ChannelBase.ts` rejects inbound messages in
`preflightInbound` (message route, group gate, DM gate, sender gate) and logs
a one-line reason through `logPreflightRejected`.
- `packages/channels/github/src/GithubAdapter.ts` polls GitHub notifications,
filters by `reasonFilter`, deduplicates by cursor bookkeeping, tracks inbound
tasks in `github-inbound-tasks.json`, and records publication outcomes in
`github-audit.jsonl`.
- `packages/cli/src/serve/routes/workspace-channel-management.ts` exposes
Channel configuration, lifecycle, and pairing routes; none of them read
runtime activity.
- `packages/web-shell/client/components/channels/ChannelsManagerPage.tsx` is
the only Channel surface in the Web Shell.
## Design
### 1. Journal (`packages/channels/base/src/channel-events.ts`)
A Channel appends one JSON line per event. The file lives beside the other
workspace-scoped Channel state files:
```
~/.qwen/channels/<workspaceScopeDir>/<encodedName>-<nameHash16>-channel-events.jsonl
```
`<workspaceScopeDir>` is `getWorkspaceScopeDirName(config.cwd)`, and the file
name reuses the `encodedName` + `nameHash16` scheme already used by the GitHub
state files, so a Channel name cannot escape the directory.
Record:
| Field | Meaning |
| ------------- | --------------------------------------------------------------------- |
| `at` | ISO timestamp |
| `channel` | Channel instance name, kept in full (see limits below) |
| `type` | `inbound` / `outbound` / `task` / `poll` |
| `outcome` | `accepted` / `skipped` / `posted` / `failed` / `ok` / a task state |
| `reason` | machine reason code for skips (`sender_denied`, `reason_filter`, ...) |
| `triggerKind` | Channel-specific trigger (`mention`, `assign`, ...) |
| `repository` | conversation container (for GitHub, `owner/repo`) |
| `thread` | thread identity (`issue:12`, `pr:34`) |
| `number` | issue or PR number |
| `actor` | sender login |
| `sessionId` | session that handled the message |
| `messageId` | source message identity |
| `durationMs` | poll duration |
| `error` | sanitized single-line error, capped at 200 characters |
| `detail` | flat primitive map, at most 12 keys |
Limits: free-text fields and `detail` values are capped at 200 characters. The
Channel identity is the exception: the management API accepts names up to 255
UTF-8 bytes, and code points never exceed bytes, so the identity is kept up to
255 code points — every legal name survives intact and stays matchable by the
`channel` filter. Only the UI shortens it for display.
The file rotates to `<name>.1` when an append would exceed 1 MiB; the previous
`.1` is replaced. Directory mode is `0700`, file mode `0600`. Every write
failure is swallowed into one stderr line: diagnostics must never break message
handling.
### 2. Emit points
| Where | Event |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `ChannelBase.logPreflightRejected` | `inbound` / `skipped` with the rejection reason |
| `ChannelBase.preflightInbound` (message route, `mention_required`) | `inbound` / `skipped` with `message_route` or `group_mention_required` |
| `ChannelBase.processPreflightedInbound` | `inbound` / `accepted` |
| `PollingChannelBase.runLoop` | `poll` / `ok` with `durationMs` and the adapter's detail, `poll` / `failed` with `consecutiveErrors` |
| `GithubAdapter` reason filter | `inbound` / `skipped`, reason `reason_filter`; past 20 in one poll, one row with `detail.filtered` counting the rest |
| `GithubAdapter` comment lane (`onlyMentioned` miss) | `inbound` / `skipped`, reason `mention_required` |
| `GithubAdapter` dedupe hit | `inbound` / `skipped`, reason `duplicate` |
| `GithubAdapter.claimInboundTask` | `task` / `accepted` |
| `GithubAdapter.transitionInboundTask` | `task` / `<state>` with `attempts` and `error` |
| `GithubAdapter.recordPublicationAudit` | `outbound` / `<outcome>` (also keep the audit file) |
`PollingChannelBase` gains a `setPollEventDetail` seam so an adapter can attach
its own counters (GitHub reports how many notifications a poll returned)
without the base class knowing about them. The base class clears the field
before the poll and consumes it after that poll settles, so an event never
carries the previous poll's counters against the current timestamp.
### 3. Daemon route
```
GET /workspace/channels/events
GET /workspaces/:workspace/channels/events
```
Query: `limit` (1-500, default 100), `type` (comma separated), `channel`
(repeat the parameter once per instance name), `since` (ISO timestamp).
Response: `{ "events": [...], "truncated": boolean }`, `Cache-Control:
no-store`, the same trust and client checks as the other workspace Channel
routes.
The route never accepts a path from the request. The scope directory is
derived from the resolved runtime's workspace cwd through
`getWorkspaceScopeDirName`; a managed Channel's working directory must
canonicalize to that same workspace, so its journal lives there. A `channel`
filter narrows the candidate files by their name-derived identity **before**
any cap, so an explicitly requested, quieter Channel stays reachable even when
busier peers have newer files.
Reads are asynchronous and chunked, because this route runs on the daemon that
also serves sessions: a synchronous multi-megabyte scan would stall every other
request for its whole duration. The bounds are 256 KiB per journal file
(current and `.1`), 4 MiB per request in total, 32 journal files per request,
and 500 records returned; candidates are ordered freshest-first so the
allowance is spent on the journals an operator is most likely looking at.
Anything a bound clips sets `truncated`: dropped files, an exhausted byte
budget, **and a journal taller than the tail window** — a file grows to 1 MiB
before it rotates, so a 256 KiB window omits that Channel's own older events.
The page's completeness banner therefore cannot claim a timeline is whole when
any of those happened. The budget is reserved per journal — a file is read only
if its whole window fits in what remains — so a small journal is still read when
the remainder is under one window, and the flag always means "whole journals
were left out". A window starting mid-file reads one leading byte to tell a
partial first line from a complete one, so a window that lands exactly on a line
boundary keeps every record it read.
Every line still has to be parsed to filter it, but the accumulator is trimmed
to the newest `limit` once it passes four times that, so a busy workspace's
poll does not retain every record it parsed. The trim cannot change the answer:
a dropped record is older than every kept one, and the sort is stable, so
records sharing an instant keep their write order. Trimming counts as hitting
the record limit, so it sets `truncated` too.
A missing scope directory means "no Channel has written yet" and stays an empty
result. Any other read failure (permissions, I/O) propagates, and the route
answers 5xx instead of an empty list: "this workspace's Channels did nothing"
must not be the answer when the journal could not be read.
### 4. Web Shell
- `useChannelEvents` polls the route while the page is mounted and visible
(5 s), pauses while the tab is hidden, and reloads when it becomes visible
again; the page also offers a manual refresh. **Exactly one polling cycle may
be in flight**: restoring visibility during a pending request must not start a
second one, because both would schedule on settling while only the last timer
handle is retained, leaving an orphan that polls forever — ordinary tab
switches on a slow response would permanently multiply journal reads. A
restore during a pending request therefore lets that request finish and
reschedule. Both read paths are time-bounded (the SDK's 30 s fetch default and
the action timeout), so the guard cannot stall the loop. Every snapshot is
tagged with the query that produced it (workspace, Channel, types, limit) and
mismatched data is masked, the way `useDaemonChannels` does: the underlying
resource keeps its previous data while a request is in flight or after it
fails, so without the tag the last workspace's rows — and their Channel names
in the filter — would show under the newly selected workspace.
- `ChannelEventsPage` renders a newest-first timeline with a workspace
selector, a Channel filter, a type filter, and a row per event: time, Channel,
type, outcome, target (`owner/repo#12`), actor, reason or trigger kind,
duration, and error. The workspace selector lists the registered, non-live
workspaces exactly as the management page does, because the provider's cwd is
the daemon-wide primary workspace and a Channel started in a secondary
workspace would otherwise be unreachable. Changing the workspace resets the
Channel filter.
- It is a standalone page: `activePanel` value `channel-events`, URL
`/channel-events`, and a sidebar primary-nav item `channelEvents`.
- It is gated by the `channel_management` capability and a trusted workspace,
matching the management page.
## Files affected
- `packages/channels/base/src/channel-events.ts` (new) and its test
- `packages/channels/base/src/ChannelBase.ts`
- `packages/channels/base/src/PollingChannelBase.ts`
- `packages/channels/base/src/index.ts`
- `packages/channels/github/src/GithubAdapter.ts`
- `packages/cli/src/serve/routes/workspace-channel-management.ts`
- `packages/sdk-typescript/src/daemon/types.ts` and its barrel
- `packages/web-shell/client/hooks/useChannelEvents.ts` (new)
- `packages/web-shell/client/components/channels/ChannelEventsPage.tsx` (new)
- `packages/web-shell/client/components/channels/channel-events-messages.ts` (new)
- `packages/web-shell/client/App.tsx`,
`components/sidebar/WebShellSidebar.tsx`, `utils/navigationUrl.ts`,
`i18n.tsx`
## Design decisions
- **File journal, not IPC.** Channels run in a worker process forked by the
daemon. A file keeps the daemon route read-only and stateless, and survives a
worker restart, which is exactly when an operator looks for the last events.
- **One generic journal, not a GitHub-only one.** Every Channel type inherits
the base emit points; the page is cross-Channel, so a GitHub-only store would
have forced a second implementation later.
- **No message text.** The journal cannot become a second, unredacted copy of
message history; publication events carry the outcome, while the existing
audit file keeps `bodySha256` and `bodyChars`.
- **Reuse `channel_management`.** A new capability bit would add three
registries and a drift test for no user-visible gain: the journal only exists
where Channel management exists.
- **Keep `github-audit.jsonl`.** It answers a different question (what was
published, with a body digest) and is already read during recovery.
## Risks
- Ambient skips on a busy group Channel can be frequent. The journal is capped
by rotation and the page asks for the newest 200 events; nothing is
unbounded. (100 is the route's own default, used when a caller sends no
`limit`.) Rotation alone is not enough for the _usefulness_ of the window:
the GitHub poll cursor does not advance while an inbound task is recoverable
— `accepted`, `running`, or `failed` with attempts left — so the provider
returns the same notifications on every poll, and filtered notifications are
skipped before any dedupe. An in-flight turn does not cause this on its own:
the poll loop awaits `pollOnce`, so no poll runs while a turn is in progress;
the reachable case is a task that failed and is still retryable. A real
run with a 2 s poll and a turn that kept failing re-journaled one batch of
300 filtered notifications until the journal rotated, pushing the
accepted/posted rows out of the page's window. The adapter therefore journals
at most 20 `reason_filter` rows per poll and collapses the remainder into one
row carrying `detail.filtered`, which the page renders as a count. Measured
against the real adapter: 15 polls served 4,500 notifications and wrote 315
rows instead of 4,500, and the journal did not rotate. The budget is per
poll, so a genuinely new batch is still described in full.
- A synchronous append sits in the message path. The record is small, and a
failure is swallowed; the alternative (async queue) would lose the last
events on a crash, which is when they matter.
- The route reads from disk on every request. Reads are awaited in 64 KiB
chunks under a 4 MiB per-request budget, so a poll yields to the rest of the
daemon instead of blocking it, and the page polls every 5 s only while its
tab is visible.
## Validation
- Unit: journal append/read/filter/rotate/limit and swallowed write failure;
GitHub emits inbound-skip, task, and publication events; the route filters by
Channel and type, clamps to the resolved workspace, and honours `since`.
- Web Shell: page renders rows, applies both filters, and shows the empty and
error states.
- Manual: run a GitHub Channel against a test repository, confirm the page
shows the filter skips before any session exists, and confirm an accepted
trigger, a task transition, and the publication outcome appear in order.
## Acceptance criteria
- An operator can see, in the Web Shell, why a GitHub notification produced no
reply (reason filter, missing mention, gate rejection, dedupe) without
reading stderr.
- Publication outcomes and inbound task failures appear in the same timeline.
- Poll failures and recovery appear as `poll` events with the consecutive error
count.
- No message body is written to the journal, and no route can read outside the
resolved workspace's Channel state.
@@ -0,0 +1,154 @@
# Web Shell 中的渠道事件时间线
[English](2026-11-02-channel-events-timeline.md) | [简体中文](2026-11-02-channel-events-timeline.zh-CN.md)
状态:已实现。日期:2026-11-02。
## 问题
渠道的大量行为目前只能从 stderr 看出来。当 GitHub 渠道没有回复某条评论时,运维人员在 Web Shell 里无法判断:通知是被 `reasonFilter` 过滤了、因为评论没有 @ 机器人被丢弃、被发送方门禁拒绝、被去重跳过,还是已经回答但最终被抑制(suppressed)而没有发表。渠道管理页只显示配置和运行时徽标(`stopped` / `starting` / `connected` / `partial` / `error`),不显示渠道实际做了什么。
能回答这些问题的信息其实零散存在:preflight 拒绝和轮询失败写 stderr,发表结果写 `github-audit.jsonl`,入站任务状态写 `github-inbound-tasks.json`,通知原因过滤只写 debug payload。这些都没有 daemon 可读的出口,所以任何页面都拿不到。
## 目标
- 按渠道记录渠道做了什么,写入一个有上限、只追加的事件日志。
- 用工作区作用域的只读 daemon 路由暴露该日志。
- 在 Web Shell 以独立页面展示跨渠道时间线,支持按渠道和事件类型过滤。
## 非目标
- 日志不记录消息正文。运维人员看到的是路由决策和结果,不是会话内容。
- 不做实时推送(SSE)。页面用轮询,因为渠道 worker 与 daemon 是不同进程。
- 不新增能力位。页面复用 `channel_management` 门禁——事件日志本来就只在该能力存在的地方存在。
- 除一个轮转文件外不做保留策略。事件日志是诊断用的,`github-audit.jsonl` 仍然是发表记录。
## 现状
- `packages/channels/base/src/PollingChannelBase.ts` 负责轮询和退避,轮询失败只写 stderr。
- `packages/channels/base/src/ChannelBase.ts` 在 `preflightInbound` 中拒绝入站消息(消息路由、群门禁、私信门禁、发送方门禁),并通过 `logPreflightRejected` 打一行原因。
- `packages/channels/github/src/GithubAdapter.ts` 轮询 GitHub 通知,按 `reasonFilter` 过滤,用游标记账去重,把入站任务记在 `github-inbound-tasks.json`,把发表结果记在 `github-audit.jsonl`。
- `packages/cli/src/serve/routes/workspace-channel-management.ts` 提供渠道配置、生命周期和配对路由,没有任何读取运行时活动的接口。
- `packages/web-shell/client/components/channels/ChannelsManagerPage.tsx` 是 Web Shell 唯一的渠道界面。
## 设计
### 1. 事件日志(`packages/channels/base/src/channel-events.ts`)
渠道按事件追加一行 JSON。文件与其他工作区作用域的渠道状态文件放在一起:
```
~/.qwen/channels/<workspaceScopeDir>/<encodedName>-<nameHash16>-channel-events.jsonl
```
其中 `<workspaceScopeDir>` 为 `getWorkspaceScopeDirName(config.cwd)`,文件名沿用 GitHub 状态文件已有的 `encodedName` + `nameHash16` 规则,渠道名无法逃出该目录。
记录字段:
| 字段 | 含义 |
| ------------- | -------------------------------------------------------------- |
| `at` | ISO 时间戳 |
| `channel` | 渠道实例名,完整保留(见下方上限) |
| `type` | `inbound` / `outbound` / `task` / `poll` |
| `outcome` | `accepted` / `skipped` / `posted` / `failed` / `ok` / 任务状态 |
| `reason` | 跳过原因的机器码(`sender_denied`、`reason_filter` 等) |
| `triggerKind` | 渠道特定的触发类型(`mention`、`assign` 等) |
| `repository` | 会话容器(GitHub 下为 `owner/repo`) |
| `thread` | 线程标识(`issue:12`、`pr:34`) |
| `number` | issue 或 PR 编号 |
| `actor` | 发送方登录名 |
| `sessionId` | 处理该消息的会话 |
| `messageId` | 来源消息标识 |
| `durationMs` | 轮询耗时 |
| `error` | 单行化错误信息,上限 200 字符 |
| `detail` | 扁平原始类型映射,最多 12 个键 |
上限:自由文本字段与 `detail` 的值截断到 200 字符。渠道标识是例外:管理接口接受最长 255 UTF-8 字节的名字,而码点数不会超过字节数,因此标识保留到 255 个码点——任何合法名字都完整保留,并且仍能被 `channel` 过滤匹配。只有界面在展示时缩短它。
追加会超过 1 MiB 时轮转为 `<name>.1`,旧的 `.1` 被替换。目录权限 `0700`,文件权限 `0600`。任何写失败都吞掉并只打一行 stderr:诊断设施绝不能打断消息处理。
### 2. 记录点
| 位置 | 事件 |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ChannelBase.logPreflightRejected` | `inbound` / `skipped`,带拒绝原因 |
| `ChannelBase.preflightInbound`(消息路由、`mention_required`) | `inbound` / `skipped`,原因 `message_route` 或 `group_mention_required` |
| `ChannelBase.processPreflightedInbound` | `inbound` / `accepted` |
| `PollingChannelBase.runLoop` | `poll` / `ok` 带 `durationMs` 与适配器详情;`poll` / `failed` 带 `consecutiveErrors` |
| `GithubAdapter` 原因过滤 | `inbound` / `skipped`,原因 `reason_filter`;单轮超过 20 条后,其余合并为一条带 `detail.filtered` 计数的记录 |
| `GithubAdapter` 评论通道(`onlyMentioned` 未命中) | `inbound` / `skipped`,原因 `mention_required` |
| `GithubAdapter` 去重命中 | `inbound` / `skipped`,原因 `duplicate` |
| `GithubAdapter.claimInboundTask` | `task` / `accepted` |
| `GithubAdapter.transitionInboundTask` | `task` / `<state>`,带 `attempts` 与 `error` |
| `GithubAdapter.recordPublicationAudit` | `outbound` / `<outcome>`(审计文件继续保留) |
`PollingChannelBase` 增加 `setPollEventDetail` 挂点,适配器可以挂自己的计数(GitHub 报告本次轮询取到多少条通知),基类不需要知道这些字段。基类在轮询开始前清空该字段,并在本次轮询结束后才消费它,因此事件绝不会把上一次轮询的计数记到本次的时间戳上。
### 3. daemon 路由
```
GET /workspace/channels/events
GET /workspaces/:workspace/channels/events
```
查询参数:`limit`(1-500,默认 100)、`type`(逗号分隔)、`channel`(每个实例名重复一次该参数)、`since`(ISO 时间戳)。
响应:`{ "events": [...], "truncated": boolean }`,`Cache-Control: no-store`,并沿用其他工作区渠道路由的信任与客户端校验。
路由从不接受请求给出的路径。作用域目录由解析后运行时的 workspace cwd 经 `getWorkspaceScopeDirName` 推导;受管渠道的工作目录必须规范化到同一个工作区,因此它的日志就在那里。`channel` 过滤会先按名字推导出的标识收窄候选文件,**再**套用任何上限,因此即使更繁忙的同伴有更新的文件,被显式请求的安静渠道仍然可达。
读取是异步且分块的,因为这条路由跑在同时服务会话的 daemon 上:同步扫描数 MiB 会让其他所有请求在整个扫描期间停摆。上限为每个日志文件(当前与 `.1`)256 KiB、每次请求合计 4 MiB、每次请求 32 个日志文件、最多返回 500 条记录;候选按最近修改优先排序,把额度花在运维最可能查看的日志上。任何被上限裁掉的情况都会置 `truncated`:丢弃文件、字节预算耗尽,**以及单个日志高于尾部窗口**——文件在轮转前会长到 1 MiB,因此 256 KiB 窗口会漏掉该渠道自己更早的事件。这样页面的完整性提示在上述任一情况发生时都不会声称时间线完整。预算按整份日志预留——只有剩余额度装得下它的整个窗口才读它——因此余量不足一个窗口时,小日志仍会被读取,标记也始终意味着「有整份日志被漏掉」。从文件中间开始的窗口会多读一个前导字节,用来区分残缺首行与完整首行,因此正好落在行边界上的窗口会保留它读到的每一条记录。
每一行仍要被解析才能过滤,但累积量一旦超过 `limit` 的四倍就裁到最新的 `limit` 条,因此繁忙工作区的一次轮询不会把它解析过的每条记录都留在内存里。裁剪不会改变答案:被丢弃的记录比留下的每一条都旧,而排序是稳定的,因此同一时刻的记录保持写入顺序。裁剪等同于触及记录数上限,因此同样会置 `truncated`。
作用域目录不存在表示「还没有渠道写过」,仍返回空结果。其他读取失败(权限、I/O)会向上抛出,路由回 5xx 而不是空列表:读不到日志时,绝不能把答案说成「这个工作区的渠道什么都没做」。
### 4. Web Shell
- `useChannelEvents` 在页面挂载且可见期间轮询该路由(5 秒),标签页隐藏时暂停、重新可见时刷新;页面另提供手动刷新。**同一时刻只允许一个轮询周期在途**:请求未完成时恢复可见绝不能再启动一个,否则两者结束时都会 schedule,而只保留最后一个定时器句柄,另一个成为永远轮询的孤儿——慢响应下的普通标签页切换会永久倍增日志读取。因此请求在途时恢复可见,是让该请求结束后再重新排程。两条读取路径都有时间上界(SDK 默认 30 秒 fetch 超时与 action 超时),所以该守卫不会让循环停摆。每份快照都带上产生它的查询(工作区、渠道、类型、limit),不匹配的数据会被遮蔽,做法与 `useDaemonChannels` 一致:底层 resource 在请求进行中或失败后仍保留旧数据,没有这个标记的话,上一个工作区的行——以及过滤选项里它的渠道名——就会显示在新选中的工作区下。
- `ChannelEventsPage` 渲染按时间倒序的时间线,带工作区选择器、渠道过滤、类型过滤,每行显示:时间、渠道、类型、结果、目标(`owner/repo#12`)、操作者、原因或触发类型、耗时、错误。工作区选择器与管理页一样列出已注册的非 live 工作区,因为 provider 的 cwd 是 daemon 全局的主工作区,否则在次工作区启动的渠道将无法查看。切换工作区会重置渠道过滤。
- 它是独立页面:`activePanel` 取值 `channel-events`,URL 为 `/channel-events`,侧边栏主导航项为 `channelEvents`。
- 由 `channel_management` 能力位与受信任工作区门禁,与管理页一致。
## 涉及文件
- `packages/channels/base/src/channel-events.ts`(新增)及其测试
- `packages/channels/base/src/ChannelBase.ts`
- `packages/channels/base/src/PollingChannelBase.ts`
- `packages/channels/base/src/index.ts`
- `packages/channels/github/src/GithubAdapter.ts`
- `packages/cli/src/serve/routes/workspace-channel-management.ts`
- `packages/sdk-typescript/src/daemon/types.ts` 及其 barrel
- `packages/web-shell/client/hooks/useChannelEvents.ts`(新增)
- `packages/web-shell/client/components/channels/ChannelEventsPage.tsx`(新增)
- `packages/web-shell/client/components/channels/channel-events-messages.ts`(新增)
- `packages/web-shell/client/App.tsx`、
`components/sidebar/WebShellSidebar.tsx`、`utils/navigationUrl.ts`、
`i18n.tsx`
## 设计决策
- **用文件日志而不是 IPC。** 渠道运行在 daemon fork 出来的 worker 进程里。文件让 daemon 路由保持只读、无状态,并在 worker 重启后仍然存在——而运维人员正是在重启前后查最近发生了什么。
- **一个通用日志,而不是 GitHub 专用。** 所有渠道类型都继承基类的记录点;页面本身是跨渠道的,只做 GitHub 会导致很快需要第二套实现。
- **不记录消息正文。** 日志不能变成第二份未脱敏的消息历史;发表事件只带结果,正文摘要仍由现有审计文件的 `bodySha256` 与 `bodyChars` 承担。
- **复用 `channel_management`。** 新增能力位要动三处注册表和漂移断言,却没有用户可见收益:事件日志只在渠道管理存在的地方存在。
- **保留 `github-audit.jsonl`。** 它回答的是另一个问题(发表了什么、正文摘要),恢复流程已经在读它。
## 风险
- 繁忙群渠道的环境消息跳过可能很频繁。日志靠轮转封顶,页面请求最新 200 条事件,没有无界增长。(100 是调用方不带 `limit` 时路由自身的默认值。)但只靠轮转不足以保住窗口的**有用性**:只要还有可恢复的入站任务——`accepted`、`running`,或 `failed` 且仍有重试次数——GitHub 的轮询游标就不前进,于是服务端每轮都返回同一批通知,而被过滤的通知在任何去重之前就被跳过。进行中的回合本身不会造成这一点:轮询循环 `await pollOnce()`,回合进行中根本不会发起新的轮询;真正可达的情形是任务失败但仍可重试。一次真实运行(2 秒轮询 + 一个反复失败的回合)把同一批 300 条被过滤通知反复写入,直到日志轮转,把 accepted/posted 行挤出了页面窗口。因此适配器每轮最多记 20 条 `reason_filter`,其余合并为一条带 `detail.filtered` 的记录,页面把它渲染成计数。对真实适配器实测:15 轮共返回 4500 条通知,只写入 315 行而非 4500 行,且日志没有轮转。该额度按轮计算,因此真正新到的一批仍会被完整描述。
- 同步追加位于消息路径上。记录很小且失败被吞掉;改成异步队列会在崩溃时丢掉最后几条事件,而那时恰恰最需要它们。
- 路由每次请求都读磁盘。读取以 64 KiB 分块 await、每次请求预算 4 MiB,因此一次轮询会让出事件循环而不是阻塞它;页面仅在标签页可见时每 5 秒轮询一次。
## 验证
- 单元测试:日志的追加/读取/过滤/轮转/上限与被吞掉的写失败;GitHub 的入站跳过、任务与发表事件;路由按渠道和类型过滤、夹在解析后的工作区内、支持 `since`。
- Web Shell:页面渲染行、两个过滤器生效、空态与错误态正确。
- 手工:用一个测试仓库跑 GitHub 渠道,确认页面能看到会话创建之前的过滤跳过,并按顺序看到接受的触发、任务状态变化与发表结果。
## 验收标准
- 运维人员能在 Web Shell 里查明某条 GitHub 通知为什么没有产生回复(原因过滤、未 @、门禁拒绝、去重),不必翻 stderr。
- 发表结果与入站任务失败出现在同一条时间线上。
- 轮询失败与恢复以 `poll` 事件出现,并带上连续错误次数。
- 日志不写消息正文,任何路由都无法读到解析工作区之外的渠道状态。
+22
View File
@@ -1237,8 +1237,30 @@ Read-only discovery uses:
- `GET /workspace/channel-types`
- `GET /workspace/channels`
- `GET /workspace/channels/events`
- `GET /workspaces/:workspace/channel-types`
- `GET /workspaces/:workspace/channels`
- `GET /workspaces/:workspace/channels/events`
`GET .../channels/events` returns the resolved workspace's Channel event
journal as `{ "events": [...], "truncated": boolean }` with
`Cache-Control: no-store`. Each event records what a Channel did — an inbound
message it accepted or skipped (with the machine reason: `reason_filter`,
`mention_required`, `duplicate`, a sender-gate rejection), a publication
outcome, an inbound task transition, or a poll result — and never carries
message text. A GitHub poll that filters more than 20 notifications journals
the first 20 individually and one further `reason_filter` row whose
`detail.filtered` counts the rest, so a pinned poll cursor cannot flood the
journal with repeats of the same batch. Query parameters: `limit` (1-500, default 100), `type`
(comma-separated `inbound`, `outbound`, `task`, `poll`), `channel`
(repeat the parameter once per instance name), and `since` (ISO 8601). The route reads only
the resolved workspace's Channel state directory and needs no operator authority. Reads are
asynchronous and bounded: the current journal plus one rotated file per Channel, 256 KiB per
file, 4 MiB per request, 32 files per request, freshest first. `truncated` is true when the
record limit or any read bound clipped the result, including a journal taller than the
per-file tail window. A workspace with no journal yet
returns an empty list; a scope directory that cannot be read answers 5xx rather than an empty
list.
The catalog marks the types supported by this management API with
`manageable: true`. Instance snapshots include a revision, redacted secret
+18
View File
@@ -589,6 +589,24 @@ All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the ag
Named-task commands work on all channel types (Telegram, WeChat, QQ, DingTalk, WeCom, Feishu, GitHub). `/cancel` is currently registered only by Telegram, and loop creation requires proactive delivery support for the current adapter and target.
## Channel Events in the Web Shell
The Web Shell's **Channel events** page shows a timeline of what every Channel
in the current workspace did: the inbound messages it accepted, and the ones it
skipped and why (`reason_filter`, `mention_required`, `duplicate`, a sender-gate
rejection), the replies it published or suppressed, its inbound task
transitions, and how each poll went. Pick the workspace whose Channels you want
to inspect, then filter by Channel and by event type; the page refreshes every
few seconds while its tab is visible, pauses in a background tab, and has a
manual refresh.
Use it to answer "why did the bot not reply to that comment?" without reading
the daemon log. Events never include message text — only routing decisions,
identifiers, and errors. The page and its route exist wherever Channel
management is available (`channel_management`), and each Channel keeps its
journal under `~/.qwen/channels/<workspace>/` with one rotated file, so it
never grows without bound.
## Running
```bash
+22 -1
View File
@@ -1,4 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
afterAll,
beforeAll,
describe,
it,
expect,
vi,
beforeEach,
} from 'vitest';
import { EventEmitter } from 'node:events';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -40,6 +48,19 @@ import {
import { PairingStore } from './PairingStore.js';
import type { CreatePairingRequestResult } from './PairingStore.js';
// Channel event journals are written from the message path; keep them out of
// the developer's real ~/.qwen while these tests drive rejections.
const testQwenHome = mkdtempSync(join(tmpdir(), 'channel-base-home-'));
const savedQwenHome = process.env['QWEN_HOME'];
beforeAll(() => {
process.env['QWEN_HOME'] = testQwenHome;
});
afterAll(() => {
rmSync(testQwenHome, { recursive: true, force: true });
if (savedQwenHome === undefined) delete process.env['QWEN_HOME'];
else process.env['QWEN_HOME'] = savedQwenHome;
});
// Concrete test implementation
class TestChannel extends ChannelBase {
sent: Array<{ chatId: string; text: string }> = [];
+62 -6
View File
@@ -36,6 +36,8 @@ import {
ChannelProactiveDeliveryError,
isChannelProactiveDeliveryError,
} from './ChannelProactiveDeliveryError.js';
import { appendChannelEvent } from './channel-events.js';
import type { ChannelEventInput } from './channel-events.js';
import { GroupGate } from './GroupGate.js';
import { DmGate } from './DmGate.js';
import { GroupHistoryStore } from './group-history-store.js';
@@ -6368,7 +6370,10 @@ export abstract class ChannelBase {
envelope: Envelope,
options: PreflightInboundOptions = {},
): boolean | Promise<boolean> {
if (!this.applyMessageRoute(envelope)) return false;
if (!this.applyMessageRoute(envelope)) {
this.recordPreflightRejected('message_route', envelope);
return false;
}
const groupResult = this.groupGate.check(envelope, {
createPairingRequest: !options.deferPairingRequests,
});
@@ -6378,7 +6383,7 @@ export abstract class ChannelBase {
(envelope.isMentioned || envelope.isReplyToBot);
if (!groupResult.allowed && !deferredGroupPairing) {
if (groupResult.pairing !== undefined) {
this.logPreflightRejected('group_pairing_required');
this.logPreflightRejected('group_pairing_required', envelope);
return this.onGroupPairingRequired(
envelope.chatId,
groupResult.pairing,
@@ -6398,17 +6403,23 @@ export abstract class ChannelBase {
if (groupResult.reason === 'mention_required') {
// This is the expected high-frequency drop path for group bots.
this.recordPendingGroupHistory(envelope);
// Journaled but not logged: the operator needs the reason, the log
// must not gain a line per ambient comment.
this.recordPreflightRejected('group_mention_required', envelope);
} else if (groupResult.reason === 'pairing_trigger_required') {
return false;
} else {
this.logPreflightRejected(`group_${groupResult.reason ?? 'denied'}`);
this.logPreflightRejected(
`group_${groupResult.reason ?? 'denied'}`,
envelope,
);
}
return false;
}
const dmResult = this.dmGate.check(envelope);
if (!dmResult.allowed) {
this.logPreflightRejected(`dm_${dmResult.reason ?? 'denied'}`);
this.logPreflightRejected(`dm_${dmResult.reason ?? 'denied'}`, envelope);
return false;
}
@@ -6427,7 +6438,7 @@ export abstract class ChannelBase {
const result = senderGate.check(envelope.senderId, envelope.senderName);
if (!result.allowed) {
if (result.pairing !== undefined) {
this.logPreflightRejected('sender_pairing_required');
this.logPreflightRejected('sender_pairing_required', envelope);
return this.onPairingRequired(
envelope.chatId,
result.pairing,
@@ -6446,6 +6457,7 @@ export abstract class ChannelBase {
}
this.logPreflightRejected(
senderGate === this.gate ? 'sender_denied' : 'group_sender_denied',
envelope,
);
return false;
}
@@ -6454,13 +6466,46 @@ export abstract class ChannelBase {
return true;
}
protected logPreflightRejected(reason: string): void {
/**
* Logs a preflight rejection and journals it. `envelope` supplies the
* identifiers an operator needs to find the message the skip refers to; it
* is optional because some adapters reject before an envelope exists.
*/
protected logPreflightRejected(reason: string, envelope?: Envelope): void {
process.stderr.write(
`[Channel:${this.name}] preflight rejected reason=${sanitizeLogText(
reason,
80,
)}\n`,
);
this.recordPreflightRejected(reason, envelope);
}
/**
* Journals a preflight rejection without a stderr line, for the drop paths
* that are expected to be frequent.
*/
protected recordPreflightRejected(reason: string, envelope?: Envelope): void {
this.recordChannelEvent({
type: 'inbound',
outcome: 'skipped',
reason,
...(envelope
? {
repository: envelope.chatId,
thread: envelope.threadId,
messageId: envelope.messageId,
actor: envelope.senderId,
...(envelope.messageRoute
? { detail: { route: envelope.messageRoute } }
: {}),
}
: {}),
});
}
protected recordChannelEvent(event: ChannelEventInput): void {
appendChannelEvent(this.name, this.config.cwd, event);
}
protected logDebugPayload(platform: string, payload: unknown): void {
@@ -6536,6 +6581,17 @@ export abstract class ChannelBase {
envelope: Envelope,
process: () => Promise<void> = () => this.processInbound(envelope),
): Promise<void> {
this.recordChannelEvent({
type: 'inbound',
outcome: 'accepted',
repository: envelope.chatId,
thread: envelope.threadId,
messageId: envelope.messageId,
actor: envelope.senderId,
...(envelope.messageRoute
? { detail: { route: envelope.messageRoute } }
: {}),
});
if (this.namedSessions && !(await this.prepareNamedTurnBinding(envelope))) {
return;
}
@@ -11,14 +11,16 @@ import {
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PollingChannelBase } from './PollingChannelBase.js';
import { channelEventFilePath } from './channel-events.js';
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
import type { ChannelConfig } from './types.js';
const testHome = mkdtempSync(join(tmpdir(), 'poll-test-'));
vi.mock('./paths.js', () => ({
getGlobalQwenDir: () => testHome,
}));
vi.mock('./paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./paths.js')>();
return { ...actual, getGlobalQwenDir: () => testHome };
});
afterAll(() => {
rmSync(testHome, { recursive: true, force: true });
@@ -226,4 +228,70 @@ describe('PollingChannelBase', () => {
expect(files.some((f: string) => f.includes(h1))).toBe(true);
expect(files.some((f: string) => f.includes(h2))).toBe(true);
});
it('records the detail produced by the same poll, not the previous one', async () => {
class DetailPoller extends PollingChannelBase<TestCursor> {
private readonly counts = [7, 2, 11];
private index = 0;
protected createInitialCursor(): TestCursor {
return { ts: '2026-01-01T00:00:00.000Z', count: 0 };
}
protected get pollInterval(): number {
return 1;
}
protected async pollOnce(): Promise<void> {
this.setPollEventDetail({
notifications: this.counts[this.index++]!,
});
}
async connect(): Promise<void> {
this.startPollLoop();
}
disconnect(): void {
this.stopPollLoop();
}
async sendMessage(): Promise<void> {}
}
vi.useFakeTimers();
const poller = new DetailPoller(
'detail-poller',
makeConfig(),
makeBridge(),
);
try {
poller.connect();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(1);
poller.disconnect();
} finally {
vi.useRealTimers();
}
const journal = readFileSync(
channelEventFilePath('detail-poller', '/tmp'),
'utf-8',
)
.split('\n')
.filter((line) => line.length > 0)
.map(
(line) =>
JSON.parse(line) as {
type: string;
detail?: { notifications?: number };
},
)
.filter((event) => event.type === 'poll');
expect(journal.map((event) => event.detail?.notifications)).toEqual([
7, 2, 11,
]);
});
});
@@ -5,6 +5,7 @@ import process from 'node:process';
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
import type { ChannelBaseOptions } from './ChannelBase.js';
import { ChannelBase } from './ChannelBase.js';
import type { ChannelEventDetail } from './channel-events.js';
import type { ChannelConfig } from './types.js';
import { getGlobalQwenDir } from './paths.js';
@@ -16,6 +17,16 @@ export abstract class PollingChannelBase<Cursor> extends ChannelBase {
private abortController = new AbortController();
private running = false;
private consecutiveErrors = 0;
private pollEventDetail: ChannelEventDetail | undefined;
/**
* Attaches adapter-specific counters (for example how many notifications a
* poll returned) to the poll event the base class records when the poll
* settles. The value is consumed once, by the poll it belongs to.
*/
protected setPollEventDetail(detail: ChannelEventDetail | undefined): void {
this.pollEventDetail = detail;
}
protected abortableSleep(ms: number): Promise<void> {
const signal = this.abortController.signal;
@@ -91,19 +102,45 @@ export abstract class PollingChannelBase<Cursor> extends ChannelBase {
private async runLoop(): Promise<void> {
const signal = this.abortController.signal;
while (this.running && !signal.aborted) {
const startedAt = Date.now();
// The adapter populates the detail while its poll runs, so it is reset
// here and consumed after the poll settles — reading it up front would
// record the previous poll's counters against this poll's timestamp.
this.pollEventDetail = undefined;
try {
await this.pollOnce();
this.saveCursor();
this.consecutiveErrors = 0;
const detail = this.pollEventDetail;
this.pollEventDetail = undefined;
this.recordChannelEvent({
type: 'poll',
outcome: 'ok',
durationMs: Date.now() - startedAt,
...(detail ? { detail } : {}),
});
} catch (err) {
this.consecutiveErrors++;
const backoff = Math.min(
INITIAL_BACKOFF * 2 ** (this.consecutiveErrors - 1),
MAX_BACKOFF,
);
const detail = this.pollEventDetail;
this.pollEventDetail = undefined;
process.stderr.write(
`[Channel:${this.name}] poll error (attempt ${this.consecutiveErrors}), backing off ${backoff}ms: ${err}\n`,
);
this.recordChannelEvent({
type: 'poll',
outcome: 'failed',
durationMs: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
detail: {
...(detail ?? {}),
consecutiveErrors: this.consecutiveErrors,
backoffMs: backoff,
},
});
await this.abortableSleep(backoff);
continue;
}
@@ -0,0 +1,609 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
appendChannelEvent,
channelEventFilePath,
channelEventScopeDir,
normalizeChannelEventRecord,
readChannelEvents,
} from './channel-events.js';
let qwenHome: string;
let cwd: string;
beforeEach(() => {
qwenHome = mkdtempSync(join(tmpdir(), 'channel-events-'));
cwd = join(qwenHome, 'workspace');
vi.stubEnv('QWEN_HOME', qwenHome);
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(qwenHome, { recursive: true, force: true });
});
function append(
index: number,
channel = 'bot',
type: 'inbound' | 'poll' = 'inbound',
) {
appendChannelEvent(channel, cwd, {
at: new Date(2026, 0, 1, 0, 0, index).toISOString(),
type,
outcome: type === 'poll' ? 'ok' : 'skipped',
reason: 'reason_filter',
repository: 'owner/repo',
thread: 'pr:7',
number: 7,
actor: 'alice',
});
}
describe('channel events journal', async () => {
it('appends and reads back the newest records', async () => {
for (let index = 0; index < 5; index += 1) append(index);
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(truncated).toBe(false);
expect(events).toHaveLength(5);
expect(events.at(-1)).toMatchObject({
channel: 'bot',
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
repository: 'owner/repo',
thread: 'pr:7',
number: 7,
actor: 'alice',
});
});
it('filters by channel and type and reports truncation', async () => {
append(1, 'bot');
append(2, 'other');
append(3, 'bot', 'poll');
append(4, 'bot');
const byChannel = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
channels: ['bot'],
});
expect(byChannel.events).toHaveLength(3);
const byType = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
types: ['poll'],
});
expect(byType.events).toHaveLength(1);
expect(byType.events[0]?.outcome).toBe('ok');
const limited = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 2,
});
expect(limited.events).toHaveLength(2);
expect(limited.truncated).toBe(true);
});
it('honours since by timestamp', async () => {
append(1);
append(5);
const cutoff = new Date(2026, 0, 1, 0, 0, 3).getTime();
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
since: cutoff,
});
expect(events).toHaveLength(1);
});
it('keeps journals of different channels and workspaces apart', async () => {
append(1, 'bot');
append(2, 'other');
const otherCwd = join(qwenHome, 'other-workspace');
appendChannelEvent('bot', otherCwd, {
type: 'poll',
outcome: 'ok',
});
expect(channelEventFilePath('bot', cwd)).not.toBe(
channelEventFilePath('bot', otherCwd),
);
expect(
(await readChannelEvents({ scopeDirs: [channelEventScopeDir(cwd)] }))
.events,
).toHaveLength(2);
expect(
(await readChannelEvents({ scopeDirs: [channelEventScopeDir(otherCwd)] }))
.events,
).toHaveLength(1);
});
it('never writes outside its own scope directory for a hostile name', async () => {
const path = channelEventFilePath('../../etc/passwd', cwd);
expect(path.startsWith(channelEventScopeDir(cwd))).toBe(true);
expect(path.includes('..')).toBe(false);
});
it('keeps the journal readable and writable only by its account', async () => {
append(1);
const path = channelEventFilePath('bot', cwd);
expect(statSync(path).mode & 0o077).toBe(0);
expect(statSync(dirname(path)).mode & 0o077).toBe(0);
});
it('bounds how many channel files one read opens', async () => {
for (let index = 0; index < 40; index += 1) {
appendChannelEvent(`bot-${index}`, cwd, {
at: new Date(2026, 0, 1, 0, 0, index).toISOString(),
type: 'poll',
outcome: 'ok',
});
}
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
expect(events.length).toBeLessThanOrEqual(32);
// Dropping whole Channels is incomplete data, so the completeness flag
// must say so even when the returned rows fit the limit.
expect(truncated).toBe(true);
});
it('reads events that only survive in the rotated file', async () => {
const path = channelEventFilePath('rotated', cwd);
mkdirSync(dirname(path), { recursive: true });
const older = JSON.stringify({
at: new Date(2026, 0, 1, 0, 0, 1).toISOString(),
channel: 'rotated',
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
});
writeFileSync(`${path}.1`, `${older}\n`);
appendChannelEvent('rotated', cwd, {
at: new Date(2026, 0, 2, 0, 0, 1).toISOString(),
type: 'poll',
outcome: 'ok',
});
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(events.map((event) => event.type)).toEqual(['inbound', 'poll']);
});
it('clamps a limit above the maximum instead of returning everything', async () => {
const path = channelEventFilePath('many', cwd);
mkdirSync(dirname(path), { recursive: true });
const line = JSON.stringify({
at: new Date(2026, 0, 1).toISOString(),
channel: 'many',
type: 'poll',
outcome: 'ok',
});
writeFileSync(path, `${`${line}\n`.repeat(501)}`);
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 100000,
});
expect(events).toHaveLength(500);
expect(truncated).toBe(true);
});
it('trims the accumulator without changing which events come back', async () => {
// Four journals whose records interleave in time and share instants, read
// freshest-first: a trim that kept the last-written rows instead of the
// newest ones, or that disturbed the order of equal instants, would differ
// from the same read with a limit large enough never to trim.
for (let index = 0; index < 10; index += 1) {
for (let channel = 0; channel < 4; channel += 1) {
append(index, `chan-${channel}`);
}
}
const scopeDirs = [channelEventScopeDir(cwd)];
const whole = await readChannelEvents({ scopeDirs, limit: 500 });
expect(whole.events).toHaveLength(40);
expect(whole.truncated).toBe(false);
const trimmed = await readChannelEvents({ scopeDirs, limit: 3 });
expect(trimmed.truncated).toBe(true);
expect(trimmed.events).toEqual(whole.events.slice(-3));
});
it('reports truncation when trimming leaves exactly the limit', async () => {
// 21 records with limit 5: the accumulator passes 4 x 5 and is trimmed to
// 5, so the final count alone cannot reveal that older events were dropped.
for (let index = 0; index < 21; index += 1) append(index);
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 5,
});
expect(events).toHaveLength(5);
expect(truncated).toBe(true);
expect(events[0]?.at).toBe(new Date(2026, 0, 1, 0, 0, 16).toISOString());
expect(events.at(-1)?.at).toBe(
new Date(2026, 0, 1, 0, 0, 20).toISOString(),
);
});
it('reports truncation when a single journal is taller than the tail window', async () => {
// ~1 KiB per record: a 1 MiB journal holds ~1000 records, but the 256 KiB
// tail window only reaches the newest ~250 — fewer than the 500 limit, so
// only the skipped head can explain a truncated result.
const line = JSON.stringify({
at: new Date(2026, 0, 1).toISOString(),
channel: 'tall',
type: 'poll',
outcome: 'ok',
detail: { pad: 'x'.repeat(900) },
});
const path = channelEventFilePath('tall', cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${line}\n`.repeat(1020));
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
expect(events.length).toBeGreaterThan(0);
expect(events.length).toBeLessThan(500);
expect(truncated).toBe(true);
});
it('reports truncation when the byte budget stops the read', async () => {
// 20 journals of 24 records each: every file fits one whole 256 KiB tail
// window (so no per-file clipping), together they exceed the 4 MiB request
// budget, and all 480 records together stay under the 500 record limit —
// the budget is the only possible reason for a truncated result.
const line = JSON.stringify({
at: new Date(2026, 0, 1).toISOString(),
channel: 'bulk',
type: 'poll',
outcome: 'ok',
detail: { pad: 'x'.repeat(10700) },
});
const block = `${line}\n`.repeat(24);
expect(Buffer.byteLength(block)).toBeLessThanOrEqual(256 * 1024);
for (let index = 0; index < 20; index += 1) {
const path = channelEventFilePath(`bulk-${index}`, cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, block);
}
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
expect(truncated).toBe(true);
expect(events.length).toBeGreaterThan(0);
expect(events.length).toBeLessThan(480);
});
it('reads a small journal that fits the budget left over', async () => {
// 17 journals of exactly 240 KiB plus one tiny journal, together under the
// 4 MiB budget. The tiny one is the oldest, so it is read last, when less
// than one 256 KiB window remains — it must still be read, because the
// 16 KiB remainder covers its 200 bytes eighty times over.
const at = new Date(2026, 0, 1).toISOString();
const sized = (channel: string, sizeBytes: number): string => {
const shape = { at, channel, type: 'poll', outcome: 'ok' };
const base = JSON.stringify({ ...shape, detail: { pad: '' } });
const line = JSON.stringify({
...shape,
detail: { pad: 'x'.repeat(sizeBytes - 1 - Buffer.byteLength(base)) },
});
expect(Buffer.byteLength(line) + 1).toBe(sizeBytes);
return line;
};
const epoch = new Date(Date.UTC(2026, 0, 1));
const write = (channel: string, body: string, mtime: Date): void => {
const path = channelEventFilePath(channel, cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body);
utimesSync(path, mtime, mtime);
};
write('win-last', `${sized('win-last', 200)}\n`, epoch);
let total = 200;
for (let index = 0; index < 17; index += 1) {
const channel = `bulk-${index}`;
const block = Array.from({ length: 10 }, () => sized(channel, 24576));
write(
channel,
`${block.join('\n')}\n`,
new Date(epoch.getTime() + (index + 1) * 1000),
);
total += 245760;
}
expect(total).toBeLessThan(4 * 1024 * 1024);
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
expect(truncated).toBe(false);
expect(events).toHaveLength(171);
expect(events.filter((event) => event.channel === 'win-last')).toHaveLength(
1,
);
});
it('yields to the event loop once per chunk, not once per file', async () => {
// The daemon must be able to serve sessions between chunks, so the turns a
// read takes have to grow with the bytes read, not just with the file
// count. Measure the same six journals short (one chunk each) and tall (a
// full 256 KiB window, chunked several times). Every awaited read costs at
// least one turn and a tall window takes four more reads per journal than a
// short one, so the tall read leads by 24 turns at minimum; the bound below
// leaves room for scheduling noise. Reading those windows synchronously
// would erase the difference.
const measure = async (repeat: number): Promise<number> => {
rmSync(channelEventScopeDir(cwd), { recursive: true, force: true });
for (let index = 0; index < 6; index += 1) {
const channel = `yield-${index}`;
const line = JSON.stringify({
at: new Date(2026, 0, 1).toISOString(),
channel,
type: 'poll',
outcome: 'ok',
detail: { pad: 'x'.repeat(900) },
});
const path = channelEventFilePath(channel, cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${line}\n`.repeat(repeat));
}
let turns = 0;
let reading = true;
const tick = (): void => {
turns += 1;
if (reading) setImmediate(tick);
};
setImmediate(tick);
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
reading = false;
expect(events.length).toBeGreaterThan(0);
return turns;
};
const short = await measure(4);
const tall = await measure(400);
expect(tall).toBeGreaterThan(short + 12);
});
it('keeps every complete record when the window starts on a line boundary', async () => {
// 300 records of exactly 1 KiB, so the 256 KiB window starts exactly at
// record 44: the first line it reads is complete and must not be dropped.
const at = new Date(2026, 0, 1).toISOString();
const lines = Array.from({ length: 300 }, (_, index) => {
const shape = {
at,
channel: 'aligned',
type: 'poll',
outcome: 'ok',
number: index,
};
const base = JSON.stringify({ ...shape, detail: { pad: '' } });
const line = JSON.stringify({
...shape,
detail: { pad: 'x'.repeat(1024 - 1 - Buffer.byteLength(base)) },
});
expect(Buffer.byteLength(line) + 1).toBe(1024);
return line;
});
const path = channelEventFilePath('aligned', cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${lines.join('\n')}\n`);
const { events, truncated } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
limit: 500,
});
expect(truncated).toBe(true);
expect(events).toHaveLength(256);
expect(events[0]?.number).toBe(44);
expect(events.at(-1)?.number).toBe(299);
});
it('shows an undated record unfiltered but not under a since bound', async () => {
appendChannelEvent('bot', cwd, {
at: 'not-a-date',
type: 'poll',
outcome: 'ok',
});
appendChannelEvent('bot', cwd, {
at: new Date(2026, 0, 1, 0, 0, 5).toISOString(),
type: 'poll',
outcome: 'ok',
});
const scopeDirs = [channelEventScopeDir(cwd)];
const all = await readChannelEvents({ scopeDirs });
expect(all.events).toHaveLength(2);
expect(all.events[0]?.at).toBe('not-a-date');
const bounded = await readChannelEvents({
scopeDirs,
since: new Date(2026, 0, 1, 0, 0, 1).getTime(),
});
expect(bounded.events).toHaveLength(1);
expect(bounded.events[0]?.at).not.toBe('not-a-date');
});
it('fails loudly when a journal file cannot be read', async () => {
append(1);
const path = channelEventFilePath('bot', cwd);
chmodSync(path, 0o000);
try {
await expect(
readChannelEvents({ scopeDirs: [channelEventScopeDir(cwd)] }),
).rejects.toThrow();
} finally {
chmodSync(path, 0o600);
}
});
it('fails loudly when the journal directory cannot be read', async () => {
append(1);
const dir = channelEventScopeDir(cwd);
chmodSync(dir, 0o000);
try {
await expect(readChannelEvents({ scopeDirs: [dir] })).rejects.toThrow();
} finally {
chmodSync(dir, 0o700);
}
});
it('keeps an explicitly requested Channel reachable past the file cap', async () => {
appendChannelEvent('requested', cwd, {
at: new Date(2026, 0, 1, 0, 0, 0).toISOString(),
type: 'poll',
outcome: 'ok',
});
for (let index = 0; index < 32; index += 1) {
appendChannelEvent(`peer-${index}`, cwd, {
at: new Date(2026, 0, 2, 0, 0, index).toISOString(),
type: 'poll',
outcome: 'ok',
});
}
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
channels: ['requested'],
});
expect(events).toHaveLength(1);
expect(events[0]?.channel).toBe('requested');
});
it('preserves a full legal Channel name for display and filtering', async () => {
const longName = 'c'.repeat(100);
appendChannelEvent(longName, cwd, { type: 'poll', outcome: 'ok' });
const all = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(all.events[0]?.channel).toBe(longName);
const filtered = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
channels: [longName],
});
expect(filtered.events).toHaveLength(1);
});
it('keeps names that share a long prefix distinguishable', async () => {
const prefix = 'd'.repeat(90);
appendChannelEvent(`${prefix}-alpha`, cwd, { type: 'poll', outcome: 'ok' });
appendChannelEvent(`${prefix}-beta`, cwd, { type: 'poll', outcome: 'ok' });
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
channels: [`${prefix}-alpha`],
});
expect(events).toHaveLength(1);
expect(events[0]?.channel).toBe(`${prefix}-alpha`);
});
it('rotates once the journal reaches its size cap', async () => {
const path = channelEventFilePath('bot', cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${'x'.repeat(1024 * 1024)}\n`);
appendChannelEvent('bot', cwd, { type: 'poll', outcome: 'ok' });
expect(statSync(`${path}.1`).size).toBeGreaterThan(1024 * 1024);
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(events).toHaveLength(1);
expect(readFileSync(`${path}.1`, 'utf-8')).not.toContain('"type":"poll"');
});
it('drops corrupt lines instead of failing the whole read', async () => {
append(1);
const path = channelEventFilePath('bot', cwd);
writeFileSync(
path,
`${readFileSync(path, 'utf-8')}not json\n{"type":"poll"}\n`,
);
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(events).toHaveLength(1);
});
it('swallows a write failure into one stderr line', async () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// A file where the scope directory belongs makes mkdir fail.
mkdirSync(dirname(channelEventScopeDir(cwd)), { recursive: true });
writeFileSync(channelEventScopeDir(cwd), 'blocked');
expect(() =>
appendChannelEvent('bot', cwd, { type: 'poll', outcome: 'ok' }),
).not.toThrow();
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining('channel event write failed'),
);
stderr.mockRestore();
});
it('rejects records missing required fields or carrying unknown types', async () => {
expect(normalizeChannelEventRecord({})).toBeUndefined();
expect(
normalizeChannelEventRecord({
at: new Date().toISOString(),
channel: 'bot',
type: 'unknown',
outcome: 'ok',
}),
).toBeUndefined();
expect(
normalizeChannelEventRecord({
at: new Date().toISOString(),
channel: 'bot',
type: 'poll',
outcome: 'ok',
detail: { notifications: 3, extra: { nested: true } },
}),
).toMatchObject({ detail: { notifications: 3 } });
});
});
@@ -0,0 +1,472 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import {
appendFileSync,
chmodSync,
mkdirSync,
renameSync,
statSync,
unlinkSync,
} from 'node:fs';
import { open, readdir, stat, type FileHandle } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { getGlobalQwenDir, getWorkspaceScopeDirName } from './paths.js';
import { sanitizeLogText } from './sanitize.js';
/**
* One bounded, append-only record of what a Channel did: an inbound message it
* accepted or skipped, a publication outcome, an inbound task transition, or a
* poll result. Records carry routing decisions and identifiers only — never
* message text — so the journal cannot become a second copy of conversation
* history.
*/
export const CHANNEL_EVENT_TYPES = [
'inbound',
'outbound',
'task',
'poll',
] as const;
export type ChannelEventType = (typeof CHANNEL_EVENT_TYPES)[number];
export const CHANNEL_EVENT_MAX_TEXT = 200;
export const CHANNEL_EVENT_MAX_DETAIL_KEYS = 12;
export const CHANNEL_EVENT_MAX_READ = 500;
/**
* A Channel identity is matched against the name the management API accepted,
* which allows up to 255 UTF-8 bytes. Code points never exceed bytes, so this
* cap keeps every legal name intact; only the UI shortens it for display.
*/
const CHANNEL_EVENT_MAX_NAME = 255;
const CHANNEL_EVENT_MAX_FILE_BYTES = 1024 * 1024;
/**
* Read bounds. The journal is a diagnostic tail, not an archive: a request
* reads at most this many bytes per file and this many in total, in awaited
* chunks, so one page poll cannot stall the daemon that also serves sessions.
* Anything the bounds clip is reported through `truncated`.
*/
const CHANNEL_EVENT_READ_TAIL_BYTES = 256 * 1024;
const CHANNEL_EVENT_READ_BUDGET_BYTES = 4 * 1024 * 1024;
const CHANNEL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
const CHANNEL_EVENT_MAX_FILES = 32;
/**
* Filtering needs every line parsed, but retaining every parsed record would
* hold a whole workspace's journals in memory on a page that re-polls every
* 5 s. Past this multiple of `limit` the accumulator is trimmed to the newest
* `limit`, which bounds retention without changing the answer.
*/
const CHANNEL_EVENT_READ_TRIM_FACTOR = 4;
const EVENT_FILE_SUFFIX = 'channel-events.jsonl';
export type ChannelEventDetail = Record<string, string | number | boolean>;
export interface ChannelEventRecord {
at: string;
channel: string;
type: ChannelEventType;
outcome: string;
reason?: string;
triggerKind?: string;
repository?: string;
thread?: string;
number?: number;
actor?: string;
sessionId?: string;
messageId?: string;
durationMs?: number;
error?: string;
detail?: ChannelEventDetail;
}
export type ChannelEventInput = Omit<ChannelEventRecord, 'at' | 'channel'> & {
at?: string;
};
export interface ChannelEventQuery {
/** Workspace-scope directories already resolved by the caller. */
scopeDirs: readonly string[];
channels?: readonly string[];
types?: readonly ChannelEventType[];
since?: number;
limit?: number;
}
export interface ChannelEventQueryResult {
events: ChannelEventRecord[];
/**
* True when older events are missing: the record limit was reached, a
* journal was left out by the file cap or the byte budget, or a journal was
* taller than the tail window.
*/
truncated: boolean;
}
function text(
value: unknown,
maxLen = CHANNEL_EVENT_MAX_TEXT,
): string | undefined {
if (typeof value !== 'string') return undefined;
const sanitized = sanitizeLogText(value, maxLen);
return sanitized.length > 0 ? sanitized : undefined;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function normalizeDetail(value: unknown): ChannelEventDetail | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const detail: ChannelEventDetail = {};
for (const [key, entry] of Object.entries(value)) {
const name = text(key, 64);
if (!name) continue;
if (typeof entry === 'string') {
detail[name] = sanitizeLogText(entry, CHANNEL_EVENT_MAX_TEXT);
} else if (typeof entry === 'number' && Number.isFinite(entry)) {
detail[name] = entry;
} else if (typeof entry === 'boolean') {
detail[name] = entry;
} else {
continue;
}
if (Object.keys(detail).length >= CHANNEL_EVENT_MAX_DETAIL_KEYS) break;
}
return Object.keys(detail).length > 0 ? detail : undefined;
}
/**
* Rebuilds a record from untrusted input (a journal line, or an event handed to
* `appendChannelEvent`). Returns undefined when the required fields are missing
* or invalid, so a corrupt line can never surface as a half-rendered row.
*/
export function normalizeChannelEventRecord(
value: unknown,
): ChannelEventRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
const at = text(raw['at'], 40);
const channel = text(raw['channel'], CHANNEL_EVENT_MAX_NAME);
const outcome = text(raw['outcome'], 64);
const type = raw['type'];
if (
!at ||
!channel ||
!outcome ||
!CHANNEL_EVENT_TYPES.includes(type as ChannelEventType)
) {
return undefined;
}
const record: ChannelEventRecord = {
at,
channel,
type: type as ChannelEventType,
outcome,
};
const reason = text(raw['reason'], 80);
if (reason) record.reason = reason;
const triggerKind = text(raw['triggerKind'], 64);
if (triggerKind) record.triggerKind = triggerKind;
const repository = text(raw['repository'], 140);
if (repository) record.repository = repository;
const thread = text(raw['thread'], 80);
if (thread) record.thread = thread;
const number = finiteNumber(raw['number']);
if (number !== undefined) record.number = number;
const actor = text(raw['actor'], 100);
if (actor) record.actor = actor;
const sessionId = text(raw['sessionId'], 120);
if (sessionId) record.sessionId = sessionId;
const messageId = text(raw['messageId'], 120);
if (messageId) record.messageId = messageId;
const durationMs = finiteNumber(raw['durationMs']);
if (durationMs !== undefined) record.durationMs = durationMs;
const error = text(raw['error']);
if (error) record.error = error;
const detail = normalizeDetail(raw['detail']);
if (detail) record.detail = detail;
return record;
}
/**
* Directory holding a Channel's workspace-scoped state files.
*/
export function channelEventScopeDir(cwd: string): string {
return join(getGlobalQwenDir(), 'channels', getWorkspaceScopeDirName(cwd));
}
function channelEventFilePrefix(channelName: string): string {
const encodedName = channelName.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 200);
const nameHash = createHash('sha256')
.update(channelName)
.digest('hex')
.slice(0, 16);
return `${encodedName}-${nameHash}-`;
}
export function channelEventFilePath(channelName: string, cwd: string): string {
return join(
channelEventScopeDir(cwd),
`${channelEventFilePrefix(channelName)}${EVENT_FILE_SUFFIX}`,
);
}
function rotateIfNeeded(path: string): void {
let size: number;
try {
size = statSync(path).size;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
throw err;
}
if (size < CHANNEL_EVENT_MAX_FILE_BYTES) return;
const rotated = `${path}.1`;
// Windows refuses to rename onto an existing file, so drop the previous
// rotation first instead of relying on POSIX rename semantics.
try {
unlinkSync(rotated);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
renameSync(path, rotated);
}
/**
* Appends one event to a Channel's journal. A failure is reported once on
* stderr and swallowed: diagnostics must never break message handling.
*/
export function appendChannelEvent(
channelName: string,
cwd: string,
input: ChannelEventInput,
): void {
try {
const record = normalizeChannelEventRecord({
...input,
at: input.at ?? new Date().toISOString(),
channel: channelName,
});
if (!record) return;
const path = channelEventFilePath(channelName, cwd);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try {
chmodSync(dirname(path), 0o700);
} catch {
// A directory we do not own stays as created; the append below still
// decides whether this Channel can write to it.
}
rotateIfNeeded(path);
appendFileSync(path, `${JSON.stringify(record)}\n`, {
encoding: 'utf-8',
mode: 0o600,
});
} catch (err) {
process.stderr.write(
`[Channel:${sanitizeLogText(channelName, 80)}] channel event write failed: ${sanitizeLogText(
err instanceof Error ? err.message : String(err),
200,
)}\n`,
);
}
}
async function readTail(
path: string,
maxBytes: number,
): Promise<{ lines: string[]; bytes: number; clipped: boolean }> {
let handle: FileHandle;
try {
handle = await open(path, 'r');
} catch (err) {
// A journal that has not been written yet is not a failure; anything else
// (permissions, I/O) must not read as "this Channel did nothing".
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { lines: [], bytes: 0, clipped: false };
}
throw err;
}
try {
const { size } = await handle.stat();
const start = Math.max(0, size - maxBytes);
// A window starting mid-file usually lands inside a line, but it can land
// exactly on a boundary. Read one leading byte to tell the two apart, so an
// aligned window keeps every complete record instead of dropping one for
// nothing. Folding that byte into the first chunk keeps the read to one
// awaited call per chunk — the daemon serves other requests between them.
const lead = start > 0 ? 1 : 0;
const chunks: Buffer[] = [];
let position = start - lead;
let bytes = 0;
while (position < size) {
const length = Math.min(CHANNEL_EVENT_READ_CHUNK_BYTES, size - position);
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, position);
if (bytesRead <= 0) break;
chunks.push(buffer.subarray(0, bytesRead));
bytes += bytesRead;
position += bytesRead;
}
const window = Buffer.concat(chunks);
const lines = window.subarray(lead).toString('utf-8').split('\n');
if (lead === 1 && window[0] !== 0x0a) lines.shift();
// The skipped head is clipped data too: a journal grows to 1 MiB before it
// rotates, so a 256 KiB tail of a busy Channel omits its own older events.
return {
lines: lines.filter((line) => line.length > 0),
bytes: Math.max(0, bytes - lead),
clipped: start > 0,
};
} finally {
await handle.close();
}
}
async function eventFiles(
scopeDir: string,
channels?: readonly string[],
): Promise<{ files: Array<{ path: string; size: number }>; clipped: boolean }> {
let names: string[];
try {
names = await readdir(scopeDir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { files: [], clipped: false };
}
throw err;
}
// Narrow to the requested identities before the read cap: otherwise busier
// peers (rotated files count too) would make an explicitly asked for,
// quieter Channel's retained history unreachable.
const prefixes = channels?.map(channelEventFilePrefix);
const candidates = names
.filter((name) => {
if (
!name.endsWith(`-${EVENT_FILE_SUFFIX}`) &&
!name.endsWith(`-${EVENT_FILE_SUFFIX}.1`)
) {
return false;
}
return !prefixes || prefixes.some((prefix) => name.startsWith(prefix));
})
.map((name) => join(scopeDir, name));
const stated = await Promise.all(
candidates.map(async (file) => {
try {
const { mtimeMs, size } = await stat(file);
return { file, at: mtimeMs, size };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw err;
}
}),
);
// Freshest first, so both the file cap and the byte budget spend their
// allowance on the journals an operator is most likely looking at.
const files = stated
.filter(
(entry): entry is { file: string; at: number; size: number } =>
entry !== undefined,
)
.sort((left, right) => right.at - left.at)
.map((entry) => ({ path: entry.file, size: entry.size }));
return {
files: files.slice(0, CHANNEL_EVENT_MAX_FILES),
clipped: files.length > CHANNEL_EVENT_MAX_FILES,
};
}
// Journal lines carry ISO timestamps from different writers; order by the
// parsed instant so a record with an offset cannot jump the queue.
function eventTime(record: ChannelEventRecord): number {
const parsed = Date.parse(record.at);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Reads the newest events across the given workspace scopes. Every candidate
* path is a file found inside an already-resolved scope directory, so a query
* value can never select a path.
*
* The read is asynchronous and chunked: this runs inside the daemon that also
* serves sessions, so a synchronous multi-megabyte scan would stall every
* other request for as long as it takes.
*/
export async function readChannelEvents(
query: ChannelEventQuery,
): Promise<ChannelEventQueryResult> {
const limit = Math.max(
1,
Math.min(CHANNEL_EVENT_MAX_READ, Math.floor(query.limit ?? 100)),
);
const channels = query.channels?.length ? new Set(query.channels) : undefined;
const types = query.types?.length
? new Set<ChannelEventType>(query.types)
: undefined;
const events: ChannelEventRecord[] = [];
let budget = CHANNEL_EVENT_READ_BUDGET_BYTES;
let clipped = false;
let overLimit = false;
for (const scopeDir of query.scopeDirs) {
const candidates = await eventFiles(scopeDir, query.channels);
clipped = clipped || candidates.clipped;
for (const file of candidates.files) {
// Reserve what this journal actually needs, not a full window: a small
// journal must still be read when the remainder is under one window but
// comfortably over its size. Stopping only when a whole journal cannot
// fit keeps the flag's meaning ("whole journals were left out") exact.
const window = Math.min(CHANNEL_EVENT_READ_TAIL_BYTES, file.size);
if (budget < window) {
clipped = true;
break;
}
const tail = await readTail(file.path, window);
budget -= tail.bytes;
clipped = clipped || tail.clipped;
for (const line of tail.lines) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const record = normalizeChannelEventRecord(parsed);
if (!record) continue;
if (channels && !channels.has(record.channel)) continue;
if (types && !types.has(record.type)) continue;
if (query.since !== undefined) {
// A record whose instant cannot be parsed has no known time, so it
// cannot satisfy a time bound. An unfiltered read still returns it
// (the page renders the raw string) ahead of every dated record.
const at = Date.parse(record.at);
if (!Number.isFinite(at) || at < query.since) continue;
}
events.push(record);
}
if (events.length > limit * CHANNEL_EVENT_READ_TRIM_FACTOR) {
// Dropping the oldest here cannot change the answer: every later
// record sorts after an equal instant, so a dropped record can never
// re-enter the newest `limit`. The sort is stable, which is what makes
// that hold for records sharing a timestamp.
events.sort((left, right) => eventTime(left) - eventTime(right));
events.splice(0, events.length - limit);
overLimit = true;
}
}
}
events.sort((left, right) => eventTime(left) - eventTime(right));
if (overLimit || events.length > limit) {
return { events: events.slice(-limit), truncated: true };
}
return { events, truncated: clipped };
}
+9
View File
@@ -3,6 +3,15 @@ export {
getWorkspaceScopeDirName,
resolvePath,
} from './paths.js';
export {
appendChannelEvent,
channelEventFilePath,
channelEventScopeDir,
CHANNEL_EVENT_MAX_READ,
CHANNEL_EVENT_TYPES,
readChannelEvents,
} from './channel-events.js';
export type { ChannelEventQuery, ChannelEventType } from './channel-events.js';
export { PollingChannelBase } from './PollingChannelBase.js';
export { ACP_EVENT_LOOP_STALL_RESTART_MS, AcpBridge } from './AcpBridge.js';
export {
@@ -5213,4 +5213,222 @@ describe('GithubChannel', () => {
).resolves.toBe('https://github.example.com');
});
});
describe('event journal', () => {
function readJournal(
channelName = 'test-github',
): Array<Record<string, unknown>> {
const path = inboundTaskPath('/tmp/test', channelName).replace(
'github-inbound-tasks.json',
'channel-events.jsonl',
);
try {
return readFileSync(path, 'utf-8')
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
.filter((event) => event['type'] !== 'poll');
} catch {
return [];
}
}
it('records a notification dropped by reasonFilter before any session exists', async () => {
await initWithoutLoop({ reasonFilter: ['mention'] });
mockOctokit.paginate.mockResolvedValueOnce([
makeNotification({ reason: 'comment' }),
]);
await pollOnce();
expect(readJournal()).toEqual([
expect.objectContaining({
channel: 'test-github',
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
triggerKind: 'comment',
repository: 'owner/repo',
thread: 'issue:42',
number: 42,
}),
]);
});
function filteredFlood(count: number) {
return Array.from({ length: count }, (_, index) =>
makeNotification({
id: `flood-${index}`,
reason: 'comment',
subject: {
title: `Issue ${index}`,
url: `https://api.github.com/repos/owner/repo/issues/${100 + index}`,
type: 'Issue',
},
}),
);
}
it('collapses a flood of filtered notifications into one counted row', async () => {
// A task that failed and is still retryable keeps the poll cursor put, so
// the same filtered notifications come back on every poll. Only the first
// few are journaled individually; the rest become one row carrying the
// count, so a busy repository cannot rotate the useful rows out of the
// page's window.
await initWithoutLoop({ reasonFilter: ['mention'] });
mockOctokit.paginate.mockResolvedValueOnce(filteredFlood(25));
await pollOnce();
const events = readJournal();
expect(events).toHaveLength(21);
const individual = events.filter(
(event) => event['detail'] === undefined,
);
expect(individual).toHaveLength(20);
expect(individual[0]).toMatchObject({
reason: 'reason_filter',
thread: 'issue:100',
number: 100,
});
expect(individual.at(-1)).toMatchObject({
thread: 'issue:119',
number: 119,
});
expect(events.at(-1)).toEqual(
expect.objectContaining({
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
detail: { filtered: 5 },
}),
);
expect(events.at(-1)).not.toHaveProperty('repository');
expect(events.at(-1)).not.toHaveProperty('thread');
});
it('resets the collapse budget on the next poll', async () => {
await initWithoutLoop({ reasonFilter: ['mention'] });
const flood = filteredFlood(25);
mockOctokit.paginate
.mockResolvedValueOnce(flood)
.mockResolvedValueOnce(flood);
await pollOnce();
await pollOnce();
const events = readJournal();
expect(events).toHaveLength(42);
const counted = events.filter(
(event) =>
(event['detail'] as { filtered?: number } | undefined)?.filtered !==
undefined,
);
expect(counted).toHaveLength(2);
expect(
counted.every((event) => event['detail']?.['filtered'] === 5),
).toBe(true);
});
it('records a comment skipped because it does not mention the bot', async () => {
await initWithoutLoop();
mockOctokit.paginate
.mockResolvedValueOnce([makeNotification({ reason: 'mention' })])
.mockResolvedValueOnce([makeComment({ body: 'no mention here' })]);
mockOctokit.rest.issues.get.mockResolvedValue({
data: { title: 'Test Issue', body: '', user: { login: 'alice' } },
});
await pollOnce();
expect(readJournal()).toEqual([
expect.objectContaining({
type: 'inbound',
outcome: 'skipped',
reason: 'mention_required',
messageId: 'C_1001',
actor: 'alice',
}),
]);
});
it('records a deduplicated comment', async () => {
await initWithoutLoop();
channel.cursor = {
lastProcessedAt: '2026-07-01T00:00:00.000Z',
dispatchedComments: ['C_1001'],
};
mockOctokit.paginate
.mockResolvedValueOnce([makeNotification({ reason: 'mention' })])
.mockResolvedValueOnce([makeComment()]);
mockOctokit.rest.issues.get.mockResolvedValue({
data: { title: 'Test Issue', body: '', user: { login: 'alice' } },
});
await pollOnce();
expect(readJournal()).toEqual([
expect.objectContaining({
type: 'inbound',
outcome: 'skipped',
reason: 'duplicate',
messageId: 'C_1001',
}),
]);
});
it('records the inbound acceptance, task transitions, and publication', async () => {
mockOctokit.rest.issues.createComment.mockResolvedValue({
data: { id: 500, html_url: 'https://github.com/owner/repo/500' },
});
const liveChannel = new LiveGithubChannel(
'test-journal',
makeConfig(),
makeBridge(),
);
mockOctokit.paginate.mockResolvedValueOnce([]);
await liveChannel.connect();
liveChannel.disconnect();
liveChannel.setCursorForTest('2026-07-01T00:00:00.000Z');
mockOctokit.paginate
.mockResolvedValueOnce([makeNotification()])
.mockResolvedValueOnce([makeComment()]);
await liveChannel.pollForTest();
expect(readJournal('test-journal')).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'inbound',
outcome: 'accepted',
repository: 'owner/repo',
thread: 'issue:42',
messageId: '1001',
actor: 'alice',
}),
expect.objectContaining({
type: 'task',
outcome: 'accepted',
triggerKind: 'mention',
number: 42,
}),
expect.objectContaining({
type: 'task',
outcome: 'running',
messageId: '1001',
}),
expect.objectContaining({
type: 'outbound',
outcome: 'posting',
repository: 'owner/repo',
}),
expect.objectContaining({
type: 'outbound',
outcome: 'posted',
sessionId: expect.any(String),
}),
]),
);
});
});
});
+112 -6
View File
@@ -321,6 +321,14 @@ type InboundTaskState =
| 'cancelled';
const MAX_INBOUND_TASK_ATTEMPTS = 3;
/**
* The poll cursor does not advance while an inbound task is recoverable, so a
* slow or stuck turn makes GitHub return the same notifications on every poll.
* Filtered notifications are skipped before any dedupe, so journaling each one
* every time would rotate the useful rows out of the page's window. Journal the
* first few individually and collapse the rest into one counted row.
*/
const MAX_REASON_FILTER_EVENTS_PER_POLL = 20;
interface InboundTaskDedupe {
dispatchedBodies?: string[];
@@ -1246,6 +1254,7 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
);
notifications.sort((a, b) => a.updated_at.localeCompare(b.updated_at));
this.setPollEventDetail({ notifications: notifications.length });
const maxUpdatedAt =
notifications.length > 0
@@ -1265,6 +1274,8 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
? maxUpdatedAt
: this.pendingCursorUpdatedAt;
}
let reasonFilterJournaled = 0;
let reasonFilterCollapsed = 0;
for (const notification of notifications) {
if (!notification.subject.url) continue;
const extracted = this.extractFromSubjectUrl(notification.subject.url);
@@ -1285,6 +1296,20 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
threadId,
reason: notification.reason,
});
if (reasonFilterJournaled < MAX_REASON_FILTER_EVENTS_PER_POLL) {
reasonFilterJournaled += 1;
this.recordChannelEvent({
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
triggerKind: reason,
repository: chatId,
thread: threadId,
number: issueNumber,
});
} else {
reasonFilterCollapsed += 1;
}
continue;
}
const ctx: NotificationContext = {
@@ -1330,6 +1355,17 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
continue;
}
}
if (reasonFilterCollapsed > 0) {
// One counted row instead of a row per notification: the threads differ,
// so this carries no single repository/thread, and the page renders the
// count beside the reason.
this.recordChannelEvent({
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
detail: { filtered: reasonFilterCollapsed },
});
}
if (this.hasRecoverableInboundTasks()) {
this.inboundRecoveryPending = true;
}
@@ -1357,15 +1393,41 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
for (const comment of comments) {
const key = comment.node_id || String(comment.id);
if (this.cursor.dispatchedComments?.includes(key)) continue;
const senderId = (comment.user?.login || 'unknown').toLowerCase();
if (this.cursor.dispatchedComments?.includes(key)) {
this.recordChannelEvent({
type: 'inbound',
outcome: 'skipped',
reason: 'duplicate',
triggerKind: 'comment',
repository: ctx.chatId,
thread: ctx.threadId,
number: ctx.issueNumber,
messageId: key,
actor: senderId,
});
continue;
}
const body = comment.body || '';
const hasMention = this.botUsername
? testBotMention(body, this.botUsername)
: false;
if (onlyMentioned && !hasMention) continue;
if (onlyMentioned && !hasMention) {
this.recordChannelEvent({
type: 'inbound',
outcome: 'skipped',
reason: 'mention_required',
triggerKind: ctx.reason,
repository: ctx.chatId,
thread: ctx.threadId,
number: ctx.issueNumber,
messageId: key,
actor: senderId,
});
continue;
}
const senderId = (comment.user?.login || 'unknown').toLowerCase();
// The same per-conversation gate preflight applies, so an approved
// paired group admits its members here too.
const allowed = this.senderGateFor({
@@ -1733,6 +1795,16 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
};
this.updateInboundTasks((records) => [...records, task]);
this.applyTaskDedupe(task);
this.recordChannelEvent({
type: 'task',
outcome: 'accepted',
triggerKind: parseTriggerKind(envelope.metadata),
repository: task.source.chatId,
thread: task.source.threadId,
number: task.issueNumber,
messageId: task.source.messageId,
actor: envelope.senderId,
});
return task;
}
@@ -1919,8 +1991,8 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
>
> = {},
): void {
this.updateInboundTasks((records) =>
records.map((record) =>
const records = this.updateInboundTasks((existing) =>
existing.map((record) =>
record.id === taskId
? {
...record,
@@ -1931,6 +2003,27 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
: record,
),
);
const task = records.find((record) => record.id === taskId);
if (!task) return;
const envelope = task.envelope;
this.recordChannelEvent({
type: 'task',
outcome: state,
...(envelope
? {
triggerKind: parseTriggerKind(envelope.metadata),
actor: envelope.senderId,
}
: {}),
repository: task.source.chatId,
thread: task.source.threadId,
number: task.issueNumber,
messageId: task.source.messageId,
...(updates.attempts !== undefined
? { detail: { attempts: updates.attempts } }
: {}),
...(updates.error ? { error: updates.error } : {}),
});
}
private removeInboundTask(taskId: string): void {
@@ -1941,13 +2034,14 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
private updateInboundTasks(
update: (records: InboundTaskRecord[]) => InboundTaskRecord[],
): void {
): InboundTaskRecord[] {
try {
const records = update(this.readInboundTasks());
this.writeInboundTasks(records);
this.recoverableInboundTasks = records.filter((record) =>
this.isRecoverableInboundTask(record),
).length;
return records;
} catch (err) {
this.inboundPersistenceBlocked = true;
throw err;
@@ -2039,6 +2133,18 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
)}\n`,
);
}
this.recordChannelEvent({
type: 'outbound',
outcome: record.outcome,
...(record.triggerKind ? { triggerKind: record.triggerKind } : {}),
repository: record.repository,
...(record.threadId ? { thread: record.threadId } : {}),
...(record.number !== undefined ? { number: record.number } : {}),
sessionId: record.sessionId,
...(record.sourceMessageId ? { messageId: record.sourceMessageId } : {}),
...(record.actor ? { actor: record.actor } : {}),
...(record.failureError ? { error: record.failureError } : {}),
});
}
private migrateLegacyPublicationState(): void {
@@ -1,6 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { rmSync } from 'node:fs';
import { dirname } from 'node:path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { TelegramChannel } from './TelegramAdapter.js';
import type {
ChannelAgentBridge,
@@ -195,6 +205,17 @@ function installFakeBot(channel: TelegramChannel): {
}
describe('TelegramChannel', () => {
const qwenHome = mkdtempSync(join(tmpdir(), 'telegram-channel-home-'));
beforeAll(() => {
vi.stubEnv('QWEN_HOME', qwenHome);
});
afterAll(() => {
vi.unstubAllEnvs();
rmSync(qwenHome, { recursive: true, force: true });
});
beforeEach(() => {
vi.useFakeTimers();
});
@@ -400,6 +400,8 @@ describe('WeComChannel', () => {
vi.stubEnv('TMPDIR', suiteTmpDir);
vi.stubEnv('TMP', suiteTmpDir);
vi.stubEnv('TEMP', suiteTmpDir);
// Channel event journals must not land in the developer's real ~/.qwen.
vi.stubEnv('QWEN_HOME', join(suiteTmpDir, 'qwen-home'));
});
afterAll(() => {
@@ -1,7 +1,19 @@
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import type { Server } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import type {
ChannelAgentBridge,
ChannelConfig,
@@ -85,6 +97,16 @@ describe('WeCom Callback adapter', () => {
let config: ChannelConfig & Record<string, unknown>;
let bridge: ChannelAgentBridge;
const api = vi.fn<typeof fetch>();
const qwenHome = mkdtempSync(join(tmpdir(), 'wecom-callback-home-'));
beforeAll(() => {
vi.stubEnv('QWEN_HOME', qwenHome);
});
afterAll(() => {
vi.unstubAllEnvs();
rmSync(qwenHome, { recursive: true, force: true });
});
beforeEach(async () => {
const reservation = createServer();
@@ -5,8 +5,22 @@
*/
import express, { type RequestHandler } from 'express';
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
appendChannelEvent,
channelEventFilePath,
channelEventScopeDir,
} from '@qwen-code/channel-base';
import type {
ChannelManagementService,
ChannelMutationResult,
@@ -718,3 +732,162 @@ describe('workspace Channel management routes', () => {
expect(primaryService.setStartup).not.toHaveBeenCalled();
});
});
describe('workspace Channel event journal', () => {
let qwenHome: string;
let savedQwenHome: string | undefined;
beforeEach(() => {
savedQwenHome = process.env['QWEN_HOME'];
qwenHome = mkdtempSync(join(tmpdir(), 'channel-events-route-'));
process.env['QWEN_HOME'] = qwenHome;
});
afterEach(() => {
rmSync(qwenHome, { recursive: true, force: true });
if (savedQwenHome === undefined) delete process.env['QWEN_HOME'];
else process.env['QWEN_HOME'] = savedQwenHome;
});
function append(cwd: string, index: number, channel = 'bot') {
const inbound = index % 2 === 0;
appendChannelEvent(channel, cwd, {
at: new Date(2026, 0, 1, 0, 0, index).toISOString(),
type: inbound ? 'inbound' : 'poll',
outcome: inbound ? 'skipped' : 'ok',
...(inbound ? { reason: 'reason_filter' } : {}),
repository: 'owner/repo',
number: 42,
});
}
it('serves a workspace journal newest-last without operator auth', async () => {
const { app } = mount();
append('/work/primary', 1);
append('/work/primary', 2);
append('/work/primary', 3, 'other');
const response = await request(app).get(
'/workspace/channels/events?limit=2',
);
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body.truncated).toBe(true);
expect(response.body.events).toHaveLength(2);
expect(response.body.events.at(-1)).toMatchObject({
channel: 'other',
type: 'poll',
outcome: 'ok',
});
});
it('filters by type and channel', async () => {
const { app } = mount();
append('/work/primary', 1);
append('/work/primary', 2);
const byType = await request(app).get(
'/workspace/channels/events?type=inbound',
);
expect(byType.body.events).toHaveLength(1);
expect(byType.body.events[0].reason).toBe('reason_filter');
const byChannel = await request(app).get(
'/workspace/channels/events?channel=other',
);
expect(byChannel.body.events).toEqual([]);
});
it('takes one repeatable parameter per channel name, commas included', async () => {
const { app } = mount();
append('/work/primary', 1, 'bot');
append('/work/primary', 2, 'a,b');
append('/work/primary', 3, 'other');
const repeated = await request(app).get(
'/workspace/channels/events?channel=bot&channel=other',
);
expect(
repeated.body.events.map((event: { channel: string }) => event.channel),
).toEqual(['bot', 'other']);
const commaNamed = await request(app).get(
'/workspace/channels/events?channel=a%2Cb',
);
expect(commaNamed.body.events).toHaveLength(1);
expect(commaNamed.body.events[0].channel).toBe('a,b');
});
it('reads only the resolved workspace scope', async () => {
const { app } = mount();
append('/work/primary', 1);
append('/work/secondary', 2);
const primary = await request(app).get('/workspace/channels/events');
const secondary = await request(app).get(
'/workspaces/secondary/channels/events',
);
expect(primary.body.events).toHaveLength(1);
expect(secondary.body.events).toHaveLength(1);
expect(primary.body.events[0].at).not.toBe(secondary.body.events[0].at);
});
it('does not serve an untrusted workspace journal', async () => {
const { app } = mount(false);
const response = await request(app).get(
'/workspaces/secondary/channels/events',
);
expect(response.status).toBe(403);
});
it.each([
['limit=0', '`limit` must be a positive integer.'],
['type=bogus', '`type` accepts only: inbound, outbound, task, poll.'],
['since=yesterday', '`since` must be an ISO 8601 timestamp.'],
])('rejects %s with 400', async (query, message) => {
const { app } = mount();
const response = await request(app).get(
`/workspace/channels/events?${query}`,
);
expect(response.status).toBe(400);
expect(response.body.code).toBe('invalid_channel_management_request');
expect(response.body.error).toBe(message);
});
it('clamps an oversized limit to the maximum page', async () => {
const { app } = mount();
const path = channelEventFilePath('bot', '/work/primary');
mkdirSync(dirname(path), { recursive: true });
const line = JSON.stringify({
at: new Date(2026, 0, 1).toISOString(),
channel: 'bot',
type: 'poll',
outcome: 'ok',
});
writeFileSync(path, `${line}\n`.repeat(501));
const response = await request(app).get(
'/workspace/channels/events?limit=100000',
);
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(500);
expect(response.body.truncated).toBe(true);
});
it('reports a failure instead of an empty journal when the scope is unreadable', async () => {
const { app } = mount();
append('/work/primary', 1);
const dir = channelEventScopeDir('/work/primary');
chmodSync(dir, 0o000);
try {
const response = await request(app).get('/workspace/channels/events');
expect(response.status).toBe(500);
expect(response.body.code).toBe('channel_management_failed');
} finally {
chmodSync(dir, 0o700);
}
});
});
@@ -6,7 +6,17 @@
import type { Application, Request, RequestHandler, Response } from 'express';
import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
import { sanitizeLogText } from '@qwen-code/channel-base';
import {
channelEventScopeDir,
CHANNEL_EVENT_MAX_READ,
CHANNEL_EVENT_TYPES,
readChannelEvents,
sanitizeLogText,
} from '@qwen-code/channel-base';
import type {
ChannelEventQuery,
ChannelEventType,
} from '@qwen-code/channel-base';
import { supportedChannelCatalog } from '../../commands/channel/channel-registry.js';
import type {
ChannelPairingApprovalSubject,
@@ -226,6 +236,103 @@ function parsePairingApprovalSubject(
return undefined;
}
function sendInvalidEventsQuery(res: Response, detail: string): undefined {
res.status(400).json({
error: detail,
code: 'invalid_channel_management_request',
});
return undefined;
}
/**
* Parses the read-only event journal query. `type` and `channel` accept a
* comma-separated list so a client can ask for a pre-filtered slice without
* loading the whole tail.
*/
function parseEventsQuery(
req: Request,
res: Response,
): Omit<ChannelEventQuery, 'scopeDirs'> | undefined {
const rawLimit = req.query['limit'];
let limit: number | undefined;
if (rawLimit !== undefined) {
const parsed =
typeof rawLimit === 'string' && /^\d+$/.test(rawLimit)
? Number(rawLimit)
: Number.NaN;
if (!Number.isFinite(parsed) || parsed < 1) {
return sendInvalidEventsQuery(res, '`limit` must be a positive integer.');
}
limit = Math.min(parsed, CHANNEL_EVENT_MAX_READ);
}
const rawTypes = req.query['type'];
let types: ChannelEventType[] | undefined;
if (rawTypes !== undefined) {
if (typeof rawTypes !== 'string') {
return sendInvalidEventsQuery(
res,
'`type` must be a comma-separated list of event types.',
);
}
const values = rawTypes.split(',').map((value) => value.trim());
if (values.some((value) => value.length === 0)) {
return sendInvalidEventsQuery(
res,
'`type` must be a comma-separated list of event types.',
);
}
if (
values.some(
(value) => !CHANNEL_EVENT_TYPES.includes(value as ChannelEventType),
)
) {
return sendInvalidEventsQuery(
res,
`\`type\` accepts only: ${CHANNEL_EVENT_TYPES.join(', ')}.`,
);
}
types = values as ChannelEventType[];
}
// Repeat `channel` for each instance, like `type`: a Channel name is a
// portable path component and may legally contain a comma, which a joined
// list could not represent.
const rawChannel = req.query['channel'];
let channels: string[] | undefined;
if (rawChannel !== undefined) {
const values = Array.isArray(rawChannel) ? rawChannel : [rawChannel];
if (values.some((value) => typeof value !== 'string')) {
return sendInvalidEventsQuery(
res,
'`channel` must repeat one Channel name per parameter.',
);
}
const names = (values as string[]).filter((value) => value.length > 0);
if (names.length > 0) channels = names;
}
const rawSince = req.query['since'];
let since: number | undefined;
if (rawSince !== undefined) {
const parsed = typeof rawSince === 'string' ? Date.parse(rawSince) : NaN;
if (!Number.isFinite(parsed)) {
return sendInvalidEventsQuery(
res,
'`since` must be an ISO 8601 timestamp.',
);
}
since = parsed;
}
return {
...(limit !== undefined ? { limit } : {}),
...(types ? { types } : {}),
...(channels ? { channels } : {}),
...(since !== undefined ? { since } : {}),
};
}
function errorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
try {
@@ -403,6 +510,31 @@ export function registerWorkspaceChannelManagementRoutes(
}
});
app.get(`${prefix}/channels/events`, async (req, res) => {
// Reading the journal needs the resolved workspace only: it is a
// read-only view of files this workspace's Channels wrote, so it must
// not depend on a Channel management service being available.
const runtime = resolveRuntime(req, res);
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
if (!validateClient(req, res, runtime)) return;
const query = parseEventsQuery(req, res);
if (!query) return;
try {
noStore(res);
// A managed Channel's working directory must canonicalize to this
// workspace, so its journal lives in the workspace's own scope.
const scopeDir = channelEventScopeDir(runtime.workspaceCwd);
res.status(200).json(
await readChannelEvents({
scopeDirs: [scopeDir],
...query,
}),
);
} catch (error) {
sendManagementError(res, error);
}
});
app.get(
`${prefix}/channels/:name/pairing-requests`,
pairingRead,
+1
View File
@@ -4692,6 +4692,7 @@ describe('createServeApp', () => {
it.each([
'/plugins',
'/channels',
'/channel-events',
'/live',
'/scheduled-tasks',
'/goals',
@@ -43,6 +43,7 @@ export function isDocumentNavigation(req: Request): boolean {
export const WEB_SHELL_PAGE_PATHS = [
'/plugins',
'/channels',
'/channel-events',
'/live',
'/scheduled-tasks',
'/goals',
@@ -202,6 +202,8 @@ import type {
DaemonChannelPairingRequestsSnapshot,
DaemonChannelPairingRevocationRequest,
DaemonChannelPairingRevocationResult,
DaemonChannelEventsQuery,
DaemonChannelEventsSnapshot,
DaemonChannelsSnapshot,
DaemonChannelStartupRequest,
DaemonChannelTypeCatalog,
@@ -484,6 +486,21 @@ const MCP_RESTART_DEFAULT_TIMEOUT_MS =
const CLIENT_ID_HEADER = 'X-Qwen-Client-Id';
const urlEncode = encodeURIComponent;
function channelEventsQuerySuffix(
query: DaemonChannelEventsQuery | undefined,
): string {
const params = new URLSearchParams();
if (query?.limit !== undefined) params.set('limit', String(query.limit));
if (query?.types?.length) params.set('type', query.types.join(','));
// One parameter per name: a Channel name may contain a comma, so a joined
// list could not be split back into the names the caller asked for.
for (const channel of query?.channels ?? [])
params.append('channel', channel);
if (query?.since !== undefined) params.set('since', query.since);
const encoded = params.toString();
return encoded ? `?${encoded}` : '';
}
function transcriptPageSuffix(
opts: DaemonSessionTranscriptPageOptions,
): string {
@@ -6254,6 +6271,17 @@ export class DaemonClient {
);
}
workspaceChannelEvents(
query?: DaemonChannelEventsQuery,
opts?: DaemonChannelManagementOptions,
): Promise<DaemonChannelEventsSnapshot> {
return this.jsonRequest<DaemonChannelEventsSnapshot>(
`/workspace/channels/events${channelEventsQuerySuffix(query)}`,
'GET /workspace/channels/events',
{ clientId: opts?.clientId, timeoutMs: opts?.timeoutMs, mode: 'rest' },
);
}
upsertWorkspaceChannel(
name: string,
request: DaemonChannelUpsertRequest,
@@ -7862,6 +7890,18 @@ export class WorkspaceDaemonClient {
);
}
workspaceChannelEvents(
query?: DaemonChannelEventsQuery,
opts?: DaemonChannelManagementOptions,
): Promise<DaemonChannelEventsSnapshot> {
return this.channelRequest(
`/channels/events${channelEventsQuerySuffix(query)}`,
'GET /workspaces/:workspace/channels/events',
undefined,
opts,
);
}
upsertWorkspaceChannel(
name: string,
request: DaemonChannelUpsertRequest,
@@ -459,6 +459,10 @@ export type {
DaemonChannelSecretState,
DaemonChannelInstanceSnapshot,
DaemonChannelsSnapshot,
DaemonChannelEvent,
DaemonChannelEventsSnapshot,
DaemonChannelEventsQuery,
DaemonChannelEventType,
DaemonChannelSecretUpdate,
DaemonRevisionRequest,
DaemonChannelUpsertRequest,
@@ -5018,6 +5018,47 @@ export interface DaemonChannelsSnapshot {
instances: Record<string, DaemonChannelInstanceSnapshot>;
}
export type DaemonChannelEventType = 'inbound' | 'outbound' | 'task' | 'poll';
/**
* One entry of a Channel's event journal: what the Channel did, not what a
* message said. Records never carry message text.
*/
export interface DaemonChannelEvent {
at: string;
channel: string;
type: DaemonChannelEventType;
outcome: string;
reason?: string;
triggerKind?: string;
repository?: string;
thread?: string;
number?: number;
actor?: string;
sessionId?: string;
messageId?: string;
durationMs?: number;
error?: string;
detail?: Record<string, string | number | boolean>;
}
export interface DaemonChannelEventsSnapshot {
events: DaemonChannelEvent[];
/**
* True when older events are missing: the record limit was reached, a
* journal was left out by the route's read bounds, or a journal was taller
* than the tail window the route reads.
*/
truncated: boolean;
}
export interface DaemonChannelEventsQuery {
limit?: number;
types?: readonly DaemonChannelEventType[];
channels?: readonly string[];
since?: string;
}
export type DaemonChannelSecretUpdate =
| { operation: 'preserve' }
| { operation: 'replace'; value: string }
@@ -12406,6 +12406,34 @@ describe('DaemonClient', () => {
expect(JSON.parse(calls[0]!.body!)).toEqual({ groupId: 'group-1' });
});
it('requests the Channel event journal with one parameter per name', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, { events: [], truncated: false }),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client.workspaceChannelEvents({
limit: 5,
types: ['inbound', 'poll'],
channels: ['bot', 'a,b'],
since: '2026-11-02T00:00:00.000Z',
});
await client
.workspaceByCwd('/tmp/work space')
.workspaceChannelEvents({ channels: ['bot'] });
expect(calls.map(({ method, url }) => [method, url])).toEqual([
[
'GET',
'http://daemon/workspace/channels/events?limit=5&type=inbound%2Cpoll&channel=bot&channel=a%2Cb&since=2026-11-02T00%3A00%3A00.000Z',
],
[
'GET',
'http://daemon/workspaces/%2Ftmp%2Fwork%20space/channels/events?channel=bot',
],
]);
});
});
describe('sessionWorkflowTaskAction', () => {
+7
View File
@@ -2446,6 +2446,13 @@ vi.doMock('./components/channels/ChannelsManagerPage', async () => {
React.createElement('div', { 'data-testid': 'channels-manager-page' }),
};
});
vi.doMock('./components/channels/ChannelEventsPage', async () => {
const React = await import('react');
return {
ChannelEventsPage: () =>
React.createElement('div', { 'data-testid': 'channel-events-page' }),
};
});
vi.doMock('./components/SplitView', async () => {
const React = await import('react');
return {
+29 -1
View File
@@ -345,6 +345,7 @@ import { buildGoalControlRequest } from './utils/goalControlRequest';
import { ExtensionsManagerPage } from './components/extensions/ExtensionsManagerPage';
import { PluginManagerPage } from './components/plugins/PluginManagerPage';
import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage';
import { ChannelEventsPage } from './components/channels/ChannelEventsPage';
import { ShadowDomBoundary } from './components/ShadowDomBoundary';
import {
McpAppHostContext,
@@ -10163,6 +10164,7 @@ export function App({
| 'plugins'
| 'agents'
| 'channels'
| 'channel-events'
| 'live'
| 'workspaces'
| null
@@ -10320,6 +10322,7 @@ export function App({
| 'plugins'
| 'agents'
| 'channels'
| 'channel-events'
| 'live'
| 'workspaces',
) => {
@@ -10466,6 +10469,9 @@ export function App({
sidebarRailEnabled &&
sidebarOptions.showLive &&
sidebarOptions.primaryNav?.items?.includes('live') !== false;
const channelNavigationEnabled =
projectFeaturesAvailable &&
(sidebarOptions.primaryNav?.items?.includes('channelEvents') ?? true);
useEffect(() => {
setSidebarSection((current) =>
liveSidebarEnabled && connection.sessionContext?.kind === 'live'
@@ -10613,7 +10619,9 @@ export function App({
? 'scheduledTasks'
: page === 'pr-watches'
? 'prWatches'
: page;
: page === 'channel-events'
? 'channelEvents'
: page;
const allowed =
page === 'chat' ||
(page === 'settings'
@@ -10623,6 +10631,7 @@ export function App({
: projectFeaturesAvailable &&
(page !== 'live' || liveSidebarEnabled) &&
(page !== 'pr-watches' || prWatchesAvailable) &&
(page !== 'channel-events' || channelNavigationEnabled) &&
(hostSidebar?.primaryNav?.items?.some(
(item) => item === primaryItem,
) ??
@@ -10636,6 +10645,7 @@ export function App({
setActivePanel(
nextPage === 'plugins' ||
nextPage === 'channels' ||
nextPage === 'channel-events' ||
nextPage === 'live' ||
nextPage === 'settings'
? nextPage
@@ -10660,6 +10670,7 @@ export function App({
hostSidebar?.primaryNav?.items,
liveSidebarEnabled,
prWatchesAvailable,
channelNavigationEnabled,
workspaceCapabilitiesReady,
]);
useEffect(() => {
@@ -22720,6 +22731,14 @@ export function App({
});
}
}}
onOpenChannelEvents={
channelNavigationEnabled
? () => {
closeMobileDrawer();
openPanel('channel-events');
}
: undefined
}
onOpenManagedSessions={
managedAgentProvider
? () => {
@@ -23540,6 +23559,15 @@ export function App({
}
initialFocusRef={panelHeadingRef}
/>
) : activePanel === 'channel-events' ? (
<ChannelEventsPage
onClose={
channelNavigationEnabled && navigationRailVisible
? undefined
: returnToChat
}
initialFocusRef={panelHeadingRef}
/>
) : activePanel === 'live' ? (
<div className="-mx-5 -mt-4">
<div className={styles.fullPageHeader}>
@@ -0,0 +1,207 @@
.page {
display: flex;
min-height: 100%;
flex-direction: column;
margin: -16px -20px 0;
}
.pageHeader {
display: flex;
min-height: 46px;
flex: 0 0 auto;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-block-end: 1px solid var(--border);
}
.backButton {
width: 30px;
height: 30px;
flex: 0 0 30px;
color: var(--muted-foreground);
}
.title {
min-width: 0;
color: var(--foreground);
font-size: 14px;
font-weight: 600;
line-height: 1.35;
outline: none;
}
.pageBody {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
padding: 20px 24px 32px;
}
.subtitle {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.6;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.filters {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.channelSelect {
width: 200px;
min-width: 0;
min-height: 34px;
background: var(--background);
}
.typeFilters {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.refreshButton {
min-height: 34px;
margin-inline-start: auto;
}
.loadingState {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted-foreground);
font-size: 12px;
}
.truncated {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
.eventList {
display: flex;
min-width: 0;
flex-direction: column;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: 10px;
list-style: none;
overflow: hidden;
}
.eventRow {
display: grid;
min-width: 0;
grid-template-columns:
68px minmax(90px, max-content) max-content minmax(80px, max-content)
minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-block-end: 1px solid var(--border);
font-size: 12px;
}
.eventRow:last-child {
border-block-end: none;
}
.time {
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.channel {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type {
white-space: nowrap;
}
.outcome {
color: var(--foreground);
white-space: nowrap;
}
.outcome[data-outcome='failed'] {
color: var(--destructive);
}
.target {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
overflow: hidden;
color: var(--foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.actor {
color: var(--muted-foreground);
}
.marker {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.markerCode {
padding: 1px 4px;
border-radius: 4px;
background: var(--muted);
color: var(--muted-foreground);
font-family: var(--font-mono, monospace);
font-size: 11px;
}
.duration {
color: var(--muted-foreground);
}
.error {
grid-column: 1 / -1;
color: var(--destructive);
overflow-wrap: anywhere;
}
/* Six columns do not fit a phone-width panel: let the target and reason wrap
onto their own lines instead of clipping them. */
@media (max-width: 720px) {
.eventRow {
grid-template-columns: max-content max-content minmax(0, 1fr);
row-gap: 4px;
}
.target,
.marker {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,261 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// jsdom lacks PointerEvent and pointer capture; radix Select needs both to
// open from a pointerdown (mirrors the sidebar and overview panel tests).
if (!globalThis.PointerEvent) {
globalThis.PointerEvent = MouseEvent as typeof PointerEvent;
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
const { eventsState, eventsMock, useChannelsMock, workspaceState } = vi.hoisted(
() => ({
eventsState: {
current: {
data: undefined as
| { events: Array<Record<string, unknown>>; truncated: boolean }
| undefined,
loading: false,
error: undefined as Error | undefined,
reload: vi.fn(),
},
},
eventsMock: vi.fn(),
useChannelsMock: vi.fn(),
workspaceState: {
current: {
workspaceCwd: '/workspace/demo',
capabilities: {
features: ['channel_management'] as string[],
workspaces: undefined as
| Array<{
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
}>
| undefined,
},
},
},
}),
);
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useChannelEvents: (options: unknown) => {
eventsMock(options);
return eventsState.current;
},
useChannels: (options: unknown) => {
useChannelsMock(options);
return { channels: { bot: {}, 'ci-reporter': {} } };
},
useWorkspace: () => workspaceState.current,
}));
const { ChannelEventsPage } = await import('./ChannelEventsPage');
const { I18nProvider } = await import('../../i18n');
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
eventsState.current = {
data: undefined,
loading: false,
error: undefined,
reload: vi.fn(),
};
eventsMock.mockClear();
useChannelsMock.mockClear();
workspaceState.current.capabilities.features = ['channel_management'];
workspaceState.current.capabilities.workspaces = undefined;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
async function renderPage() {
await act(async () => {
root.render(
<I18nProvider language="en">
<ChannelEventsPage onClose={vi.fn()} />
</I18nProvider>,
);
});
}
async function click(label: string) {
const button = [...container.querySelectorAll('button')].find(
(candidate) => candidate.textContent === label,
);
if (!button) throw new Error(`No button labelled ${label}`);
await act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
// Radix Select opens on pointerdown and commits on click; a native click alone
// is not enough in jsdom (mirrors the overview panel's test helper).
function pointerClick(element: HTMLElement): void {
element.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
);
element.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
describe('ChannelEventsPage', () => {
it('renders a row per event with its target and reason', async () => {
eventsState.current.data = {
truncated: false,
events: [
{
at: '2026-11-02T02:00:00.000Z',
channel: 'bot',
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
repository: 'owner/repo',
number: 42,
actor: 'alice',
},
{
at: '2026-11-02T02:01:00.000Z',
channel: 'ci-reporter',
type: 'poll',
outcome: 'ok',
durationMs: 12,
detail: { notifications: 3 },
},
],
};
await renderPage();
const rows = container.querySelectorAll('li');
expect(rows).toHaveLength(2);
expect(rows[0]?.textContent).toContain('ci-reporter');
expect(rows[0]?.textContent).toContain('Poll');
expect(rows[0]?.textContent).toContain('OK');
expect(rows[0]?.textContent).toContain('3 notifications');
expect(rows[0]?.textContent).toContain('12 ms');
expect(rows[1]?.textContent).toContain('Skipped');
expect(rows[1]?.textContent).toContain('owner/repo#42');
expect(rows[1]?.textContent).toContain('@alice');
expect(rows[1]?.textContent).toContain('reason_filter');
});
it('shows how many further notifications a collapsed skip stands for', async () => {
eventsState.current.data = {
truncated: false,
events: [
{
at: '2026-11-02T02:00:00.000Z',
channel: 'bot',
type: 'inbound',
outcome: 'skipped',
reason: 'reason_filter',
detail: { filtered: 280 },
},
],
};
await renderPage();
const rows = container.querySelectorAll('li');
expect(rows).toHaveLength(1);
expect(rows[0]?.textContent).toContain('reason_filter');
expect(rows[0]?.textContent).toContain('280 more filtered this poll');
});
it('refetches with the selected event type', async () => {
await renderPage();
eventsMock.mockClear();
await click('Outbound');
expect(eventsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ types: ['outbound'] }),
);
});
it('shows the empty state before any event exists', async () => {
eventsState.current.data = { truncated: false, events: [] };
await renderPage();
expect(container.textContent).toContain('No events yet');
});
it('shows the error detail when the journal cannot be read', async () => {
eventsState.current.error = new Error('journal unavailable');
await renderPage();
expect(container.textContent).toContain('Could not load Channel events');
expect(container.textContent).toContain('journal unavailable');
});
it('explains that the workspace has no Channel management', async () => {
workspaceState.current.capabilities.features = [];
await renderPage();
expect(container.textContent).toContain(
'Channel events need Channel management in this workspace.',
);
expect(eventsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('reads the selected workspace, not only the primary one', async () => {
workspaceState.current.capabilities.workspaces = [
{ id: 'primary', cwd: '/work/alpha', primary: true, trusted: true },
{ id: 'secondary', cwd: '/work/beta', primary: false, trusted: true },
];
await renderPage();
expect(eventsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ workspaceCwd: '/work/alpha' }),
);
const trigger = container.querySelector<HTMLElement>(
'button[aria-label="Workspace"]',
);
expect(trigger).not.toBeNull();
await act(async () => {
pointerClick(trigger!);
});
const option = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).find((item) => item.textContent?.trim() === 'beta');
expect(option).toBeDefined();
await act(async () => {
pointerClick(option!);
});
expect(eventsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ workspaceCwd: '/work/beta' }),
);
expect(useChannelsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ workspaceCwd: '/work/beta' }),
);
});
});
@@ -0,0 +1,380 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useMemo, useState, type Ref } from 'react';
import {
ActivityIcon,
AlertCircleIcon,
ArrowLeftIcon,
RefreshCwIcon,
} from 'lucide-react';
import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon';
import {
useChannelEvents,
useChannels,
useWorkspace,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { extractErrorDetail } from '../../utils/errorDetail';
import { workspaceLabel } from '../../utils/workspace';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '../ui/empty';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Spinner } from '../ui/spinner';
import styles from './ChannelEventsPage.module.css';
interface ChannelEventsPageProps {
onClose?: () => void;
initialFocusRef?: Ref<HTMLHeadingElement>;
}
const EVENT_TYPES = ['inbound', 'outbound', 'task', 'poll'] as const;
type EventTypeFilter = (typeof EVENT_TYPES)[number] | 'all';
const ALL_CHANNELS = '__all__';
const KNOWN_OUTCOMES = new Set([
'accepted',
'skipped',
'posted',
'suppressed',
'posting',
'failed',
'ok',
'running',
'reply_pending',
'cancelled',
]);
export function ChannelEventsPage({
onClose,
initialFocusRef,
}: ChannelEventsPageProps) {
const { t, language } = useI18n();
const workspace = useWorkspace();
const supportsChannelEvents =
workspace.capabilities?.features.includes('channel_management') === true;
// The provider's cwd is the daemon-wide primary workspace, so a Channel
// started in a registered secondary workspace needs its own selection here
// — the same one the management page offers.
const registeredWorkspaces = useMemo<DaemonWorkspaceCapability[]>(() => {
const listed = (workspace.capabilities?.workspaces ?? []).filter(
(entry) => entry.kind !== 'live',
);
if (listed.length > 0) return listed;
if (!workspace.workspaceCwd) return [];
return [
{
id: 'primary',
cwd: workspace.workspaceCwd,
primary: true,
trusted: true,
},
];
}, [workspace.capabilities?.workspaces, workspace.workspaceCwd]);
const defaultWorkspace =
registeredWorkspaces.find((entry) => entry.primary) ??
registeredWorkspaces.find((entry) => entry.trusted) ??
registeredWorkspaces[0];
const [selectedWorkspaceCwd, setSelectedWorkspaceCwd] = useState<
string | undefined
>();
const activeWorkspace =
registeredWorkspaces.find((entry) => entry.cwd === selectedWorkspaceCwd) ??
defaultWorkspace;
const workspaceCwd = activeWorkspace?.cwd;
const [channel, setChannel] = useState<string>(ALL_CHANNELS);
const [type, setType] = useState<EventTypeFilter>('all');
useEffect(() => {
setChannel(ALL_CHANNELS);
}, [workspaceCwd]);
const { data, loading, error, reload } = useChannelEvents({
enabled: supportsChannelEvents && Boolean(workspaceCwd),
workspaceCwd,
...(channel === ALL_CHANNELS ? {} : { channel }),
...(type === 'all' ? {} : { types: [type] }),
limit: 200,
});
const { channels } = useChannels({
autoLoad: supportsChannelEvents,
enabled: supportsChannelEvents,
workspaceCwd,
});
const events = useMemo(() => data?.events ?? [], [data?.events]);
const channelOptions = useMemo(() => {
const names = new Set(Object.keys(channels));
for (const event of events) names.add(event.channel);
return [...names].sort((left, right) => left.localeCompare(right));
}, [channels, events]);
const timeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(language, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
[language],
);
const outcomeLabel = (outcome: string) =>
KNOWN_OUTCOMES.has(outcome)
? t(`channelEvents.outcome.${outcome}`)
: outcome;
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
{onClose && (
<Button
variant="ghost"
size="icon"
className={styles.backButton}
onClick={onClose}
aria-label={t('common.back')}
>
<ArrowLeftIcon />
</Button>
)}
<h1 ref={initialFocusRef} tabIndex={-1} className={styles.title}>
{t('channelEvents.title')}
</h1>
</header>
<div className={styles.pageBody}>
<p className={styles.subtitle}>{t('channelEvents.subtitle')}</p>
{!supportsChannelEvents ? (
<Alert>
<AlertCircleIcon />
<AlertTitle>{t('channelEvents.errorTitle')}</AlertTitle>
<AlertDescription>
{t('channelEvents.unavailable')}
</AlertDescription>
</Alert>
) : (
<>
<div className={styles.toolbar}>
<div className={styles.filters}>
{registeredWorkspaces.length > 0 && (
<Select
value={workspaceCwd ?? ''}
onValueChange={setSelectedWorkspaceCwd}
>
<SelectTrigger
className={styles.channelSelect}
aria-label={t('channels.workspace.label')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{registeredWorkspaces.map((entry) => (
<SelectItem
key={entry.id}
value={entry.cwd}
disabled={!entry.trusted}
>
{workspaceLabel(entry)}
{entry.primary
? ` · ${t('channels.workspace.primary')}`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={channel} onValueChange={setChannel}>
<SelectTrigger
className={styles.channelSelect}
aria-label={t('channelEvents.filterChannel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CHANNELS}>
{t('channelEvents.allChannels')}
</SelectItem>
{channelOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div
className={styles.typeFilters}
role="group"
aria-label={t('channelEvents.filterType')}
>
{(['all', ...EVENT_TYPES] as EventTypeFilter[]).map(
(value) => (
<Button
key={value}
type="button"
size="sm"
variant={type === value ? 'secondary' : 'outline'}
aria-pressed={type === value}
onClick={() => setType(value)}
>
{value === 'all'
? t('channelEvents.typeAll')
: t(`channelEvents.type.${value}`)}
</Button>
),
)}
</div>
</div>
<Button
variant="outline"
className={styles.refreshButton}
disabled={loading}
onClick={() => void reload()}
>
{loading ? <Spinner /> : <RefreshCwIcon />}
{t('channelEvents.refresh')}
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{t('channelEvents.errorTitle')}</AlertTitle>
<AlertDescription>{extractErrorDetail(error)}</AlertDescription>
</Alert>
) : null}
{loading && events.length === 0 ? (
<div
className={styles.loadingState}
role="status"
aria-label={t('channelEvents.loading')}
>
<Spinner />
{t('channelEvents.loading')}
</div>
) : null}
{!loading && !error && events.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ActivityIcon />
</EmptyMedia>
<EmptyTitle>{t('channelEvents.emptyTitle')}</EmptyTitle>
<EmptyDescription>
{t('channelEvents.emptyDescription')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{data?.truncated ? (
<p className={styles.truncated}>{t('channelEvents.truncated')}</p>
) : null}
{events.length > 0 ? (
<ul
className={styles.eventList}
data-testid="channel-events-list"
>
{events
.slice()
.reverse()
.map((event, index) => {
const at = new Date(event.at);
const target = event.repository
? `${event.repository}${
event.number !== undefined ? `#${event.number}` : ''
}`
: event.thread;
return (
<li
key={`${event.at}-${event.channel}-${index}`}
className={styles.eventRow}
>
<span className={styles.time}>
{Number.isNaN(at.getTime())
? event.at
: timeFormatter.format(at)}
</span>
<Badge variant="outline" className={styles.channel}>
{event.channel}
</Badge>
<Badge variant="secondary" className={styles.type}>
{t(`channelEvents.type.${event.type}`)}
</Badge>
<span
className={styles.outcome}
data-outcome={event.outcome}
>
{outcomeLabel(event.outcome)}
</span>
<span className={styles.target}>
{target ?? ''}
{event.actor ? (
<span className={styles.actor}>@{event.actor}</span>
) : null}
</span>
<span className={styles.marker}>
{event.reason || event.triggerKind ? (
<code className={styles.markerCode}>
{event.reason ?? event.triggerKind}
</code>
) : null}
{event.type === 'poll' &&
typeof event.detail?.['notifications'] ===
'number' ? (
<span className={styles.duration}>
{t('channelEvents.notifications', {
count: event.detail['notifications'] as number,
})}
</span>
) : null}
{typeof event.detail?.['filtered'] === 'number' ? (
<span className={styles.duration}>
{t('channelEvents.moreFiltered', {
count: event.detail['filtered'] as number,
})}
</span>
) : null}
{event.durationMs !== undefined ? (
<span className={styles.duration}>
{t('channelEvents.duration', {
ms: event.durationMs,
})}
</span>
) : null}
</span>
{event.error ? (
<span className={styles.error}>{event.error}</span>
) : null}
</li>
);
})}
</ul>
) : null}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
type ChannelEventsMessage =
| string
| ((vars?: Record<string, string | number>) => string);
export const CHANNEL_EVENTS_MESSAGES_EN: Record<string, ChannelEventsMessage> =
{
'channelEvents.title': 'Channel events',
'channelEvents.subtitle':
'What each Channel did: what it accepted or skipped, what it published, how its tasks moved, and how its polls went.',
'channelEvents.refresh': 'Refresh',
'channelEvents.filterChannel': 'Channel',
'channelEvents.allChannels': 'All channels',
'channelEvents.filterType': 'Type',
'channelEvents.typeAll': 'All',
'channelEvents.type.inbound': 'Inbound',
'channelEvents.type.outbound': 'Outbound',
'channelEvents.type.task': 'Task',
'channelEvents.type.poll': 'Poll',
'channelEvents.outcome.accepted': 'Accepted',
'channelEvents.outcome.skipped': 'Skipped',
'channelEvents.outcome.posted': 'Posted',
'channelEvents.outcome.suppressed': 'Suppressed',
'channelEvents.outcome.posting': 'Posting',
'channelEvents.outcome.failed': 'Failed',
'channelEvents.outcome.ok': 'OK',
'channelEvents.outcome.running': 'Running',
'channelEvents.outcome.reply_pending': 'Reply pending',
'channelEvents.outcome.cancelled': 'Cancelled',
'channelEvents.emptyTitle': 'No events yet',
'channelEvents.emptyDescription':
'Events appear as soon as a Channel polls, receives a message, or publishes a reply.',
'channelEvents.errorTitle': 'Could not load Channel events',
'channelEvents.unavailable':
'Channel events need Channel management in this workspace.',
'channelEvents.truncated': 'Older events are not shown.',
'channelEvents.loading': 'Loading events…',
'channelEvents.duration': (v) => `${v?.ms ?? 0} ms`,
'channelEvents.notifications': (v) =>
`${v?.count ?? 0} notification${v?.count === 1 ? '' : 's'}`,
'channelEvents.moreFiltered': (v) =>
`${v?.count ?? 0} more filtered this poll`,
};
export const CHANNEL_EVENTS_MESSAGES_ZH: Record<string, ChannelEventsMessage> =
{
'channelEvents.title': '频道事件',
'channelEvents.subtitle':
'每个频道做了什么:接收或跳过了什么、发表了什么、入站任务如何流转、轮询是否正常。',
'channelEvents.refresh': '刷新',
'channelEvents.filterChannel': '频道',
'channelEvents.allChannels': '全部频道',
'channelEvents.filterType': '类型',
'channelEvents.typeAll': '全部',
'channelEvents.type.inbound': '入站',
'channelEvents.type.outbound': '出站',
'channelEvents.type.task': '任务',
'channelEvents.type.poll': '轮询',
'channelEvents.outcome.accepted': '已接收',
'channelEvents.outcome.skipped': '已跳过',
'channelEvents.outcome.posted': '已发表',
'channelEvents.outcome.suppressed': '已抑制',
'channelEvents.outcome.posting': '发表中',
'channelEvents.outcome.failed': '失败',
'channelEvents.outcome.ok': '正常',
'channelEvents.outcome.running': '执行中',
'channelEvents.outcome.reply_pending': '等待发表',
'channelEvents.outcome.cancelled': '已取消',
'channelEvents.emptyTitle': '还没有事件',
'channelEvents.emptyDescription':
'频道轮询、收到消息或发表回复后,事件会出现在这里。',
'channelEvents.errorTitle': '无法加载频道事件',
'channelEvents.unavailable': '当前工作区未启用频道管理,无法查看频道事件。',
'channelEvents.truncated': '更早的事件未显示。',
'channelEvents.loading': '正在加载事件…',
'channelEvents.duration': (v) => `${v?.ms ?? 0} 毫秒`,
'channelEvents.notifications': (v) => `${v?.count ?? 0} 条通知`,
'channelEvents.moreFiltered': (v) => `本轮另有 ${v?.count ?? 0} 条被过滤`,
};
@@ -298,6 +298,7 @@ export type WebShellSidebarPrimaryNavItem =
| 'agents'
| 'plugins'
| 'channels'
| 'channelEvents'
| 'live'
| 'scheduledTasks'
| 'prWatches'
@@ -347,6 +348,7 @@ export const DEFAULT_PRIMARY_NAV_ITEMS: readonly WebShellSidebarPrimaryNavItem[]
'agents',
'plugins',
'channels',
'channelEvents',
'live',
'scheduledTasks',
'prWatches',
@@ -466,6 +468,9 @@ interface WebShellSidebarProps {
onOpenAgents?: (view?: 'agents' | 'squads' | 'runtime') => void;
onOpenPlugins: () => void;
onOpenChannels: () => void;
/** Opens the cross-Channel event timeline; absent when the host does not
* offer it. */
onOpenChannelEvents?: () => void;
onOpenLive?: () => void;
onLiveVoiceSlotChange?: (element: HTMLDivElement | null) => void;
onOpenManagedSessions?: () => void;
@@ -1123,6 +1128,7 @@ export function WebShellSidebar({
onOpenAgents,
onOpenPlugins,
onOpenChannels,
onOpenChannelEvents,
onOpenLive,
onLiveVoiceSlotChange,
onOpenManagedSessions,
@@ -1281,6 +1287,8 @@ export function WebShellSidebar({
(primaryNavItems.has('plugins') ||
(primaryNavItems.has('agents') && Boolean(onOpenAgents)) ||
primaryNavItems.has('channels') ||
(primaryNavItems.has('channelEvents') &&
Boolean(onOpenChannelEvents)) ||
(showLive && primaryNavItems.has('live') && Boolean(onOpenLive)) ||
primaryNavItems.has('scheduledTasks') ||
(primaryNavItems.has('prWatches') && Boolean(onOpenPrWatches)) ||
@@ -1316,6 +1324,8 @@ export function WebShellSidebar({
primaryNavItems.has('channels');
const channelView =
channelNavigationEnabled && activePage === 'channels' && !homePreviewOpen;
const channelEventsNavigationEnabled =
projectFeaturesEnabled && primaryNavItems.has('channelEvents');
const liveNavigationEnabled =
railLayout &&
projectFeaturesEnabled &&
@@ -6374,6 +6384,27 @@ export function WebShellSidebar({
{!collapsed && !railLayout && <span>{t('sidebar.channels')}</span>}
</button>
)}
{channelEventsNavigationEnabled && onOpenChannelEvents && (
<button
className={styles.pluginButton}
type="button"
title={railLayout ? undefined : t('sidebar.channelEvents')}
aria-label={t('sidebar.channelEvents')}
aria-current={
railLayout && activePage === 'channel-events' ? 'page' : undefined
}
onClick={() =>
openNavigation(onOpenChannelEvents, channelEventsNavigationEnabled)
}
>
<span className={styles.navIcon}>
<ActivityIcon size={16} strokeWidth={1.2} />
</span>
{!collapsed && !railLayout && (
<span>{t('sidebar.channelEvents')}</span>
)}
</button>
)}
{liveNavigationEnabled && onOpenLive && (
<button
className={styles.pluginButton}
@@ -85,6 +85,9 @@ export { useDaemonAuth as useAuth } from './daemon/index.js';
/** Channel catalog, configuration, lifecycle, and pairing management. */
export { useDaemonChannels as useChannels } from './daemon/index.js';
/** A workspace's Channel event journal, polled while enabled. */
export { useDaemonChannelEvents as useChannelEvents } from './daemon/index.js';
/** Language diagnostics (errors, warnings) from the workspace. */
export { useDaemonDiagnostics as useDiagnostics } from './daemon/index.js';
@@ -95,6 +95,7 @@ export {
useDaemonAgents,
useDaemonAuth,
useDaemonChannels,
useDaemonChannelEvents,
useDaemonDiagnostics,
useDaemonFiles,
useDaemonGlob,
@@ -127,6 +128,8 @@ export type {
DaemonResourceOptions,
DaemonChannelPairingActions,
DaemonChannelsResource,
DaemonChannelEventEntry,
DaemonChannelEventList,
DaemonStatusReportOptions,
DaemonUsageDashboardOptions,
DaemonWorkspaceActions,
@@ -206,6 +206,18 @@ export function createDaemonWorkspaceActions({
return { catalog, snapshot };
},
async listChannelEvents(query) {
const workspace = requireWorkspaceClient(
getClient,
getWorkspaceCwd,
'Load channel events failed',
);
return withActionTimeout(
workspace.workspaceChannelEvents(query),
'Load channel events timed out',
);
},
async upsertChannel(name, request) {
const workspace = requireWorkspaceClient(
getClient,
@@ -7,6 +7,10 @@
export { useDaemonAgents } from './useDaemonAgents.js';
export { useDaemonAuth } from './useDaemonAuth.js';
export { useDaemonChannels } from './useDaemonChannels.js';
export {
CHANNEL_EVENTS_RELOAD_MS,
useDaemonChannelEvents,
} from './useDaemonChannelEvents.js';
export { useDaemonDiagnostics } from './useDaemonDiagnostics.js';
export { useDaemonFiles } from './useDaemonFiles.js';
export { useDaemonGlob } from './useDaemonGlob.js';
@@ -0,0 +1,257 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DaemonChannelEventList } from '../types.js';
const { actions, client, context, qualifiedWorkspace } = vi.hoisted(() => {
const qualifiedWorkspace = { workspaceChannelEvents: vi.fn() };
const client = { workspaceByCwd: vi.fn(() => qualifiedWorkspace) };
return {
actions: { listChannelEvents: vi.fn() },
client,
context: {
current: { workspaceCwd: '/workspace-a' as string | undefined },
},
qualifiedWorkspace,
};
});
vi.mock('../DaemonWorkspaceProvider.js', () => ({
useDaemonWorkspace: () => ({
...context.current,
client,
actions,
}),
}));
const { useDaemonChannelEvents } = await import('./useDaemonChannelEvents.js');
function journal(channel: string): DaemonChannelEventList {
return {
events: [
{
at: '2026-11-02T00:00:00.000Z',
channel,
type: 'poll',
outcome: 'ok',
},
],
truncated: false,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('useDaemonChannelEvents', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
context.current = { workspaceCwd: '/workspace-a' };
actions.listChannelEvents.mockReset();
qualifiedWorkspace.workspaceChannelEvents.mockReset();
client.workspaceByCwd.mockClear();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('hides the previous workspace events while the next request is pending', async () => {
actions.listChannelEvents.mockResolvedValue(journal('alpha-bot'));
const pending = deferred<DaemonChannelEventList>();
qualifiedWorkspace.workspaceChannelEvents.mockReturnValue(pending.promise);
let result: ReturnType<typeof useDaemonChannelEvents> | undefined;
function TestComponent({ cwd }: { cwd: string }) {
result = useDaemonChannelEvents({ workspaceCwd: cwd });
return null;
}
await act(async () =>
root.render((<TestComponent cwd="/workspace-a" />) as ReactNode),
);
expect(result?.data?.events.map((event) => event.channel)).toEqual([
'alpha-bot',
]);
await act(async () =>
root.render((<TestComponent cwd="/workspace-b" />) as ReactNode),
);
expect(client.workspaceByCwd).toHaveBeenCalledWith('/workspace-b');
expect(result?.data).toBeUndefined();
expect(result?.loading).toBe(true);
await act(async () => {
pending.resolve(journal('beta-bot'));
});
expect(result?.data?.events.map((event) => event.channel)).toEqual([
'beta-bot',
]);
});
it('hides the previous workspace events when the next request fails', async () => {
actions.listChannelEvents.mockResolvedValue(journal('alpha-bot'));
const failing = deferred<DaemonChannelEventList>();
qualifiedWorkspace.workspaceChannelEvents.mockReturnValue(failing.promise);
let result: ReturnType<typeof useDaemonChannelEvents> | undefined;
function TestComponent({ cwd }: { cwd: string }) {
result = useDaemonChannelEvents({ workspaceCwd: cwd });
return null;
}
await act(async () =>
root.render((<TestComponent cwd="/workspace-a" />) as ReactNode),
);
expect(result?.data?.events).toHaveLength(1);
await act(async () =>
root.render((<TestComponent cwd="/workspace-b" />) as ReactNode),
);
await act(async () => {
failing.reject(new Error('workspace-b unavailable'));
});
expect(result?.data).toBeUndefined();
expect(result?.error?.message).toBe('workspace-b unavailable');
});
it('does not poll while the tab is hidden and resumes when it is visible', async () => {
const setVisibility = (value: DocumentVisibilityState) => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => value,
});
};
actions.listChannelEvents.mockResolvedValue(journal('alpha-bot'));
function TestComponent() {
useDaemonChannelEvents({ workspaceCwd: '/workspace-a' });
return null;
}
vi.useFakeTimers();
setVisibility('hidden');
try {
await act(async () => root.render((<TestComponent />) as ReactNode));
await vi.advanceTimersByTimeAsync(20_000);
expect(actions.listChannelEvents).not.toHaveBeenCalled();
setVisibility('visible');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
await vi.advanceTimersByTimeAsync(0);
expect(actions.listChannelEvents).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5_000);
expect(actions.listChannelEvents).toHaveBeenCalledTimes(2);
setVisibility('hidden');
await vi.advanceTimersByTimeAsync(20_000);
expect(actions.listChannelEvents).toHaveBeenCalledTimes(2);
} finally {
setVisibility('visible');
vi.useRealTimers();
}
});
it('keeps one polling loop when visibility is restored mid-request', async () => {
const setVisibility = (value: DocumentVisibilityState) => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => value,
});
};
// Every request stays pending until the test releases it, so a visibility
// restore lands in the middle of an in-flight cycle.
const pending: Array<{ resolve: (value: DaemonChannelEventList) => void }> =
[];
actions.listChannelEvents.mockImplementation(() => {
const d = deferred<DaemonChannelEventList>();
pending.push(d);
return d.promise;
});
function TestComponent() {
useDaemonChannelEvents({ workspaceCwd: '/workspace-a' });
return null;
}
const release = async () => {
const d = pending.shift();
expect(d).toBeDefined();
await act(async () => {
d!.resolve(journal('alpha-bot'));
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
};
vi.useFakeTimers();
setVisibility('visible');
try {
await act(async () => root.render((<TestComponent />) as ReactNode));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(actions.listChannelEvents).toHaveBeenCalledTimes(1);
// Three overlapping hide/show cycles while that request is pending. Each
// one used to start another run, and every run schedules its own timer.
for (let cycle = 0; cycle < 3; cycle += 1) {
setVisibility('hidden');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
setVisibility('visible');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
expect(actions.listChannelEvents).toHaveBeenCalledTimes(1);
await release();
// One request per interval, not one per overlapping cycle.
for (let interval = 0; interval < 3; interval += 1) {
const before = actions.listChannelEvents.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(actions.listChannelEvents.mock.calls.length - before).toBe(1);
await release();
}
expect(pending).toHaveLength(0);
} finally {
setVisibility('visible');
vi.useRealTimers();
}
});
});
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useMemo } from 'react';
import type { DaemonChannelEventsQuery } from '@qwen-code/sdk/daemon';
import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js';
import type {
DaemonChannelEventEntry,
DaemonChannelEventList,
DaemonResourceOptions,
} from '../types.js';
import { useDaemonResource } from './useDaemonResource.js';
/** Channels react within seconds; a diagnostic view can lag a little. */
export const CHANNEL_EVENTS_RELOAD_MS = 5_000;
interface DaemonChannelEventsOptions extends DaemonResourceOptions {
workspaceCwd?: string;
/** Restrict the timeline to one Channel instance. */
channel?: string;
types?: readonly DaemonChannelEventEntry['type'][];
limit?: number;
}
/** A loaded journal together with the query that produced it. */
interface ChannelEventsSnapshot {
queryKey: string;
list: DaemonChannelEventList;
}
/**
* A workspace's Channel event journal, polled while `enabled`. The page can
* also call `reload` for an explicit refresh.
*/
export function useDaemonChannelEvents(
options: DaemonChannelEventsOptions = {},
) {
const {
actions,
client,
workspaceCwd: providerWorkspaceCwd,
} = useDaemonWorkspace();
const { workspaceCwd: requestedWorkspaceCwd, ...resourceOptions } = options;
const workspaceCwd = requestedWorkspaceCwd ?? providerWorkspaceCwd;
const usesProviderWorkspace = workspaceCwd === providerWorkspaceCwd;
const workspaceClient = useMemo(
() =>
workspaceCwd && !usesProviderWorkspace
? client.workspaceByCwd(workspaceCwd)
: undefined,
[client, usesProviderWorkspace, workspaceCwd],
);
const enabled =
resourceOptions.enabled !== false && workspaceCwd !== undefined;
const { channel, limit } = resourceOptions;
// An array literal from props changes identity on every render, which would
// restart the poll on each one; the joined key keeps it stable.
const typesKey = (resourceOptions.types ?? []).join(',');
const types = useMemo(
() =>
typesKey
? (typesKey.split(',') as DaemonChannelEventEntry['type'][])
: undefined,
[typesKey],
);
// The resource keeps its previous data while a new query is in flight or
// after it fails, so every snapshot is tagged with the query that produced
// it and mismatched data is masked — otherwise the last workspace's rows
// (and their Channel names) would show under the newly selected one.
// NUL cannot appear in any part: the management route rejects control
// characters in an instance name, journal names are sanitized, and a
// filesystem path cannot contain it.
const queryKey = [
workspaceCwd ?? '',
channel ?? '',
typesKey,
limit ?? '',
].join('\u0000');
const load = useCallback(async (): Promise<ChannelEventsSnapshot> => {
if (!workspaceCwd) {
throw new Error('Channel events require a workspace.');
}
const query: DaemonChannelEventsQuery = {
...(limit !== undefined ? { limit } : {}),
...(channel ? { channels: [channel] } : {}),
...(types ? { types } : {}),
};
const list = workspaceClient
? await workspaceClient.workspaceChannelEvents(query)
: await actions.listChannelEvents(query);
return { queryKey, list };
}, [actions, channel, limit, queryKey, types, workspaceClient, workspaceCwd]);
const resource = useDaemonResource(load, {
...resourceOptions,
autoLoad: false,
enabled,
});
const { reload } = resource;
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let inFlight = false;
let timer = 0;
const schedule = () => {
// A forgotten background tab must not keep reading journals on a daemon
// that is also serving sessions. Check again when the timer fires: the
// tab can go hidden after the timer was armed.
if (document.visibilityState === 'hidden') return;
timer = window.setTimeout(() => {
if (document.visibilityState === 'hidden') return;
void run();
}, CHANNEL_EVENTS_RELOAD_MS);
};
const run = async () => {
// One cycle at a time. Restoring visibility while a request is pending
// would otherwise start a second run: both settle, both call schedule(),
// and only the last timer handle is kept — the other polls forever, so
// ordinary tab switches on a slow response multiply journal reads.
if (inFlight) return;
inFlight = true;
try {
await reload();
} finally {
inFlight = false;
}
if (cancelled) return;
schedule();
};
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') return;
window.clearTimeout(timer);
void run();
};
document.addEventListener('visibilitychange', onVisibilityChange);
if (document.visibilityState !== 'hidden') void run();
return () => {
cancelled = true;
window.clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [enabled, reload]);
const data =
resource.data?.queryKey === queryKey ? resource.data.list : undefined;
return { ...resource, data };
}
@@ -28,6 +28,8 @@ export type {
DaemonResourceOptions,
DaemonChannelPairingActions,
DaemonChannelsResource,
DaemonChannelEventEntry,
DaemonChannelEventList,
DaemonWorkspaceActions,
DaemonWorkspaceContextValue,
DaemonWorkspaceProviderProps,
@@ -39,6 +41,8 @@ export {
useDaemonAgents,
useDaemonAuth,
useDaemonChannels,
useDaemonChannelEvents,
CHANNEL_EVENTS_RELOAD_MS,
useDaemonDiagnostics,
useDaemonFiles,
useDaemonGlob,
@@ -13,6 +13,9 @@ import type {
DaemonAuthProviderInstallResult,
DaemonAuthStatusSnapshot,
DaemonCapabilities,
DaemonChannelEvent,
DaemonChannelEventsQuery,
DaemonChannelEventsSnapshot,
DaemonChannelMutationResult,
DaemonChannelPairingApprovalResult,
DaemonChannelPairingApprovalsSnapshot,
@@ -483,6 +486,11 @@ export interface DaemonAutofixPrWatchList {
unreadable?: Array<{ pr: number; error: string }>;
}
/** One entry of a Channel's event journal (`GET .../channels/events`). */
export type DaemonChannelEventEntry = DaemonChannelEvent;
export type DaemonChannelEventList = DaemonChannelEventsSnapshot;
export interface DaemonWorkspacePathSuggestion {
name: string;
path: string;
@@ -727,6 +735,12 @@ export interface DaemonWorkspaceActions {
* `listScheduledTasks`. Only to a daemon advertising `autofix_pr_watches`. */
listAutofixPrWatches(workspaceId?: string): Promise<DaemonAutofixPrWatchList>;
/** The provider workspace's Channel event journal, newest last. A scoped
* workspace reads its own through its client. */
listChannelEvents(
query?: DaemonChannelEventsQuery,
): Promise<DaemonChannelEventList>;
// Goals (session-scoped runtimes, listed workspace-wide)
listGoals(): Promise<DaemonGoalList>;
controlGoal(
+8
View File
@@ -27,6 +27,10 @@ import {
TOKEN_USAGE_MESSAGES_EN,
TOKEN_USAGE_MESSAGES_ZH,
} from './components/artifacts/token-usage-messages.js';
import {
CHANNEL_EVENTS_MESSAGES_EN,
CHANNEL_EVENTS_MESSAGES_ZH,
} from './components/channels/channel-events-messages.js';
import {
createContext,
useContext,
@@ -2144,6 +2148,7 @@ const EN: Messages = {
'sidebar.moveWorkspaceDown': 'Move down',
'sidebar.plugins': 'Plugins',
'sidebar.channels': 'Channels',
'sidebar.channelEvents': 'Channel events',
'sidebar.sessionSource': 'Session source',
'sidebar.archiveSessionSource': 'Archived session source',
'sidebar.sessionSource.tasks': 'Tasks',
@@ -3661,6 +3666,7 @@ const EN: Messages = {
'contextUsage.unknown':
'Context usage has not been observed for this session yet.',
...TOKEN_USAGE_MESSAGES_EN,
...CHANNEL_EVENTS_MESSAGES_EN,
'trajectory.title': 'Trajectory',
'trajectory.description': 'View time and tokens',
'trajectory.empty': 'No records in this session yet.',
@@ -6708,6 +6714,7 @@ const ZH: Messages = {
'sidebar.moveWorkspaceDown': '下移',
'sidebar.plugins': '插件',
'sidebar.channels': '频道',
'sidebar.channelEvents': '频道事件',
'sidebar.sessionSource': '会话来源',
'sidebar.archiveSessionSource': '归档会话来源',
'sidebar.sessionSource.tasks': '任务',
@@ -8095,6 +8102,7 @@ const ZH: Messages = {
'contextUsage.unavailable': '当前会话无法读取上下文使用情况。',
'contextUsage.unknown': '当前会话尚无已观测的上下文占用数据。',
...TOKEN_USAGE_MESSAGES_ZH,
...CHANNEL_EVENTS_MESSAGES_ZH,
'trajectory.title': '轨迹',
'trajectory.description': '查看时间和 token',
'trajectory.empty': '这个会话还没有记录。',
@@ -7,6 +7,7 @@
export const WEB_SHELL_PAGES = [
'plugins',
'channels',
'channel-events',
'live',
'scheduled-tasks',
'pr-watches',