diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a208a8cf8..d1d2f6e44 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -68,6 +68,17 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: client-tauri/src-tauri + - name: Ensure rustfmt/clippy components + run: rustup component add rustfmt clippy + # 注:runner 是 ubuntu-latest。rustfmt 只做语法层排版,不评估 cfg,所以下面 fmt --check + # 覆盖全部源码,包括 windows.rs/main.rs 里 `#[cfg(target_os = "macos"/"windows")]` 门下的分支。 + # 但 clippy 是真编译再 lint,Linux target 编译期会把那些平台专属分支直接剔除—— + # 因此 clippy 这一步只对 Linux 生效的代码路径生效,macOS/Windows 专属分支的 clippy + # 仍需在对应平台本机跑(与现有 rust-system-i18n job 里 cargo test 的覆盖范围一致,非本次新增的局限)。 + - name: cargo fmt --check (desktop shell, all cfg branches) + run: cargo fmt --manifest-path client-tauri/src-tauri/Cargo.toml --check + - name: cargo clippy -D warnings (desktop shell, Linux cfg paths only) + run: cargo clippy --manifest-path client-tauri/src-tauri/Cargo.toml --all-targets -- -D warnings - uses: pnpm/action-setup@v4 with: version: 11.0.9 diff --git a/apps/api/src/agent-runs.test.ts b/apps/api/src/agent-runs.test.ts index 3785de756..2e3abdda2 100644 --- a/apps/api/src/agent-runs.test.ts +++ b/apps/api/src/agent-runs.test.ts @@ -3276,7 +3276,7 @@ test("agent run read routes fall back to the run owner/admin gate when work item const readRoutes = [ `/api/agent-runs/${queued.run_id}`, `/api/agent-runs/${queued.run_id}/trace`, - `/api/agent-runs/${queued.run_id}/handoff`, + // R20 R19-29:/handoff 端点已删(死冗余,见 routes/agent-runs.ts)。 `/api/agent-runs/${queued.run_id}/replay` ]; @@ -3339,7 +3339,7 @@ test("agent run direct routes stay scoped to the actor workspace", async () => { const readRoutes = [ `/api/agent-runs/${queued.run_id}`, `/api/agent-runs/${queued.run_id}/trace`, - `/api/agent-runs/${queued.run_id}/handoff`, + // R20 R19-29:/handoff 端点已删(死冗余,见 routes/agent-runs.ts)。 `/api/agent-runs/${queued.run_id}/replay` ]; const ownerCookie = await cookie(runtimeSettings); @@ -3409,7 +3409,7 @@ test("agent run read routes allow users who can open the backing work item", asy for (const route of [ `/api/agent-runs/${queued.run_id}`, `/api/agent-runs/${queued.run_id}/trace`, - `/api/agent-runs/${queued.run_id}/handoff`, + // R20 R19-29:/handoff 端点已删(死冗余,见 routes/agent-runs.ts)。 `/api/agent-runs/${queued.run_id}/replay` ]) { const response = await app.request(route, { headers: { Cookie: collaboratorCookie } }); @@ -5434,6 +5434,94 @@ test("agent run queue keeps the lease alive during a long provider call", async assert.notEqual(duringProvider?.claim?.heartbeat_at, duringProvider?.claim?.claimed_at); }); +// P3-02:claim 心跳成功续租时,refreshClaim 会顺带续预留租约(reservationRepo.refreshLease)。此前那次调用 +// 是 `.catch(() => {})`——不管是 DB 抛错还是命中 0 行(预留早被 releaseExpired 判过期/从未成功 reserve 过), +// 全部悄悄吞掉,运维完全看不出这个仍在跑的 run 已经没有生效预留、outstanding 计算正在漏计它。根因测试: +// 让 refreshLease 命中 0 行,断言必须能在结构化日志里看到 agent_run_budget_lease_renew_no_rows—— +// 修复前这条断言会红(吞掉后日志管道里什么都没有),修复后转绿。 +test("P3-02 budget lease renewal that updates 0 rows surfaces a structured log instead of being silently swallowed", async () => { + const runtimeSettings = settings(); + const persistence = new MemoryAgentRunPersistence(); + const renewSeen = deferred(); + const releaseProvider = deferred(); + const workdir = await mkdtemp(path.join(os.tmpdir(), "workhub-agent-run-budget-lease-renew-test-")); + let tick = 0; + const longProviderClient: AgentLoopClient = { + model: "deepseek-v4-flash", + messages: { + async create() { + await releaseProvider.promise; + return { + id: "msg-budget-lease-renew", + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1 }, + usageRecord: { + provider: "deepseek", + model: "deepseek-v4-flash", + task: "worker", + inputTokens: 1, + outputTokens: 1, + estimatedCostCny: "0.001", + source: "agent_step", + createdAt: "2026-06-05T00:00:00.000Z" + }, + content: [{ type: "text", text: "done" }] + }; + } + } + }; + const fakeReservationRepo = { + reserve: async () => ({ ok: true as const }), + reconcile: async () => 0, + releaseExpired: async () => 0, + refreshLease: async () => { + renewSeen.resolve(); + return 0; + }, + outstandingForScopes: async () => new Map() + }; + const logLines: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + logLines.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stdout.write; + try { + const queue = createInMemoryAgentRunQueue({ + settings: runtimeSettings, + now: () => new Date(now.getTime() + tick++ * 100), + id: () => "40000000-0000-4000-8000-00000000002f", + workerId: "worker-budget-lease-renew", + leaseMs: 300, + heartbeatIntervalMs: 10, + workdir: () => workdir, + client: () => longProviderClient, + persistence, + reservationRepo: fakeReservationRepo as unknown as BudgetReservationRepository, + confidence: false, + proposals: false, + notifications: false, + eventBus: false, + requireDeliverable: false + }); + const run = await queue.enqueue({ + workItemId, + actorId: userId, + title: "Budget lease renew 0-row run" + }); + + const running = queue.runNext(); + await renewSeen.promise; + releaseProvider.resolve(); + await running; + } finally { + process.stdout.write = originalWrite; + } + + const sawNoRowsLog = logLines.some((line) => line.includes("agent_run_budget_lease_renew_no_rows")); + assert.equal(sawNoRowsLog, true, "0-row budget lease renewal must be observable via structured log, not silently swallowed"); +}); + test("agent run abort propagates an AbortSignal to the in-flight provider request", async () => { const runtimeSettings = settings(); const providerStarted = deferred(); diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 7834b8e9f..84fa90145 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -383,7 +383,7 @@ test("GET /api/openapi.json exposes the headless daemon contract seed", async () ["get", "/api/projects/{id}/instructions"], ["patch", "/api/projects/{id}/instructions"], ["post", "/api/workitems/{id}/proposals"], - ["get", "/api/workitems/{id}/proposals"], + // R20 R19-29:GET /api/workitems/{id}/proposals(list-work-item-proposals)已删(死冗余,无消费)。 ["get", "/api/workitems/{id}/conflicts"], ["get", "/api/proposals/{id}"], ["post", "/api/proposals/{id}/review"], @@ -397,7 +397,7 @@ test("GET /api/openapi.json exposes the headless daemon contract seed", async () ["post", "/api/workitems/{id}/agent-runs"], ["get", "/api/agent-runs/{id}"], ["get", "/api/agent-runs/{id}/trace"], - ["get", "/api/agent-runs/{id}/handoff"], + // R20 R19-29:GET /api/agent-runs/{id}/handoff 已删(死冗余,无消费)。 ["post", "/api/agent-runs/{id}/abort"], ["get", "/api/agent-runs/{id}/replay"], ["post", "/api/agent-runs/{id}/revert"], @@ -420,7 +420,7 @@ test("GET /api/openapi.json exposes the headless daemon contract seed", async () ["post", "/api/knowledge/search"], ["get", "/api/workitems/{id}/audit"], ["get", "/api/pilot/day1/metrics"], - ["get", "/api/ai-worklog/today"], + // R20 R19-29:GET /api/ai-worklog/today 已删(死冗余,无消费;数据早已内嵌进 attention 页 VM)。 ["get", "/api/conversations/{id}/army"], ["get", "/api/me/army"], ["post", "/api/action-card-items/{id}/decide"], @@ -853,6 +853,30 @@ test("runtime API routes stay in lockstep with the OpenAPI document", async () = }); }); +// R20 R19-29:三个冗余死读端点(无任何前端/客户端消费,专用端点 + SDK 桩无调用者)——根因回归: +// 基线上这三条路径仍然可达/仍被文档化,删除后必须整条从运行时路由与 OpenAPI 文档一起消失。 +// 这条测试在删除改动落地前会红(路径仍存在/仍是 200-401 而非 404),落地后转绿。 +test("R19-29 dead read endpoints (handoff / ai-worklog·today / list-work-item-proposals) are fully removed", async () => { + const response = await app.request("/api/openapi.json"); + const body = await response.json() as { paths: Record> }; + + // GET /api/agent-runs/{id}/handoff:整条路径已删(唯一方法就是这个 get)。 + assert.equal(body.paths["/api/agent-runs/{id}/handoff"], undefined, "handoff path must be gone from OpenAPI"); + + // GET /api/ai-worklog/today:整条路径已删;无鉴权直接命中路由层 404(不再进 requireCurrentUser 中间件, + // 也就不会是 401)——这一步是活的 HTTP 回归,不只是静态 OpenAPI 检查。 + assert.equal(body.paths["/api/ai-worklog/today"], undefined, "ai-worklog/today path must be gone from OpenAPI"); + const worklogHttp = await app.request("/api/ai-worklog/today"); + assert.equal(worklogHttp.status, 404, "GET /api/ai-worklog/today must 404 once the route is removed"); + + // GET /api/workitems/{id}/proposals:只删 get,POST(创建提议)必须原样保留。 + const workItemProposalsPath = body.paths["/api/workitems/{id}/proposals"] as + | Record + | undefined; + assert.equal(workItemProposalsPath?.["get"], undefined, "list-work-item-proposals get must be gone"); + assert.ok(workItemProposalsPath?.["post"], "create-proposal post must stay (still consumed)"); +}); + test("templated OpenAPI paths declare their required path parameters", async () => { const response = await app.request("/api/openapi.json"); const body = await response.json() as { paths: Record> }; @@ -1080,7 +1104,6 @@ test("project and drive OpenAPI routes document runtime path and query parameter ["/api/meetings/projects/{projectId}/insights/{insightId}/dismiss", "post", ["projectId", "insightId"]], ["/api/meetings/workitems/{workItemId}/proposal-draft", "post", ["workItemId"]], ["/api/workitems/{id}/proposals", "post", ["id"]], - ["/api/workitems/{id}/proposals", "get", ["id"]], ["/api/workitems/{id}/conflicts", "get", ["id"]], ["/api/proposals/{id}", "get", ["id"]], ["/api/proposals/{id}/review", "post", ["id"]], @@ -1094,7 +1117,6 @@ test("project and drive OpenAPI routes document runtime path and query parameter ["/api/workitems/{id}/agent-runs", "post", ["id"]], ["/api/agent-runs/{id}", "get", ["id"]], ["/api/agent-runs/{id}/trace", "get", ["id"]], - ["/api/agent-runs/{id}/handoff", "get", ["id"]], ["/api/agent-runs/{id}/abort", "post", ["id"]], ["/api/agent-runs/{id}/replay", "get", ["id"]], ["/api/agent-runs/{id}/revert", "post", ["id"]], @@ -1360,7 +1382,7 @@ test("push streams and audit OpenAPI routes document runtime UUID guards and res assert.deepEqual(auditNotFoundError?.properties?.code, { type: "string", enum: ["not_found"] }); }); -test("pilot metrics and AI worklog OpenAPI routes document query and response contracts", async () => { +test("pilot metrics OpenAPI route documents query and response contracts", async () => { const response = await app.request("/api/openapi.json"); const body = await response.json() as { paths: Record> }; @@ -1398,32 +1420,8 @@ test("pilot metrics and AI worklog OpenAPI routes document query and response co enum: ["validation_error", "invalid_range"] }); - const worklogResponse = jsonResponseSchema(body.paths, "/api/ai-worklog/today", "get", "200"); - const worklogData = worklogResponse?.properties?.data as { required?: string[]; properties?: Record } | undefined; - assert.deepEqual(worklogResponse?.required, ["ok", "data"]); - assert.deepEqual(worklogData?.required, [ - "runs_today", - "autonomy_rate", - "accepted_today", - "saved_hours_estimate", - "skills_promoted_today", - "skills_refined_today", - "generated_at" - ]); - assert.deepEqual(Object.keys(worklogData?.properties ?? {}).sort(), [ - "accepted_today", - "autonomy_rate", - "generated_at", - "range_label", - "runs_today", - "saved_hours_estimate", - "skills_promoted_today", - "skills_refined_today" - ]); - const worklogAuth = jsonResponseSchema(body.paths, "/api/ai-worklog/today", "get", "401"); - const worklogAuthError = worklogAuth?.properties?.error as { properties?: Record } | undefined; - assert.deepEqual(worklogAuth?.required, ["ok", "error"]); - assert.deepEqual(worklogAuthError?.properties?.code, { type: "string", enum: ["not_identified"] }); + // R20 R19-29:GET /api/ai-worklog/today 已删(死冗余,无消费;数据早已内嵌进 attention 页 VM)—— + // 原先这里的 worklogResponse/worklogAuth 契约断言随路由一并删除,见下方新增的删除回归断言。 }); test("Task intake and AgentRun OpenAPI responses document the execution chain", async () => { @@ -1615,7 +1613,7 @@ test("Task intake and AgentRun OpenAPI responses document the execution chain", ["/api/workitems/{id}/agent-runs", "post"], ["/api/agent-runs/{id}", "get"], ["/api/agent-runs/{id}/trace", "get"], - ["/api/agent-runs/{id}/handoff", "get"], + // R20 R19-29:/handoff 端点已删(死冗余,无消费)。 ["/api/agent-runs/{id}/abort", "post"], ["/api/agent-runs/{id}/replay", "get"] ] as const) { @@ -1629,7 +1627,7 @@ test("Task intake and AgentRun OpenAPI responses document the execution chain", ["/api/workitems/{id}/agent-runs", "post"], ["/api/agent-runs/{id}", "get"], ["/api/agent-runs/{id}/trace", "get"], - ["/api/agent-runs/{id}/handoff", "get"], + // R20 R19-29:/handoff 端点已删(死冗余,无消费)。 ["/api/agent-runs/{id}/abort", "post"], ["/api/agent-runs/{id}/replay", "get"] ] as const) { @@ -1645,7 +1643,7 @@ test("Task intake and AgentRun OpenAPI responses document the execution chain", ["/api/workitems/{id}/agent-runs", "post"], ["/api/agent-runs/{id}", "get"], ["/api/agent-runs/{id}/trace", "get"], - ["/api/agent-runs/{id}/handoff", "get"], + // R20 R19-29:/handoff 端点已删(死冗余,无消费)。 ["/api/agent-runs/{id}/abort", "post"], ["/api/agent-runs/{id}/replay", "get"] ] as const) { @@ -1671,9 +1669,8 @@ test("Task intake and AgentRun OpenAPI responses document the execution chain", enum: ["validation_error"] }); - const handoffResponse = jsonResponseSchema(body.paths, "/api/agent-runs/{id}/handoff", "get", "200"); - assert.deepEqual(handoffResponse?.required, ["ok", "data"]); - assert.ok(handoffResponse?.properties?.data, "GET /api/agent-runs/{id}/handoff missing nullable handoff data schema"); + // R20 R19-29:GET /api/agent-runs/{id}/handoff 已删(死冗余,无消费)——原先这里的 handoffResponse + // 契约断言随路由/openapi 条目一并删除;同样的结构化 handoff 数据已在下面 replayResponse 里覆盖。 const replayResponse = jsonResponseSchema(body.paths, "/api/agent-runs/{id}/replay", "get", "200"); assert.deepEqual(replayResponse?.required, ["ok", "data", "meta"]); @@ -2121,7 +2118,7 @@ test("Proposal OpenAPI contracts document review, merge, and conflict action pay for (const [path, method] of [ ["/api/workitems/{id}/proposals", "post"], - ["/api/workitems/{id}/proposals", "get"], + // R20 R19-29:GET /api/workitems/{id}/proposals(list)已删(死冗余,无消费)。 ["/api/workitems/{id}/conflicts", "get"], ["/api/proposals/{id}", "get"], ["/api/proposals/{id}/review", "post"], @@ -2171,12 +2168,11 @@ test("Proposal OpenAPI contracts document review, merge, and conflict action pay enum: ["proposal_already_exists"] }); - for (const [path, method] of [ - ["/api/workitems/{id}/proposals", "get"], - ["/api/proposals/{id}", "get"] - ] as const) { - const schema = jsonResponseSchema(body.paths, path, method, "200"); - assert.deepEqual(schema?.required, ["ok", "data"], `${method.toUpperCase()} ${path} missing proposal response`); + // R20 R19-29:GET /api/workitems/{id}/proposals(list)已删(死冗余,无消费)——原先与它同批断言的 + // 200 响应形状检查随路由/openapi 条目一并删除,/api/proposals/{id} 的等价检查已在别处覆盖。 + { + const schema = jsonResponseSchema(body.paths, "/api/proposals/{id}", "get", "200"); + assert.deepEqual(schema?.required, ["ok", "data"], "GET /api/proposals/{id} missing proposal response"); } const reviewRequest = jsonRequestSchema(body.paths, "/api/proposals/{id}/review", "post"); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 576d5aed1..b0fe0640f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,7 +22,6 @@ import { createPushRoutes } from "./routes/push.js"; import { createNotificationRoutes } from "./routes/notifications.js"; import { createAuditRoutes } from "./routes/audit.js"; import { createPageRoutes } from "./routes/pages.js"; -import { createAiWorklogRoutes } from "./routes/ai-worklog.js"; import { createDriveRoutes } from "./routes/drive.js"; import { createMeetingRoutes } from "./routes/meetings.js"; import { createPilotRoutes } from "./routes/pilot.js"; @@ -317,7 +316,9 @@ app.route("/api", createPersonalProjectRoutes()); // R20 P2A(R19-21):工作区级审计列表(GET /api/workspace/audit,仅管理员,工作区硬隔离)。 app.route("/api", createWorkspaceAuditRoutes()); app.route("/api/pilot", createPilotRoutes()); -app.route("/api/ai-worklog", createAiWorklogRoutes()); +// R20 R19-29:/api/ai-worklog/today(createAiWorklogRoutes)已删——web/desktop 均无调用者,同样的今日 +// AI 工作量数据早已由 GET /api/pages/attention 等页面 VM 内嵌 AiWorklogMetricsService 交付。核实零消费 +// 后连路由文件(routes/ai-worklog.ts)一并删除;服务本身(services/ai-worklog-metrics.ts)仍在用,未动。 app.onError((error, c) => { if (error instanceof ZodError) { diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index c514471aa..6106bcf24 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -173,6 +173,13 @@ class MemoryUsers implements UserRepository { row.updatedAt = at; return row; } + + // 含已软删墓碑的批量引用(真库 findRefsByIds 语义)——P2-02 停用善后重试入口据此分辨墓碑 vs 不存在。 + async findRefsByIds(ids: string[]) { + return this.rows + .filter((row) => ids.includes(row.id)) + .map((row) => ({ id: row.id, deletedAt: row.deletedAt })); + } } class MemoryDevices implements ClientDeviceRepository { @@ -323,6 +330,18 @@ class ThrowingCleanupSessions extends MemorySessions { } } +// P2-02:先失败、后(外部瞬时故障恢复后)成功的会话仓库——用于验证停用善后清理可重跑收敛。 +class FlakyCleanupSessions extends MemorySessions { + public fail = true; + + override revokeAllForUser(userId: string, at: Date): ReturnType { + if (this.fail) { + return Promise.reject(new Error("session cleanup transient failure")); + } + return super.revokeAllForUser(userId, at); + } +} + function credentialRow(input: CreateUserCredentialInput, seq = 1): UserCredentialRow { return { id: input.id ?? `40000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, @@ -1760,7 +1779,9 @@ test("POST /users/:id/deactivate (admin) soft-deletes the user and revokes their assert.equal(targetDevices.every((d) => d.revokedAt !== null), true, "devices revoked"); }); -test("POST /users/:id/deactivate still succeeds when post-delete cleanup fails", async () => { +test("P2-02: deactivate surfaces cleanup failure (does not fake success as { ok: true })", async () => { + // 根因:停用善后(撤会话/设备/凭据/在线态)此前是尽力而为——中途失败被静默吞掉、仍回 200 ok:true, + // 留下半清理态且无从感知。修复后:任一善后步失败 → 非 200 + 结构化告知失败步(不吞错伪装成功)。 const runtimeSettings = settings(); const admin = user({ id: "10000000-0000-4000-8000-0000000000d5", nickname: "admin", isAdmin: true }); const target = user({ id: "10000000-0000-4000-8000-0000000000d6", nickname: "target", cookieToken: "cookie-target-cleanup" }); @@ -1784,8 +1805,85 @@ test("POST /users/:id/deactivate still succeeds when post-delete cleanup fails", headers: { Cookie: await signedCookie(admin.cookieToken, runtimeSettings) } }); - assert.equal(res.status, 200); - assert.equal(await users.findActiveById(target.id), null, "target is soft-deleted even if cleanup is unavailable"); + // 失败可见:不再伪装成功。账号墓碑已置(停用是权威动作),但善后未完成 → 500 + 失败步清单。 + assert.equal(res.status, 500, "incomplete cleanup surfaces as non-200 (no fake success)"); + const body = (await res.json()) as { + ok: boolean; + deactivated?: boolean; + cleanup?: { complete: boolean; steps: Array<{ step: string; ok: boolean }> }; + }; + assert.equal(body.ok, false, "response is not ok when cleanup incomplete"); + assert.equal(body.deactivated, true, "tombstone is set even though cleanup is incomplete"); + assert.equal(body.cleanup?.complete, false, "cleanup reported as not complete"); + const failedSteps = (body.cleanup?.steps ?? []).filter((entry) => !entry.ok).map((entry) => entry.step); + // 会话/设备/凭据/在线态四步都失败 → 都进失败清单(调用方可据此判断残留)。 + for (const step of ["credentials.delete_by_user", "sessions.revoke_all_for_user", "devices.revoke_for_user", "presence.forget_user"]) { + assert.ok(failedSteps.includes(step), `failed step reported: ${step}`); + } + assert.equal(await users.findActiveById(target.id), null, "target is soft-deleted (tombstone) despite cleanup failure"); +}); + +test("P2-02: deactivate cleanup is re-entrant — retry converges to fully-cleaned", async () => { + // 根因:善后失败后没有可重入的重试路径——重发停用请求会因 softDelete 落空一律 404,半清理态永久卡住。 + // 修复后:墓碑存在时重发本请求即重跑幂等清理并收敛;某会话撤销瞬时失败 → 首发 500、残留会话; + // 瞬时故障恢复后重发 → 200、会话/设备全撤、cleanup.complete。 + const runtimeSettings = settings(); + const admin = user({ id: "10000000-0000-4000-8000-0000000000d7", nickname: "admin", isAdmin: true }); + const target = user({ id: "10000000-0000-4000-8000-0000000000d8", nickname: "target", cookieToken: "cookie-target-retry" }); + const users = new MemoryUsers([admin, target]); + const sessions = new FlakyCleanupSessions(); + const devices = new MemoryDevices([ + device({ id: "20000000-0000-4000-8000-0000000000d8", userId: target.id, clientTokenHash: hashClientToken("target-device-retry") }) + ]); + const forgotten: string[] = []; + const deps: AuthDependencies = { + users, + devices, + sessions, + settings: runtimeSettings, + now: () => now, + forgetUser: (userId) => { + forgotten.push(userId); + } + }; + await sessions.create({ + userId: target.id, + tokenHash: "target-retry-session-hash", + authMethod: "password", + absoluteExpiresAt: new Date(now.getTime() + 3_600_000), + idleExpiresAt: new Date(now.getTime() + 1_800_000) + }); + + const app = withErrors(new Hono()); + app.route("/auth", createAuthRoutes(deps)); + const path = "/auth/users/" + target.id + "/deactivate"; + const cookie = await signedCookie(admin.cookieToken, runtimeSettings); + + // 首发:会话撤销瞬时失败 → 500、半清理态(墓碑已置但会话仍在)。 + const first = await app.request(path, { method: "POST", headers: { Cookie: cookie } }); + assert.equal(first.status, 500, "transient session-revoke failure surfaces as 500"); + assert.equal(await users.findActiveById(target.id), null, "target soft-deleted after first attempt"); + assert.equal( + sessions.rows.filter((row) => row.userId === target.id && row.revokedAt === null).length, + 1, + "half-cleaned: session still active because revoke step failed" + ); + + // 瞬时故障恢复,管理员重发同一停用请求(重试入口)——不再 404,重跑幂等清理收敛到全清理。 + sessions.fail = false; + const retry = await app.request(path, { method: "POST", headers: { Cookie: cookie } }); + assert.equal(retry.status, 200, "retry succeeds once the transient failure clears"); + const retryBody = (await retry.json()) as { ok: boolean }; + assert.equal(retryBody.ok, true, "retry reports ok once cleanup is complete"); + // 收敛证据:残留会话/设备/在线态被彻底清干净。 + assert.equal( + sessions.rows.filter((row) => row.userId === target.id && row.revokedAt === null).length, + 0, + "converged: sessions revoked after retry" + ); + const targetDevices = await devices.listByUser(target.id); + assert.equal(targetDevices.every((d) => d.revokedAt !== null), true, "devices revoked after retry"); + assert.ok(forgotten.includes(target.id), "presence forgotten after retry"); }); test("POST /users/:id/deactivate hands over the target's active claims (unassign + audit) and leaves terminal items", async () => { diff --git a/apps/api/src/conversations.test.ts b/apps/api/src/conversations.test.ts index 95d87e5d2..ad697a605 100644 --- a/apps/api/src/conversations.test.ts +++ b/apps/api/src/conversations.test.ts @@ -1862,6 +1862,42 @@ test("renameConversation forwards a tenant-safe title write and returns the rena assert.equal(result.conversation.participant_role, "owner"); }); +// R20 P2-04(会话 rename 跨端同步):改名后广播 conversation.title.updated 到会话私有流,携带新 title—— +// 让别的开着这个会话的客户端就地改左栏树叶 / web 镜像页标题,不必等下次全量轮询。修复前 renameConversation +// 不发任何领域事件(published 为空),本断言即为红。 +test("renameConversation broadcasts conversation.title.updated to the conversation-private topic with the new title", async () => { + const capture = capturingBus(); + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "owner" }); + }, + async renameConversation(input) { + return conversationRow({ title: input.title, updatedAt: new Date("2026-07-18T09:00:00.000Z") }); + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + bus: capture.bus, + now: () => now + } + ); + + await service.renameConversation({ actor: actor(), conversationId, payload: { title: "改第三幕" } }); + + assert.equal(capture.published.length, 1); + assert.equal(capture.published[0]?.topic, `conversation:${conversationId}`); + assert.equal(capture.published[0]?.type, "conversation.title.updated"); + const event = capture.published[0]?.data as { type: string; topic: string; data: { conversation_id: string; title: string } }; + // 契约门(parseOutputContract)已在发布前跑过一遍——这里再核一次形状+topic绑定(title-updated 的 topic + // 必须与 data.conversation_id 匹配)与新 title 透传。 + assert.equal(event.type, "conversation.title.updated"); + assert.equal(event.topic, `conversation:${conversationId}`); + assert.deepEqual(event.data, { conversation_id: conversationId, title: "改第三幕" }); +}); + test("renameConversation refuses a non-collab (main) conversation with 403 and never writes", async () => { let renameCalls = 0; const service = createConversationService( diff --git a/apps/api/src/event-outbox.test.ts b/apps/api/src/event-outbox.test.ts new file mode 100644 index 000000000..f8bd9b631 --- /dev/null +++ b/apps/api/src/event-outbox.test.ts @@ -0,0 +1,465 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { loadSettings } from "@workhub/config"; +import { + createConversationRepository, + createDatabaseClient, + createEventOutboxRepository, + runMigrations, + type ConversationAccessRecord, + type ConversationMessageRow, + type ConversationRepository, + type ConversationRow, + type EventOutboxRepository, + type EventOutboxRow +} from "@workhub/db"; + +import type { AuthActor } from "./middleware/auth.js"; +import { createConversationService } from "./services/conversations.js"; +import { createEventOutboxDrain } from "./services/event-outbox.js"; +import type { DrivePageService } from "./services/drive-pages.js"; + +// R20 P2-01(事务性 outbox)根因测试:修的裂缝是「会话消息 DB commit 与 publish 之间无 outbox/replay」—— +// 消息先落库提交,再 best-effort bus.publish;两步之间进程崩溃或 publish 抛错,则 conversation.message.created +// 永久丢失(SSE resume_mode='fresh' 不重放)。下面既有纯内存的确定性复现(默认跑),也有真 PG 集成复现 +// (WORKHUB_R20_OUTBOX_REAL_PG=1 才跑,跟本仓既有 real-PG 惯例)。 + +const now = new Date("2026-07-18T09:00:00.000Z"); +const silentLogger = { warn: () => {} } as const; + +// ── 纯内存 fake:模拟「消息行 + outbox 行同事务原子提交」 ───────────────────────────────────── + +function makeRepository( + outboxStore: EventOutboxRow[], + fixtures: { conversationRow: ConversationRow; accessRecord: ConversationAccessRecord; messageId: string } +): ConversationRepository { + const impl: Partial = { + async findVisibleAccessRecord() { + return fixtures.accessRecord; + }, + async createUserMessage(input, options) { + const created: ConversationMessageRow = { + id: fixtures.messageId, + conversationId: input.conversationId, + seq: 1, + senderType: "user", + senderUserId: input.senderUserId, + kind: input.kind, + contentJson: input.contentJson, + threadRootId: input.threadRootId ?? null, + editedAt: null, + deletedAt: null, + deletedByUserId: null, + replyToMessageId: (input.kind === "text" ? input.replyToMessageId : undefined) ?? null, + pinnedAt: null, + pinnedByUserId: null, + createdAt: now + }; + // 模拟真事务:入队钩子在这里同步执行、把 outbox 行随消息行一起「原子提交」进共享内存仓库。 + // 崩溃点在这一步之后、publish 之前——本 fake 把 publish 交给外部 drain,故只要不 drain 就复现崩溃态。 + const enqueue = options?.enqueueOutbox; + if (enqueue) { + const row = enqueue(created); + if (row) { + outboxStore.push({ + id: randomUUID(), + workspaceId: row.workspaceId, + topic: row.topic, + eventType: row.eventType, + eventId: row.eventId, + payload: row.payload, + status: "pending", + attempts: 0, + lastError: null, + createdAt: new Date(), + publishedAt: null + }); + } + } + return created; + }, + async listReactionsForMessages() { + return new Map(); + }, + async listReplyPreviews() { + return new Map(); + } + }; + // 未桩方法一律抛错(本套件只走 createMessage 路径)。 + return new Proxy(impl, { + get(target, prop: string) { + if (prop in target) { + return (target as Record)[prop]; + } + return async () => { + throw new Error(`repository.${String(prop)} not stubbed`); + }; + } + }) as ConversationRepository; +} + +function makeOutboxRepository(store: EventOutboxRow[]): EventOutboxRepository { + return { + async listPending({ limit }) { + return store.filter((row) => row.status === "pending").slice(0, Math.max(0, limit)); + }, + async markPublished({ id, at }) { + const row = store.find((entry) => entry.id === id); + if (row && row.status === "pending") { + row.status = "published"; + row.publishedAt = at ?? new Date(); + row.lastError = null; + } + }, + async markFailed({ id, error }) { + const row = store.find((entry) => entry.id === id); + if (row && row.status === "pending") { + row.attempts += 1; + row.lastError = error; + } + } + }; +} + +// backend + publish 兼作 PushBus 桩;failsLeft>0 时前几次 publish 抛错(模拟 broker 抖动 / 崩溃窗口)。 +function flakyBus(failsLeft: number) { + const published: Array<{ topic: string; type: string; data: unknown }> = []; + let remainingFailures = failsLeft; + return { + published, + bus: { + backend: "memory" as const, + async publish(topic: string, type: string, data: unknown) { + if (remainingFailures > 0) { + remainingFailures -= 1; + throw new Error("broker unavailable during crash window"); + } + published.push({ topic, type, data }); + } + } + }; +} + +function fixtures() { + const workspaceId = "aa000000-0000-4000-8000-000000000001"; + const projectId = "aa000000-0000-4000-8000-000000000002"; + const conversationId = "aa000000-0000-4000-8000-000000000003"; + const userId = "aa000000-0000-4000-8000-000000000004"; + const messageId = "aa000000-0000-4000-8000-000000000005"; + const conversationRow: ConversationRow = { + id: conversationId, + workspaceId, + projectId, + kind: "main", + title: "主区", + parentConversationId: null, + sourceMessageId: null, + visibility: "project", + nextSeq: 1, + cuuEnabled: true, + contextSummaryMd: null, + contextSummaryThroughSeq: 0, + dmKey: null, + createdBy: userId, + deletedAt: null, + deletedByUserId: null, + createdAt: now, + updatedAt: now + }; + const accessRecord: ConversationAccessRecord = { + conversation: conversationRow, + projectOwnerUserId: userId, + projectIsPersonal: false, + projectInstructionsMd: null, + projectIsDmContainer: false, + membershipRole: "member", + participantRole: null, + participantCount: 1 + }; + const actor: AuthActor = { + kind: "human", + id: userId, + label: "R20 Sender", + userId, + isAdmin: false, + orgId: "aa000000-0000-4000-8000-000000000000", + workspaceId + }; + return { workspaceId, projectId, conversationId, userId, messageId, conversationRow, accessRecord, actor }; +} + +const driveNotUsed: Pick = { + file: async () => { + throw new Error("drive must not be called for text messages"); + } +}; + +// ── 纯内存 drain 单元测试 ──────────────────────────────────────────────────────────────────── + +test("createEventOutboxDrain publishes pending rows and marks them published", async () => { + const store: EventOutboxRow[] = [ + { + id: randomUUID(), + workspaceId: "w", + topic: "conversation:c1", + eventType: "conversation.message.created", + eventId: randomUUID(), + payload: { hello: "world" }, + status: "pending", + attempts: 0, + lastError: null, + createdAt: new Date(), + publishedAt: null + } + ]; + const outbox = makeOutboxRepository(store); + const { published, bus } = flakyBus(0); + const drain = createEventOutboxDrain({ outbox, bus, logger: silentLogger }); + + const result = await drain(); + + assert.deepEqual(result, { scanned: 1, published: 1, failed: 0 }); + assert.equal(published.length, 1); + assert.equal(published[0]?.topic, "conversation:c1"); + assert.equal(store[0]?.status, "published"); + assert.ok(store[0]?.publishedAt); +}); + +test("createEventOutboxDrain keeps a row pending when publish fails, then replays it on the next drain", async () => { + const eventId = randomUUID(); + const store: EventOutboxRow[] = [ + { + id: randomUUID(), + workspaceId: "w", + topic: "conversation:c2", + eventType: "conversation.message.created", + eventId, + payload: { event_id: eventId }, + status: "pending", + attempts: 0, + lastError: null, + createdAt: new Date(), + publishedAt: null + } + ]; + const outbox = makeOutboxRepository(store); + const { published, bus } = flakyBus(1); // 第一次 publish 抛错,之后恢复 + const drain = createEventOutboxDrain({ outbox, bus, logger: silentLogger }); + + const firstPass = await drain(); + assert.deepEqual(firstPass, { scanned: 1, published: 0, failed: 1 }); + assert.equal(published.length, 0, "publish 失败时事件不能被投递出去"); + assert.equal(store[0]?.status, "pending", "失败行必须留在 pending 等重放"); + assert.equal(store[0]?.attempts, 1, "失败行 attempts +1"); + assert.ok(store[0]?.lastError, "失败行须记 last_error(禁空 catch 吞错)"); + + const secondPass = await drain(); + assert.deepEqual(secondPass, { scanned: 1, published: 1, failed: 0 }); + assert.equal(published.length, 1, "恢复后下一轮 drain 必须补发"); + assert.equal(store[0]?.status, "published"); +}); + +// ── 根因复现(纯内存,走真 createConversationService):崩溃窗口 → 下一轮 drain 补发 ────────────── + +test("R20 root cause: a committed message whose publish crashed is replayed by the next outbox drain", async () => { + const f = fixtures(); + const store: EventOutboxRow[] = []; + const repository = makeRepository(store, { + conversationRow: f.conversationRow, + accessRecord: f.accessRecord, + messageId: f.messageId + }); + const outbox = makeOutboxRepository(store); + const { published, bus } = flakyBus(1); // 即席 drain 的第一次 publish 抛错 = commit 后、publish 前崩溃 + const drain = createEventOutboxDrain({ outbox, bus, logger: silentLogger }); + const service = createConversationService(repository, { + driveFiles: driveNotUsed, + bus, + logger: silentLogger, + outboxDrain: drain, + now: () => now + }); + + const message = await service.createMessage({ + actor: f.actor, + conversationId: f.conversationId, + payload: { kind: "text", content: { text: "hello outbox" } } + }); + + // 崩溃中间态:消息行已「提交」并返回给调用方,但事件还没 publish 出去,outbox 行停在 pending。 + assert.equal(message.id, f.messageId); + assert.equal(published.length, 0, "崩溃窗口:事件尚未 publish"); + assert.equal(store.length, 1, "消息落库必须在同事务里留下恰好一条 outbox 行"); + assert.equal(store[0]?.status, "pending"); + assert.equal(store[0]?.eventType, "conversation.message.created"); + assert.equal(store[0]?.topic, `conversation:${f.conversationId}`); + + // 恢复:下一轮 drain(模拟定时调度器 / 重启后补扫)——broker 已恢复。 + const recovery = await drain(); + assert.equal(recovery.published, 1); + assert.equal(published.length, 1, "崩溃后事件被下一轮 drain 补发,绝不丢"); + assert.equal(published[0]?.type, "conversation.message.created"); + assert.equal(published[0]?.topic, `conversation:${f.conversationId}`); + const envelope = published[0]?.data as { event_id: string; type: string; data: { id: string } }; + assert.equal(envelope.event_id, store[0]?.eventId, "补发事件的 event_id = outbox 幂等键"); + assert.equal(envelope.data.id, f.messageId, "补发事件承载的正是这条消息"); + assert.equal(store[0]?.status, "published"); +}); + +test("R20 contrast: without the outbox (legacy direct publish), a publish failure loses the event unrecoverably", async () => { + const f = fixtures(); + const store: EventOutboxRow[] = []; + const repository = makeRepository(store, { + conversationRow: f.conversationRow, + accessRecord: f.accessRecord, + messageId: f.messageId + }); + const outbox = makeOutboxRepository(store); + const { published, bus } = flakyBus(1); + const legacyDrain = createEventOutboxDrain({ outbox, bus, logger: silentLogger }); + // 关键:不注入 outboxDrain → 走既有 best-effort「提交后直发」路径(修复前行为)。 + const service = createConversationService(repository, { + driveFiles: driveNotUsed, + bus, + logger: silentLogger, + now: () => now + }); + + const message = await service.createMessage({ + actor: f.actor, + conversationId: f.conversationId, + payload: { kind: "text", content: { text: "hello legacy" } } + }); + + assert.equal(message.id, f.messageId); + // 直发在崩溃窗口 publish 抛错被 best-effort 吞掉;没有 outbox 行 → 任何后续 drain 都补不回来 = 永久丢失。 + assert.equal(published.length, 0, "直发路径:publish 失败,事件没发出去"); + assert.equal(store.length, 0, "直发路径不留 outbox 行——这正是被修复的丢投裂缝"); + const recovery = await legacyDrain(); + assert.deepEqual(recovery, { scanned: 0, published: 0, failed: 0 }); + assert.equal(published.length, 0, "没有 outbox,事件永久丢失,无从补发"); +}); + +// ── 真 PG 集成复现(opt-in):端到端跑通「commit 已发生、publish 未发生 → drain 补发」 ───────────── + +test("R20 event outbox real-PG replays a committed-but-unpublished conversation message", { + skip: process.env.WORKHUB_R20_OUTBOX_REAL_PG !== "1", + timeout: 120_000 +}, async () => { + const databaseUrl = process.env.DATABASE_URL; + assert.ok(databaseUrl, "real-PG outbox 复现需要 DATABASE_URL"); + const databaseName = decodeURIComponent(new URL(databaseUrl).pathname.slice(1)); + assert.match( + databaseName, + /^workhub_r20_outbox_[a-z0-9_]+$/u, + "real-PG 复现只允许指向专用 workhub_r20_outbox_* 草稿库" + ); + + const orgId = randomUUID(); + const workspaceId = randomUUID(); + const userId = randomUUID(); + const projectId = randomUUID(); + const conversationId = randomUUID(); + const membershipId = randomUUID(); + const runTag = randomUUID().slice(0, 8); + + const settings = loadSettings({ + APP_ENV: "test", + DATABASE_URL: databaseUrl, + COOKIE_SECRET: "r20-outbox-real-pg-secret", + DEFAULT_ORG_ID: orgId, + DEFAULT_WORKSPACE_ID: workspaceId + }); + await runMigrations(settings); + const client = createDatabaseClient(settings); + try { + await client.pool.query(`insert into orgs (id, name, slug, plan) values ($1, $2, $3, 'lan')`, [ + orgId, + "R20 Outbox Org", + `r20-outbox-org-${runTag}` + ]); + await client.pool.query(`insert into workspaces (id, org_id, name, slug) values ($1, $2, $3, $4)`, [ + workspaceId, + orgId, + "R20 Outbox Workspace", + `r20-outbox-ws-${runTag}` + ]); + await client.pool.query(`insert into users (id, nickname, cookie_token, is_admin) values ($1, $2, $3, false)`, [ + userId, + `R20 Outbox Sender ${runTag}`, + `r20-outbox-cookie-${runTag}` + ]); + await client.pool.query( + `insert into workspace_memberships (id, workspace_id, user_id, role, default_workspace) + values ($1, $2, $3, 'owner', true)`, + [membershipId, workspaceId, userId] + ); + await client.pool.query( + `insert into projects (id, workspace_id, name, slug, owner_nickname, owner_user_id) + values ($1, $2, $3, $4, $5, $6)`, + [projectId, workspaceId, "R20 Outbox Project", `r20-outbox-project-${runTag}`, `R20 Outbox Sender ${runTag}`, userId] + ); + await client.pool.query( + `insert into project_conversations (id, workspace_id, project_id, kind, title, visibility, next_seq, created_by) + values ($1, $2, $3, 'main', $4, 'project', 0, $5)`, + [conversationId, workspaceId, projectId, "主区", userId] + ); + + const repository = createConversationRepository(client.db); + const outbox = createEventOutboxRepository(client.db); + const { published, bus } = flakyBus(1); // 即席 drain 第一次 publish 抛错 = commit 后、publish 前崩溃 + const drain = createEventOutboxDrain({ outbox, bus, logger: silentLogger }); + const service = createConversationService(repository, { + driveFiles: driveNotUsed, + bus, + logger: silentLogger, + outboxDrain: drain, + now: () => new Date() + }); + const actor: AuthActor = { + kind: "human", + id: userId, + label: "R20 Outbox Sender", + userId, + isAdmin: false, + orgId, + workspaceId + }; + + const message = await service.createMessage({ + actor, + conversationId, + payload: { kind: "text", content: { text: "hello real-pg outbox" } } + }); + + // 崩溃中间态(真 PG):conversation_messages 行已提交,事件未 publish,event_outbox 行 pending。 + assert.equal(published.length, 0, "崩溃窗口:事件尚未 publish"); + const committedMessage = await client.pool.query(`select id from conversation_messages where id = $1`, [message.id]); + assert.equal(committedMessage.rowCount, 1, "消息行必须真的已提交"); + const pending = await client.pool.query( + `select id, status, event_type, topic, event_id, payload from event_outbox where status = 'pending'` + ); + assert.equal(pending.rowCount, 1, "已提交消息必须在同事务里留下恰好一条 pending outbox 行"); + assert.equal(pending.rows[0].event_type, "conversation.message.created"); + assert.equal(pending.rows[0].topic, `conversation:${conversationId}`); + + // 恢复:下一轮 drain(broker 已恢复)补发。 + const recovery = await drain(); + assert.equal(recovery.published, 1); + assert.equal(published.length, 1, "崩溃后事件被下一轮 drain 补发"); + assert.equal(published[0]?.topic, `conversation:${conversationId}`); + const envelope = published[0]?.data as { event_id: string; data: { id: string } }; + assert.equal(envelope.event_id, pending.rows[0].event_id); + assert.equal(envelope.data.id, message.id); + + const settled = await client.pool.query(`select status, published_at from event_outbox where event_id = $1`, [ + pending.rows[0].event_id + ]); + assert.equal(settled.rows[0].status, "published"); + assert.ok(settled.rows[0].published_at, "published_at 必须落值"); + } finally { + await client.close(); + } +}); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index b487f3759..1cd8fc7ba 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -807,13 +807,62 @@ const authInviteListResponses = { "404": authNotFoundResponse } } as const; +// P2-02:账号已停用(墓碑已置)但善后清理(撤会话/设备/凭据/交接/在线态)未完成——回 500 让调用方感知 +// (不伪装成功),携带失败步清单;清理步幂等,重发本请求即为重试入口,直至 cleanup.complete。 +const authDeactivateCleanupIncompleteResponse = { + description: "User deactivated (tombstone set) but post-deactivation cleanup did not complete; retry the same request", + content: { + "application/json": { + schema: { + type: "object", + required: ["ok", "error", "deactivated", "cleanup"], + properties: { + ok: { type: "boolean", const: false }, + error: { + type: "object", + required: ["code", "message"], + properties: { + code: { type: "string", enum: ["offboard_cleanup_incomplete"] }, + message: { type: "string", minLength: 1 } + }, + additionalProperties: false + }, + deactivated: { type: "boolean", const: true }, + cleanup: { + type: "object", + required: ["complete", "steps"], + properties: { + complete: { type: "boolean" }, + steps: { + type: "array", + items: { + type: "object", + required: ["step", "ok"], + properties: { + step: { type: "string" }, + ok: { type: "boolean" }, + error: { type: "string" } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + } + }, + additionalProperties: false + } + } + } +} as const; const authDeactivateResponses = { responses: { "200": rawJsonResponse(authOkResponseSchema, "Deactivated user").responses["200"], "400": authBadRequestResponse, "401": authNotIdentifiedResponse, "403": authForbiddenResponse, - "404": authNotFoundResponse + "404": authNotFoundResponse, + "500": authDeactivateCleanupIncompleteResponse } } as const; // R20 P1-05:撤销邀请回体 { ok, invite_id }。 @@ -1345,18 +1394,8 @@ const createTaskPlanResponse = { "503": createTaskPlanUnavailableResponse } } as const; -const proposalListResponseSchema = { - type: "array", - items: proposalResponseSchema -} as const; -const proposalListResponse = { - responses: { - "200": jsonDataResponse(proposalListResponseSchema, "Work item proposals").responses["200"], - "401": proposalNotIdentifiedResponse, - "403": proposalForbiddenResponse, - "404": proposalNotFoundResponse - } -} as const; +// R20 R19-29:GET /workitems/{id}/proposals(list-work-item-proposals)已删——曾靠 proposalListResponseSchema/ +// proposalListResponse 撑门面,核实零消费后随路由一并删掉,POST(下面 createProposalResponse 那条)保留。 const readProposalResponse = { responses: { "200": jsonDataResponse(proposalResponseSchema, "Deliverable change proposal").responses["200"], @@ -4443,12 +4482,6 @@ const agentRunLiveResponseSchema = { }, additionalProperties: false } as const; -const agentRunHandoffResponseSchema = { - anyOf: [ - structuredHandoffResponseSchema, - { type: "null" } - ] -} as const; const snapshotResponseSchema = { type: "object", required: ["id", "work_item_id", "kind", "ref", "created_by_kind", "created_at"], @@ -4561,14 +4594,6 @@ const agentRunTraceResponse = { "422": agentRunValidationResponse } } as const; -const agentRunHandoffResponse = { - responses: { - "200": jsonDataResponse(agentRunHandoffResponseSchema, "Escalated AI worker handoff").responses["200"], - "401": agentRunNotIdentifiedResponse, - "403": agentRunForbiddenResponse, - "404": agentRunNotFoundResponse - } -} as const; const abortAgentRunResponse = { responses: { "200": jsonDataResponse(agentRunLiveResponseSchema, "Cancelled AI worker run").responses["200"], @@ -4718,29 +4743,6 @@ const pilotDay1MetricsResponseSchema = { }, additionalProperties: false } as const; -const aiWorklogResponseSchema = { - type: "object", - required: [ - "runs_today", - "autonomy_rate", - "accepted_today", - "saved_hours_estimate", - "skills_promoted_today", - "skills_refined_today", - "generated_at" - ], - properties: { - runs_today: { type: "integer", minimum: 0 }, - autonomy_rate: { type: "integer", minimum: 0, maximum: 100 }, - accepted_today: { type: "integer", minimum: 0 }, - saved_hours_estimate: { type: "number", minimum: 0 }, - skills_promoted_today: { type: "integer", minimum: 0 }, - skills_refined_today: { type: "integer", minimum: 0 }, - generated_at: dateTimeStringSchema, - range_label: { type: "string", minLength: 1 } - }, - additionalProperties: false -} as const; const pilotDay1MetricsResponses = { responses: { "200": jsonDataResponse(pilotDay1MetricsResponseSchema, "Day 1 pilot metrics snapshot").responses["200"], @@ -4753,14 +4755,8 @@ const pilotDay1MetricsResponses = { ]).responses["422"] } } as const; -const aiWorklogTodayResponses = { - responses: { - "200": jsonDataResponse(aiWorklogResponseSchema, "Today's AI worklog metrics").responses["200"], - "401": jsonErrorStatusResponse("401", "AI worklog metrics require a current authenticated user", [ - "not_identified" - ]).responses["401"] - } -} as const; +// R20 R19-29:GET /api/ai-worklog/today 已删(曾靠 aiWorklogResponseSchema/aiWorklogTodayResponses 撑门面) +// ——见 paths 里的说明,核实零消费后随路由一并删掉。 const revertAgentRunRequestBodySchema = { type: "object", required: ["snapshot_id"], @@ -8103,13 +8099,9 @@ export function getOpenApiDocument() { parameters: [pathUuidParameter("id")], ...jsonRequestBody(createProposalRequestSchema), ...createProposalResponse - }, - get: { - tags: ["proposals"], - summary: "List proposals for a work item", - parameters: [pathUuidParameter("id")], - ...proposalListResponse } + // R20 R19-29:GET(list-work-item-proposals)已删——web/desktop 均无调用点,数据早已内嵌进工作项 + // 详情页 VM(GET /api/pages/workitems/{id})。核实零消费后连路由一并删除,POST 不受影响。 }, "/api/workitems/{id}/task-plan": { post: { @@ -9762,14 +9754,9 @@ export function getOpenApiDocument() { ...agentRunTraceResponse } }, - "/api/agent-runs/{id}/handoff": { - get: { - tags: ["agent-runs"], - summary: "Read the structured handoff for an escalated AI worker run", - parameters: [pathUuidParameter("id")], - ...agentRunHandoffResponse - } - }, + // R20 R19-29:GET /api/agent-runs/{id}/handoff 已删——SDK 曾有 getAgentRunHandoff 桩但 web/desktop + // 均无调用点,同一份结构化 handoff 数据早已内嵌进 GET /api/agent-runs/{id}/replay 的回放页。核实 + // 过零消费后连路由与本文档一并删除。 "/api/agent-runs/{id}/abort": { post: { tags: ["agent-runs"], @@ -10020,14 +10007,10 @@ export function getOpenApiDocument() { ], ...pilotDay1MetricsResponses } - }, - "/api/ai-worklog/today": { - get: { - tags: ["worklog"], - summary: "Read today's AI worklog metrics", - ...aiWorklogTodayResponses - } } + // R20 R19-29:/api/ai-worklog/today 已删——web/desktop 均无调用者(SDK 从未包装这条路径), + // 同样的今日 AI 工作量数据早已经由 GET /api/pages/attention 等页面 VM 内嵌 AiWorklogMetricsService + // 交付。核实过零消费后连路由(routes/ai-worklog.ts)与本文档一并删除,不留死冗余端点。 } }); } diff --git a/apps/api/src/proposals.test.ts b/apps/api/src/proposals.test.ts index e7866da96..f1d5bcb9b 100644 --- a/apps/api/src/proposals.test.ts +++ b/apps/api/src/proposals.test.ts @@ -1396,7 +1396,7 @@ test("proposal routes require work item access before read and write operations" Cookie: await cookie(runtimeSettings) }; - const list = await app.request(`/api/workitems/${itemManifest.work_item_id}/proposals`, { headers }); + // R20 R19-29:GET /workitems/:id/proposals(list)已删(死冗余),本测试只留仍存在的读写路由。 const create = await app.request(`/api/workitems/${itemManifest.work_item_id}/proposals`, { method: "POST", headers, @@ -1427,7 +1427,6 @@ test("proposal routes require work item access before read and write operations" body: JSON.stringify({ conflict_resolution: { accept_incoming_target_keys: "not-an-array" } }) }); - assert.equal(list.status, 403); assert.equal(create.status, 403); assert.equal(read.status, 403); assert.equal(review.status, 403); @@ -3724,22 +3723,18 @@ test("proposal routes create, read, and render a page VM from a DeliverableChang const raw = await app.request(`/api/proposals/${proposalId}`, { headers: { Cookie: await cookie(runtimeSettings) } }); - const list = await app.request(`/api/workitems/${created.data.diff_manifest.work_item_id}/proposals`, { - headers: { Cookie: await cookie(runtimeSettings) } - }); + // R20 R19-29:GET /workitems/:id/proposals(list)已删(死冗余,见 routes/proposals.ts);同样的提议 + // 列表数据靠 /api/pages/proposals/:id 页 VM 与下面这条 raw GET 覆盖,不再单独验证 list 端点。 const page = await app.request(`/api/pages/proposals/${proposalId}`, { headers: { Cookie: await cookie(runtimeSettings) } }); assert.equal(raw.status, 200); - assert.equal(list.status, 200); assert.equal(page.status, 200); const rawBody = await raw.json() as { ok: true; data: { diff_manifest: DeliverableChangeManifest } }; - const listBody = await list.json() as { ok: true; data: { id: string }[] }; const pageBody = await page.json() as { ok: true; data: ReturnType }; assert.equal(rawBody.data.diff_manifest.proposal_id, proposalId); - assert.equal(listBody.data.some((proposal) => proposal.id === proposalId), true); assert.equal(pageBody.data.proposal_id, proposalId); assert.equal(pageBody.data.manifest.review.reason_required_on_reject, true); assert.equal(pageBody.data.review_actions.request_changes.requires_reason, true); diff --git a/apps/api/src/routes/agent-runs.ts b/apps/api/src/routes/agent-runs.ts index 266f3832b..a1d37f31e 100644 --- a/apps/api/src/routes/agent-runs.ts +++ b/apps/api/src/routes/agent-runs.ts @@ -359,14 +359,9 @@ export function createAgentRunRoutes(deps: AgentRunRoutesDependencies = {}) { return c.json({ ok: true, data: toAgentRunLiveVm(data) }); }); - routes.get("/agent-runs/:id/handoff", createCurrentUserMiddleware(authSource), async (c) => { - const run = await queue.get(requireUuidParam(c.req.param("id"))); - if (!run) { - throw new HTTPException(404, { message: "没有找到这次 AI 执行。" }); - } - await assertCanReadRun(run, c.var.actor, replayWorkItems); - return c.json({ ok: true, data: run.handoff ?? null }); - }); + // R20 R19-29:GET /agent-runs/:id/handoff 已删——SDK 有 getAgentRunHandoff 桩但 web/desktop 均无调用点, + // 核实过零消费(结构化 handoff 数据早已内嵌进下面 /replay 页的 handoff/handoff_md 字段)后删除, + // 不留死冗余端点。 routes.get("/agent-runs/:id/replay", createCurrentUserMiddleware(authSource), async (c) => { const locale = requestLocale(c); diff --git a/apps/api/src/routes/ai-worklog.ts b/apps/api/src/routes/ai-worklog.ts deleted file mode 100644 index 3d25c591f..000000000 --- a/apps/api/src/routes/ai-worklog.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Hono } from "hono"; - -import { - createCurrentUserMiddleware, - getDefaultAuthDependencies, - type AuthDependencySource, - type AuthEnv -} from "../middleware/auth.js"; -import { - getDefaultAiWorklogMetricsService, - type AiWorklogMetricsService -} from "../services/ai-worklog-metrics.js"; - -export type AiWorklogRoutesDependencies = { - auth?: AuthDependencySource; - metrics?: AiWorklogMetricsService; -}; - -export function createAiWorklogRoutes(deps: AiWorklogRoutesDependencies = {}) { - const routes = new Hono(); - const authSource = deps.auth ?? getDefaultAuthDependencies; - const metrics = deps.metrics ?? getDefaultAiWorklogMetricsService(); - - // 非 admin:所有登录用户都能看到"今天 AI 干了多少活"(不含成本明细,成本仍走 admin cost 页)。 - routes.get("/today", createCurrentUserMiddleware(authSource), async (c) => { - // AUTHZ-2:与首页横幅(/api/pages/attention)同源,同样按请求者工作区收口,避免跨租户聚合泄露。 - const data = await metrics.getTodayMetrics({ workspaceId: c.var.actor.workspaceId }); - return c.json({ ok: true, data }); - }); - - return routes; -} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 5efc60f3a..221dbf214 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -152,6 +152,99 @@ async function bestEffortAuthCleanup(action: string, cleanup: () => Promise { + const steps: OffboardCleanupStep[] = []; + const runStep = async (step: string, fn: () => Promise): Promise => { + try { + await fn(); + steps.push({ step, ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // 结构化告警:停用善后某步失败会留下半清理态,须可见、可重跑(P2-02)。 + console.warn( + JSON.stringify({ + event: "auth.offboard_cleanup_step_failed", + step, + target_user_id: targetId, + actor_user_id: actingUserId, + error: message + }) + ); + steps.push({ step, ok: false, error: message }); + } + }; + + // 释放邮箱(幂等:删不存在的凭据 = 0 行)。user_credentials.email 是 citext UNIQUE,停用用户若留着 + // 凭据行会让那个邮箱永远无法再注册;用户行仍以 deletedAt 软删保留供审计,只删对停用用户已无意义的凭据。 + if (deps.credentials) { + await runStep("credentials.delete_by_user", () => deps.credentials!.deleteByUserId(targetId)); + } + // 工作交接(幂等:只退回仍在认领中的非终态事项,已退回则为空集)。提交人(submitter)字段保留以保溯源, + // 终态(merged/done/cancelled)与已软删事项不动;每退回一项写一笔审计让交接可追溯。 + if (deps.workItems) { + await runStep("workitems.handover", async () => { + const reassigned = await deps.workItems!.unassignActiveClaimsForUser(targetId, at); + if (deps.auditLogs) { + for (const item of reassigned) { + await deps.auditLogs.createAuditLog({ + actorKind: "human", + actorUserId: actingUserId, + entityType: "work_item", + entityId: item.id, + action: "work_item.unassigned_on_offboarding", + detailJson: { offboarded_user_id: targetId } + }); + } + } + }); + } + // 立即切断访问(幂等:撤已撤的会话/设备 = 无操作)。 + if (deps.sessions) { + await runStep("sessions.revoke_all_for_user", async () => { + await deps.sessions!.revokeAllForUser(targetId, at); + }); + } + await runStep("devices.revoke_for_user", async () => { + const devices = await deps.devices.listByUser(targetId); + for (const device of devices) { + if (device.revokedAt === null) { + await deps.devices.revokeByIdForUser(device.id, targetId, at); + } + } + }); + await runStep("presence.forget_user", async () => { + await deps.forgetUser?.(targetId); + }); + + return { complete: steps.every((entry) => entry.ok), steps }; +} + +// P2-02 重试入口判据:softDelete 落空(isNull(deletedAt) 守卫)时,分辨「已停用(墓碑在,本次即善后 +// 重跑)」与「从不存在(真 404)」。复用既有 findRefsByIds(含墓碑、无需新迁移/新仓库方法)。 +async function isSoftDeletedUser(deps: AuthDependencies, id: string): Promise { + if (!deps.users.findRefsByIds) { + return false; // 无该可选查询的运行时:分辨不了墓碑与不存在,保持既有 404 行为(保守 fail-closed)。 + } + const refs = await deps.users.findRefsByIds([id]); + const ref = refs.find((entry) => entry.id === id); + return Boolean(ref && ref.deletedAt !== null); +} + // ENV-01 修复(R12 人工验收):昵称 identify / 桌面首启引导此前只调用 getOrCreateActiveByNickname // 建 user 行,从不建 workspace_memberships——conversations/workbench 等路由的鉴权都要求 active // membership,新用户由此处处 404。密码注册路径(见下方 /register)已有先例(memberships.create + @@ -901,63 +994,49 @@ export function createAuthRoutes( } const at = (deps.now ?? (() => new Date()))(); const deleted = await deps.users.softDelete(targetId, actingUser.id, at); - if (!deleted) { - throw new HTTPException(404, { message: "用户不存在或已停用" }); - } - // 安全事件:账号被管理员停用(账号级,区别于下方 G3 的逐工作项交接审计)。entityId=被停用用户; - // actor=执行停用的管理员。这是账号生命周期终止的权威审计点。 - await auditSecurityEvent(deps, { - actorUserId: actingUser.id, - entityId: targetId, - action: "auth.user_deactivated", - detailJson: { deactivated_nickname: deleted.nickname } - }); - // 释放邮箱:user_credentials.email 是 citext UNIQUE,停用用户若留着凭据行会让那个邮箱永远无法再注册。 - // 用户行仍以 deletedAt 软删保留供审计,只删对停用用户已无意义的凭据。顺序写——软删成功后残留凭据无害, - // 故删凭据失败不必回滚软删(与本路由后续撤会话/设备同为尽力而为的善后步骤)。 - if (deps.credentials) { - await bestEffortAuthCleanup("credentials.delete_by_user", () => deps.credentials!.deleteByUserId(targetId)); - } - // 工作交接:把被停用用户认领中的非终态事项退回可领取池(claimed_by_user_id=null),避免在岗工作卡在 - // 已消失的人身上。提交人(submitter)字段保留以保溯源,终态(merged/done/cancelled)与已软删事项不动。 - // 每退回一项写一笔审计(actor=执行停用的管理员)让交接可追溯。尽力而为——审计写失败不得使停用失败, - // 故整段裹 try/catch 仅告警(与上方撤凭据/下方撤会话设备同为善后步骤)。 - if (deps.workItems) { - try { - const reassigned = await deps.workItems.unassignActiveClaimsForUser(targetId, at); - if (deps.auditLogs) { - for (const item of reassigned) { - await deps.auditLogs.createAuditLog({ - actorKind: "human", - actorUserId: actingUser.id, - entityType: "work_item", - entityId: item.id, - action: "work_item.unassigned_on_offboarding", - detailJson: { offboarded_user_id: targetId } - }); - } - } - } catch (error) { - console.warn("offboarding work-item handover failed (best-effort)", error); + if (deleted) { + // 安全事件:账号被管理员停用(账号级,区别于 G3 的逐工作项交接审计)。entityId=被停用用户; + // actor=执行停用的管理员。这是账号生命周期终止的权威审计点,只在首次停用(软删命中)时写一笔。 + await auditSecurityEvent(deps, { + actorUserId: actingUser.id, + entityId: targetId, + action: "auth.user_deactivated", + detailJson: { deactivated_nickname: deleted.nickname } + }); + } else { + // softDelete 落空(isNull(deletedAt) 守卫):或【已停用】(墓碑在,本次即善后重跑) 或【从不存在】(真 404)。 + // P2-02:停用善后是 best-effort 步骤,中途失败会留下半清理态;重发本请求即为重试入口——重跑幂等 + // 清理收敛,不再一律 404 把重试路径堵死。用 findRefsByIds(含墓碑)分辨墓碑与不存在。 + const alreadyDeactivated = await isSoftDeletedUser(deps, targetId); + if (!alreadyDeactivated) { + throw new HTTPException(404, { message: "用户不存在或已停用" }); } } - // 立即切断访问:撤销全部服务端会话 + 客户端设备令牌。 - if (deps.sessions) { - await bestEffortAuthCleanup("sessions.revoke_all_for_user", async () => { - await deps.sessions!.revokeAllForUser(targetId, at); - }); + + // 善后清理:幂等 + 可重跑。逐步捕获失败(不吞错伪装成功),失败步落结构化日志并记入 cleanup.steps。 + const cleanup = await runOffboardCleanup(deps, targetId, actingUser.id, at); + if (!cleanup.complete) { + // 失败可见(P2-02):账号墓碑已置(首次)或早已置(重试),但某清理步未完成 → 残留访问可能仍在。 + // 回 500 让调用方感知(不伪装成功),重发本请求即重试,直至 cleanup.complete。 + const failed = cleanup.steps.filter((entry) => !entry.ok).map((entry) => entry.step); + console.warn( + JSON.stringify({ + event: "auth.offboard_cleanup_incomplete", + target_user_id: targetId, + actor_user_id: actingUser.id, + failed_steps: failed + }) + ); + return c.json( + { + ok: false, + error: { code: "offboard_cleanup_incomplete", message: "账号已停用,但部分善后清理未完成,请重试。" }, + deactivated: true, + cleanup + }, + 500 + ); } - await bestEffortAuthCleanup("devices.revoke_for_user", async () => { - const devices = await deps.devices.listByUser(targetId); - for (const device of devices) { - if (device.revokedAt === null) { - await deps.devices.revokeByIdForUser(device.id, targetId, at); - } - } - }); - await bestEffortAuthCleanup("presence.forget_user", async () => { - await deps.forgetUser?.(targetId); - }); return c.json({ ok: true }); }); diff --git a/apps/api/src/routes/proposals.ts b/apps/api/src/routes/proposals.ts index e4abb1d4e..901dc892e 100644 --- a/apps/api/src/routes/proposals.ts +++ b/apps/api/src/routes/proposals.ts @@ -1336,14 +1336,9 @@ export function createWorkItemProposalRoutes(deps: ProposalRoutesDependencies = } }); - routes.get("/workitems/:id/proposals", createCurrentUserMiddleware(authSource), async (c) => { - await assertCanReadWorkItem(c.req.param("id"), c.var.actor); - const rows = await proposals.listByWorkItem(c.req.param("id")); - return c.json({ - ok: true, - data: rows.map(({ reviews: _reviews, ...proposal }) => proposal) - }); - }); + // R20 R19-29:GET /workitems/:id/proposals(list-work-item-proposals)已删——SDK 有 listWorkItemProposals + // 桩但 web/desktop 均无调用点,同样的提议列表数据早已内嵌进工作项详情页 VM(GET /api/pages/workitems/:id)。 + // 核实过零消费后删除,POST(上面创建提议)与下面 /conflicts 不受影响。 routes.get("/workitems/:id/conflicts", createCurrentUserMiddleware(authSource), async (c) => { await assertCanReadWorkItem(c.req.param("id"), c.var.actor); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ad78bb675..0007d122a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -11,6 +11,7 @@ import { getDefaultSessionSweepScheduler } from "./workers/session-sweep.js"; import { getDefaultRiskMonitorScheduler } from "./workers/risk-monitor.js"; import { getDefaultGithubSyncScheduler } from "./workers/github-poll.js"; import { getDefaultPulseScheduler } from "./workers/pulse-scheduler.js"; +import { getDefaultEventOutboxDrainScheduler } from "./workers/event-outbox-drain.js"; // 进程级兜底:未捕获异常/未处理 rejection 此前无人接,一次走线的 throw/reject 会静默杀掉 daemon // 或留下半死状态。早注册(先于 server start),与下方 SIGINT/SIGTERM 优雅退出互补、不替代。 @@ -62,6 +63,11 @@ githubPollScheduler.start(); const pulseScheduler = settings.pulse.enabled ? getDefaultPulseScheduler() : undefined; pulseScheduler?.start(); +// R20 P2-01(事务性 outbox):会话消息事件的 outbox drain——启动即补发上次崩溃残留在 pending 的行, +// 之后周期性重放 publish 失败的行。纯 DB + bus,无 LLM 依赖,与 risk-monitor 同档无条件启动。 +const eventOutboxDrainScheduler = getDefaultEventOutboxDrainScheduler(); +eventOutboxDrainScheduler.start(); + // R12 批3:主区静默观察者——LLM provider 未配置时不启动(tick 会逐会话打 LLM,未配置只会 // 刷 consecutive_failures 噪音),与 meta-planner/cross-agent-judge 的 isConfigured 守卫同款语义。 const conversationObserverScheduler = getDefaultProviderRegistry().isConfigured() @@ -108,6 +114,7 @@ function shutdown(exitCode: number) { riskMonitorScheduler.stop(); githubPollScheduler.stop(); pulseScheduler?.stop(); + eventOutboxDrainScheduler.stop(); conversationObserverScheduler?.stop(); conversationReplyJudgeScheduler?.stop(); const forceExit = setTimeout(() => process.exit(exitCode), 2000); diff --git a/apps/api/src/services/conversations.ts b/apps/api/src/services/conversations.ts index 87fa025aa..245e68e7a 100644 --- a/apps/api/src/services/conversations.ts +++ b/apps/api/src/services/conversations.ts @@ -19,6 +19,7 @@ import { ConversationThreadRootMismatchError, createAiFeedbackRepository, createConversationRepository, + createEventOutboxRepository, getSharedDatabaseClient, type AiFeedbackRepository, type AiFeedbackRow, @@ -29,6 +30,8 @@ import { type ConversationRepository, type ConversationRow, type CreateUserMessageInput, + type EnqueueUserMessageOutbox, + type EventOutboxRepository, type MessageReactionAggregate, type ReplyPreviewTargetRow, type VisibleConversationRow, @@ -52,6 +55,7 @@ import { conversationReadCursorVmSchema, conversationReadReceiptsVmSchema, conversationReadUpdatedEventSchema, + conversationTitleUpdatedEventSchema, createConversationResultVmSchema, openDmResultVmSchema, dmListVmSchema, @@ -84,7 +88,7 @@ import { type UpdateConversationCuuRequest, type UpdateConversationCuuResultVM } from "@workhub/contracts"; -import { makeWorkHubEvent, topics } from "@workhub/events"; +import { createWorkHubEventId, makeWorkHubEvent, topics } from "@workhub/events"; // ── R17 批 G1(群成员管理 · 拍板 A:项目成员=会话参与者的可管理化) ───────────────────────── // 决策(06-gap-fix-plan.md 拍板 A,负责人代决可逆):当前产品单工作区形态下不新建「项目团队」数据层。 @@ -110,6 +114,7 @@ import type { PresenceStore } from "../broker/types.js"; import { getDefaultStructuredLogger, type StructuredLogger } from "../logging.js"; import type { AuthActor } from "../middleware/auth.js"; import { parseOutputContract } from "../pages/output-contract.js"; +import { createEventOutboxDrain, type EventOutboxDrain } from "./event-outbox.js"; import { notifyConversationMessage } from "./conversation-message-notify.js"; import { createNotificationService, type NotificationService } from "./notifications.js"; import { getDefaultConversationReplyJudgeService } from "./conversation-reply-judge.js"; @@ -269,6 +274,11 @@ export type ConversationServiceOptions = { presence: Pick; notifications: Pick; }; + // R20 P2-01(事务性 outbox):给定时——createMessage 把 conversation.message.created 事件与消息行在 + // 同一事务里写进 event_outbox,提交后调这个 drain 立刻 publish(失败留 pending 等定时/下次即席 drain + // 重放,绝不丢)。省略时(既有测试的 createConversationService 调用点)退回既有 best-effort「提交后直发」 + // 路径,行为逐字一致、零回归——只有默认服务(getDefaultConversationService)与真 PG 集成测试会注入它。 + outboxDrain?: EventOutboxDrain; }; function requireHumanActor(actor: AuthActor): HumanConversationActor { @@ -771,6 +781,35 @@ export function createConversationService( } } + // R20 P2-04(会话 rename 跨端同步):改名后广播 conversation.title.updated(best-effort,同 + // publishConversationCuuUpdated 的既有取舍——改名本身已经落库成功,不因广播失败回滚)。让别的开着这个会话的 + // 客户端就地改左栏树叶 / web 镜像页标题,接不上就等下次重挂时用会话 VM 里的 title 兜底。投到会话私有流 + // (topics.conversation,仅参与者可订,不广播全工作区)。 + async function publishConversationTitleUpdated(access: ConversationRow, title: string) { + const conversationTopic = topics.conversation(access.id).topic; + const event = parseOutputContract( + conversationTitleUpdatedEventSchema, + makeWorkHubEvent({ + type: eventTypes.conversationTitleUpdated, + topic: conversationTopic, + ts: now(), + data: { conversation_id: access.id, title } + }), + "conversations.title.event.updated" + ); + try { + await bus.publish(conversationTopic, eventTypes.conversationTitleUpdated, event); + } catch (error) { + logger.warn("conversation_title_updated_publish_failed", { + event_id: event.event_id, + topic: conversationTopic, + conversation_id: access.id, + broker_backend: bus.backend, + error + }); + } + } + function parseReactionKey(rawKey: string): ConversationReactionKey { const parsed = conversationReactionKeySchema.safeParse(rawKey); if (!parsed.success) { @@ -951,8 +990,10 @@ export function createConversationService( // 1. 会话可见(visibleConversation 已 404 挡住不可见者); // 2. 仅 collab 会话可改名——main(团队主区/个人空间单聊)一律 403,不给一个点了必失败的入口; // 3. 仅参与者/owner(participantRole !== null)——project 可见的 collab 里的旁观者不能改名。 - // 没有 conversation.updated 事件(本仓库事件面只到 message/reaction/read 三类),改名后靠客户端 - // 就地更新左栏树叶 / 下次拉会话树刷新,不广播(见交付报告说明)。 + // R20 P2-04(会话 rename 跨端同步):改名后广播 conversation.title.updated(best-effort,见 + // publishConversationTitleUpdated),让别的开着这个会话的客户端就地改左栏树叶 / web 镜像页标题,不必等 + // 下次全量轮询。历史遗留的「没有 conversation.updated 事件、只靠客户端就地更新/下次拉会话树刷新」这条 + // 取舍到此收口。 async renameConversation(input) { const { human, access } = await visibleConversation(input); const conversation = access.conversation; @@ -981,6 +1022,7 @@ export function createConversationService( } catch (error) { mapRepositoryError(error); } + await publishConversationTitleUpdated(updated, updated.title); return parseOutputContract( renameConversationResultVmSchema, { conversation: conversationToVm(updated, access.participantRole) }, @@ -1271,9 +1313,67 @@ export function createConversationService( }; } + const conversationTopic = topics.conversation(access.conversation.id).topic; + + // conversation.message.created 信封的组装口径——outbox 入队钩子(事务内,data=刚落库消息的 VM) + // 与既有 best-effort 直发路径(事务外,data=富化后的响应 VM)共用,保证两条路径产出的事件逐字一致。 + const previewTextOf = (vm: ConversationMessageVM): string => + vm.kind === "text" ? vm.content.text : vm.kind === "file_card" ? vm.content.snapshot_name : vm.kind; + const buildCreatedEvent = (data: ConversationMessageVM, eventId?: string) => + parseOutputContract( + conversationMessageCreatedEventSchema, + makeWorkHubEvent({ + ...(eventId ? { event_id: eventId } : {}), + type: eventTypes.conversationMessageCreated, + topic: conversationTopic, + ts: now(), + actor: { + actor_kind: "human", + actor_user_id: human.userId, + label: human.actor.label + }, + project_id: access.conversation.projectId, + preview_text: previewTextOf(data), + data + }), + "conversations.messages.event.created" + ); + + // R20 P2-01(事务性 outbox):开了 outbox 模式(注入 outboxDrain)时,先预取引用预览,让「消息落库的 + // 同一事务」里能同步把 conversation.message.created 写进 event_outbox——彻底关掉「消息已提交、事件未 + // publish」这条崩溃丢投窗口。省略 outboxDrain 时下面这两步都跳过,走既有直发路径,逐字零回归。 + const replyToId = input.payload.kind === "text" ? input.payload.reply_to_message_id : undefined; + const outboxReplyPreview = options.outboxDrain && replyToId + ? (await repository.listReplyPreviews({ + conversationId: access.conversation.id, + messageIds: [replyToId] + })).get(replyToId) + : undefined; + const enqueueOutbox: EnqueueUserMessageOutbox | undefined = options.outboxDrain + ? (createdRow) => { + const vm = parseOutputContract( + conversationMessageVmSchema, + messageToVm(createdRow, { replyTarget: outboxReplyPreview }), + "conversations.messages.create" + ); + const eventId = createWorkHubEventId(); + const outboxEvent = buildCreatedEvent(vm, eventId); + return { + workspaceId: human.workspaceId, + topic: conversationTopic, + eventType: eventTypes.conversationMessageCreated, + eventId, + payload: outboxEvent as unknown as Record + }; + } + : undefined; + let created: ConversationMessageRow; try { - created = await repository.createUserMessage(writeInput); + created = await repository.createUserMessage( + writeInput, + enqueueOutbox ? { enqueueOutbox } : undefined + ); } catch (error) { mapRepositoryError(error); } @@ -1283,41 +1383,36 @@ export function createConversationService( await enrichSingleMessage(access.conversation.id, created, human.userId), "conversations.messages.create" ); - const conversationTopic = topics.conversation(access.conversation.id).topic; - const previewText = message.kind === "text" - ? message.content.text - : message.kind === "file_card" - ? message.content.snapshot_name - : message.kind; - const event = parseOutputContract( - conversationMessageCreatedEventSchema, - makeWorkHubEvent({ - type: eventTypes.conversationMessageCreated, - topic: conversationTopic, - ts: now(), - actor: { - actor_kind: "human", - actor_user_id: human.userId, - label: human.actor.label - }, - project_id: access.conversation.projectId, - preview_text: previewText, - data: message - }), - "conversations.messages.event.created" - ); - try { - await bus.publish(conversationTopic, eventTypes.conversationMessageCreated, event); - } catch (error) { - logger.warn("conversation_message_publish_failed", { - event_id: event.event_id, - topic: conversationTopic, - conversation_id: message.conversation_id, - message_id: message.id, - seq: message.seq, - broker_backend: bus.backend, - error + const previewText = previewTextOf(message); + + if (options.outboxDrain) { + // 事件已在上面的事务里入队;这里立刻 drain 把它 publish 出去(happy path 与既有直发同样即时)。 + // publish 失败/进程崩溃 → 行留 pending,由定时 drain 或下一次即席 drain 重放,绝不丢。 + await options.outboxDrain().catch((error) => { + logger.warn("conversation_message_outbox_drain_failed", { + topic: conversationTopic, + conversation_id: message.conversation_id, + message_id: message.id, + seq: message.seq, + error + }); }); + } else { + // 既有 best-effort 直发路径(未接 outbox 时逐字保留):发送者已看到消息落库成功,广播失败仅 warn。 + const event = buildCreatedEvent(message); + try { + await bus.publish(conversationTopic, eventTypes.conversationMessageCreated, event); + } catch (error) { + logger.warn("conversation_message_publish_failed", { + event_id: event.event_id, + topic: conversationTopic, + conversation_id: message.conversation_id, + message_id: message.id, + seq: message.seq, + broker_backend: bus.backend, + error + }); + } } // R15 批 A(A5 消息通知):给其他参与者扇出 conversation.message 通知——fire-and-forget,绝不阻塞/ @@ -1584,6 +1679,25 @@ export function createConversationService( let defaultDbClient: WorkHubDatabaseClient | undefined; let defaultConversationService: ConversationService | undefined; +let defaultEventOutboxRepository: EventOutboxRepository | undefined; +let defaultEventOutboxDrain: EventOutboxDrain | undefined; + +// R20 P2-01:共享的 outbox 仓库/ drain 单例——createMessage 的即席 drain 与后台 +// event-outbox-drain 调度器用的是同一个 drain 实例(同一把进程内互斥、同一个 bus), +// 即席与定时两条触发路径因此不会重复扫、不会重复发同一行。 +export function getDefaultEventOutboxRepository(): EventOutboxRepository { + defaultEventOutboxRepository ??= createEventOutboxRepository(getSharedDatabaseClient().db); + return defaultEventOutboxRepository; +} + +export function getDefaultEventOutboxDrain(): EventOutboxDrain { + defaultEventOutboxDrain ??= createEventOutboxDrain({ + outbox: getDefaultEventOutboxRepository(), + bus: getDefaultPushBus(), + logger: getDefaultStructuredLogger() + }); + return defaultEventOutboxDrain; +} export function getDefaultConversationService(): ConversationService { if (!defaultConversationService) { @@ -1609,7 +1723,10 @@ export function getDefaultConversationService(): ConversationService { messageNotify: { presence: getDefaultPresenceStore(), notifications: createNotificationService() - } + }, + // R20 P2-01(事务性 outbox):消息落库与 conversation.message.created 事件同事务写 event_outbox, + // 提交后即席 drain publish。杜绝「消息已提交、事件未 publish」的崩溃丢投窗口。 + outboxDrain: getDefaultEventOutboxDrain() } ); } diff --git a/apps/api/src/services/event-outbox.ts b/apps/api/src/services/event-outbox.ts new file mode 100644 index 000000000..1a568ea88 --- /dev/null +++ b/apps/api/src/services/event-outbox.ts @@ -0,0 +1,89 @@ +import type { EventOutboxRepository, EventOutboxRow } from "@workhub/db"; + +import type { PushBus } from "../broker/index.js"; +import type { StructuredLogger } from "../logging.js"; + +// R20 P2-01(事务性 outbox · drain):把 event_outbox 里的 pending 行 publish 到 SSE/事件总线,publish +// 成功才置 published;失败则 attempts+1、记 last_error、保持 pending 等下一轮重放(至少一次投递)。 +// event_id 是幂等键——消费端本就按全量重拉对账,drain 重启/重叠导致的重复投递无害。这修的裂缝:会话 +// 消息此前是「消息行提交 → 事后 best-effort bus.publish」,两步之间进程崩溃或 publish 抛错则推送永久蒸发。 + +export type EventOutboxDrainDeps = { + outbox: Pick; + bus: Pick; + logger: Pick; + now?: () => Date; +}; + +export type EventOutboxDrainResult = { + scanned: number; + published: number; + failed: number; +}; + +export type EventOutboxDrain = (options?: { limit?: number }) => Promise; + +const DEFAULT_DRAIN_BATCH = 100; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// 单实例 drain:闭包持一把进程内互斥,把并发调用(即席 drain + 定时 drain)串行化——同进程不重复扫、 +// 不重复发同一行;跨进程的重复投递由消费端幂等兜底。默认服务共享同一个实例(请求路径与调度器都用它)。 +export function createEventOutboxDrain(deps: EventOutboxDrainDeps): EventOutboxDrain { + const now = deps.now ?? (() => new Date()); + let inFlight: Promise | undefined; + + async function runOnce(limit: number): Promise { + const rows: EventOutboxRow[] = await deps.outbox.listPending({ limit }); + let published = 0; + let failed = 0; + for (const row of rows) { + try { + await deps.bus.publish(row.topic, row.eventType, row.payload); + } catch (error) { + failed += 1; + deps.logger.warn("event_outbox_publish_failed", { + outbox_id: row.id, + event_id: row.eventId, + event_type: row.eventType, + topic: row.topic, + attempts: row.attempts, + error + }); + try { + await deps.outbox.markFailed({ id: row.id, error: errorMessage(error) }); + } catch (markError) { + deps.logger.warn("event_outbox_mark_failed_error", { outbox_id: row.id, error: markError }); + } + continue; + } + try { + await deps.outbox.markPublished({ id: row.id, at: now() }); + published += 1; + } catch (markError) { + // publish 已成功但标记失败——下一轮 drain 会重发(重复投递,消费端幂等兜底)。留 warn 供排查。 + deps.logger.warn("event_outbox_mark_published_error", { + outbox_id: row.id, + event_id: row.eventId, + error: markError + }); + } + } + return { scanned: rows.length, published, failed }; + } + + return (options = {}) => { + // 进行中的 drain 未结束则复用它——避免并发重复发。复用方拿到的可能不含自己刚入队的行,但该行仍是 + // pending,下一次即席/定时 drain 会补发,绝不丢(消费端另按全量重拉对账)。 + if (inFlight) { + return inFlight; + } + const limit = options.limit ?? DEFAULT_DRAIN_BATCH; + inFlight = runOnce(limit).finally(() => { + inFlight = undefined; + }); + return inFlight; + }; +} diff --git a/apps/api/src/workers/agent-runner.ts b/apps/api/src/workers/agent-runner.ts index ddf1ca9db..6c7b56152 100644 --- a/apps/api/src/workers/agent-runner.ts +++ b/apps/api/src/workers/agent-runner.ts @@ -1094,9 +1094,30 @@ export function createInMemoryAgentRunQueue(options: { // 仍在生效的预留,导致无预留的重跑集体超预算。与入队时 reserve 用的 reservationLeaseMs 保持同一视界。 if (reservationRepo) { const reservationLeaseExpiresAt = new Date(heartbeatAt.getTime() + reservationLeaseMs); + const reservationWorkspaceId = run.workspace_id ?? settings.auth.defaultWorkspaceId; + // P3-02:这次续租此前是 `.catch(() => {})`——DB 抛错和"命中 0 行"全被悄悄吞掉,没人知道预留租约丢了。 + // 0 行意味着这个 run 已经没有 active 预留可续(早被 releaseExpired 判过期释放,或从未成功 reserve + // 过),outstanding 计算会从此漏掉这个仍在跑的 run,并发预留可能悄悄超预算却查不出来。改为:抛错与 + // 0 行都留一条结构化日志给运维排查;不改变 run 本身的执行/状态(续租失败不等于 run 失联,claim 心跳 + // 是独立信号,已由 agent_run_claim_heartbeat_failed / agent_run_claim_lease_lost 覆盖)。 await reservationRepo - .refreshLease(run.workspace_id ?? settings.auth.defaultWorkspaceId, run.run_id, reservationLeaseExpiresAt) - .catch(() => {}); + .refreshLease(reservationWorkspaceId, run.run_id, reservationLeaseExpiresAt) + .then((renewedRows) => { + if (renewedRows === 0) { + getDefaultStructuredLogger().warn("agent_run_budget_lease_renew_no_rows", { + runId: run.run_id, + workspaceId: reservationWorkspaceId, + reservationLeaseExpiresAt: reservationLeaseExpiresAt.toISOString() + }); + } + }) + .catch((error) => { + getDefaultStructuredLogger().warn("agent_run_budget_lease_renew_failed", { + runId: run.run_id, + workspaceId: reservationWorkspaceId, + error + }); + }); } } diff --git a/apps/api/src/workers/event-outbox-drain.ts b/apps/api/src/workers/event-outbox-drain.ts new file mode 100644 index 000000000..461ad7b6d --- /dev/null +++ b/apps/api/src/workers/event-outbox-drain.ts @@ -0,0 +1,113 @@ +import { getDefaultStructuredLogger } from "../logging.js"; +import { getDefaultEventOutboxDrain } from "../services/conversations.js"; +import type { EventOutboxDrain, EventOutboxDrainResult } from "../services/event-outbox.js"; + +// R20 P2-01(事务性 outbox · 崩溃恢复):event_outbox 里 pending 的领域事件由这个调度器周期性 drain。 +// 正常发消息走即席 drain(createMessage 提交后立刻发);这个后台循环补的是两类残留: +// 1) 进程在「消息事务已提交、事件未 publish」之间崩溃——重启后 start() 先跑一次 drain 把它们补发; +// 2) publish 一时失败(如 broker 抖动)留 pending 的行——按 interval 重放至发出。 +// 与 session-sweep 同款:unref 定时器不阻塞进程退出;tick() 可独立直测(不依赖定时器)。 + +const DEFAULT_INTERVAL_MS = 30 * 1000; // 30s + +export type EventOutboxDrainScheduler = { + tick: () => Promise; + start: () => void; + stop: () => void; + stats: () => { + running: boolean; + tick_count: number; + published_count: number; + failed_count: number; + error_count: number; + last_tick_at?: string; + last_error_message?: string; + }; +}; + +export function createEventOutboxDrainScheduler(options: { + drain: EventOutboxDrain; + intervalMs?: number; + now?: () => Date; + onError?: (error: unknown) => void; +}): EventOutboxDrainScheduler { + const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + const now = options.now ?? (() => new Date()); + let timer: ReturnType | undefined; + let running = false; + let tickCount = 0; + let publishedCount = 0; + let failedCount = 0; + let errorCount = 0; + let lastTickAt: string | undefined; + let lastErrorMessage: string | undefined; + + async function tick(): Promise { + if (running) { + // 上一 tick 还没跑完 → 跳过本次(drain 自身已有进程内互斥,这里再挡一层避免堆积)。 + return { scanned: 0, published: 0, failed: 0 }; + } + running = true; + try { + const result = await options.drain(); + tickCount += 1; + publishedCount += result.published; + failedCount += result.failed; + lastTickAt = now().toISOString(); + return result; + } catch (error) { + errorCount += 1; + lastErrorMessage = error instanceof Error ? error.message : String(error); + options.onError?.(error); + throw error; + } finally { + running = false; + } + } + + function start() { + if (timer || intervalMs <= 0) { + return; + } + // 启动即跑一次:补发上次进程崩溃残留在 pending 的行(崩溃恢复的关键一跳)。 + void tick().catch((error) => { + getDefaultStructuredLogger().error("event_outbox_drain_tick_failed", { error }); + }); + timer = setInterval(() => { + void tick().catch((error) => { + getDefaultStructuredLogger().error("event_outbox_drain_tick_failed", { error }); + }); + }, intervalMs); + timer.unref?.(); + } + + function stop() { + if (!timer) { + return; + } + clearInterval(timer); + timer = undefined; + } + + return { + tick, + start, + stop, + stats: () => ({ + running, + tick_count: tickCount, + published_count: publishedCount, + failed_count: failedCount, + error_count: errorCount, + ...(lastTickAt ? { last_tick_at: lastTickAt } : {}), + ...(lastErrorMessage ? { last_error_message: lastErrorMessage } : {}) + }) + }; +} + +let defaultScheduler: EventOutboxDrainScheduler | undefined; + +export function getDefaultEventOutboxDrainScheduler(): EventOutboxDrainScheduler { + defaultScheduler ??= createEventOutboxDrainScheduler({ drain: getDefaultEventOutboxDrain() }); + return defaultScheduler; +} diff --git a/apps/desktop-webview/src/main.test.ts b/apps/desktop-webview/src/main.test.ts index 669415a96..497640c3d 100644 --- a/apps/desktop-webview/src/main.test.ts +++ b/apps/desktop-webview/src/main.test.ts @@ -366,9 +366,7 @@ function fakeClient(surface: DesktopTestSurface, session: SessionVM = intakeSess async abortAgentRun() { return { ...liveRun, status: "cancelled", run: { ...liveRun.run, status: "cancelled" } }; }, - async getAgentRunHandoff() { - return null; - }, + // R20 R19-29:getAgentRunHandoff 已从 WorkHubApiClient 删除(死端点,无消费)。 async respondApprovalsBatch(): Promise<{ approved: number; skipped: number }> { throw new Error("not needed"); }, @@ -411,15 +409,16 @@ function fakeClient(surface: DesktopTestSurface, session: SessionVM = intakeSess async createProposalFromManifest() { throw new Error("not needed"); }, - async listWorkItemProposals() { - throw new Error("not needed"); - }, + // R20 R19-29:listWorkItemProposals 已从 WorkHubApiClient 删除(死端点,无消费)。 async listWorkItemConflicts(workItemId: string) { const conflicts = ((surface as unknown as { conflicts?: ProposalConflict[] }).conflicts ?? []).filter( (conflict) => conflict.work_item_id === workItemId ); return conflicts.length > 0 ? { conflicts } : { conflicts, empty_state: "no_conflicts" as const }; }, + async getWorkItemAuditTimeline() { + throw new Error("not needed"); + }, async getProposal() { throw new Error("not needed"); }, diff --git a/apps/desktop-webview/src/spotlight/views/replay.test.ts b/apps/desktop-webview/src/spotlight/views/replay.test.ts index 8ccb96c3a..36fed9dab 100644 --- a/apps/desktop-webview/src/spotlight/views/replay.test.ts +++ b/apps/desktop-webview/src/spotlight/views/replay.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { AgentRunLiveVM } from "@workhub/contracts"; +import type { AgentRunLiveVM, AgentStep } from "@workhub/contracts"; import { createReplayView, runListHtml } from "./replay.js"; @@ -11,6 +11,34 @@ function tick() { return new Promise((resolve) => setImmediate(resolve)); } +// R20 R19-30:trace 增量轮询用 setTimeout 自排程,单测里不能真等 4s——照 search.test.ts 的既有做法 +// 劫持全局 setTimeout,把回调收进数组由测试手动触发,制造确定性。 +function withMockedSetTimeout(run: (scheduled: Array<() => void>) => Promise | T): Promise { + const previousSetTimeout = globalThis.setTimeout; + const previousClearTimeout = globalThis.clearTimeout; + const scheduled: Array<() => void> = []; + (globalThis as { setTimeout: unknown }).setTimeout = ((fn: () => void) => { + scheduled.push(fn); + return scheduled.length as unknown as ReturnType; + }) as typeof setTimeout; + (globalThis as { clearTimeout: unknown }).clearTimeout = (() => {}) as typeof clearTimeout; + const restore = () => { + (globalThis as { setTimeout: unknown }).setTimeout = previousSetTimeout; + (globalThis as { clearTimeout: unknown }).clearTimeout = previousClearTimeout; + }; + try { + const result = run(scheduled); + if (result instanceof Promise) { + return result.finally(restore); + } + restore(); + return Promise.resolve(result); + } catch (error) { + restore(); + throw error; + } +} + function liveRun(): AgentRunLiveVM { return { run: { @@ -71,7 +99,12 @@ test("R9.7 desktop replay hides raw tool ids from visible trace copy", async () addEventListener() {} } as unknown as HTMLElement; const view = createReplayView(); - view.mount({ + // R20 R19-30:liveRun() 的 status 是 running——mount 后会排一次增量轮询的 setTimeout。这里不关心轮询 + // 本身(专测见下面的 R19-30 用例),但必须在测试结束前 dispose 清掉这个定时器,否则真 4s 定时器会在 + // 测试进程里空跑,拖慢/污染后续测试。 + // mount() 的契约类型允许异步返回清理函数,但 createReplayView 的实现是同步的——按实际形状断言, + // 与本文件其它用例一致(避免为一个已知同步的实现拉进不必要的 await/thenable 判别)。 + const dispose = view.mount({ client: { async getAgentRun() { return liveRun(); } }, locale: "zh-CN", body, @@ -83,7 +116,7 @@ test("R9.7 desktop replay hides raw tool ids from visible trace copy", async () requestResize() {}, refocusBody() {}, signal: new AbortController().signal - } as unknown as Parameters[0]); + } as unknown as Parameters[0]) as (() => void) | undefined; await tick(); assert.match(body.innerHTML, /工具结果/u); @@ -91,4 +124,78 @@ test("R9.7 desktop replay hides raw tool ids from visible trace copy", async () // R9.7 review: the old replay line appended raw `tool_name`; visible Spotlight // copy must not expose machine ids even when the trace keeps them. assert.doesNotMatch(body.innerHTML, /read_project_file|markdown-report/u); + dispose?.(); +}); + +// R20 R19-30:GET /api/agent-runs/{id}/trace 早支持 ?after= 增量游标(apps/api/src/routes/agent-runs.ts), +// 但桌面/网页此前从没人传过——trace 详情页开着时若 run 还在跑,只会整份 run 重新拉(或干脆不刷新)。 +// 根因测试:run 仍在跑(status running)时,详情页排的下一轮轮询必须调 getAgentRunTrace 并带上"目前见过 +// 的最大 step_no"作游标,而不是重新整份 getAgentRun——这条断言在改动落地前会红(视图从不调 +// getAgentRunTrace),落地后转绿。 +test("R19-30 in-progress run trace polls incrementally with the after cursor instead of re-fetching the whole run", async () => { + const body = { + innerHTML: "", + addEventListener() {} + } as unknown as HTMLElement; + + await withMockedSetTimeout(async (scheduled) => { + const getAgentRunCalls: string[] = []; + const traceCalls: Array<{ runId: string; after: number | undefined }> = []; + const newStep: AgentStep = { + id: "93000000-0000-4000-8000-000000000922", + agent_run_id: "93000000-0000-4000-8000-000000000911", + step_no: 2, + phase: "tool_call", + tool_name: "list_work_items", + input_json: {}, + created_at: ts + }; + const view = createReplayView(); + const dispose = view.mount({ + client: { + async getAgentRun(runId: string) { + getAgentRunCalls.push(runId); + return liveRun(); + }, + async getAgentRunTrace(runId: string, after?: number) { + traceCalls.push({ runId, after }); + return [newStep]; + } + }, + locale: "zh-CN", + body, + back() {}, + open() {}, + target: { id: "93000000-0000-4000-8000-000000000911" }, + setSubtitle() {}, + toast() {}, + requestResize() {}, + refocusBody() {}, + signal: new AbortController().signal + } as unknown as Parameters[0]) as (() => void) | undefined; + await tick(); + + // 首次加载仍是全量(getAgentRun 一次),游标端点这时还没被调用。 + assert.deepEqual(getAgentRunCalls, ["93000000-0000-4000-8000-000000000911"]); + assert.deepEqual(traceCalls, []); + assert.equal(scheduled.length, 1, "an active run must schedule exactly one poll timer"); + + // 手动推进这一轮轮询(不真等 4s)。 + scheduled[0]?.(); + await tick(); + await tick(); + + // 游标语义:liveRun() 初始 trace 只有 step_no=1,增量请求必须带 after=1(目前见过的最大 step_no), + // 不是 0/undefined(那就是"当全量用")。 + assert.deepEqual(traceCalls, [{ runId: "93000000-0000-4000-8000-000000000911", after: 1 }]); + // 第 1 轮既无 final 步也不是第 5 轮,不该触发全量 getAgentRun 兜底核对——否则等于白做了增量。 + assert.deepEqual(getAgentRunCalls, ["93000000-0000-4000-8000-000000000911"]); + // 新步骤(step_no=2, phase=tool_call)真的追加渲染出来了,而不是原地不动;渲染层按 R9.7 既有约束 + // 只出阶段标签/固定摘要,不裸露 tool_name(同上一条测试的红线)。 + assert.match(body.innerHTML, /调用工具/u); + assert.match(body.innerHTML, /正在调用工具。/u); + assert.doesNotMatch(body.innerHTML, /list_work_items/u); + + dispose?.(); + }); }); diff --git a/apps/desktop-webview/src/spotlight/views/replay.ts b/apps/desktop-webview/src/spotlight/views/replay.ts index fa8aece65..1a1ca2262 100644 --- a/apps/desktop-webview/src/spotlight/views/replay.ts +++ b/apps/desktop-webview/src/spotlight/views/replay.ts @@ -2,7 +2,7 @@ // 列出在跑/排队的 AI 运行(pages.attention.background_runs)→ 点开看该运行的时间线(getAgentRun trace)。 // list→detail 都在盒子内联 morph;历史已完成运行从工作项/审批进入,这里聚焦「Cuu 正在干什么」。 -import type { AgentRunLiveVM, AttentionHomeVM } from "@workhub/contracts"; +import type { AgentRunLiveVM, AgentStep, AttentionHomeVM } from "@workhub/contracts"; import { escapeHtml } from "@workhub/web-runtime"; import { spotlightErrorHtml, type SpotlightCapabilityView, type SpotlightViewContext } from "../view-context.js"; @@ -10,6 +10,22 @@ import { agentRunStatusLabel, agentStepPhaseLabel, agentStepPublicSummary } from type BgRun = AttentionHomeVM["background_runs"][number]; +// R20 R19-30:详情页打开时若这个 run 还在跑,每隔这么久用 GET /api/agent-runs/{id}/trace?after= 的游标 +// 增量拉一次新步骤——只拿"上次看到的最大 step_no 之后"的新行,不是每次都整个 run 重新拉一遍(trace 端点 +// 本来就支持这个游标,此前前端从来没人传过,见 R19-30 发现)。 +const TRACE_POLL_INTERVAL_MS = 4000; +// 增量端点不带 status/usage,单靠"出现 final 步"判断收尾不够稳(失败/升级路径不一定落 final 步)——每 +// 这么多轮兜底做一次 getAgentRun 全量核对,跟丢终态。 +const STATUS_RESYNC_EVERY_N_POLLS = 5; + +function highestStepNo(steps: AgentStep[]): number { + return steps.reduce((max, step) => Math.max(max, step.step_no), 0); +} + +function isActiveRunStatus(status: AgentRunLiveVM["status"]): boolean { + return status === "queued" || status === "running"; +} + function stateLabel(state: BgRun["state"], zh: boolean): string { const map: Record = { queued: ["排队中", "Queued"], @@ -70,8 +86,19 @@ export function createReplayView(): SpotlightCapabilityView { let loadGen = 0; // rank7:上次失败的加载器,点「重试」即重跑。 let retry: (() => void) | undefined; + // R20 R19-30:增量轮询定时器——list↔trace 切换、离开能力都要先停旧的,否则前一个 run 的轮询 + // 会在后台继续拿新 run 名下不存在的 after 游标乱撞(虽然服务端会正确按 runId 过滤,但纯属浪费)。 + let pollTimer: ReturnType | undefined; + + const stopPolling = () => { + if (pollTimer !== undefined) { + clearTimeout(pollTimer); + pollTimer = undefined; + } + }; const showList = async () => { + stopPolling(); const gen = ++loadGen; ctx.setSubtitle(zh ? "AI 运行" : "AI runs"); ctx.body.innerHTML = `
${zh ? "正在拉运行…" : "Loading runs…"}
`; @@ -93,7 +120,55 @@ export function createReplayView(): SpotlightCapabilityView { ctx.requestResize(); }; + // R20 R19-30:仅当详情页打开时这个 run 还在跑(queued/running)才起轮询;已终结的 run 时间线不会 + // 再变,起了也白起。每轮先走增量 trace 游标只要新步骤,省掉整个 run 重新序列化/传输;「出现 final + // 步」或每 N 轮兜底做一次全量 getAgentRun 核对 status/usage 并判断是否已收尾。 + const pollTrace = (runId: string, gen: number, seedVm: AgentRunLiveVM, waiting: boolean) => { + if (!isActiveRunStatus(seedVm.status)) { + return; + } + let activeVm = seedVm; + let cursor = highestStepNo(seedVm.trace ?? []); + let pollCount = 0; + + const tick = async () => { + pollTimer = undefined; + if (disposed || gen !== loadGen) return; + pollCount += 1; + try { + const newSteps = await ctx.client.getAgentRunTrace(runId, cursor); + if (disposed || gen !== loadGen) return; + if (newSteps.length > 0) { + cursor = Math.max(cursor, highestStepNo(newSteps)); + activeVm = { ...activeVm, trace: [...(activeVm.trace ?? []), ...newSteps] }; + ctx.body.innerHTML = traceHtml(activeVm, zh, waiting); + ctx.requestResize(); + } + const shouldResync = newSteps.some((step) => step.phase === "final") + || pollCount % STATUS_RESYNC_EVERY_N_POLLS === 0; + if (shouldResync) { + const refreshed = await ctx.client.getAgentRun(runId); + if (disposed || gen !== loadGen) return; + activeVm = refreshed; + cursor = Math.max(cursor, highestStepNo(refreshed.trace ?? [])); + ctx.body.innerHTML = traceHtml(activeVm, zh, waiting); + ctx.requestResize(); + if (!isActiveRunStatus(refreshed.status)) { + return; + } + } + } catch { + // best-effort:瞬时网络抖动跳过这一轮,下一轮重试,不弹错误态盖掉已经在展示的时间线。 + } + if (!disposed && gen === loadGen) { + pollTimer = setTimeout(() => void tick(), TRACE_POLL_INTERVAL_MS); + } + }; + pollTimer = setTimeout(() => void tick(), TRACE_POLL_INTERVAL_MS); + }; + const showTrace = async (runId: string, runState?: string) => { + stopPolling(); const gen = ++loadGen; ctx.body.innerHTML = `
${zh ? "正在拉时间线…" : "Loading trace…"}
`; ctx.requestResize(); @@ -101,7 +176,9 @@ export function createReplayView(): SpotlightCapabilityView { const vm = await ctx.client.getAgentRun(runId); if (disposed || gen !== loadGen) return; ctx.setSubtitle(zh ? "运行时间线" : "Run trace"); - ctx.body.innerHTML = traceHtml(vm, zh, runState === "waiting_for_user"); + const waiting = runState === "waiting_for_user"; + ctx.body.innerHTML = traceHtml(vm, zh, waiting); + pollTrace(runId, gen, vm, waiting); } catch { if (disposed || gen !== loadGen) return; // L14 回归修复:retry 必须带上 runState,否则重试成功后「去拍板」按钮(仅 waiting_for_user 显示)会丢失。 @@ -140,6 +217,7 @@ export function createReplayView(): SpotlightCapabilityView { } return () => { disposed = true; + stopPolling(); }; } }; diff --git a/apps/desktop-webview/src/workbench/chat/events.test.ts b/apps/desktop-webview/src/workbench/chat/events.test.ts index 2dedea0bf..027c6d75f 100644 --- a/apps/desktop-webview/src/workbench/chat/events.test.ts +++ b/apps/desktop-webview/src/workbench/chat/events.test.ts @@ -10,6 +10,7 @@ import { parseIncomingObserverAnalyzing, parseIncomingReactionUpdated, parseIncomingReadUpdated, + parseIncomingConversationTitleUpdated, parseIncomingTyping } from "./events.js"; @@ -444,6 +445,51 @@ test("parseIncomingConversationCuuUpdated rejects garbage / wrong conversation i ); }); +// —— R20 P2-04:conversation.title.updated(会话 rename 跨端同步) —— // + +function validTitleUpdatedEvent(overrides: Record = {}): Record { + return { + event_id: eventId, + type: "conversation.title.updated", + topic: `conversation:${conversationId}`, + ts, + data: { + conversation_id: conversationId, + title: "改第三幕" + }, + ...overrides + }; +} + +test("parseIncomingConversationTitleUpdated accepts a well-formed event and returns the new title", () => { + assert.equal(parseIncomingConversationTitleUpdated(validTitleUpdatedEvent(), conversationId), "改第三幕"); +}); + +test("parseIncomingConversationTitleUpdated rejects garbage / wrong conversation / empty title instead of throwing", () => { + assert.equal(parseIncomingConversationTitleUpdated({ nope: true }, conversationId), undefined); + assert.equal(parseIncomingConversationTitleUpdated(null, conversationId), undefined); + assert.equal( + parseIncomingConversationTitleUpdated(validTitleUpdatedEvent(), "40000000-0000-4000-8000-000000000099"), + undefined + ); + // 空标题违约(min(1))——静默丢弃,不崩渲染。 + assert.equal( + parseIncomingConversationTitleUpdated( + { ...validTitleUpdatedEvent(), data: { conversation_id: conversationId, title: "" } }, + conversationId + ), + undefined + ); + // topic 与 data.conversation_id 不绑定——superRefine 拒绝。 + assert.equal( + parseIncomingConversationTitleUpdated( + { ...validTitleUpdatedEvent(), topic: "conversation:40000000-0000-4000-8000-000000000099" }, + conversationId + ), + undefined + ); +}); + // —— R14 批 CHAT:conversation.observer.analyzing(瞬态指示灯) —— // function validObserverAnalyzingEvent(overrides: Record = {}): Record { diff --git a/apps/desktop-webview/src/workbench/chat/events.ts b/apps/desktop-webview/src/workbench/chat/events.ts index d8d303a6f..a54c0b221 100644 --- a/apps/desktop-webview/src/workbench/chat/events.ts +++ b/apps/desktop-webview/src/workbench/chat/events.ts @@ -13,6 +13,7 @@ import { conversationPresenceTypingEventSchema, conversationReactionUpdatedEventSchema, conversationReadUpdatedEventSchema, + conversationTitleUpdatedEventSchema, type ConversationActionCardUpdatedEvent, type ConversationMessageReactionVM, type ConversationMessageVM @@ -199,6 +200,20 @@ export function parseIncomingConversationParticipantsUpdated( return { change: parsed.data.data.change, userId: parsed.data.data.user_id }; } +// R20 P2-04(会话 rename 跨端同步):conversation.title.updated——别的客户端给这条会话改了名,data 带新 title。 +// 同 parseIncomingConversationCuuUpdated:未过 zod 校验/会话 id 不匹配一律 undefined,调用方(view.ts)拿到 +// 新 title 就地更新左栏树叶(renameCollabConversationInVm),不整页刷。 +export function parseIncomingConversationTitleUpdated(raw: unknown, conversationId: string): string | undefined { + const parsed = conversationTitleUpdatedEventSchema.safeParse(raw); + if (!parsed.success) { + return undefined; + } + if (parsed.data.data.conversation_id !== conversationId) { + return undefined; + } + return parsed.data.data.title; +} + // R14 批 CHAT:conversation.observer.analyzing——瞬态(照 typing 模式,ttl 30s),观察者开始整理讨论时 // 发布。同 parseIncomingTyping:只回过期时刻毫秒,调用方在指示灯行区域渲「Cuu 正在整理刚才的讨论…」, // TTL 过期或收到行动卡事件即消。 diff --git a/apps/desktop-webview/src/workbench/chat/view.ts b/apps/desktop-webview/src/workbench/chat/view.ts index 045cae053..c389e8888 100644 --- a/apps/desktop-webview/src/workbench/chat/view.ts +++ b/apps/desktop-webview/src/workbench/chat/view.ts @@ -76,6 +76,7 @@ import { parseIncomingObserverAnalyzing, parseIncomingReactionUpdated, parseIncomingReadUpdated, + parseIncomingConversationTitleUpdated, parseIncomingTyping, type IncomingReactionUpdate } from "./events.js"; @@ -506,6 +507,10 @@ export function mountChatView( // R15 批 I2(决策 digest 卡):pending_digest 卡「打开收件箱」按钮点击——宿主(shell.ts)切中栏到 I1 // 的决策收件箱视图(centerTab='inbox')。可选:不接(测试/其它宿主)时 digest 卡不渲这个按钮。 onOpenInbox?: () => void; + // R20 P2-04(会话 rename 跨端同步):别的客户端把这条会话改了名——conversation.title.updated 到达时,本视图 + // 拿到新 title 交给宿主(shell.ts)就地更新左栏树叶(renameCollabConversationInVm),不整页刷。可选:不接 + // (测试/其它宿主)时纯本地丢弃,下次重挂时用会话 VM 里的 title 兜底。 + onConversationTitleUpdated?: (title: string) => void; // R14 批 CHAT(桌宠彩蛋,stretch):有人给 Cuu 的一条消息新加了个反应时,把该露的情绪信号交出去 // (celebrating/worried/thinking,见 reaction-emotion.ts 的映射)。detection 在这里做——只有本地持 // 有上一份 reactions 快照的 view.ts 能 diff 出「新增了哪个键」(reaction.updated 是全量聚合,无增量)。 @@ -2643,6 +2648,15 @@ export function mountChatView( void loadParticipants(); return; } + // R20 P2-04(会话 rename 跨端同步):conversation.title.updated——别的客户端把这条会话改了名。把新 title + // 交给宿主就地更新左栏树叶(renameCollabConversationInVm,不整页刷)。会话头(成员条 / DM 昵称)不渲会话 + // 标题,故本视图无需自渲;main/DM 收不到这个事件(服务端只在 collab renameConversation 广播)。不缓冲: + // 只是标题状态同步,不产生消息/气泡。 + const renamedTitle = parseIncomingConversationTitleUpdated(event.data, input.conversationId); + if (renamedTitle !== undefined) { + input.onConversationTitleUpdated?.(renamedTitle); + return; + } // R14 批 CHAT:conversation.observer.analyzing(瞬态指示灯)——不缓冲(渲在独立指示行区域,不进 // 滚动区),照 typing 静默丢弃模式,设过期时刻并渲指示灯,TTL 到期或收到行动卡事件即消。 const observerAnalyzing = parseIncomingObserverAnalyzing(event.data, input.conversationId); diff --git a/apps/desktop-webview/src/workbench/shell.ts b/apps/desktop-webview/src/workbench/shell.ts index 2009aafd7..3d30b39ff 100644 --- a/apps/desktop-webview/src/workbench/shell.ts +++ b/apps/desktop-webview/src/workbench/shell.ts @@ -48,6 +48,7 @@ import { bumpDmUnread, mountWorkbenchRail, reconcileConversationUnreadFromVm, + renameCollabConversationInVm, setConversationUnreadInVm, setDmUnread, type WorkbenchRailApiClient @@ -1326,7 +1327,19 @@ export function mountWorkbenchShell( onApproveProposal: approveProposalFromChat, onRequestChangesProposal: requestChangesProposalFromChat, // R16-W3:产出卡「在编辑器中查看」→ 中栏变更编辑器。 - onOpenProposalInEditor: openProposalInEditor + onOpenProposalInEditor: openProposalInEditor, + // R20 P2-04(会话 rename 跨端同步):别的客户端把这条协同会话改了名——就地把左栏树叶标题换掉 + // (renameCollabConversationInVm,同 rail 本地改名走同一个纯函数),不重拉整份 VM。 + onConversationTitleUpdated: (title: string) => { + const latestVm = store.getState().vm; + if (!latestVm) { + return; + } + const nextVm = renameCollabConversationInVm(latestVm, collabConversation.id, title); + if (nextVm !== latestVm) { + store.setState({ vm: nextVm }); + } + } }); chatMountKey = key; // R13 批 P1:情境面板默认态挂军团三区——会话情境存在时(这里是刚挂上这个协同会话的 chat 视图) diff --git a/apps/web/src/avatar-crop-modal.test.ts b/apps/web/src/avatar-crop-modal.test.ts index c21a4f7d3..1fa80ff09 100644 --- a/apps/web/src/avatar-crop-modal.test.ts +++ b/apps/web/src/avatar-crop-modal.test.ts @@ -11,6 +11,14 @@ import { openAvatarCropModal, type AvatarCropDeps, type AvatarCropElement } from // 纯裁剪数学(clampCropOffset/zoomCropTo/cropSourceRect 等)已经在 packages/ui/src/avatar/ // avatar-crop.test.ts 里被穷举单测覆盖,这里只验证「桌面/web 各自的薄 DOM 接线」本身没接错线。 +// R20 P2-10(焦点生命周期):FakeFocusTracker 模拟真实浏览器的 document.activeElement——每个 +// FakeElement.focus() 调用都把自己记成"当前持有焦点的元素",deps.getActiveElement 读它。这不是在 +// 重新发明 jsdom 的焦点系统,只是给"开弹窗焦点进去/Tab 圈闭/关弹窗焦点还回去"这条纯编排逻辑一个 +// 可观察、可断言的替身。 +class FakeFocusTracker { + active: FakeElement | null = null; +} + class FakeElement implements AvatarCropElement { style: Record = {}; className = ""; @@ -26,8 +34,11 @@ class FakeElement implements AvatarCropElement { attrs: Record = {}; children: AvatarCropElement[] = []; removed = false; + focusCount = 0; private listeners = new Map void>>(); + constructor(private readonly tracker?: FakeFocusTracker) {} + appendChild(child: AvatarCropElement): void { this.children.push(child); } @@ -47,22 +58,30 @@ class FakeElement implements AvatarCropElement { handler(event); } } + focus(): void { + this.focusCount += 1; + if (this.tracker) { + this.tracker.active = this; + } + } } function makeFakeDeps(overrides: Partial = {}) { + const tracker = new FakeFocusTracker(); const created: FakeElement[] = []; const body: FakeElement[] = []; const releaseState = { released: false }; - const previewElement = new FakeElement(); + const previewElement = new FakeElement(tracker); const deps: AvatarCropDeps = { createElement: (_tag: string) => { - const el = new FakeElement(); + const el = new FakeElement(tracker); created.push(el); return el; }, appendToBody: (el) => { body.push(el as FakeElement); }, + getActiveElement: () => tracker.active, loadImage: async () => ({ previewElement, drawSource: "fake-source-token", @@ -74,7 +93,11 @@ function makeFakeDeps(overrides: Partial = {}) { renderCrop: async () => new Blob(["fake-webp-bytes"], { type: "image/webp" }), ...overrides }; - return { deps, created, body, releaseState, previewElement }; + return { deps, created, body, releaseState, previewElement, tracker }; +} + +function keydownEvent(key: string, shiftKey = false) { + return { key, shiftKey, preventDefault: () => undefined }; } function findByTextContent(elements: FakeElement[], text: string): FakeElement { @@ -143,9 +166,11 @@ test("select image -> crop (drag + zoom) -> confirm -> upload succeeds end to en assert.equal(releaseState.released, true, "the object URL / loaded image resources must be released after confirm"); }); -test("cancel closes the modal without ever calling onConfirm or uploading anything", async () => { +test("cancel closes the modal without ever calling onConfirm or uploading anything, and restores focus to the trigger", async () => { const file = new File(["fake-picked-bytes"], "photo.png", { type: "image/png" }); - const { deps, created, body, releaseState } = makeFakeDeps(); + const { deps, created, body, releaseState, tracker } = makeFakeDeps(); + const triggerButton = new FakeElement(tracker); + tracker.active = triggerButton; let confirmCalls = 0; const openPromise = openAvatarCropModal(file, true, async () => { @@ -153,6 +178,9 @@ test("cancel closes the modal without ever calling onConfirm or uploading anythi }, deps); await flush(); + // 中间态断言:真的要先"挪走"过(进了弹窗),下面的"还回去"才不是因为压根没动过而巧合成立。 + assert.notEqual(tracker.active, triggerButton, "opening the modal must move focus into it first"); + const cancelBtn = findByTextContent(created, "取消"); cancelBtn.dispatch("click"); await openPromise; @@ -160,6 +188,65 @@ test("cancel closes the modal without ever calling onConfirm or uploading anythi assert.equal(confirmCalls, 0); assert.equal(body[0]!.removed, true); assert.equal(releaseState.released, true); + // R20 P2-10:关闭(这里是取消)必须把焦点还给弹窗打开前持有焦点的那个元素。 + assert.equal(tracker.active, triggerButton); +}); + +// R20 P2-10(根因):这个弹窗此前完全没有键盘焦点生命周期——开弹窗焦点不进去、Tab 会漏到弹窗 +// 之外的背景页面、Esc 不关闭、关闭后焦点也不还给触发钮。下面两个测试锁定四条要求:开=焦点进首个 +// 可操作件、Tab=在三个控件之间圈闭(不漏到模态之外)、Esc=像取消一样关闭、关=焦点还给触发钮。 +test("opening the crop modal focuses the zoom slider first, and Tab traps focus in a loop across the three controls", async () => { + const file = new File(["fake-picked-bytes"], "photo.png", { type: "image/png" }); + const { deps, created, body, tracker } = makeFakeDeps(); + + const openPromise = openAvatarCropModal(file, true, async () => undefined, deps); + await flush(); + + const slider = findByType(created, "range"); + const cancelBtn = findByTextContent(created, "取消"); + const confirmBtn = findByTextContent(created, "确认"); + const overlay = body[0]!; + + assert.equal(tracker.active, slider, "the zoom slider (the first operable control) must receive focus once the modal opens"); + + overlay.dispatch("keydown", keydownEvent("Tab")); + assert.equal(tracker.active, cancelBtn, "Tab from the slider must move to Cancel"); + + overlay.dispatch("keydown", keydownEvent("Tab")); + assert.equal(tracker.active, confirmBtn, "Tab from Cancel must move to Confirm"); + + overlay.dispatch("keydown", keydownEvent("Tab")); + assert.equal(tracker.active, slider, "Tab from Confirm must loop back to the slider — focus must never leak past the last control"); + + overlay.dispatch("keydown", keydownEvent("Tab", true)); + assert.equal(tracker.active, confirmBtn, "Shift+Tab from the slider must loop backward to Confirm"); + + cancelBtn.dispatch("click"); + await openPromise; +}); + +test("Escape closes the crop modal like Cancel (no confirm/upload) and restores focus to whatever had focus before it opened", async () => { + const file = new File(["fake-picked-bytes"], "photo.png", { type: "image/png" }); + const { deps, body, releaseState, tracker } = makeFakeDeps(); + const triggerButton = new FakeElement(tracker); + tracker.active = triggerButton; + let confirmCalls = 0; + + const openPromise = openAvatarCropModal(file, true, async () => { + confirmCalls += 1; + }, deps); + await flush(); + + assert.notEqual(tracker.active, triggerButton, "opening the modal must move focus away from the trigger and into the modal"); + + const overlay = body[0]!; + overlay.dispatch("keydown", keydownEvent("Escape")); + await openPromise; + + assert.equal(confirmCalls, 0, "Escape must never confirm/upload"); + assert.equal(body[0]!.removed, true); + assert.equal(releaseState.released, true); + assert.equal(tracker.active, triggerButton, "closing (via Escape) must restore focus to the element that had it before the modal opened"); }); test("a failed image load rejects instead of opening a modal for a broken file", async () => { diff --git a/apps/web/src/avatar-crop-modal.ts b/apps/web/src/avatar-crop-modal.ts index 2789e455c..2d7a57c4c 100644 --- a/apps/web/src/avatar-crop-modal.ts +++ b/apps/web/src/avatar-crop-modal.ts @@ -47,6 +47,10 @@ export type AvatarCropElement = { setAttribute(name: string, value: string): void; addEventListener(type: string, handler: (event: any) => void): void; setPointerCapture?(pointerId: number): void; + // R20 P2-10(焦点生命周期):开弹窗要把焦点送进去、Tab 圈闭要挪焦点、关弹窗要把焦点还给触发钮—— + // 三处都要能对着某个 AvatarCropElement 调用 .focus()(真实 DOM 元素天生就有;测试假元素补一个 + // 可观察的桩即可,不需要真的模拟浏览器焦点系统)。 + focus(): void; }; export type AvatarCropRect = { sx: number; sy: number; sWidth: number; sHeight: number }; @@ -65,12 +69,17 @@ export type AvatarCropDeps = { appendToBody: (el: AvatarCropElement) => void; loadImage: (file: File) => Promise; renderCrop: (source: unknown, rect: AvatarCropRect, outputSize: number) => Promise; + // R20 P2-10:当前持有焦点的元素——打开时用来记住"触发裁剪的那个按钮",关闭时把焦点还回去; + // Tab 圈闭时用来判断"现在焦点在三个可操作件里的第几个"。真实浏览器 = document.activeElement; + // 缺省(未注入)时视为 null(不做焦点管理,向后兼容任何没有传这个 dep 的调用点)。 + getActiveElement?: () => AvatarCropElement | null; }; export function defaultAvatarCropDeps(): AvatarCropDeps { return { createElement: (tag) => document.createElement(tag) as unknown as AvatarCropElement, appendToBody: (el) => document.body.appendChild(el as unknown as Node), + getActiveElement: () => document.activeElement as unknown as AvatarCropElement | null, loadImage: (file) => new Promise((resolve, reject) => { const url = URL.createObjectURL(file); @@ -131,6 +140,10 @@ export function openAvatarCropModal( onConfirm: (blob: Blob) => void | Promise, deps: AvatarCropDeps = defaultAvatarCropDeps() ): Promise { + // R20 P2-10(焦点生命周期):记下打开裁剪层之前谁有焦点(通常是"更换头像"触发钮)——关闭(无论 + // 取消/确认/Esc)都要把焦点原样还回去。loadImage 是异步的,必须在它之前、同步地捕获,否则等图片 + // 加载完时焦点可能已经不在原处了(哪怕这个窗口通常很短)。 + const triggerElement = deps.getActiveElement?.() ?? null; return new Promise((resolveOpen, rejectOpen) => { void deps .loadImage(file) @@ -209,6 +222,9 @@ export function openAvatarCropModal( previewEl.style.top = `${state.offset.y}px`; }; applyState(); + // R20 P2-10(开弹窗焦点移入):首个可操作件是缩放滑杆(DOM 里第一个真正可聚焦的控件—— + // 取景框本身不接受焦点,只能拖拽)。不挪的话读屏/键盘用户开了弹窗却毫无焦点提示。 + zoomSlider.focus(); const close = () => { if (disposed) { @@ -217,8 +233,35 @@ export function openAvatarCropModal( disposed = true; overlay.remove(); loaded.release(); + // R20 P2-10:关闭(取消/确认/Esc 任意路径都走这一个 close)把焦点原样还给触发钮, + // 键盘/读屏用户不能在裁剪层消失后跌回文档顶部、丢失原本的操作上下文。 + triggerElement?.focus(); }; + // R20 P2-10(Tab 圈闭):这个模态只有三个可操作件(缩放滑杆/取消/确认),DOM 顺序即视觉顺序即 + // 期望的 Tab 顺序。overlay 是它们共同的祖先——键盘事件会冒泡上来,在这一层统一拦截管理,不依赖 + // 浏览器原生 Tab 顺序(背景页面其余可聚焦元素仍在文档里,放任原生 Tab 会漏出模态之外)。 + const focusOrder: AvatarCropElement[] = [zoomSlider, cancelBtn, confirmBtn]; + overlay.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault?.(); + close(); + resolveOpen(); + return; + } + if (event.key !== "Tab") { + return; + } + event.preventDefault?.(); + const active = deps.getActiveElement?.() ?? null; + const currentIndex = active ? focusOrder.indexOf(active) : -1; + const lastIndex = focusOrder.length - 1; + const nextIndex = event.shiftKey + ? (currentIndex <= 0 ? lastIndex : currentIndex - 1) + : (currentIndex === -1 || currentIndex === lastIndex ? 0 : currentIndex + 1); + focusOrder[nextIndex]?.focus(); + }); + let dragging = false; let dragStart = { x: 0, y: 0 }; let dragBase = state; diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index 5d6abaceb..d90e2e453 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -4,6 +4,7 @@ import { classifyGoldPathHref, goldPathT, normalizeWorkHubLocale, + renderWorkItemAuditTimelineRows, type GoldPathAppShell, type WorkHubLocale } from "@workhub/ui/gold-path"; @@ -11,6 +12,7 @@ import { renderProposalConflictCards } from "@workhub/ui/proposal"; import { renderOnboardingScreen, renderInviteAcceptScreen } from "@workhub/ui"; import { openAvatarCropModal } from "./avatar-crop-modal.js"; import { armConfirmButton } from "./confirm-button.js"; +import { runOnboardingLocaleSync } from "./onboarding-locale-sync.js"; import { buildSettingsDeviceRow, humanizeDeviceRevokeError } from "./settings-devices.js"; import { acceptedDeliverableRestoreFromHref, @@ -2116,6 +2118,7 @@ function bindReadyRoute(result: WebRouteReadyResult, client: BrowserApiClient, l bindGoldPathNavigation(root, result.shell, client, locale, (href) => navigateWebRoute(href, client, locale), signal); bindNotificationMutePanel(root, result, client, locale, signal); bindHomeProjectsRetry(root, client, locale, signal); + bindWorkItemAuditTimelinePanel(root, result, client, locale, signal); bindProjectHomePlansPanel(root, result, client, locale, signal); bindProjectHomeInstructionsPanel(root, result, client, locale, signal); bindProjectHomeMembersPanel(root, result, client, locale, signal); @@ -2401,6 +2404,56 @@ function bindProjectHomePlansPanel( void load(); } +// R20 R19-27:工作项详情页跨 run 审计时间线——服务端早有 GET /api/workitems/:id/audit(快照 + 审计 +// 事实 + manifest 校验,packages/db audit-repository 有测试覆盖),但此前没有任何类型化客户端方法能 +// 调用它,web 端也从没渲染过。route-components.ts 只出一个待水合的占位卡(加载中文案);这里挂真实 +// 取数——成功即渲时间线(时间+动作+操作者,本地化,纯渲染逻辑在 renderWorkItemAuditTimelineRows); +// 403(无权)与其它失败分开,同 P1-07 project-home-plans 先例:无权≠没有数据,其它失败给可见告警 + +// 可点重试,绝不能拿"暂无记录"糊弄一次真实的取数失败。 +function bindWorkItemAuditTimelinePanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "workitem") { + return; + } + const section = container.querySelector("[data-r20-workitem-audit-timeline]"); + const body = section?.querySelector("[data-r20-workitem-audit-timeline-body]"); + const workItemId = section?.getAttribute("data-r20-workitem-audit-timeline-workitem") ?? ""; + if (!section || !body || !workItemId) { + return; + } + const zh = locale === "zh-CN"; + const load = async () => { + try { + const timeline = await client.getWorkItemAuditTimeline(workItemId); + if (signal.aborted) { + return; + } + body.innerHTML = renderWorkItemAuditTimelineRows(timeline.audit_logs, locale); + } catch (error) { + if (signal.aborted) { + return; + } + if (error instanceof WorkHubApiError && error.status === 403) { + body.innerHTML = `

${escapeHtml( + zh ? "你没有查看这个事项审计记录的权限。" : "You don't have permission to view this work item's audit history." + )}

`; + return; + } + body.innerHTML = `

${escapeHtml( + zh ? "审计时间线加载失败,稍后重试。" : "Couldn't load the audit timeline — retry later." + )}

`; + body.querySelector("[data-r20-workitem-audit-timeline-retry]") + ?.addEventListener("click", () => void load(), { signal }); + } + }; + void load(); +} + // G4 #24(项目自定义指令 web 入口):项目主页「自定义指令」卡——GET /api/projects/:id/instructions // 展示;能管项目(GET 成功)→ 可编辑 textarea + 失焦 PATCH 保存;无权(403)→ 只读说明。错误矩阵对齐 // 桌面 W4b1(403 forbidden / 422 validation / 其它 network,保存失败绝不回滚用户刚敲的内容)。 @@ -5144,8 +5197,17 @@ async function submitOnboarding(client: BrowserApiClient, locale: WorkHubLocale) }); currentIdentity = identityUserFrom(identity) ?? { nickname, isAdmin: false }; persistBrowserLocale(locale); - void client.updatePreferences({ locale }).catch(() => undefined); + // R20 P2-09:此前 `.catch(() => undefined)` 把语言偏好同步失败整个吞掉——用户以为界面语言已经 + // 存到服务端,换设备/清缓存后又会掉回默认语言。不阻塞进入工作台(与 renderCurrentRouteOrOnboard + // 并发发起),但落地后要是没同步成功,就给可见告警 + 就地重试按钮,不能悄悄丢。编排逻辑本身在 + // onboarding-locale-sync.ts(browser.ts 顶层引用 document,没法被单测覆盖)。 + const localeSyncedPromise = runOnboardingLocaleSync({ + updatePreferences: () => client.updatePreferences({ locale }).then(() => undefined), + showSyncFailedNotice: (retry) => showOnboardingLocaleSyncFailedNotice(locale, retry), + showSyncSucceededNotice: () => showOnboardingLocaleSyncSucceededNotice(locale) + }); await renderCurrentRouteOrOnboard(client, locale); + await localeSyncedPromise; } catch (error) { const errorText = error instanceof Error && error.message ? error.message @@ -5154,6 +5216,29 @@ async function submitOnboarding(client: BrowserApiClient, locale: WorkHubLocale) } } +// R20 P2-09:引导页语言偏好落服务端失败的可见告警——持久 notice(timeoutMs=0,不自动消失)+ 重试按钮。 +function onboardingLocaleRetryActionHtml(locale: WorkHubLocale) { + const label = locale === "en-US" ? "Retry" : "重试"; + return ``; +} + +function showOnboardingLocaleSyncFailedNotice(locale: WorkHubLocale, retry: () => void) { + if (!root) { + return; + } + showRouteNotice(root, localePersistenceFailedNotice(locale, "onboarding_locale_sync"), onboardingLocaleRetryActionHtml(locale), 0); + root + .querySelector("[data-r20-onboarding-locale-retry]") + ?.addEventListener("click", retry, { once: true }); +} + +function showOnboardingLocaleSyncSucceededNotice(locale: WorkHubLocale) { + if (!root) { + return; + } + showRouteNotice(root, actionSuccessNotice(locale, locale === "en-US" ? "Language preference saved." : "语言偏好已保存。")); +} + // R20 P1-05:邀请接受落地页(/invite,未登录可达)。boot() 在识别流之前特判此路径,渲染独立接受屏, // 不进 SPA 路由注册表(避免动 routeMatchers/routeTree 计数)。成功后服务端已 mint 会话 cookie, // location.assign("/") 触发一次全新 boot(),以新账号身份进入工作台。 diff --git a/apps/web/src/chrome-launch.ts b/apps/web/src/chrome-launch.ts index 6dd6ebcc3..095794eb1 100644 --- a/apps/web/src/chrome-launch.ts +++ b/apps/web/src/chrome-launch.ts @@ -83,23 +83,90 @@ async function stopChrome(child: ChildProcessWithoutNullStreams | undefined) { }); } -async function waitForDebugTarget(port: number, timeoutMs = 45_000) { +const stderrTailLimit = 4000; + +// R20 P2-11:launchChrome 失败此前只抛「Timed out waiting for Chrome CDP target: 」—— +// fetch 错误几乎总是 ECONNREFUSED(端口没起来),从不告诉你 Chrome 进程本身是否真的启动了、 +// 是不是秒退了、退出码/信号是什么、路径对不对、stderr 里有没有真正的根因(缺共享库/沙箱权限/ +// profile 损坏……)。QA 排障只能本地重跑加日志。这里把 spawn 错误码、进程提前退出的 exit +// code/signal、stderr 尾部、以及 chromePath/debugPort/userDataDir 全部收进同一条结构化错误信息里。 +function describeExit(code: number | null, signal: NodeJS.Signals | null): string { + if (signal) { + return `signal ${signal}`; + } + return `exit code ${code ?? "null"}`; +} + +type LaunchFailureContext = { + chromePath: string; + debugPort: number; + userDataDir: string; +}; + +function buildLaunchFailureMessage(context: LaunchFailureContext, reason: string, stderrTail: string): string { + const parts = [ + reason, + `chromePath=${context.chromePath}`, + `debugPort=${context.debugPort}`, + `userDataDir=${context.userDataDir}` + ]; + if (stderrTail.trim()) { + parts.push(`stderr=${JSON.stringify(stderrTail.trim())}`); + } + return parts.join(" | "); +} + +async function waitForDebugTarget( + child: ChildProcessWithoutNullStreams, + context: LaunchFailureContext, + timeoutMs: number, + getStderrTail: () => string +) { const deadline = Date.now() + timeoutMs; let lastError: unknown; - while (Date.now() < deadline) { - try { - const response = await fetch(`http://127.0.0.1:${port}/json/list`); - const pages = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>; - const page = pages.find((item) => item.type === "page" && item.webSocketDebuggerUrl); - if (page?.webSocketDebuggerUrl) { - return page.webSocketDebuggerUrl; + let spawnError: NodeJS.ErrnoException | undefined; + const onSpawnError = (error: NodeJS.ErrnoException) => { + spawnError = error; + }; + child.on("error", onSpawnError); + try { + while (Date.now() < deadline) { + if (spawnError) { + throw new Error( + buildLaunchFailureMessage( + context, + `Chrome process failed to spawn (${spawnError.code ?? spawnError.message})`, + getStderrTail() + ) + ); + } + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + buildLaunchFailureMessage( + context, + `Chrome process exited before its CDP debug target came up (${describeExit(child.exitCode, child.signalCode)})`, + getStderrTail() + ) + ); } - } catch (error) { - lastError = error; + try { + const response = await fetch(`http://127.0.0.1:${context.debugPort}/json/list`); + const pages = await response.json() as Array<{ type?: string; webSocketDebuggerUrl?: string }>; + const page = pages.find((item) => item.type === "page" && item.webSocketDebuggerUrl); + if (page?.webSocketDebuggerUrl) { + return page.webSocketDebuggerUrl; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 120)); } - await new Promise((resolve) => setTimeout(resolve, 120)); + throw new Error( + buildLaunchFailureMessage(context, `Timed out waiting for Chrome CDP target: ${String(lastError)}`, getStderrTail()) + ); + } finally { + child.off("error", onSpawnError); } - throw new Error(`Timed out waiting for Chrome CDP target: ${String(lastError)}`); } function chromeExtraArgs() { @@ -117,6 +184,7 @@ export async function launchChrome( ) { await rm(userDataDir, { recursive: true, force: true }); await mkdir(userDataDir, { recursive: true }); + const context: LaunchFailureContext = { chromePath, debugPort, userDataDir }; const child = spawn(chromePath, [ "--headless=new", ...chromeExtraArgs(), @@ -129,10 +197,18 @@ export async function launchChrome( `--user-data-dir=${userDataDir}`, "--window-size=1365,1100", "about:blank" - ], { stdio: "ignore" }) as ChildProcessWithoutNullStreams; + ], { stdio: ["ignore", "ignore", "pipe"] }) as unknown as ChildProcessWithoutNullStreams; + + let stderrTail = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString("utf8")).slice(-stderrTailLimit); + }); + // Avoid an unhandled 'error' on the stderr stream itself turning into noise unrelated to the launch outcome. + child.stderr.on("error", () => undefined); + let cdp: CdpClient | undefined; try { - const websocketUrl = await waitForDebugTarget(debugPort, options.debugTargetTimeoutMs); + const websocketUrl = await waitForDebugTarget(child, context, options.debugTargetTimeoutMs ?? 45_000, () => stderrTail); cdp = await CdpClient.connect(websocketUrl); await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); diff --git a/apps/web/src/live-stream-targets.test.ts b/apps/web/src/live-stream-targets.test.ts index bfd637efd..a9f7bf43f 100644 --- a/apps/web/src/live-stream-targets.test.ts +++ b/apps/web/src/live-stream-targets.test.ts @@ -48,6 +48,9 @@ test("R20 P2-06 the web conversation mirror route subscribes to its conversation // 订阅面 = 会改动只读镜像可见内容的会话事件(新消息 / 编辑删除置顶 / reaction / 参与者)。 assert.deepEqual(conversationTarget.eventTypes, [...CONVERSATION_MIRROR_LIVE_EVENT_TYPES]); assert.ok(conversationTarget.eventTypes?.includes("conversation.message.created")); + // R20 P2-04(会话 rename 跨端同步):会话被改名时镜像页要重渲对齐新标题——conversation.title.updated 必须 + // 在订阅面内。EventSource 按事件名订阅,漏登记 = 事件被静默丢弃、镜像页永远显示旧名字。 + assert.ok(conversationTarget.eventTypes?.includes("conversation.title.updated")); // 断线重连要补拉全量对账,不能只靠增量。 assert.equal(conversationTarget.refreshOnReconnect, true); }); diff --git a/apps/web/src/live-stream-targets.ts b/apps/web/src/live-stream-targets.ts index 9a2817621..35374a747 100644 --- a/apps/web/src/live-stream-targets.ts +++ b/apps/web/src/live-stream-targets.ts @@ -7,17 +7,20 @@ import type { WebRouteReadyResult } from "./routes.js"; // browser.ts 只负责把 client.streams 传进来并把结果交给 liveRuntime.syncTargets。 // 会话专属窄流(/api/push/stream/conversation/:id)的订阅事件面。只订「会改动只读镜像可见内容」的 -// 已定型事件——新消息、编辑/删除/置顶(整条替换)、reaction 聚合、参与者集合变化。刻意不订: +// 已定型事件——新消息、编辑/删除/置顶(整条替换)、reaction 聚合、参与者集合变化、会话改名(标题)。刻意不订: // * message.delta(AI 流式增量,会引发刷新风暴——定型后有 message.created/updated 兜底); // * presence.typing / observer.analyzing(瞬态信号,镜像不渲染); // * read.updated(镜像是只读、不展示未读态);cuu.updated / action_card.updated(镜像不渲染这些)。 // 收到其一即触发一次全量重渲(renderCurrentRouteOrOnboard 重拉 listConversationMessages)——seq 合并= // 服务端权威(按 seq 排序、去重、含墓碑),去抖窗口把乱序/重复的一批事件并成一次拉取,天然幂等。 +// R20 P2-04(会话 rename 跨端同步):conversation.title.updated 也纳入——会话被改名时镜像页标题随下一次全量 +// 重渲对齐权威(EventSource 按事件名订阅,未列入这张表的事件会被静默丢弃,故必须显式登记,见 live-runtime)。 export const CONVERSATION_MIRROR_LIVE_EVENT_TYPES: readonly string[] = [ eventTypes.conversationMessageCreated, eventTypes.conversationMessageUpdated, eventTypes.conversationReactionUpdated, - eventTypes.conversationParticipantsUpdated + eventTypes.conversationParticipantsUpdated, + eventTypes.conversationTitleUpdated ]; // browser.ts 的 client.streams 子集——只取本模块会用到的窄流 URL 构造器。 diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index cbca1008a..da853d8ac 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -241,9 +241,7 @@ function fakeClient(surface: GoldPathSurfaceVM, session: SessionVM = intakeSessi async abortAgentRun() { return { ...liveRun, status: "cancelled", run: { ...liveRun.run, status: "cancelled" } }; }, - async getAgentRunHandoff() { - return null; - }, + // R20 R19-29:getAgentRunHandoff 已从 WorkHubApiClient 删除(死端点,无消费)。 async respondApprovalsBatch(): Promise<{ approved: number; skipped: number }> { throw new Error("not needed"); }, @@ -286,15 +284,16 @@ function fakeClient(surface: GoldPathSurfaceVM, session: SessionVM = intakeSessi async createProposalFromManifest() { throw new Error("not needed"); }, - async listWorkItemProposals() { - throw new Error("not needed"); - }, + // R20 R19-29:listWorkItemProposals 已从 WorkHubApiClient 删除(死端点,无消费)。 async listWorkItemConflicts(workItemId: string) { const conflicts = ((surface as unknown as { conflicts?: ProposalConflict[] }).conflicts ?? []).filter( (conflict) => conflict.work_item_id === workItemId ); return conflicts.length > 0 ? { conflicts } : { conflicts, empty_state: "no_conflicts" as const }; }, + async getWorkItemAuditTimeline() { + throw new Error("not needed"); + }, async getProposal() { throw new Error("not needed"); }, diff --git a/apps/web/src/onboarding-locale-sync.test.ts b/apps/web/src/onboarding-locale-sync.test.ts new file mode 100644 index 000000000..513f80d19 --- /dev/null +++ b/apps/web/src/onboarding-locale-sync.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runOnboardingLocaleSync, type OnboardingLocaleSyncDeps } from "./onboarding-locale-sync.js"; + +// R20 P2-09(根因):引导页选好语言 → 报到成功后,同步语言偏好到服务端此前是 +// `void client.updatePreferences({ locale }).catch(() => undefined)`——失败被整个吞掉,用户毫无察觉、 +// 也没有重试的出路。这里直接单测被抽出来的编排单元:失败必须可见(showSyncFailedNotice 被调用)、 +// 必须可重试(拿到的 retry 回调再调一次会重新尝试 updatePreferences),绝不能对失败沉默。 + +function makeDeps(overrides: Partial = {}) { + const failedNotices: Array<() => void> = []; + const succeededCalls: number[] = []; + const deps: OnboardingLocaleSyncDeps = { + updatePreferences: async () => undefined, + showSyncFailedNotice: (retry) => { + failedNotices.push(retry); + }, + showSyncSucceededNotice: () => { + succeededCalls.push(1); + }, + ...overrides + }; + return { deps, failedNotices, succeededCalls }; +} + +test("a successful sync never shows a failure notice and resolves true", async () => { + const { deps, failedNotices, succeededCalls } = makeDeps(); + const result = await runOnboardingLocaleSync(deps); + assert.equal(result, true); + assert.equal(failedNotices.length, 0, "a successful first attempt must not surface any failure notice"); + assert.equal(succeededCalls.length, 0, "the success notice is reserved for a *retry* recovering — first-try success is silent (matches renderCurrentRouteOrOnboard landing normally)"); +}); + +test("a failed sync is never swallowed — it surfaces a visible failure notice with a working retry, and resolves false", async () => { + let callCount = 0; + const { deps, failedNotices } = makeDeps({ + updatePreferences: async () => { + callCount += 1; + throw new Error("network_error"); + } + }); + + const result = await runOnboardingLocaleSync(deps); + + assert.equal(result, false, "the caller must be able to tell the sync did not succeed"); + assert.equal(callCount, 1, "exactly one attempt so far"); + assert.equal(failedNotices.length, 1, "a failure must be visible — this is the exact swallow-bug regression: previously nothing observed the failure at all"); + + // Clicking "retry" must actually retry the network call — not just re-show the same dead notice. + failedNotices[0]!(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(callCount, 2, "retry must invoke updatePreferences again"); +}); + +test("retry can keep failing — every failed attempt (including retries) re-surfaces a visible, still-retryable notice", async () => { + let callCount = 0; + const { deps, failedNotices, succeededCalls } = makeDeps({ + updatePreferences: async () => { + callCount += 1; + throw new Error("still_down"); + } + }); + + await runOnboardingLocaleSync(deps); + assert.equal(failedNotices.length, 1); + + failedNotices[0]!(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(callCount, 2); + assert.equal(failedNotices.length, 2, "a second failed attempt must surface its own failure notice too — retrying must never go silent"); + assert.equal(succeededCalls.length, 0); +}); + +test("a retry that succeeds shows the success notice instead of failing silently again", async () => { + let callCount = 0; + const { deps, failedNotices, succeededCalls } = makeDeps({ + updatePreferences: async () => { + callCount += 1; + if (callCount === 1) { + throw new Error("network_error"); + } + } + }); + + await runOnboardingLocaleSync(deps); + assert.equal(failedNotices.length, 1); + + failedNotices[0]!(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(callCount, 2); + assert.equal(succeededCalls.length, 1, "a recovering retry must give positive visible confirmation"); + assert.equal(failedNotices.length, 1, "no further failure notice once the retry succeeded"); +}); diff --git a/apps/web/src/onboarding-locale-sync.ts b/apps/web/src/onboarding-locale-sync.ts new file mode 100644 index 000000000..0d8f3f991 --- /dev/null +++ b/apps/web/src/onboarding-locale-sync.ts @@ -0,0 +1,37 @@ +// R20 P2-09:引导页选好语言 → 报到成功后,把语言偏好同步到服务端这一步此前是 +// `void client.updatePreferences({ locale }).catch(() => undefined)`——失败被整个吞掉,用户以为 +// 界面语言已经存下,实际上服务端偏好没变;下次换设备/清本地缓存,界面又会掉回默认语言,且用户全程 +// 毫无察觉、无法重试。 +// +// 抽成独立、可注入依赖的纯编排单元(同 confirm-button.ts / avatar-crop-modal.ts 的先例):browser.ts +// 顶层引用了 `document`,这个 workspace 的测试运行器(node --import tsx --test,无 jsdom)一 import +// browser.ts 就会在模块顶层炸——重试编排逻辑本身不能和那一行顶层 DOM 访问共享同一个模块,否则永远 +// 没法被单测覆盖到。生产侧 browser.ts 只负责把 updatePreferences 调用 + 两条 notice 渲染接成 +// OnboardingLocaleSyncDeps 喂给这里。 +export type OnboardingLocaleSyncDeps = { + /** 尝试把语言偏好同步到服务端;resolve=成功,reject=失败。 */ + updatePreferences: () => Promise; + /** 同步失败(首次或重试都一样)时调用:必须渲染可见告警,并把 retry 挂到某个可点的地方 + *(例如告警里的"重试"按钮)。retry 可以被调用任意次——每次都会再尝试一次 updatePreferences。 */ + showSyncFailedNotice: (retry: () => void) => void; + /** 一次成功的同步(首次或某次重试)之后调用:渲染确认反馈。 */ + showSyncSucceededNotice: () => void; +}; + +// resolve 的布尔值=首次尝试是否直接成功(false 时代表已经触发了 showSyncFailedNotice,调用方不需要 +// 再额外处理——后续的重试/成功反馈完全由这里内部通过 deps 回调闭环,不需要调用方继续 await 任何东西)。 +export async function runOnboardingLocaleSync(deps: OnboardingLocaleSyncDeps): Promise { + const attempt = (): void => { + void deps.updatePreferences().then( + () => deps.showSyncSucceededNotice(), + () => deps.showSyncFailedNotice(attempt) + ); + }; + try { + await deps.updatePreferences(); + return true; + } catch { + deps.showSyncFailedNotice(attempt); + return false; + } +} diff --git a/apps/web/src/r4-web-live-route-interaction.test.ts b/apps/web/src/r4-web-live-route-interaction.test.ts index 732511369..f53d33ad7 100644 --- a/apps/web/src/r4-web-live-route-interaction.test.ts +++ b/apps/web/src/r4-web-live-route-interaction.test.ts @@ -61,3 +61,69 @@ async function waitForMarker(pathname: string, expected: string, timeoutMs = 4_0 } assert.equal(lastValue, expected); } + +// R20 P2-11(根因):launchChrome 失败此前只抛一句笼统的「Timed out waiting for Chrome CDP target: +// 」——fetch 错误几乎总是 ECONNREFUSED,从不告诉你 Chrome 进程是不是根本没起来、秒退了、 +// 退出码/信号是什么、stderr 里到底写了什么真正原因。下面两个测试注入假 chrome 可执行文件(同上一个 +// 测试的手法:用一个可控的 shell 脚本冒充 chrome 二进制,而不是真的起 Chrome),分别覆盖「进程压根没 +// 起来(ENOENT)」和「进程起来了但立刻带着诊断信息退出(典型的缺共享库/沙箱权限/profile 损坏)」两类 +// 根因,断言抛出的错误里带着这些根因信息,而不是只有超时提示。 + +test("launchChrome surfaces the spawn error (e.g. ENOENT) instead of just timing out silently", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "workhub-r20-chrome-spawn-error-")); + try { + const missingChromePath = path.join(tmp, "does-not-exist-chrome-binary"); + await assert.rejects( + launchChrome(missingChromePath, 65533, path.join(tmp, "profile"), { debugTargetTimeoutMs: 5_000 }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /failed to spawn/u, "must name the failure mode, not just 'timed out'"); + assert.match(error.message, /ENOENT/u, "must surface the OS-level spawn error code"); + assert.ok( + error.message.includes(missingChromePath), + "must surface the chromePath that was attempted, for fast root-causing" + ); + return true; + } + ); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +test("launchChrome surfaces exit code and captured stderr when Chrome dies immediately instead of timing out silently", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "workhub-r20-chrome-early-exit-")); + try { + const fakeChromePath = path.join(tmp, "fake-chrome-crash.sh"); + await writeFile( + fakeChromePath, + [ + "#!/bin/sh", + // Simulate a real Chrome crash: writes a diagnosable reason to stderr, then exits non-zero + // before ever opening its CDP debug port. + "echo 'error while loading shared libraries: libfoo.so.1: cannot open shared object file' 1>&2", + "exit 17", + "" + ].join("\n"), + "utf8" + ); + await chmod(fakeChromePath, 0o755); + + await assert.rejects( + launchChrome(fakeChromePath, 65532, path.join(tmp, "profile"), { debugTargetTimeoutMs: 5_000 }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /exited before its CDP debug target came up/u, "must name the failure mode"); + assert.match(error.message, /exit code 17/u, "must surface the process exit code"); + assert.match( + error.message, + /libfoo\.so\.1/u, + "must surface captured stderr so the real root cause (missing shared library, in this example) is visible without a local re-run" + ); + return true; + } + ); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/routes.test.ts b/apps/web/src/routes.test.ts index 7ebaac9aa..966c58809 100644 --- a/apps/web/src/routes.test.ts +++ b/apps/web/src/routes.test.ts @@ -59,13 +59,15 @@ type RouteClientOverrides = { teamSkillsManage?: TeamSkillManagementPageVM; conflicts?: ProposalConflict[]; conversationMessages?: ConversationMessagePageVM; - users?: { users: Array<{ id: string; nickname: string; is_admin: boolean }> }; + // R20 P1-08 收尾:会话镜像的发送者昵称解析改走工作区花名册(GET /api/workspace/roster),不再是全局 + // /api/users——起名 roster 而非 users,避免和已删的 listUsers fake 同名误导。 + roster?: Array<{ user_id: string; nickname: string; is_admin: boolean }>; attentionError?: Error; approvalsError?: Error; costError?: Error; knowledgeError?: Error; conversationMessagesError?: Error; - usersError?: Error; + rosterError?: Error; projectsError?: Error; }; @@ -754,12 +756,19 @@ function fakeRouteClient(surface: GoldPathSurfaceVM, overrides: RouteClientOverr } return overrides.projects ?? projectListVm(); }, - async listUsers() { - calls.push("listUsers"); - if (overrides.usersError) { - throw overrides.usersError; + // R20 P1-08 收尾:fetchWorkspaceRosterMembers 只依赖一个泛型 request(path) 方法(见 + // workspace-roster.ts),这里喂假实现服务 GET /api/workspace/roster,取代已删的 listUsers 假端点。 + // 单页返回全部(total===members.length),与真实分页契约一致但测试数据量小用不到翻页。 + async request(path: string) { + if (path.startsWith("/api/workspace/roster")) { + calls.push("workspaceRoster"); + if (overrides.rosterError) { + throw overrides.rosterError; + } + const members = overrides.roster ?? []; + return { members, total: members.length, limit: 100, offset: 0 }; } - return overrides.users ?? { users: [] }; + throw new Error(`fakeRouteClient: unhandled request path ${path}`); }, async listConversationMessages(conversationId: string, options?: { beforeSeq?: number; afterSeq?: number; limit?: number }) { const cursor = options?.beforeSeq !== undefined @@ -2572,19 +2581,17 @@ function conversationMessagePageVm(overrides: Partial return { messages, has_more: false, next_after_seq: 9, next_before_seq: 5, ...overrides }; } -function conversationUsers() { - return { - users: [ - { id: MIRROR_OWNER_ID, nickname: "R15 owner", is_admin: true }, - { id: MIRROR_IVY_ID, nickname: "Ivy", is_admin: false } - ] - }; +function conversationRosterMembers() { + return [ + { user_id: MIRROR_OWNER_ID, nickname: "R15 owner", is_admin: true }, + { user_id: MIRROR_IVY_ID, nickname: "Ivy", is_admin: false } + ]; } test("R15 web-mirror conversation route renders a read-only message mirror (latest page)", async () => { const { client, calls } = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm(), - users: conversationUsers() + roster: conversationRosterMembers() }); const match = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}`); assert.ok(match); @@ -2595,7 +2602,8 @@ test("R15 web-mirror conversation route renders a read-only message mirror (late // 首屏 = 最新一页:beforeSeq=MAX(O(1),同桌面首屏策略)。 assert.ok(calls.some((call) => call.startsWith(`conversationMessages:${MIRROR_CONVERSATION_ID}:before=${Number.MAX_SAFE_INTEGER}:`))); // 成员目录用于昵称解析。 - assert.ok(calls.includes("listUsers")); + // R20 P1-08 收尾:昵称解析走工作区花名册端点,不再是全局 /api/users。 + assert.ok(calls.includes("workspaceRoster")); // 只读边界:绝不 POST——没有任何写端点被触及(发消息/反应/已读游标/turns 全无)。 assert.equal(calls.filter((call) => /receipt|turn|reaction|:read|typing/u.test(call)).length, 0); @@ -2630,7 +2638,7 @@ test("R15 web-mirror conversation route renders a read-only message mirror (late test("R15 web-mirror ?seq= locates the target message and never advances the read cursor", async () => { const { client, calls } = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm(), - users: conversationUsers() + roster: conversationRosterMembers() }); const match = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}?seq=5`); assert.ok(match); @@ -2653,7 +2661,7 @@ test("R15 web-mirror pagination cursors follow the before/after read-endpoint se // 最新页 + 还有更早:只出「更早」链接(before=next_before_seq),无更新/回最新。 const latest = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm({ has_more: true, next_before_seq: 3, next_after_seq: 9 }), - users: conversationUsers() + roster: conversationRosterMembers() }); const latestMatch = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}`); assert.ok(latestMatch); @@ -2667,7 +2675,7 @@ test("R15 web-mirror pagination cursors follow the before/after read-endpoint se // 更早页(?before=):更早(before=next_before_seq)+ 更新(after=页内最新 seq)+ 回到最新 三个都在。 const before = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm({ has_more: true, next_before_seq: 3, next_after_seq: 9 }), - users: conversationUsers() + roster: conversationRosterMembers() }); const beforeMatch = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}?before=100`); assert.ok(beforeMatch); @@ -2681,7 +2689,7 @@ test("R15 web-mirror pagination cursors follow the before/after read-endpoint se // 正向页(?after=):afterSeq 请求;has_more → 更新(after=next_after_seq),更早回溯页内最旧 seq。 const after = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm({ has_more: true, next_after_seq: 42 }), - users: conversationUsers() + roster: conversationRosterMembers() }); const afterMatch = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}?after=4`); assert.ok(afterMatch); @@ -2696,7 +2704,7 @@ test("R15 web-mirror pagination cursors follow the before/after read-endpoint se test("R15 web-mirror non-participant / missing conversation falls to the recoverable not-found state", async () => { const { client } = fakeRouteClient(goldPathSurfaceVm(), { conversationMessagesError: new WorkHubApiError(404, "conversation_not_found", "没有找到这个会话。"), - users: conversationUsers() + roster: conversationRosterMembers() }); const match = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}`); assert.ok(match); @@ -2710,7 +2718,7 @@ test("R15 web-mirror non-participant / missing conversation falls to the recover test("R15 web-mirror member-directory failure degrades softly to unknown-member labels", async () => { const { client } = fakeRouteClient(goldPathSurfaceVm(), { conversationMessages: conversationMessagePageVm(), - usersError: new Error("directory unavailable") + rosterError: new Error("directory unavailable") }); const match = resolveWebRoute(`/conversations/${MIRROR_CONVERSATION_ID}`); assert.ok(match); diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index bdb7f6e1f..d89b80e26 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -1,4 +1,5 @@ import { WorkHubApiError, type WorkHubApiClient } from "@workhub/api-client"; +import { fetchWorkspaceRosterMembers } from "./workspace-roster.js"; import type { AgentArmyDashboardVM, ApprovalCenterVM, @@ -1392,10 +1393,16 @@ async function loadRouteSurface(client: WorkHubApiClient, match: WebRouteMatch, // 消息(主数据,参与者门控在服务端——非参与者 404 走既有 notFound 态)与成员目录并行拉。 // 成员目录仅用于发送者昵称解析,失败 fail-soft(消息照常渲,昵称退化为「未知成员」); // not_identified 仍冒泡去重认证。 + // R20 P1-08 收尾:昵称解析改走工作区花名册(GET /api/workspace/roster,翻页翻到底),不再用全局 + // /api/users——核实过 GET /conversations/:id/participants 这条路:main 会话没有参与者行,恒回 + // scope:"workspace" + 空列表(apps/api/src/services/conversations.ts listParticipants),对本页最常见 + // 的主区会话完全没有昵称可用;collab/DM 虽然有真实参与者行,但退群成员的历史发言同样解析不出——两条 + // 路径对"已离开的历史发言人"都不覆盖,参与者端点对主区会话覆盖面更差(不是"次优"而是"没有"),所以选 + // 工作区花名册这条更通用、且与审批转交选择器(browser.ts)已用的同一数据源一致。 const [page, members] = await Promise.all([ client.listConversationMessages(conversationId, requestOptions), - client.listUsers().then( - (value) => value.users.map((user) => ({ id: user.id, nickname: user.nickname })), + fetchWorkspaceRosterMembers(client).then( + (value) => value.map((member) => ({ id: member.user_id, nickname: member.nickname })), (error: unknown): Array<{ id: string; nickname: string }> => { if (error instanceof WorkHubApiError && error.code === "not_identified") { throw error; diff --git a/client-tauri/src-tauri/src/config.rs b/client-tauri/src-tauri/src/config.rs index 6508c5933..33b6adbb7 100644 --- a/client-tauri/src-tauri/src/config.rs +++ b/client-tauri/src-tauri/src/config.rs @@ -134,7 +134,7 @@ mod tests { locale: WorkHubLocale::EnUs, }; - assert_eq!(config.has_trusted_device_token(), true); + assert!(config.has_trusted_device_token()); assert_eq!(config.client_token_tail(), Some("4L3P".to_string())); } diff --git a/client-tauri/src-tauri/src/deep_link.rs b/client-tauri/src-tauri/src/deep_link.rs index 64637f750..377f3d11c 100644 --- a/client-tauri/src-tauri/src/deep_link.rs +++ b/client-tauri/src-tauri/src/deep_link.rs @@ -352,10 +352,9 @@ mod tests { assert_eq!(bare.route, "/workbench"); assert_eq!(bare.window_control.label, "workbench"); - let project = deep_link_plan_from_url( - "workhub://workbench/86000000-0000-4000-8000-000000000001", - ) - .unwrap(); + let project = + deep_link_plan_from_url("workhub://workbench/86000000-0000-4000-8000-000000000001") + .unwrap(); assert_eq!( project.route, "/workbench/86000000-0000-4000-8000-000000000001" diff --git a/client-tauri/src-tauri/src/events.rs b/client-tauri/src-tauri/src/events.rs index 7803bc5fc..35ba973a0 100644 --- a/client-tauri/src-tauri/src/events.rs +++ b/client-tauri/src-tauri/src/events.rs @@ -25,6 +25,18 @@ pub fn event_channel(event: ShellEvent) -> ShellEventChannel { } } +pub fn event_channel_name(event: ShellEvent) -> &'static str { + match event { + ShellEvent::PushEvent => "push-event", + ShellEvent::SseStatus => "sse-status", + ShellEvent::Navigate => "navigate", + ShellEvent::DeepLink => "deep-link", + ShellEvent::TrayAction => "tray-action", + ShellEvent::SystemNotification => "system-notification", + ShellEvent::SingleInstance => "single-instance", + } +} + #[cfg(test)] mod tests { use super::*; @@ -44,15 +56,3 @@ mod tests { ); } } - -pub fn event_channel_name(event: ShellEvent) -> &'static str { - match event { - ShellEvent::PushEvent => "push-event", - ShellEvent::SseStatus => "sse-status", - ShellEvent::Navigate => "navigate", - ShellEvent::DeepLink => "deep-link", - ShellEvent::TrayAction => "tray-action", - ShellEvent::SystemNotification => "system-notification", - ShellEvent::SingleInstance => "single-instance", - } -} diff --git a/client-tauri/src-tauri/src/main.rs b/client-tauri/src-tauri/src/main.rs index 181ed5f98..ee8e645c9 100644 --- a/client-tauri/src-tauri/src/main.rs +++ b/client-tauri/src-tauri/src/main.rs @@ -173,9 +173,11 @@ fn workhub_env_flag_enabled(name: &str, get_env: impl Fn(&str) -> Option } fn workhub_env_flag_value(name: &str, get_env: impl Fn(&str) -> Option) -> Option { - get_env(name).map(|value| match value.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => true, - _ => false, + get_env(name).map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) }) } @@ -1345,6 +1347,9 @@ fn configure_main_window_chrome(window: &tauri::WebviewWindow) -> Result<(), Str #[derive(Clone, Copy)] enum MainWindowStartupFallbackStep { Chrome, + // Linux CI 的 clippy 看不到 macOS cfg 分支里的构造点(P3-01 把 clippy -D warnings 拉上 CI 后暴露), + // 与下一行 WindowsAcrylic 同款:仅在非目标平台上放行 dead_code。 + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] MacosVibrancy, #[cfg_attr(not(target_os = "windows"), allow(dead_code))] WindowsAcrylic, @@ -1849,12 +1854,12 @@ fn main() { } }) .setup(|app| { - let shell_config = load_workhub_shell_config(&app.handle())?; + let shell_config = load_workhub_shell_config(app.handle())?; if let Ok(mut locale) = app.state::>().lock() { *locale = shell_config.locale; } create_pet_window_with_surface_flag(app)?; - if let Ok(Some(saved)) = load_pet_window_saved_placement(&app.handle()) { + if let Ok(Some(saved)) = load_pet_window_saved_placement(app.handle()) { let work_area = app .get_webview_window("pet") .map(|window| work_area_for_pet_window(&window)) @@ -1868,7 +1873,7 @@ fn main() { install_workhub_deep_links(app)?; // R15:全局热键唤起聚焦盒(交互规划 04 §二第 2 项)——注册失败(多半是 Option+Space 被 // 别的应用占用)只记日志降级,绝不 panic/绝不让应用起不来:托盘/常驻小窗仍是保底触达路径。 - if let Err(error) = install_workhub_global_hotkey(&app.handle()) { + if let Err(error) = install_workhub_global_hotkey(app.handle()) { eprintln!( "WorkHub: {error}; continuing without the global hotkey (tray icon and the docked spotlight window remain available)" ); @@ -2344,21 +2349,19 @@ mod tests { #[test] fn cuu_qa_preferences_env_enables_dom_report_when_path_is_present() { - assert_eq!( + assert!( workhub_cuu_qa_preferences_from_env(named_env(&[( WORKHUB_CUU_QA_DOM_REPORT_PATH_ENV, "C:\\temp\\cuu-tauri-dom-report.json" )])) - .pet_qa_dom_report, - true + .pet_qa_dom_report ); - assert_eq!( - workhub_cuu_qa_preferences_from_env(named_env(&[( + assert!( + !workhub_cuu_qa_preferences_from_env(named_env(&[( WORKHUB_CUU_QA_DOM_REPORT_PATH_ENV, " " )])) - .pet_qa_dom_report, - false + .pet_qa_dom_report ); } diff --git a/client-tauri/src-tauri/src/pet_commands.rs b/client-tauri/src-tauri/src/pet_commands.rs index 3d27d0a91..13da136e1 100644 --- a/client-tauri/src-tauri/src/pet_commands.rs +++ b/client-tauri/src-tauri/src/pet_commands.rs @@ -294,7 +294,7 @@ mod tests { let settings = plan.settings.expect("settings should be present"); assert_eq!(plan.command, SET_PET_WINDOW_MODE_COMMAND); assert_eq!(plan.label, "pet"); - assert_eq!(plan.focus, false); + assert!(!plan.focus); assert_eq!(placement.mode, PetWindowMode::Card); assert_eq!(placement.position, LogicalPosition { x: 1340, y: 270 }); assert_eq!(placement.size.width, 520); @@ -314,7 +314,7 @@ mod tests { let placement = plan.placement.expect("placement should be present"); assert_eq!(plan.command, SET_PET_WINDOW_MODE_COMMAND); assert_eq!(plan.label, "pet"); - assert_eq!(plan.focus, false); + assert!(!plan.focus); assert_eq!(placement.mode, PetWindowMode::BodyOnly); assert_eq!(placement.position, LogicalPosition { x: 1636, y: 676 }); } @@ -341,8 +341,8 @@ mod tests { assert_eq!(placement.position, LogicalPosition { x: 1175, y: 25 }); assert_eq!(settings.scale_percent, 125); assert_eq!(settings.opacity_percent, 80); - assert_eq!(settings.pass_through, true); - assert_eq!(settings.hide_on_hover, true); + assert!(settings.pass_through); + assert!(settings.hide_on_hover); } #[test] @@ -355,7 +355,7 @@ mod tests { assert_eq!(drag.command, START_PET_WINDOW_DRAG_COMMAND); assert_eq!(drag.drag.unwrap().animation_action, "drag_hold"); assert_eq!(save.command, SAVE_PET_WINDOW_POSITION_COMMAND); - assert_eq!(save.focus, false); + assert!(!save.focus); assert_eq!( save.saved_position, Some(LogicalPosition { x: 1280, y: 720 }) @@ -437,8 +437,8 @@ mod tests { let pointer = plan.pointer.expect("pointer should be present"); assert_eq!(plan.command, SAMPLE_PET_CURSOR_NEAR_COMMAND); - assert_eq!(pointer.inside_window, true); - assert_eq!(pointer.cursor_near, true); + assert!(pointer.inside_window); + assert!(pointer.cursor_near); assert_eq!(pointer.look_x_percent, -8); assert_eq!(pointer.look_y_percent, -11); } diff --git a/client-tauri/src-tauri/src/pet_window.rs b/client-tauri/src-tauri/src/pet_window.rs index 65fc87c31..aa97069e8 100644 --- a/client-tauri/src-tauri/src/pet_window.rs +++ b/client-tauri/src-tauri/src/pet_window.rs @@ -388,11 +388,11 @@ mod tests { assert_eq!(plan.mode, PetWindowMode::BodyOnly); assert_eq!(plan.size, PET_BODY_ONLY_SIZE); assert_eq!(plan.position, LogicalPosition { x: 1636, y: 676 }); - assert_eq!(plan.focus, false); - assert_eq!(plan.transparent, true); - assert_eq!(plan.decorations, false); - assert_eq!(plan.always_on_top, true); - assert_eq!(plan.skip_taskbar, true); + assert!(!plan.focus); + assert!(plan.transparent); + assert!(!plan.decorations); + assert!(plan.always_on_top); + assert!(plan.skip_taskbar); } #[test] @@ -453,9 +453,9 @@ mod tests { assert_eq!(card_plan.position, LogicalPosition { x: 1175, y: 25 }); assert_eq!(visual.scale_percent, 125); assert_eq!(visual.opacity_percent, 80); - assert_eq!(visual.pass_through, true); - assert_eq!(visual.hide_on_hover, true); - assert_eq!(visual.focus, false); + assert!(visual.pass_through); + assert!(visual.hide_on_hover); + assert!(!visual.focus); } #[test] @@ -501,15 +501,15 @@ mod tests { near_radius: DEFAULT_PET_CURSOR_NEAR_RADIUS, }); - assert_eq!(inside.inside_window, true); - assert_eq!(inside.cursor_near, true); + assert!(inside.inside_window); + assert!(inside.cursor_near); assert_eq!(inside.look_x_percent, -54); assert_eq!(inside.look_y_percent, -58); - assert_eq!(nearby.inside_window, false); - assert_eq!(nearby.cursor_near, true); + assert!(!nearby.inside_window); + assert!(nearby.cursor_near); assert_eq!(nearby.look_x_percent, 94); assert_eq!(nearby.look_y_percent, 83); - assert_eq!(far.cursor_near, false); + assert!(!far.cursor_near); assert_eq!(far.look_x_percent, 100); assert_eq!(far.look_y_percent, 100); } @@ -521,7 +521,7 @@ mod tests { assert_eq!(start.label, "pet"); assert_eq!(start.action, PetWindowDragAction::StartDragging); - assert_eq!(start.focus, false); + assert!(!start.focus); assert_eq!(start.animation_action, "drag_hold"); assert_eq!(save.action, PetWindowDragAction::SavePosition); assert_eq!(save.animation_action, "idle_breathe"); diff --git a/client-tauri/src-tauri/src/tray.rs b/client-tauri/src-tauri/src/tray.rs index 60096a42b..b22c53ac3 100644 --- a/client-tauri/src-tauri/src/tray.rs +++ b/client-tauri/src-tauri/src/tray.rs @@ -303,7 +303,7 @@ mod tests { assert_eq!(toggle_control.action, ShellWindowControlAction::Toggle); assert_eq!(toggle_control.source, ShellWindowControlSource::Tray); assert_eq!(toggle_control.route, Some("/pet.html".to_string())); - assert_eq!(toggle_control.focus, false); + assert!(!toggle_control.focus); } #[test] @@ -316,7 +316,7 @@ mod tests { assert_eq!(control.action, ShellWindowControlAction::Show); assert_eq!(control.source, ShellWindowControlSource::Tray); assert_eq!(control.route, Some("/pet.html".to_string())); - assert_eq!(control.focus, false); + assert!(!control.focus); } #[test] @@ -328,7 +328,7 @@ mod tests { assert_eq!(control.action, ShellWindowControlAction::ShowAndFocus); assert_eq!(control.source, ShellWindowControlSource::Tray); assert_eq!(control.route, Some(INBOX_TRAY_ROUTE.to_string())); - assert_eq!(control.focus, true); + assert!(control.focus); } #[test] @@ -340,7 +340,7 @@ mod tests { assert_eq!(control.action, ShellWindowControlAction::ShowAndFocus); assert_eq!(control.source, ShellWindowControlSource::Tray); assert_eq!(control.route, Some(SETTINGS_TRAY_ROUTE.to_string())); - assert_eq!(control.focus, true); + assert!(control.focus); } #[test] diff --git a/client-tauri/src-tauri/src/window_controls.rs b/client-tauri/src-tauri/src/window_controls.rs index 4e05177b3..fb20c7822 100644 --- a/client-tauri/src-tauri/src/window_controls.rs +++ b/client-tauri/src-tauri/src/window_controls.rs @@ -182,7 +182,7 @@ mod tests { assert_eq!(plan.action, ShellWindowControlAction::ShowAndFocus); assert_eq!(plan.source, ShellWindowControlSource::DeepLink); assert_eq!(plan.route, Some("/proposal/proposal-1".to_string())); - assert_eq!(plan.focus, true); + assert!(plan.focus); } #[test] @@ -203,9 +203,9 @@ mod tests { assert_eq!(show.label, "pet"); assert_eq!(show.action, ShellWindowControlAction::Show); assert_eq!(show.route, Some("/pet.html".to_string())); - assert_eq!(show.focus, false); + assert!(!show.focus); assert_eq!(toggle.action, ShellWindowControlAction::Toggle); - assert_eq!(toggle.focus, false); + assert!(!toggle.focus); } #[test] @@ -215,8 +215,8 @@ mod tests { assert_eq!(main.route, None); assert_eq!(pet.route, None); - assert_eq!(main.focus, false); - assert_eq!(pet.focus, false); + assert!(!main.focus); + assert!(!pet.focus); } #[test] diff --git a/client-tauri/src-tauri/src/windows.rs b/client-tauri/src-tauri/src/windows.rs index 67eee57a2..f68297bfa 100644 --- a/client-tauri/src-tauri/src/windows.rs +++ b/client-tauri/src-tauri/src/windows.rs @@ -39,7 +39,11 @@ impl ShellWindowPlan { } pub fn default_window_plans() -> Vec { - vec![main_window_plan(), pet_window_plan(), workbench_window_plan()] + vec![ + main_window_plan(), + pet_window_plan(), + workbench_window_plan(), + ] } pub fn main_window_plan() -> ShellWindowPlan { @@ -131,13 +135,13 @@ mod tests { assert_eq!(pet.label, "pet"); assert_eq!(pet.title, "Cuu"); assert_eq!(pet.route, "/pet.html"); - assert_eq!(pet.transparent, true); - assert_eq!(pet.decorations, false); - assert_eq!(pet.always_on_top, true); - assert_eq!(pet.skip_taskbar, true); - assert_eq!(pet.resizable, false); - assert_eq!(pet.visible, false); - assert_eq!(pet.focus, false); + assert!(pet.transparent); + assert!(!pet.decorations); + assert!(pet.always_on_top); + assert!(pet.skip_taskbar); + assert!(!pet.resizable); + assert!(!pet.visible); + assert!(!pet.focus); assert!(pet.width <= 280); assert!(pet.height <= 360); } @@ -149,13 +153,13 @@ mod tests { assert_eq!(main.label, "main"); assert_eq!(main.route, "/"); // 真·液态玻璃:主窗口透明,不依赖原生 vibrancy 底材。 - assert_eq!(main.transparent, true); + assert!(main.transparent); // R8:主窗 frameless(去 OS 标题栏,只剩透明玻璃聚焦盒)。 - assert_eq!(main.decorations, false); + assert!(!main.decorations); // WorkHub Spotlight should behave like Cuu: it stays above normal app windows when opened. - assert_eq!(main.always_on_top, true); - assert_eq!(main.skip_taskbar, false); - assert_eq!(main.resizable, true); + assert!(main.always_on_top); + assert!(!main.skip_taskbar); + assert!(main.resizable); // R8 真·Spotlight:小窗随内容缩放(不再是 1180×780 全屏壳)。 assert_eq!(main.width, 720); assert_eq!(main.height, 64); @@ -185,7 +189,7 @@ mod tests { // R12 起默认窗口族新增常驻工作台窗(此前为 ["main","pet"],属声明式行为变更)。 assert_eq!(labels, vec!["main", "pet", "workbench"]); - assert_eq!(window_plan_by_label("pet").unwrap().is_pet_window(), true); + assert!(window_plan_by_label("pet").unwrap().is_pet_window()); assert_eq!(window_plan_by_label("unknown"), None); } @@ -197,19 +201,19 @@ mod tests { assert_eq!(workbench.kind, ShellWindowKind::Workbench); assert_eq!(workbench.route, "/workbench.html"); // 常驻工作窗:默认隐藏等唤起、可缩放、不置顶、不跳过任务栏。 - assert_eq!(workbench.visible, false); - assert_eq!(workbench.focus, false); - assert_eq!(workbench.resizable, true); - assert_eq!(workbench.always_on_top, false); - assert_eq!(workbench.skip_taskbar, false); + assert!(!workbench.visible); + assert!(!workbench.focus); + assert!(workbench.resizable); + assert!(!workbench.always_on_top); + assert!(!workbench.skip_taskbar); // 玻璃约束:透明 + frameless(毛玻璃靠原生 vibrancy,不靠 CSS blur)。 - assert_eq!(workbench.transparent, true); - assert_eq!(workbench.decorations, false); + assert!(workbench.transparent); + assert!(!workbench.decorations); // 三栏工作台需要真实桌面级面积,且最小尺寸不能塌到三栏摆不下。 assert!(workbench.width >= 1200); assert!(workbench.height >= 720); assert_eq!(workbench.min_width, Some(960)); assert_eq!(workbench.min_height, Some(620)); - assert_eq!(workbench.is_pet_window(), false); + assert!(!workbench.is_pet_window()); } } diff --git a/client-tauri/src-tauri/tests/tauri_scaffold.rs b/client-tauri/src-tauri/tests/tauri_scaffold.rs index eaed53d41..4c616df04 100644 --- a/client-tauri/src-tauri/tests/tauri_scaffold.rs +++ b/client-tauri/src-tauri/tests/tauri_scaffold.rs @@ -64,7 +64,7 @@ fn tauri_windows_match_the_shell_window_contract() { assert_eq!(window["alwaysOnTop"], plan.always_on_top); if plan.is_pet_window() { - assert_eq!(plan.skip_taskbar, true); + assert!(plan.skip_taskbar); assert!( window.get("skipTaskbar").is_none(), "skipTaskbar stays in the WorkHub plan until it is confirmed by the Tauri v2 schema" diff --git a/packages/api-client/src/api-client.test.ts b/packages/api-client/src/api-client.test.ts index 9e9d4bcc8..5092443fe 100644 --- a/packages/api-client/src/api-client.test.ts +++ b/packages/api-client/src/api-client.test.ts @@ -272,7 +272,6 @@ test("api client exposes P0.5 gold path page and replay endpoints", async () => await client.createTaskPlan("work-1", {}, { locale: "en-US" }); await client.getAgentRun("run-1"); await client.getAgentRunTrace("run-1", 2); - await client.getAgentRunHandoff("run-1"); await client.abortAgentRun("run-1"); await client.resolveMemoryConflict("memory-conflict-1", { resolution: "merge_both", @@ -280,7 +279,6 @@ test("api client exposes P0.5 gold path page and replay endpoints", async () => expected_updated_at: "2026-07-03T10:40:00.000Z" }); await client.createProposalFromManifest("work-1", { manifest: deliverableManifestFixtures[0]! }); - await client.listWorkItemProposals("work-1"); await client.listWorkItemConflicts("work-1"); await client.getProposal("proposal-1"); await client.nextQuestion("session-1", { selected_option_ids: ["risk-first"] }); @@ -337,13 +335,11 @@ test("api client exposes P0.5 gold path page and replay endpoints", async () => 'POST /api/workitems/work-1/task-plan?locale=en-US {}', "GET /api/agent-runs/run-1", "GET /api/agent-runs/run-1/trace?after=2", - "GET /api/agent-runs/run-1/handoff", "POST /api/agent-runs/run-1/abort", // R9.7 review: the old assertion put `expected_updated_at` in the JSON body, // but durable memory-conflict cards and OpenAPI document the stale-version token as a query parameter. 'POST /api/memory-conflicts/memory-conflict-1/resolve/merge_both?expected_updated_at=2026-07-03T10%3A40%3A00.000Z {"value_md":"合并后的偏好。"}', "POST /api/workitems/work-1/proposals", - "GET /api/workitems/work-1/proposals", "GET /api/workitems/work-1/conflicts", "GET /api/proposals/proposal-1", 'POST /api/sessions/session-1/next-question {"selected_option_ids":["risk-first"]}', @@ -660,6 +656,47 @@ test("api client exposes the objective create + link endpoints (R19-1 OKR wiring ]); }); +// R20 R19-27(工作项跨 run 审计时间线):后端早有 GET /api/workitems/:id/audit(快照 + 审计事实 + +// manifest 校验,packages/db audit-repository 有测试覆盖),但此前客户端没有任何类型化方法能调用 +// 它——web 端因此从没拉过这份数据、更别提渲染。锁定 URL/方法与信封解包(envelope → data)正确。 +test("api client exposes the work item cross-run audit timeline endpoint (R19-27 wiring)", async () => { + const calls: Array<{ url: string; method: string }> = []; + const timeline = { + work_item_id: "work-1", + snapshots: [], + audit_logs: [ + { + id: "audit-1", + actor: { actor_kind: "human", actor_nickname: "小拓" }, + entity: { entity_type: "work_item", entity_id: "work-1" }, + action: "work_item.created", + detail_json: {}, + created_at: "2026-07-10T09:00:00.000Z" + } + ], + manifest_facts: { + checks: { snapshot_exists: "failed", revert_available: "warning" }, + rollback: { available: false, description: "无可回滚快照。" }, + risk: { reversible: true, irreversible_reasons: [] }, + evidence_refs: [] + } + }; + const client = createApiClient({ + fetchFn: async (input, init) => { + calls.push({ url: String(input), method: init?.method ?? "GET" }); + return new Response(JSON.stringify({ ok: true, data: timeline }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + }); + + const result = await client.getWorkItemAuditTimeline("work-1"); + + assert.deepEqual(calls, [{ url: "/api/workitems/work-1/audit", method: "GET" }]); + assert.deepEqual(result, timeline); +}); + // R14 批 MEM(记忆可见可治理):用户记忆 + 团队技能两个治理面的客户端方法——URL/方法/body 构造要 // 与服务端路由(apps/api/src/routes/{user-memory-governance,team-skill-governance}.ts)逐字对齐。 // 这两组方法在 WorkHubApiClient 上是可选字段(同上面 pages.workbench? 的既有先例,不强迫 diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index bbf2a7286..ce3a9a632 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -490,7 +490,8 @@ export function createApiClient(options: WorkHubApiClientOptions = {}): WorkHubA method: "POST", body: JSON.stringify(payload) }), - getAgentRunHandoff: (runId) => request(`/api/agent-runs/${encodeURIComponent(runId)}/handoff`), + // R20 R19-29:getAgentRunHandoff(GET /api/agent-runs/:id/handoff)已删——web/desktop 均无调用点, + // 结构化 handoff 数据早已内嵌进 replayAgentRun 的回放页,核实零消费后随后端路由一并删除。 respondApproval: (id, payload) => request(`/api/approvals/${encodeURIComponent(id)}/respond`, { method: "POST", @@ -550,8 +551,10 @@ export function createApiClient(options: WorkHubApiClientOptions = {}): WorkHubA method: "POST", body: JSON.stringify(payload) }), - listWorkItemProposals: (workItemId) => request(`/api/workitems/${encodeURIComponent(workItemId)}/proposals`), + // R20 R19-29:listWorkItemProposals(GET /api/workitems/:id/proposals)已删——web/desktop 均无调用点, + // 同样的提议列表数据早已内嵌进工作项详情页 VM,核实零消费后随后端路由一并删除。 listWorkItemConflicts: (workItemId) => request(`/api/workitems/${encodeURIComponent(workItemId)}/conflicts`), + getWorkItemAuditTimeline: (workItemId) => request(`/api/workitems/${encodeURIComponent(workItemId)}/audit`), getProposal: (id) => request(`/api/proposals/${encodeURIComponent(id)}`), reviewProposal: (id, payload, options) => request(withPageLocale(`/api/proposals/${encodeURIComponent(id)}/review`, options), { diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index f4b6313f2..928416fed 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -5,6 +5,8 @@ import type { AgentStep, AcceptedDeliverableRestoreResult, AttentionHomeVM, + // R20 R19-27(工作项跨 run 审计时间线):GET /api/workitems/:id/audit 的响应契约。 + AuditTimelineVM, BudgetPolicy, BudgetPolicyUpdate, BootstrapProjectRequest, @@ -63,7 +65,6 @@ import type { SettingsPageVM, TeamSkillsPageVM, StartAgentRunRequest, - StructuredHandoff, UpdateUserPreferencesRequest, UseEvidenceForTaskRequest, UserPreferences, @@ -372,7 +373,8 @@ export type WorkHubApiClient = { // 既有取舍):标必填会强迫 apps/web 等其它 workspace 里已有的完整 WorkHubApiClient 字面量 mock 补一个用不到的 // 桩,那些文件不在本批改动范围内;真实 createApiClient() 一定实现它,调用点用 `!` 断言(同 putProposalFeedback)。 revertAgentRun?: (runId: string, payload: RevertAgentRunRequest) => Promise; - getAgentRunHandoff: (runId: string) => Promise; + // R20 R19-29:getAgentRunHandoff(GET /api/agent-runs/:id/handoff)已删——web/desktop 均无调用点, + // 结构化 handoff 数据早已内嵌进 replayAgentRun 的回放页,核实零消费后随后端路由/openapi 一并删除。 respondApproval: (id: string, payload: RespondApprovalRequest) => Promise; // R12(批量效率):多选批量放行(allow-only)。 respondApprovalsBatch: (ids: string[]) => Promise<{ approved: number; skipped: number }>; @@ -389,8 +391,13 @@ export type WorkHubApiClient = { listApprovalComments: (id: string) => Promise; postApprovalComment: (id: string, payload: AddApprovalCommentRequest) => Promise; createProposalFromManifest: (workItemId: string, payload: CreateProposalFromManifestRequest) => Promise; - listWorkItemProposals: (workItemId: string) => Promise; + // R20 R19-29:listWorkItemProposals(GET /api/workitems/:id/proposals)已删——web/desktop 均无调用点, + // 同样的提议列表数据早已内嵌进工作项详情页 VM,核实零消费后随后端路由/openapi 一并删除。 listWorkItemConflicts: (workItemId: string) => Promise; + // R20 R19-27:跨 run 审计时间线(快照 + 审计日志事实 + manifest 校验),供工作项详情页渲染。 + // 服务端已有 GET /api/workitems/:id/audit(fail-closed 走 detailPage 同一套可见性),此前没有 + // 任何类型化客户端方法能调用它——前端因此从来没有拉过这份数据、更别提渲染。 + getWorkItemAuditTimeline: (workItemId: string) => Promise; getProposal: (id: string) => Promise; reviewProposal: (id: string, payload: ReviewProposalRequest, options?: PageRequestOptions) => Promise; mergeProposal: (id: string, payload?: MergeProposalRequest, options?: PageRequestOptions) => Promise; diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index a01d605bf..0d9140e1e 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -212,6 +212,11 @@ export const eventTypes = { // cuu.updated 的既有取舍——只带 conversation_id + 变化类型 + 受影响 user_id,客户端据此按需重拉 // GET /participants,接不上就等下次重挂时兜底,不强求必达。 conversationParticipantsUpdated: "conversation.participants.updated", + // R20 P2-04(会话 rename 跨端同步):协同会话改名后广播——data 只带 conversation_id + 新 title。让别的开着 + // 这个会话的客户端就地改左栏树叶 / web 镜像页标题,不必等下次全量轮询才看到新名字。同 cuu.updated 的既有 + // 取舍:只投到会话私有流(conversation:,仅参与者可订,不广播全工作区),接不上就等下次重挂时用会话 VM + // 里的 title 兜底,不强求这条广播必达。 + conversationTitleUpdated: "conversation.title.updated", /** @deprecated 无生产者也无消费者:仅 r12-workbench 契约测试快照枚举,工具流实际走 conversation.tool.* 之外的既有事件。 */ conversationToolBegin: "conversation.tool.begin", /** @deprecated 无生产者也无消费者:同上,仅契约测试引用。 */ diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index a0a145867..a2ab98fbf 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -487,3 +487,33 @@ export const conversationParticipantsUpdatedEventSchema = z } }); export type ConversationParticipantsUpdatedEvent = z.infer; + +// R20 P2-04(会话 rename 跨端同步):conversation.title.updated——协同会话改名(PATCH /api/conversations/:id) +// 后广播。data 带 conversation_id + 新 title(同 renameConversationRequest 的 min(1).max(256) 约束)—— +// 携带新名字让桌面端能就地改左栏树叶(renameCollabConversationInVm,不必再往返一次),web 镜像页则据事件到达 +// 触发一次全量重渲(title 以重拉的会话 VM 为准)。极简 payload(无 actor/project_id),同 cuu.updated/ +// read.updated 的既有取舍:客户端按 conversation_id 过滤,接不上就等下次重挂时用会话 VM 里的 title 兜底。 +export const conversationTitleUpdatedEventSchema = z + .object({ + event_id: idSchema, + type: z.literal("conversation.title.updated"), + topic: z.string().min(1), + ts: isoDateTimeSchema, + data: z + .object({ + conversation_id: idSchema, + title: z.string().min(1).max(256) + }) + .strict() + }) + .strict() + .superRefine((event, ctx) => { + if (event.topic !== `conversation:${event.data.conversation_id}`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["topic"], + message: "title-updated topic must match data.conversation_id" + }); + } + }); +export type ConversationTitleUpdatedEvent = z.infer; diff --git a/packages/contracts/src/r14-chat.test.ts b/packages/contracts/src/r14-chat.test.ts index cc4f3a8ed..4cbb194b3 100644 --- a/packages/contracts/src/r14-chat.test.ts +++ b/packages/contracts/src/r14-chat.test.ts @@ -375,6 +375,26 @@ test("R17 G1 remove participant result: self_left + nullable new owner, extras r ); }); +test("R20 P2-04 conversation.title.updated event: topic must match conversation_id, title bounded, extras rejected", () => { + const schema = requiredSchema>("conversationTitleUpdatedEventSchema"); + const base = { + event_id: eventId, + type: "conversation.title.updated", + topic: `conversation:${conversationId}`, + ts, + data: { conversation_id: conversationId, title: "改第三幕" } + }; + assert.equal(schema.safeParse(base).success, true); + // topic 必须与 data.conversation_id 绑定(superRefine)。 + assert.equal(schema.safeParse({ ...base, topic: "conversation:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }).success, false); + // 空标题 / 超长标题违约(min(1).max(256))。 + assert.equal(schema.safeParse({ ...base, data: { ...base.data, title: "" } }).success, false); + assert.equal(schema.safeParse({ ...base, data: { ...base.data, title: "x".repeat(257) } }).success, false); + // 极简 payload——不接受 actor/project_id 等额外键(strict)。 + assert.equal(schema.safeParse({ ...base, data: { ...base.data, extra: 1 } }).success, false); + assert.equal(schema.safeParse({ ...base, actor: { actor_kind: "human", actor_user_id: userId } }).success, false); +}); + test("R17 G1 participants.updated event: topic must match conversation_id, change is an enum", () => { const schema = requiredSchema>("conversationParticipantsUpdatedEventSchema"); const base = { diff --git a/packages/db/migrations/0069_event_outbox.sql b/packages/db/migrations/0069_event_outbox.sql new file mode 100644 index 000000000..46c44de37 --- /dev/null +++ b/packages/db/migrations/0069_event_outbox.sql @@ -0,0 +1,36 @@ +-- R20 P2-01(事务性 outbox):修「会话消息 DB commit 与 publish 之间无 outbox/replay」这条丢投裂缝。 +-- 现状:会话消息(conversation_messages)先落库提交事务,再向 SSE/事件总线 best-effort publish;两步之间 +-- 进程崩溃或 publish 抛错,则 conversation.message.created 永久丢失——在线客户端不会收到该消息的推送 +-- (SSE 是 resume_mode='fresh' 不重放,只有重连才全量补拉对账)。 +-- 修法:领域事件与业务写落在同一事务里写这张 event_outbox 表,事务提交后由 drain 循环 +-- (apps/api workers/event-outbox-drain)把 pending 行 publish,publish 成功才置 status='published'。 +-- 崩溃/重启后 drain 重放未完成行;event_id 是幂等键,消费端本就按全量重拉对账,重复投递无害。 +-- 当前仅承载 conversation.message.created(人到人 DM/协同/主区人类消息),其它 publish 点留作后续。 +-- 全 additive;CREATE TABLE / CREATE INDEX IF NOT EXISTS 保证 migration-audit replay 整链重跑安全 +-- (同 0061/0062/0063 约定)。 +CREATE TABLE IF NOT EXISTS "event_outbox" ( + "id" uuid PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE cascade, + "topic" text NOT NULL, + "event_type" text NOT NULL, + "event_id" uuid NOT NULL, + "payload" jsonb NOT NULL, + "status" varchar(16) NOT NULL DEFAULT 'pending', + "attempts" integer NOT NULL DEFAULT 0, + "last_error" text, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "published_at" timestamp with time zone, + CONSTRAINT "event_outbox_status_ck" CHECK ("status" IN ('pending','published')), + CONSTRAINT "event_outbox_attempts_ck" CHECK ("attempts" >= 0) +); +--> statement-breakpoint + +-- event_id 幂等键:一行一事件,唯一约束兜住重复入队。 +CREATE UNIQUE INDEX IF NOT EXISTS "event_outbox_event_id_uq" + ON "event_outbox" ("event_id"); +--> statement-breakpoint + +-- drain 热路径:只扫 pending 行、按落库顺序(created_at, id)发。部分索引不给已发行付索引成本。 +CREATE INDEX IF NOT EXISTS "event_outbox_pending_idx" + ON "event_outbox" ("created_at","id") + WHERE "status" = 'pending'; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 2b7e4a09f..3052d182b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1783929000000, "tag": "0068_proactive_intent_recovery", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1783929001000, + "tag": "0069_event_outbox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3b490f7f6..240c30432 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -36,6 +36,7 @@ export * from "./repositories/projects.js"; export * from "./repositories/workbench.js"; export * from "./repositories/conversations.js"; export * from "./repositories/conversation-runs.js"; +export * from "./repositories/event-outbox.js"; export * from "./repositories/ai-settings.js"; export * from "./repositories/drive.js"; export * from "./repositories/meetings.js"; diff --git a/packages/db/src/repositories/conversations.ts b/packages/db/src/repositories/conversations.ts index 955f7b652..27d643504 100644 --- a/packages/db/src/repositories/conversations.ts +++ b/packages/db/src/repositories/conversations.ts @@ -18,6 +18,7 @@ import { users, workspaceMemberships } from "../schema/index.js"; +import { enqueueEventOutbox } from "./event-outbox.js"; export type ConversationRow = typeof projectConversations.$inferSelect; export type ConversationParticipantRow = typeof conversationParticipants.$inferSelect; @@ -115,6 +116,22 @@ export type CreateUserMessageInput = CreateUserMessageBaseInput & | { kind: "file_card"; contentJson: { drive_item_id: string; snapshot_name: string } } ); +// R20 P2-01(事务性 outbox):可选的「同事务入队」钩子——服务层给定这条钩子后,createUserMessage 在插入 +// 消息行的同一事务里、拿到已落库的 created 行,同步构建 conversation.message.created 事件信封并写 outbox。 +// 钩子是纯同步的(信封所需的 seq/id/createdAt 全来自 created 行,引用预览由服务层事务外预取后闭包捕获), +// 返回 null 表示这条消息不入队(走既有 best-effort 直发路径)。省略 options 时行为与本批之前逐字一致。 +export type EnqueueUserMessageOutbox = (message: ConversationMessageRow) => { + workspaceId: string; + topic: string; + eventType: string; + eventId: string; + payload: Record; +} | null; + +export type CreateUserMessageOptions = { + enqueueOutbox?: EnqueueUserMessageOutbox; +}; + // R12 批4a:协同会话 turn 落库的 Cuu 回应——最初 kind 固定 'text'。sender_user_id 固定 null(Cuu 不是 // workspace 成员,不需要也不能过 createUserMessage 那套 membership/participant 校验),这一点在下面 // 三个分支里都不变。memory_citations 是本轮实际注入过的记忆/技能引用清单,additive,由调用方 @@ -446,7 +463,12 @@ export type ConversationRepository = { // R15 批 B(人对人私聊):actor 参与的 DM 列表(参与者门控、含两名参与者昵称)。新增只读方法,不改动 // 上面任何既有方法的签名/行为——rail「私聊」分组的唯一数据源,桌面 chat 视图据此拿到真实参与者集合。 listDmsForUser: (input: ListDmsForUserInput) => Promise; - createUserMessage: (input: CreateUserMessageInput) => Promise; + // R20 P2-01:additive 的可选 options 参数——省略时行为与本批之前逐字一致;传 enqueueOutbox 钩子时 + // 在同一事务里把 conversation.message.created 事件写进 event_outbox(原子于消息落库)。 + createUserMessage: ( + input: CreateUserMessageInput, + options?: CreateUserMessageOptions + ) => Promise; // R12 批4a:新增,不改动上面任何既有方法的签名/行为。 createCuuMessage: (input: CreateCuuMessageInput) => Promise; listMessagesAfter: (input: ListConversationMessagesInput) => Promise; @@ -1511,7 +1533,7 @@ export function createConversationRepository(db: WorkHubDb): ConversationReposit return result; }, - async createUserMessage(input) { + async createUserMessage(input, options) { assertMessageContent(input); const at = input.at ?? new Date(); const senderUserId = input.senderUserId.toLowerCase(); @@ -1641,6 +1663,15 @@ export function createConversationRepository(db: WorkHubDb): ConversationReposit if (!created) { throw new ConversationMessageInsertFailedError("message insert returned no row"); } + // R20 P2-01:事务性 outbox——若服务层给了入队钩子,在这条消息落库的同一事务里把 + // conversation.message.created 事件写进 event_outbox,与消息行原子提交。提交后由 drain 循环 + // publish(apps/api)。钩子返回 null 表示不入队(走既有 best-effort 直发路径)。 + if (options?.enqueueOutbox) { + const outboxRow = options.enqueueOutbox(created); + if (outboxRow) { + await enqueueEventOutbox(tx, outboxRow); + } + } return created; }); }, diff --git a/packages/db/src/repositories/event-outbox.ts b/packages/db/src/repositories/event-outbox.ts new file mode 100644 index 000000000..4ff0a7013 --- /dev/null +++ b/packages/db/src/repositories/event-outbox.ts @@ -0,0 +1,81 @@ +import { randomUUID } from "node:crypto"; + +import { and, asc, eq, sql } from "drizzle-orm"; + +import type { WorkHubDb } from "../client.js"; +import { eventOutbox } from "../schema/index.js"; + +// R20 P2-01(事务性 outbox · 见 0069 迁移):领域事件与业务写共一事务落这张表,事务提交后由 drain 循环 +// (apps/api workers/event-outbox-drain)把 pending 行 publish 到 SSE/事件总线,publish 成功才置 +// status='published'。这层只做数据搬运:事务内入队原语 + drain 用的读/标记原语;抑制/触发时机/信封组装 +// 全部留在应用层。 + +export type EventOutboxRow = typeof eventOutbox.$inferSelect; + +export type EnqueueEventOutboxInput = { + // 测试可注入确定性 id;生产默认 randomUUID(id 列无 DB 默认值,见 0069)。 + id?: string; + workspaceId: string; + topic: string; + eventType: string; + // 幂等键 = 事件信封里的 event_id(唯一约束兜「一行一事件」;消费端按 event_id 对账去重)。 + eventId: string; + // 已序列化好的完整事件信封——drain 原样 publish 的不可变快照。 + payload: Record; +}; + +// 事务内入队原语:与业务写共用同一个 tx(drizzle 事务对象结构上满足这里用到的 insert 构造器), +// 保证「业务行 + outbox 行」原子提交,杜绝 commit 与 publish 之间的丢投裂缝。调用方在业务写事务里调它。 +export async function enqueueEventOutbox( + tx: Pick, + input: EnqueueEventOutboxInput +): Promise { + await tx.insert(eventOutbox).values({ + id: input.id ?? randomUUID(), + workspaceId: input.workspaceId, + topic: input.topic, + eventType: input.eventType, + eventId: input.eventId, + payload: input.payload, + status: "pending", + attempts: 0 + }); +} + +export type EventOutboxRepository = { + // drain 一批:只挑 pending 行,按落库顺序(created_at, id)升序,cap limit(内部再夹一层硬上限防打爆)。 + listPending: (input: { limit: number }) => Promise; + // publish 成功后置 published——带 status='pending' 前置条件,避免与并发 drain 竞态重复标记同一行。 + markPublished: (input: { id: string; at?: Date }) => Promise; + // publish 失败:attempts+1、记 last_error,保持 pending 等下一轮 drain 重放(禁空 catch 吞错)。 + markFailed: (input: { id: string; error: string }) => Promise; +}; + +const LIST_PENDING_HARD_CAP = 500; +const LAST_ERROR_MAX_CHARS = 1000; + +export function createEventOutboxRepository(db: WorkHubDb): EventOutboxRepository { + return { + async listPending(input) { + const limit = Math.max(1, Math.min(Math.trunc(input.limit), LIST_PENDING_HARD_CAP)); + return db + .select() + .from(eventOutbox) + .where(eq(eventOutbox.status, "pending")) + .orderBy(asc(eventOutbox.createdAt), asc(eventOutbox.id)) + .limit(limit); + }, + async markPublished(input) { + await db + .update(eventOutbox) + .set({ status: "published", publishedAt: input.at ?? new Date(), lastError: null }) + .where(and(eq(eventOutbox.id, input.id), eq(eventOutbox.status, "pending"))); + }, + async markFailed(input) { + await db + .update(eventOutbox) + .set({ attempts: sql`${eventOutbox.attempts} + 1`, lastError: input.error.slice(0, LAST_ERROR_MAX_CHARS) }) + .where(and(eq(eventOutbox.id, input.id), eq(eventOutbox.status, "pending"))); + } + }; +} diff --git a/packages/db/src/schema.test.ts b/packages/db/src/schema.test.ts index 85c79b52b..ffda72c1d 100644 --- a/packages/db/src/schema.test.ts +++ b/packages/db/src/schema.test.ts @@ -682,7 +682,7 @@ test("0047 task plan status migration preserves 0031 and replaces the CHECK in s ); }); -test("migration journal ends with 0068 proactive intent recovery", () => { +test("migration journal ends with 0069 event outbox", () => { const journal = JSON.parse( readFileSync(new URL("../migrations/meta/_journal.json", import.meta.url), "utf8") ) as { @@ -698,17 +698,17 @@ test("migration journal ends with 0068 proactive intent recovery", () => { when: finalEntry.when }, { - // R20 REL-2(#P1-11 主动性 intent 崩溃恢复):0068(proactive_intent_recovery,when=1783929000000) - // 接在 W4a 的 0067(project_instructions,when=1783928000000)之后,journal 收于 0068,when 严格递增。 - idx: 68, + // R20 W5-1(#P2-01 事务性 outbox):0069(event_outbox,when=1783929001000)接在 REL-2 的 + // 0068(proactive_intent_recovery,when=1783929000000)之后,journal 收于 0069,when 严格递增。 + idx: 69, version: "7", - tag: "0068_proactive_intent_recovery", + tag: "0069_event_outbox", breakpoints: true, - when: 1783929000000 + when: 1783929001000 } ); - // when 严格递增——0068 的时间戳必须大于 0067 的 1783928000000。 - const priorEntry = journal.entries.find((entry) => entry.tag === "0067_project_instructions"); + // when 严格递增——0069 的时间戳必须大于 0068 的 1783929000000。 + const priorEntry = journal.entries.find((entry) => entry.tag === "0068_proactive_intent_recovery"); assert.ok(priorEntry && finalEntry && finalEntry.when > priorEntry.when); }); diff --git a/packages/db/src/schema/core.ts b/packages/db/src/schema/core.ts index 5e36862b8..5d763ce9f 100644 --- a/packages/db/src/schema/core.ts +++ b/packages/db/src/schema/core.ts @@ -762,6 +762,44 @@ export const conversationReadCursors = pgTable( ] ); +// R20 P2-01(事务性 outbox · 见 0069 迁移):领域事件与其业务写在同一事务里落这张表,事务提交后由 +// drain 循环(apps/api workers/event-outbox-drain)把 pending 行 publish 到 SSE/事件总线,publish 成功 +// 才置 status='published'。修的裂缝:会话消息此前是「消息行提交 → 事后 best-effort bus.publish」,两步 +// 之间进程崩溃或 publish 抛错,则这条推送永久蒸发(SSE resume_mode='fresh' 不重放,只有重连才全量补拉)。 +// event_id 是幂等键——消费端本就按全量重拉对账,drain 重启重放导致的重复投递无害。当前仅承载 +// conversation.message.created(人到人 DM/协同/主区人类消息),其它 publish 点留作后续;表本身是通用形状, +// 不与会话表耦合。 +export const eventOutbox = pgTable( + "event_outbox", + { + id: id(), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }), + // 目标主题(如 conversation:)与事件类型(如 conversation.message.created)——drain 原样透传给 + // bus.publish(topic, type, payload),不在 drain 里二次组装信封。 + topic: text("topic").notNull(), + eventType: text("event_type").notNull(), + // 幂等键 = 事件信封里的 event_id(既进 payload 也单列存,唯一约束兜「一行一事件」)。 + eventId: uuid("event_id").notNull(), + // 已序列化好的完整事件信封(发布时不可变的快照,drain 原样发出)。 + payload: jsonb("payload").$type().notNull(), + status: varchar("status", { length: 16 }).notNull().default("pending"), + // drain 每失败一次 +1,用于排障/未来封顶;last_error 存结构化失败原因(禁空 catch 吞错)。 + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + createdAt: createdAt(), + publishedAt: timestampTz("published_at") + }, + (table): PgTableExtraConfigValue[] => [ + check("event_outbox_status_ck", sql`${table.status} in ('pending', 'published')`), + check("event_outbox_attempts_ck", sql`${table.attempts} >= 0`), + uniqueIndex("event_outbox_event_id_uq").on(table.eventId), + // drain 扫描只挑未发行、按落库顺序发;部分索引只覆盖少量 pending 行,不给已发行付索引成本。 + index("event_outbox_pending_idx") + .on(table.createdAt, table.id) + .where(sql`${table.status} = 'pending'`) + ] +); + export const actionCards = pgTable( "action_cards", { diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index adb39c786..e3d1a8551 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createP05GoldPathFixture, validateP05GoldPathFixture } from "@workhub/agent/fixtures"; -import type { AgentArmyDashboardVM, AttentionItem, CalendarPageVM, ConversationMessageVM, DrivePageVM, ProjectHealthPageVM, EvidenceBubble, GoldPathSurfaceVM, MeetingPageVM, NotificationPageVM, ProjectListVM, ProposalConflict, ProposalDetailVM, SessionVM, SettingsPageVM, WorkItemDetailVM } from "@workhub/contracts"; +import type { AgentArmyDashboardVM, AttentionItem, AuditLogFact, CalendarPageVM, ConversationMessageVM, DrivePageVM, ProjectHealthPageVM, EvidenceBubble, GoldPathSurfaceVM, MeetingPageVM, NotificationPageVM, ProjectListVM, ProposalConflict, ProposalDetailVM, SessionVM, SettingsPageVM, WorkItemDetailVM } from "@workhub/contracts"; import { renderAgentRunReplay } from "../replay/index.js"; -import { renderWebRouteComponent, renderWebRouteComponents } from "./route-components.js"; +import { renderWebRouteComponent, renderWebRouteComponents, renderWorkItemAuditTimelineRows } from "./route-components.js"; import { renderOnboardingScreen } from "../onboarding.js"; import { renderWebProductShell } from "./product-shell.js"; import { renderGoldPathSurface } from "./render.js"; @@ -1155,6 +1155,78 @@ test("R4.11 WorkItem route component keeps task context, trace, acceptance, and assertNoMainWindowBoundaryLeak(workitem.html); }); +// R20 R19-27(根因):后端早有跨 run 审计时间线端点(GET /api/workitems/:id/audit,packages/db +// audit-repository 有测试),但 web 工作项详情页此前完全不渲染它——这个断言此前必然失败(页面里压根 +// 没有这段标记)。时间线本体是客户端异步水合(apps/web/src/browser.ts),这里只锁定 route-components +// 出的占位卡:正确的 data-* 挂载点(供 browser.ts 找到并水合)+ 本地化的加载中文案,两种语言都要有。 +test("R20 R19-27 WorkItem route component renders a hydration slot for the cross-run audit timeline", () => { + const vm = surfaceVm(); + const zh = renderWebRouteComponents(vm, { locale: "zh-CN" }).workitem; + const en = renderWebRouteComponents(vm, { locale: "en-US" }).workitem; + assert.ok(zh); + assert.ok(en); + + assert.equal(zh.html.includes('data-r20-workitem-audit-timeline="true"'), true); + assert.equal(zh.html.includes(`data-r20-workitem-audit-timeline-workitem="${vm.page_vms.workitem.workitem.id}"`), true); + assert.equal(zh.html.includes('data-r20-workitem-audit-timeline-body="true"'), true); + assert.equal(zh.html.includes('data-r20-workitem-audit-timeline-loading="true"'), true); + assert.equal(zh.html.includes("正在加载审计记录"), true); + assert.equal(en.html.includes("Loading audit history"), true); + assertNoMainWindowBoundaryLeak(zh.html); + assertNoMainWindowBoundaryLeak(en.html); +}); + +// R20 R19-27:审计时间线的行渲染是纯函数(renderWorkItemAuditTimelineRows),供 browser.ts 拉到数据后 +// 复用;这里直接单测它——本地化动作/操作者标签、时间戳、撤销标记、以及 evidenceRows 同款的截断诚实 +// 提示("还有 N 条…(共 M 条)"),不能让审阅者以为已经看全。 +test("R20 R19-27 renderWorkItemAuditTimelineRows localizes action/actor labels, marks undone entries, and truncates honestly", () => { + const baseEntry: AuditLogFact = { + id: "audit-1", + actor: { actor_kind: "human", actor_nickname: "小拓" }, + entity: { entity_type: "work_item", entity_id: "work-1" }, + action: "work_item.created", + detail_json: {}, + created_at: "2026-07-10T09:00:00.000Z" + }; + + const empty = renderWorkItemAuditTimelineRows([], "en-US"); + assert.equal(empty.includes("No audit history yet"), true); + + const zhRows = renderWorkItemAuditTimelineRows([baseEntry], "zh-CN"); + assert.equal(zhRows.includes('data-r20-workitem-audit-entry="audit-1"'), true); + assert.equal(zhRows.includes('data-r20-workitem-audit-entry-action="work_item.created"'), true); + assert.equal(zhRows.includes("创建工作项"), true); + assert.equal(zhRows.includes("小拓"), true); + assert.equal(zhRows.includes("2026-07-10"), true); + + const aiEntry: AuditLogFact = { + ...baseEntry, + id: "audit-2", + actor: { actor_kind: "ai" }, + action: "snapshot.reverted", + undone_at: "2026-07-10T09:05:00.000Z" + }; + const enRows = renderWorkItemAuditTimelineRows([aiEntry], "en-US"); + assert.equal(enRows.includes("File snapshot reverted"), true); + assert.equal(enRows.includes("AI (undone)"), true); + + // Unknown/future action strings must not leak the raw machine token — they fall back to a + // humanized (dot/underscore stripped, title-cased) rendering instead. + const unknownAction: AuditLogFact = { ...baseEntry, id: "audit-3", action: "some_future.action_kind" }; + const unknownRows = renderWorkItemAuditTimelineRows([unknownAction], "en-US"); + assert.equal(unknownRows.includes("Some Future Action Kind"), true); + + const many: AuditLogFact[] = Array.from({ length: 11 }, (_, index) => ({ + ...baseEntry, + id: `audit-many-${index}` + })); + const truncated = renderWorkItemAuditTimelineRows(many, "zh-CN"); + assert.equal(truncated.includes('data-r20-workitem-audit-timeline-overflow="3"'), true); + assert.equal(truncated.includes("还有 3 条审计记录未展开(共 11 条)"), true); + assert.equal(truncated.includes("audit-many-7"), true, "the 8th visible entry must still be rendered"); + assert.equal(truncated.includes("audit-many-8"), false, "the 9th entry must be truncated, not silently rendered"); +}); + // R14 批 CHAT(web-avatars):claimed_by_user_id/claimed_by_nickname 一直在契约里,web 端此前从没 // 渲过——工单详情页从没说过"这活现在是谁在认领"。新增文字 + 头像 tile 一起铺,未认领时两者都不出现。 test("R14 CHAT WorkItem route component shows an assignee avatar tile once the item is claimed", () => { diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index c6a159dce..c64340a2e 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -6,6 +6,9 @@ import type { AttentionAction, AttentionHomeVM, AttentionItem, + // R20 R19-27(工作项跨 run 审计时间线):GET /api/workitems/:id/audit 返回的审计事实行。 + AuditActor, + AuditLogFact, ConversationMessageVM, ConversationReactionKey, CostDashboardVM, @@ -2295,6 +2298,76 @@ function traceRows(vm: WorkItemDetailVM, locale: WorkHubLocale) { .join(""); } +// R20 R19-27:后端早有跨 run 审计时间线端点(GET /api/workitems/:id/audit,packages/db +// audit-repository 有测试覆盖),但 web 端从没拉过这份数据、更没渲染过——用户看不到一个工作项跨多次 +// AI 执行/快照/审批的完整审计轨迹。这里只出静态占位卡(时间线本体是客户端异步拉取,见 +// apps/web/src/browser.ts 的 bindWorkItemAuditTimelinePanel),行渲染抽成纯函数以便单测覆盖。 +const auditActionLabels: Record = { + "work_item.created": ["创建工作项", "Work item created"], + "work_item.updated": ["更新工作项", "Work item updated"], + "snapshot.created": ["生成文件快照", "File snapshot created"], + "snapshot.reverted": ["回滚文件快照", "File snapshot reverted"], + "proposal.opened": ["生成变更申请", "Proposal opened"], + "proposal.merged": ["合并变更申请", "Proposal merged"], + "proposal.rejected": ["驳回变更申请", "Proposal rejected"], + "approval.approved": ["审批通过", "Approval granted"], + "approval.rejected": ["审批打回", "Approval rejected"] +}; + +function auditActionLabel(action: string, zh: boolean): string { + const hit = auditActionLabels[action]; + if (hit) { + return zh ? hit[0] : hit[1]; + } + // 未预先收录的动作字符串(新增审计事件类型时难免)不能裸给机器串——退化成分词展示, + // 至少比 "proposal.merged" 这种没见过的原始 action 值人类友好一些。 + return humanizeToken(action.replace(/[._]/gu, " ")); +} + +function auditActorLabel(actor: AuditActor, zh: boolean): string { + if (actor.actor_kind === "ai") { + return "AI"; + } + if (actor.actor_nickname) { + return actor.actor_nickname; + } + return zh ? "系统" : "System"; +} + +const AUDIT_TIMELINE_VISIBLE_COUNT = 8; + +// 导出供 browser.ts(web,拉到数据后)与单测复用;纯函数,不做取数——加载中/加载失败/无权三种态 +// 由调用方(bindWorkItemAuditTimelinePanel)区分渲染,这里只管「已经有一批审计事实,怎么显示」。 +export function renderWorkItemAuditTimelineRows(logs: AuditLogFact[], locale: WorkHubLocale): string { + const zh = locale === "zh-CN"; + if (logs.length === 0) { + return `

${escapeHtml(zh ? "暂无审计记录。" : "No audit history yet.")}

`; + } + const visible = logs.slice(0, AUDIT_TIMELINE_VISIBLE_COUNT); + const overflowCount = logs.length - AUDIT_TIMELINE_VISIBLE_COUNT; + // R10-P1-4 同款约定(evidenceRows):截断必须诚实标出「还有 N 条」,不许让审阅者以为已看全。 + const overflow = overflowCount > 0 + ? `

${escapeHtml( + zh + ? `还有 ${overflowCount} 条审计记录未展开(共 ${logs.length} 条)。` + : `${overflowCount} more audit entries not shown (${logs.length} total).` + )}

` + : ""; + return visible + .map((entry) => `
+
+ ${escapeHtml(auditActionLabel(entry.action, zh))} +

${escapeHtml(auditActorLabel(entry.actor, zh))}${entry.undone_at ? escapeHtml(zh ? "(已撤销)" : " (undone)") : ""}

+
+ ${escapeHtml(formatApprovalTimestamp(entry.created_at))} +
`) + .join("") + overflow; +} + +function workItemAuditTimelineLoadingHtml(locale: WorkHubLocale): string { + return `

${escapeHtml(locale === "zh-CN" ? "正在加载审计记录…" : "Loading audit history…")}

`; +} + function workItemActions(vm: WorkItemDetailVM, locale: WorkHubLocale): ActionSpec[] { const proposalId = vm.latest_proposal?.proposal_id; const runId = vm.agent_trace_preview[0]?.agent_run_id; @@ -2704,6 +2777,10 @@ function renderWorkItemRouteComponent(vm: WorkItemDetailVM, locale: WorkHubLocal

${escapeHtml(uiT(locale, "generic.evidence"))}

${evidenceRows(vm.evidence_refs, locale, "r4-workitem-evidence-ref")}
+
+

${escapeHtml(locale === "zh-CN" ? "跨 run 审计时间线" : "Cross-run audit timeline")}

+
${workItemAuditTimelineLoadingHtml(locale)}
+
` }); }