diff --git a/README.md b/README.md index 2363eb1da..2cd716e11 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ AgentHub 让你像在 IM 群聊里协作一样,把真人好友、Builder、Rev - **IM 形态协作** — 单聊、群聊、@Agent,在同一条任务流里完成 - **多 Runtime 调度** — Claude Code、Codex、OpenCode 通过统一 Adapter 接入 - **Diff / Preview / Approval** — 代码变更内联展示,审批流可控 -- **三端原生** — Tauri Desktop + Web + Expo React Native Mobile(Desktop/Web 主线,Mobile 装配中) +- **三端原生** — Tauri Desktop + Web + Expo React Native Mobile(Desktop/Web 主线;Mobile 是装配中的 fixture/边界验证 lane,**非 release candidate** —— 发布链路的 `build-mobile` 受 `RELEASE_MOBILE_ENABLED` 门控、默认关闭) - **Hub-Edge 分布式** — 本地执行不依赖 Hub;Hub 提供多端同步、远程查看和审计 ## 技术栈 diff --git a/README_EN.md b/README_EN.md index b77e62651..9541ad5a2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -28,7 +28,7 @@ AgentHub lets you collaborate with human teammates and AI agents in the same gro - **IM-native collaboration** — DMs, group chats, @agent mentions — all in one task stream - **Multi-runtime dispatch** — Claude Code, Codex, OpenCode through a unified adapter interface - **Diff / Preview / Approval** — inline code changes, review workflows -- **Three native clients** — Tauri Desktop + Web + Expo React Native Mobile (Desktop/Web are the mainline; Mobile in assembly) +- **Three native clients** — Tauri Desktop + Web + Expo React Native Mobile (Desktop/Web are the mainline; Mobile is an in-assembly fixture/boundary-validation lane, **not a release candidate** — the release pipeline's `build-mobile` job is gated behind `RELEASE_MOBILE_ENABLED` and skipped by default) - **Hub-Edge distributed** — local execution does not require the Hub; the Hub adds multi-device sync, remote viewing, and audit ## Tech Stack diff --git a/api/README.md b/api/README.md index 2db45ff47..1653c41d0 100644 --- a/api/README.md +++ b/api/README.md @@ -43,10 +43,10 @@ api/ | 模块 | 负责内容 | 主要归属 | |---|---|---| | IM / Project | Project、Conversation、Thread、Message、Item、Memory | Edge / Hub | -| Execution / Runtime | AgentRun、Approval、Artifact、Preview、Workspace、Agent Runtime adapter | Edge | +| Execution / Runtime | AgentRun、Approval、Artifact、Preview、Agent Runtime adapter | Edge | | Profile / Configuration | Agent Profile、模型映射、Skill、MCP、cc-switch provider binding、审批策略 | Hub / Edge | | Target / Relay | Local Edge、Remote Edge、Cloud Edge、Hub Relay command、设备状态 | Hub / Edge | -| Hub / Sync | Auth、User、Contact、Group、Device、Sync、Cloud | Hub | +| Hub / Sync | Auth、User、Contact、Group、Device、Sync、Cloud、Workspace(元数据与列举,以 `/web/projects*` 暴露;`owner: Runner` 已退役,见 `conventions.md` §OpenAPI Metadata) | Hub | 本地执行链路 `Desktop -> Local Edge -> Agent Runtime adapter -> Agent CLI` 不依赖 Hub。云端 IM、多端同步、远程查看/审批和 Hub relay 才需要 Hub session。 @@ -68,6 +68,10 @@ api/ | P3 | Hub relay、Cloud Edge、远程执行 | | P4 | 完整联系人、群聊、团队空间、Skill/MCP/Profile 生态 | +**适用范围**:`x-agenthub-phase` 只标注 `/v1/**` 设计面。reality-face(`/client`、`/web`、`/edge`)由 `x-agenthub-status` 治理、**不带 phase**,`/health`、`/api`、`/cloud` 属 ops/非设计面同样不带 ⇒ 「284 个 operation 里 166 个没有 phase」不是覆盖率缺陷,而是这条适用范围(实测缺失分布:`/web 91`、`/client 64`、`/edge 5`、`/v1 3`、`/health`·`/api`·`/cloud` 各 1)。ADR-030 / #2258。 + +**已知例外 3 个(待产品定值,本轮不代拍)**:`GET /v1/metrics`、`GET /v1/agent-instances`、`POST /v1/permissions/decide` 属 `/v1/**` 却没有 phase(三者都是 `status: implemented`、原 `owner: Edge`,且都已带其他元数据)。`docs/architecture/` 全文没有给它们定过 P0~P4,同族兄弟端点的 phase 也是 P0/P1/P2/P4 混杂、无法照抄 ⇒ 本轮**只声明规则、不代产品填值**:填一个没有出处的 phase 等于制造下一条「文档与实况分岔」。补齐需要产品/架构 owner 给出取值,跟踪在 #2258。 + ## 使用规则 1. 新 REST 接口先改 `api/openapi.yaml`。 diff --git a/api/conventions.md b/api/conventions.md index 19c0cb9d1..853db11b0 100644 --- a/api/conventions.md +++ b/api/conventions.md @@ -62,7 +62,7 @@ ID 是字符串,使用语义前缀,不暴露数据库自增主键,创建 GET /v1/threads?projectId=proj_1&pageSize=50&pageCursor=cursor_abc ``` -响应包含 `items` 和 `page.nextCursor` / `page.hasMore`。`pageSize` 默认 `50`,最大 `200`。不要把 offset pagination 作为主方式,避免消息流和事件流错位。 +响应包含 `items` 和 `page.nextCursor` / `page.hasMore`。`pageSize` 默认 `50`,最大 `200`。不要把 offset pagination 作为主方式,避免消息流和事件流错位。**超过上限的 `pageSize` 是「夹取」而不是「报错」**(ADR-031 / #2243):请求值大于该端点自己声明、且被查询层实际执行的上限(通用列表 `MaxListPageSize = 200`、消息/通知族 `MaxMessagePageLimit = 100`、run/team-event 与文档族 `MaxPageLimit = 500`)时,服务端返回**上限条数** + HTTP 200 + 照常的 `page.nextCursor` / `page.hasMore`,余下部分跟着游标继续取;limit/offset 形态的端点(如通知列表)则继续推进 `offset`。它**不会**回落到默认值(那会把页二次缩短),也**不会**返回 400 —— 被夹短的页是可续取的,不是数据丢失,把它变成硬失败对调用方零收益。夹取由 `hub-server/internal/config/paging.go` 的 `ClampPageSize` 单点执行,所有 handler 侧列表都走它;实测口径见 #2243(13 个 handler 回传游标,另两个 clamp 端点是 limit/offset 形态)。 ### 三种列表响应形状 @@ -151,6 +151,8 @@ P0 本地模式可以先实现为单用户,但文档中的权限边界必须 事件:会触发哪些 WebSocket event ``` +两个取值域是**闭合**的(ADR-030 / #2258)。`x-agenthub-owner ∈ {Hub, Edge}`:早期的 `Runner` 已退役 —— `edge-server/internal/runners/` 只剩 `registry.go`,而 workspace 的 handler/service/repository/model 全在 `hub-server`、以 `/web/projects*` 暴露,照 `Runner` 找实现的人会在 Edge 找不到 workspace 逻辑进而新起第二套实现(正是「禁止在另一层再分叉实现」要防的事);workspace 的**元数据与列举归 Hub**,将来若真需要 Edge 侧文件内容端点,届时那个端点自己标 `owner: Edge`,不要预建。`x-agenthub-phase ∈ {P0, P1, P2, P3, P4}` **只适用于 `/v1/**` 设计面**:reality-face(`/client`、`/web`、`/edge`)由 `x-agenthub-status` 治理、不带 phase,`/health`、`/api`、`/cloud` 属 ops/非设计面同样不带。端点级 `x-agenthub-status ∈ {implemented, planned}`,schema 级另允许 `contract-draft`。 + `api/openapi.yaml` 使用扩展字段: ```yaml diff --git a/api/openapi.yaml b/api/openapi.yaml index 6a376fc0f..c6f28112d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1198,7 +1198,7 @@ paths: summary: Get workspace. x-agenthub-phase: P0 x-agenthub-status: planned - x-agenthub-owner: Runner + x-agenthub-owner: Hub parameters: - $ref: "#/components/parameters/WorkspaceId" responses: @@ -1212,7 +1212,7 @@ paths: summary: List workspace files. x-agenthub-phase: P0 x-agenthub-status: planned - x-agenthub-owner: Runner + x-agenthub-owner: Hub parameters: - $ref: "#/components/parameters/WorkspaceId" responses: @@ -1227,7 +1227,7 @@ paths: description: File path is passed in the JSON body so slash-containing paths do not become ambiguous URL segments. x-agenthub-phase: P0 x-agenthub-status: planned - x-agenthub-owner: Runner + x-agenthub-owner: Hub parameters: - $ref: "#/components/parameters/WorkspaceId" responses: @@ -1241,7 +1241,7 @@ paths: summary: Search workspace files. x-agenthub-phase: P1 x-agenthub-status: planned - x-agenthub-owner: Runner + x-agenthub-owner: Hub parameters: - $ref: "#/components/parameters/WorkspaceId" responses: @@ -7656,6 +7656,17 @@ components: name: pageSize in: query required: false + # Over-ceiling is clamped, not rejected: a request above the maximum the + # endpoint declares comes back with `maximum` items, HTTP 200 and the + # usual page.nextCursor / page.hasMore, so the rest of the collection is + # reached by following the cursor (limit/offset endpoints: by advancing + # offset). It never falls back to `default`, which would shorten the page + # a second time. See api/conventions.md §Pagination and + # hub-server/internal/config/paging.go (ADR-031 / #2243). + description: >- + Page size. Defaults to 50. Values above the endpoint's declared maximum + are clamped to that maximum (not rejected, and not reduced to the + default); follow page.nextCursor to read the rest. schema: type: integer minimum: 1 diff --git a/hub-server/internal/config/paging.go b/hub-server/internal/config/paging.go index f1d975e0c..b828af1eb 100644 --- a/hub-server/internal/config/paging.go +++ b/hub-server/internal/config/paging.go @@ -41,10 +41,13 @@ package config // larger batches than the interactive lists. An earlier revision of this comment // said "three legitimate maxima" and was contradicted by the fourth. // -// Every handler-side list clamp now goes through this function. The one -// hand-written branch left anywhere on this path is repository/message.go -// GetMessagesIncrement, whose non-positive case falls to the maximum instead of -// to def — the opposite of the rule documented above — and is tracked in #2243. +// Every clamp on this path now goes through this function, including +// repository/message.go GetMessagesIncrement, which used to be the one +// hand-written branch left. Its non-positive case falls to the maximum rather +// than to def, which looks like the opposite of the rule above but is the +// "endpoint treats 0 as no explicit limit" escape hatch documented below: it now +// spells that by passing the maximum as def, so the behaviour is unchanged and +// the shape is no longer a private copy (#2243). // // A caller passes the ceiling its own endpoint declares in api/openapi.yaml and // its own query layer enforces; passing a higher one moves the clamp down a diff --git a/hub-server/internal/repository/message.go b/hub-server/internal/repository/message.go index 9a1a62304..ab533cf52 100644 --- a/hub-server/internal/repository/message.go +++ b/hub-server/internal/repository/message.go @@ -43,9 +43,14 @@ func GetMessagesBySession(db *gorm.DB, sessionID string, beforeSeq int64, limit } func GetMessagesIncrement(db *gorm.DB, sessionID string, afterSeq int64, limit int) ([]model.Message, error) { - if limit <= 0 || limit > config.MaxIncrementalMessageLimit { - limit = config.MaxIncrementalMessageLimit - } + // 0 on this endpoint means "no explicit limit" rather than "give me the + // default page", so the requested value is passed as the default too — the + // escape hatch config.ClampPageSize documents (same shape as + // handler/agent.go's runEventFilterFromQuery). Behaviour is identical to the + // hand-written branch this replaces (<=0 and >max both land on the maximum); + // routing it through the shared helper is what removes the last hand-written + // clamp on the pagination path (#2243). + limit = config.ClampPageSize(limit, config.MaxIncrementalMessageLimit, config.MaxIncrementalMessageLimit) var msgs []model.Message err := db.Where("session_id = ? AND seq_id > ?", sessionID, afterSeq). Order("seq_id ASC").Limit(limit).Find(&msgs).Error diff --git a/scripts/verify/tests/verify-doc-entrypoints.Tests.py b/scripts/verify/tests/verify-doc-entrypoints.Tests.py index f6f0b09de..bd449feb4 100644 --- a/scripts/verify/tests/verify-doc-entrypoints.Tests.py +++ b/scripts/verify/tests/verify-doc-entrypoints.Tests.py @@ -202,10 +202,10 @@ def assert_readme_maturity_parity(self): with open(readme_path, "rb") as handle: original_bytes = handle.read() original_text = original_bytes.decode("utf-8") - if "Mobile 装配中" not in original_text: + if "Mobile 是装配中的" not in original_text: self.fail("README fixture precondition missing zh maturity marker") try: - mutated = original_text.replace("Mobile 装配中", "Mobile 已就绪", 1) + mutated = original_text.replace("Mobile 是装配中的", "Mobile 是已就绪的", 1) with open(readme_path, "w", encoding="utf-8", newline="\n") as handle: handle.write(mutated) self.assert_failure_code("DOC-README-PARITY", "README zh-only maturity change") diff --git a/scripts/verify/verify-doc-ssot.py b/scripts/verify/verify-doc-ssot.py index d6cb609ca..8c41e57fc 100644 --- a/scripts/verify/verify-doc-ssot.py +++ b/scripts/verify/verify-doc-ssot.py @@ -397,7 +397,7 @@ def check_readme_parity() -> None: Each pair below must appear in both READMEs or in neither. """ pairs = [ - ("Mobile 装配中", "Mobile in assembly"), + ("Mobile 是装配中的", "Mobile is an in-assembly"), ("Desktop/Web 主线", "Desktop/Web are the mainline"), ] readme_zh = read_text("README.md")