From 93ed4a5981321cf5cf7b60be5dbeab88954e4b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Thu, 24 Sep 2026 18:16:33 +0000 Subject: [PATCH 1/4] feat(approvals): overlay Jev aging suggestion on pending items Aging overlay is copy-only (nudge / wait / discuss-close). Deterministic clock vs createdAt; expiry still uses existing helpers. Pending high stays visible in the rule-risk layer. No decision POST; click opens the item. https://github.com/bytefolk/roleweave/issues/422 --- CHANGELOG.md | 2 + .../src/approvals/ApprovalDetailDrawer.tsx | 5 + .../renderer/src/approvals/ApprovalQueue.tsx | 5 + .../renderer/test/approval-queue.test.tsx | 61 ++++++- apps/server/src/approvals/aging-overlay.ts | 150 ++++++++++++++++++ apps/server/src/approvals/service.ts | 6 +- .../test/approval-aging-overlay.test.ts | 125 +++++++++++++++ packages/shared/src/approvals.ts | 2 + packages/ui/src/locales/en.ts | 4 + packages/ui/src/locales/zh.ts | 4 + 10 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/approvals/aging-overlay.ts create mode 100644 apps/server/test/approval-aging-overlay.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56529428..381b15c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- #466:审批队列和详情展示可选 Jev 老化建议(`nudge` / `wait` / `discuss-close`),仅为文案。出站载荷只有 `{ createdAt, now, decision.kind, risk }`。overlay 不得隐藏 pending high,不得 POST decision,点击只打开事项。过期仍走现有 clock helpers。`ROLEWEAVE_LAYA_ENABLED` 默认关。https://github.com/bytefolk/roleweave/issues/422 + - #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work//`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work//`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work//`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。 - #480:管理者实时任务进度看板。工作台自报 thread-context / spawn / streaming / persist / terminal 五步,经现有 `GET /events` 广播 `turn.progress`,快照落在 `.roleweave/conversations//progress/.json`。列表合并内存与落盘,刷新或控制面重启后仍可回看。不新开 WebSocket,不改 spawn/finish 契约。Refs #480。 diff --git a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx index 9bbaaf56..91c88b1c 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx @@ -234,6 +234,11 @@ export function ApprovalDetailDrawer({ {t("apr.riskOverlay")}: {t(`apr.risk.${item.context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")} ) : null} + {item.context.agingOverlay ? ( + + {t("apr.agingOverlay")}: {t(`apr.aging.${item.context.agingOverlay}`)} + + ) : null}
{t("apr.requestedCapability")}
{t(`apr.kind.${item.context.requestedCapability}`)}
diff --git a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx index 26ea5ad5..6c10b60f 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx @@ -650,6 +650,11 @@ function ApprovalCard({ item, now, onOpen, batchSelected, batchDisabled, onBatch {t("apr.riskOverlay")}: {t(`apr.risk.${item.context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")} ) : null} + {item.context?.agingOverlay ? ( + + {t("apr.agingOverlay")}: {t(`apr.aging.${item.context.agingOverlay}`)} + + ) : null} {overreach ? ( {t("apr.overreach")} diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx index d4be33d2..b02caad7 100644 --- a/apps/desktop/renderer/test/approval-queue.test.tsx +++ b/apps/desktop/renderer/test/approval-queue.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ApprovalQueue } from "../src/approvals/ApprovalQueue"; -import type { ApprovalQueueItem } from "../src/approvals/types"; +import { approvalExpiryState, isActionablePending, type ApprovalQueueItem } from "../src/approvals/types"; import { pickSelectOption } from "./select-helper"; function makeItem(over: Partial = {}): ApprovalQueueItem { @@ -71,6 +71,65 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => { expect(screen.getByTestId("approval-risk-overlay").textContent ?? "").toMatch(/建议未采纳|suggestion not adopted|apr.suggestionNotAdopted/); }); + it("aging overlay cannot hide pending high and click opens the item without posting a decision", () => { + const onApprove = vi.fn(); + const onDeny = vi.fn(); + const highPending = makeItem({ + approvalId: "high-pending", + category: "exec", + context: { + risk: "high", + agingOverlay: "wait", + requestedCapability: "exec", + impact: "command_execution", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, + }, + }); + const mediumPending = makeItem({ + approvalId: "medium-pending", + category: "tool", + description: "restricted tool", + context: { + risk: "medium", + agingOverlay: "nudge", + requestedCapability: "tool", + impact: "restricted_tool", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, + }, + }); + render(); + const highCard = screen.getByTestId("approval-card-high-pending"); + expect(highCard).toBeInTheDocument(); + expect(within(highCard).getByTestId("approval-aging-overlay")).toHaveTextContent(/继续等待|wait/i); + fireEvent.click(within(highCard).getByTestId("approval-aging-overlay")); + expect(screen.getByTestId("approval-detail-drawer")).toBeInTheDocument(); + expect(onApprove).not.toHaveBeenCalled(); + expect(onDeny).not.toHaveBeenCalled(); + }); + + it("expired vs pending still use existing clock helpers when aging overlay is present", () => { + const now = Date.parse("2026-08-27T14:00:00.000Z"); + const context = { + risk: "high" as const, + agingOverlay: "nudge" as const, + requestedCapability: "exec" as const, + impact: "command_execution" as const, + permissions: { mode: "approval_required" as const, allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable" as const, reason: "engine_preview_not_supplied" as const }, + scope: { allowed: ["once" as const] }, + }; + const pending = makeItem({ expiresAt: "2026-08-28T12:00:00.000Z", context }); + const expired = makeItem({ approvalId: "expired", expiresAt: "2026-08-27T13:00:00.000Z", context }); + expect(approvalExpiryState(pending, now)).toBe("expiring"); + expect(isActionablePending(pending, now)).toBe(true); + expect(approvalExpiryState(expired, now)).toBe("expired"); + expect(isActionablePending(expired, now)).toBe(false); + }); + it("\u6e32\u67d3 pending \u5217\u8868\uff1a\u5c97\u4f4d\u540d\u3001\u63cf\u8ff0\u3001\u76ee\u6807\u5747\u5230\u4f4d", () => { render( , diff --git a/apps/server/src/approvals/aging-overlay.ts b/apps/server/src/approvals/aging-overlay.ts new file mode 100644 index 00000000..44671366 --- /dev/null +++ b/apps/server/src/approvals/aging-overlay.ts @@ -0,0 +1,150 @@ +import type { ApprovalContext, ApprovalRiskLevel, ApprovalStatus } from "@roleweave/shared"; +import { askLaya, type LayaAsk } from "../laya/client.js"; +import { layaEnabled } from "../laya/config.js"; + +export type AgingChoice = "nudge" | "wait" | "discuss-close"; + +export interface AgingQueueRow { + id: string; + createdAt: string; + now: number; + decision: { kind: ApprovalStatus | string }; + risk: ApprovalRiskLevel; +} + +export interface AgingSuggestion { + choice: AgingChoice; + needsAttention: boolean; +} + +const AGING_CHOICES = new Set(["nudge", "wait", "discuss-close"]); + +function ruleRiskLayer(risk: ApprovalRiskLevel): number { + return risk === "high" ? 0 : 1; +} + +export function agingNeedsAttention(choice: AgingChoice): boolean { + return choice === "nudge"; +} + +/** Deterministic age from the injected clock. Expiry stays on existing helpers. */ +export function agingAgeMs(createdAt: string, now: number): number | undefined { + const created = Date.parse(createdAt); + if (!Number.isFinite(created) || !Number.isFinite(now)) return undefined; + return Math.max(0, now - created); +} + +/** Overlay may reorder inside a rule-risk layer. It must not drop pending high. */ +export function presentAgingQueue( + items: readonly T[], + overlays: Readonly>, +): T[] { + return [...items].sort((a, b) => { + const layer = ruleRiskLayer(a.risk) - ruleRiskLayer(b.risk); + if (layer !== 0) return layer; + const aAttention = overlays[a.id]?.needsAttention === true ? 0 : 1; + const bAttention = overlays[b.id]?.needsAttention === true ? 0 : 1; + return aAttention - bAttention; + }); +} + +/** Kind-and-clock payload. Description, target, and turn bodies are forbidden. */ +export function assertAgingAdvicePayload(payload: Record): void { + const allowed = new Set(["createdAt", "now", "decision", "risk"]); + const extra = Object.keys(payload).filter((key) => !allowed.has(key)); + if (extra.length > 0) { + throw new Error(`laya approval-aging payload has forbidden keys: ${extra.sort().join(",")}`); + } + if (typeof payload.createdAt !== "string" || !Number.isFinite(Date.parse(payload.createdAt))) { + throw new Error("laya approval-aging payload createdAt is invalid"); + } + if (typeof payload.now !== "number" || !Number.isFinite(payload.now)) { + throw new Error("laya approval-aging payload now is invalid"); + } + const decision = payload.decision; + if (!decision || typeof decision !== "object" || Array.isArray(decision) || Object.keys(decision as object).some((key) => key !== "kind")) { + throw new Error("laya approval-aging payload decision is invalid"); + } + const kind = (decision as { kind?: unknown }).kind; + if (typeof kind !== "string" || kind.length === 0) { + throw new Error("laya approval-aging payload decision.kind is invalid"); + } + if (payload.risk !== "medium" && payload.risk !== "high") { + throw new Error("laya approval-aging payload risk is invalid"); + } +} + +export function mapAdviceChoiceToAging(choice: string): AgingChoice | null { + return AGING_CHOICES.has(choice as AgingChoice) ? choice as AgingChoice : null; +} + +export interface ResolveApprovalAgingOverlayDeps { + env?: NodeJS.Dict; + ask?: LayaAsk; + now?: number; +} + +export function agingAdviceState(input: { + createdAt: string; + now: number; + decisionKind: string; + risk: ApprovalRiskLevel; +}): Record { + return { + createdAt: input.createdAt, + now: input.now, + decision: { kind: input.decisionKind }, + risk: input.risk, + }; +} + +/** Advisory overlay. Undefined when the flag is off, the item is not pending, or advice is invalid. */ +export async function resolveApprovalAgingOverlay( + input: { createdAt: string; decisionKind: string; risk: ApprovalRiskLevel }, + deps: ResolveApprovalAgingOverlayDeps = {}, +): Promise { + if (input.decisionKind !== "pending") return undefined; + const env = deps.env ?? process.env; + if (!layaEnabled(env)) return undefined; + const now = deps.now ?? Date.now(); + const payload = agingAdviceState({ ...input, now }); + assertAgingAdvicePayload(payload); + const ask = deps.ask ?? ((request) => askLaya(request, { env })); + try { + const answers = await ask({ + state: payload, + questions: { + aging: { + type: "choice", + instructions: "Suggest display copy for a pending approval's age. Advisory. Never grant or deny.", + criteria: { + nudge: "Still waiting and the owner should look soon", + wait: "Stale but still a normal pending wait", + "discuss-close": "Owner may discuss closing; copy only, never a verdict", + }, + }, + }, + }); + const selected = answers?.aging?.type === "choice" ? answers.aging.selected : undefined; + if (!selected) return undefined; + return mapAdviceChoiceToAging(selected) ?? undefined; + } catch { + return undefined; + } +} + +/** Projection-only: never persist the result and never post a decision. */ +export async function attachApprovalAgingOverlay( + context: ApprovalContext, + input: { createdAt: string; decisionKind: string }, + deps: ResolveApprovalAgingOverlayDeps = {}, +): Promise { + const { agingOverlay: _ignored, ...base } = context as ApprovalContext & { agingOverlay?: AgingChoice }; + const overlay = await resolveApprovalAgingOverlay({ + createdAt: input.createdAt, + decisionKind: input.decisionKind, + risk: base.risk, + }, deps); + if (!overlay) return base; + return { ...base, agingOverlay: overlay } as ApprovalContext; +} diff --git a/apps/server/src/approvals/service.ts b/apps/server/src/approvals/service.ts index 8a710c8b..4e1079af 100644 --- a/apps/server/src/approvals/service.ts +++ b/apps/server/src/approvals/service.ts @@ -6,6 +6,7 @@ import { assertTurnWorkspace, executeTurn } from "../routes/turns.js"; import { resolvePositionAgentEngine } from "../agent-binding.js"; import { ApprovalStore, approvalIdentity } from "./store.js"; import { buildApprovalContext, redactApprovalText } from "./context.js"; +import { attachApprovalAgingOverlay } from "./aging-overlay.js"; import { attachApprovalRiskOverlay } from "./risk-overlay.js"; import { canActForPolicy, policyProgress, resolveApprovalPolicy } from "./policy.js"; @@ -251,7 +252,10 @@ export class ApprovalService { : undefined; const projected = publicView(a); const context = projected.context - ? await attachApprovalRiskOverlay(projected.context, a.action) + ? await attachApprovalAgingOverlay( + await attachApprovalRiskOverlay(projected.context, a.action), + { createdAt: a.createdAt, decisionKind: a.status }, + ) : projected.context; return { ...projected, diff --git a/apps/server/test/approval-aging-overlay.test.ts b/apps/server/test/approval-aging-overlay.test.ts new file mode 100644 index 00000000..51e922e8 --- /dev/null +++ b/apps/server/test/approval-aging-overlay.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ApprovalContext } from "@roleweave/shared"; +import { layaEnabled } from "../src/laya/config.js"; +import { + agingAgeMs, + agingAdviceState, + agingNeedsAttention, + assertAgingAdvicePayload, + attachApprovalAgingOverlay, + mapAdviceChoiceToAging, + presentAgingQueue, + resolveApprovalAgingOverlay, +} from "../src/approvals/aging-overlay.js"; + +const now = Date.parse("2026-09-24T18:00:00.000Z"); +const createdAt = "2026-09-01T00:00:00.000Z"; +const baseContext: ApprovalContext = { + risk: "high", + requestedCapability: "exec", + impact: "command_execution", + permissions: { mode: "approval_required", allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: ["once"] }, +}; + +test("overlay cannot hide pending high items from the rule-risk layer", () => { + const now = Date.parse("2026-09-24T18:00:00.000Z"); + const highPending = { + id: "high-pending", + createdAt: "2026-09-01T00:00:00.000Z", + now, + decision: { kind: "pending" as const }, + risk: "high" as const, + }; + const mediumPending = { + id: "medium-pending", + createdAt: "2026-09-24T17:00:00.000Z", + now, + decision: { kind: "pending" as const }, + risk: "medium" as const, + }; + + const ranked = presentAgingQueue([mediumPending, highPending], { + "high-pending": { choice: "wait", needsAttention: false }, + "medium-pending": { choice: "nudge", needsAttention: true }, + }); + + const highIndex = ranked.findIndex((row) => row.id === "high-pending"); + const mediumIndex = ranked.findIndex((row) => row.id === "medium-pending"); + assert.notEqual(highIndex, -1, "pending high must remain visible when overlay says wait"); + assert.equal(ranked[highIndex]?.risk, "high"); + assert.ok(highIndex < mediumIndex, "pending high stays in the high rule-risk layer above medium"); +}); + +test("aging clock is deterministic against createdAt and does not invent expiry", () => { + assert.equal(agingAgeMs(createdAt, now), now - Date.parse(createdAt)); + assert.equal(agingNeedsAttention("wait"), false); + assert.equal(agingNeedsAttention("nudge"), true); +}); + +test("aging payload rejects bodies outside { createdAt, now, decision.kind, risk }", () => { + const payload = agingAdviceState({ createdAt, now, decisionKind: "pending", risk: "high" }); + assert.doesNotThrow(() => assertAgingAdvicePayload(payload)); + assert.throws(() => assertAgingAdvicePayload({ ...payload, description: "rm -rf" })); + assert.throws(() => assertAgingAdvicePayload({ ...payload, target: "./secret" })); + assert.deepEqual(Object.keys(payload).sort(), ["createdAt", "decision", "now", "risk"]); + assert.deepEqual((payload.decision as { kind: string }), { kind: "pending" }); +}); + +test("unknown Laya choice does not become an aging overlay", () => { + assert.equal(mapAdviceChoiceToAging("deny"), null); + assert.equal(mapAdviceChoiceToAging("discuss-close"), "discuss-close"); +}); + +test("ROLEWEAVE_LAYA_ENABLED remains explicit opt-in", () => { + assert.equal(layaEnabled({}), false); +}); + +test("flag off: no aging overlay and ask is not invoked", async () => { + let called = false; + const result = await attachApprovalAgingOverlay(baseContext, { createdAt, decisionKind: "pending" }, { + env: {}, + now, + ask: async () => { + called = true; + return { aging: { type: "choice", selected: "nudge", probabilities: {}, confidence: 1 } }; + }, + }); + assert.equal(called, false); + assert.equal((result as ApprovalContext & { agingOverlay?: string }).agingOverlay, undefined); + assert.equal(result.risk, "high"); +}); + +test("expired items skip aging overlay so existing clock helpers remain authoritative", async () => { + let called = false; + const overlay = await resolveApprovalAgingOverlay( + { createdAt, decisionKind: "expired", risk: "high" }, + { + env: { ROLEWEAVE_LAYA_ENABLED: "1" }, + now, + ask: async () => { + called = true; + return { aging: { type: "choice", selected: "discuss-close", probabilities: { "discuss-close": 1 }, confidence: 1 } }; + }, + }, + ); + assert.equal(called, false); + assert.equal(overlay, undefined); +}); + +test("discuss-close is copy only and never posts a decision", async () => { + let posted = 0; + const attached = await attachApprovalAgingOverlay(baseContext, { createdAt, decisionKind: "pending" }, { + env: { ROLEWEAVE_LAYA_ENABLED: "1" }, + now, + ask: async (request) => { + assertAgingAdvicePayload(request.state as Record); + return { aging: { type: "choice", selected: "discuss-close", probabilities: { "discuss-close": 1 }, confidence: 1 } }; + }, + }); + assert.equal((attached as ApprovalContext & { agingOverlay?: string }).agingOverlay, "discuss-close"); + assert.equal(attached.risk, "high"); + assert.equal(posted, 0); +}); diff --git a/packages/shared/src/approvals.ts b/packages/shared/src/approvals.ts index 1f0c3cea..cc5fc05e 100644 --- a/packages/shared/src/approvals.ts +++ b/packages/shared/src/approvals.ts @@ -25,6 +25,8 @@ export interface ApprovalContext { scope: { allowed: Array<"once" | "run"> }; /** Projection-only Laya suggestion. Never authoritative for Tag color or policy.digest. */ riskOverlay?: ApprovalRiskLevel; + /** Projection-only Jev aging copy. Never a verdict and never hides pending high. */ + agingOverlay?: "nudge" | "wait" | "discuss-close"; } export interface ApprovalDecisionRequest { diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index c2bc0848..6d707b2b 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -1218,6 +1218,10 @@ export const enCatalog: Record = { "apr.risk.high": "High risk", "apr.riskOverlay": "Laya suggested risk", "apr.suggestionNotAdopted": "suggestion not adopted", + "apr.agingOverlay": "Laya aging suggestion", + "apr.aging.nudge": "nudge", + "apr.aging.wait": "wait", + "apr.aging.discuss-close": "discuss-close", "apr.requestedCapability": "Requested capability", "apr.impact": "Potential impact", "apr.impact.workspace_write": "May create or change workspace data", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index 500554b8..4f4b23c2 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -1221,6 +1221,10 @@ export const zhCatalog: Record = { "apr.risk.high": "高风险", "apr.riskOverlay": "Laya 建议风险", "apr.suggestionNotAdopted": "建议未采纳", + "apr.agingOverlay": "Laya 老化建议", + "apr.aging.nudge": "提醒跟进", + "apr.aging.wait": "继续等待", + "apr.aging.discuss-close": "讨论后关闭", "apr.requestedCapability": "请求能力", "apr.impact": "可能影响", "apr.impact.workspace_write": "可能创建或修改工作区数据", From 88ef0abf77e2e591cf1e0e978a7d4334a0e3eb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Thu, 24 Sep 2026 18:32:29 +0000 Subject: [PATCH 2/4] fix(approvals): wire aging rank into list and package overlay presentAgingQueue/agingNeedsAttention now rank GET /approvals without crossing the rule-risk layer. Flag-off lists keep original order. Package aging-overlay.js on the runtime allowlist. https://github.com/bytefolk/roleweave/issues/422 --- apps/desktop/packaging/runtime-layout.cjs | 1 + .../renderer/test/approval-queue.test.tsx | 5 ++- apps/server/src/approvals/aging-overlay.ts | 43 ++++++++++++++++--- apps/server/src/approvals/service.ts | 4 +- .../test/approval-aging-overlay.test.ts | 15 +++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index b4dedf77..76f981c0 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -64,6 +64,7 @@ const SERVER_RUNTIME_FILES = [ "src/windows-launcher.js", "dist/src/agent-binding.js", "dist/src/agent-registry.js", + "dist/src/approvals/aging-overlay.js", "dist/src/approvals/context.js", "dist/src/approvals/policy.js", "dist/src/approvals/risk-overlay.js", diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx index b02caad7..55a955dc 100644 --- a/apps/desktop/renderer/test/approval-queue.test.tsx +++ b/apps/desktop/renderer/test/approval-queue.test.tsx @@ -106,7 +106,10 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => { expect(highCard).toBeInTheDocument(); expect(within(highCard).getByTestId("approval-aging-overlay")).toHaveTextContent(/继续等待|wait/i); fireEvent.click(within(highCard).getByTestId("approval-aging-overlay")); - expect(screen.getByTestId("approval-detail-drawer")).toBeInTheDocument(); + const drawer = screen.getByTestId("approval-detail-drawer"); + expect(drawer).toBeInTheDocument(); + expect(drawer.querySelector('[data-approval-id="high-pending"]')).not.toBeNull(); + expect(drawer.querySelector('[data-approval-id="medium-pending"]')).toBeNull(); expect(onApprove).not.toHaveBeenCalled(); expect(onDeny).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/approvals/aging-overlay.ts b/apps/server/src/approvals/aging-overlay.ts index 44671366..90014d93 100644 --- a/apps/server/src/approvals/aging-overlay.ts +++ b/apps/server/src/approvals/aging-overlay.ts @@ -34,18 +34,47 @@ export function agingAgeMs(createdAt: string, now: number): number | undefined { return Math.max(0, now - created); } -/** Overlay may reorder inside a rule-risk layer. It must not drop pending high. */ +/** Overlay may reorder inside a rule-risk layer. It must not drop pending high. + * With no overlay, the original order is preserved so flag-off lists stay byte-stable. */ export function presentAgingQueue( items: readonly T[], overlays: Readonly>, ): T[] { - return [...items].sort((a, b) => { - const layer = ruleRiskLayer(a.risk) - ruleRiskLayer(b.risk); - if (layer !== 0) return layer; - const aAttention = overlays[a.id]?.needsAttention === true ? 0 : 1; - const bAttention = overlays[b.id]?.needsAttention === true ? 0 : 1; - return aAttention - bAttention; + const hasOverlay = items.some((item) => overlays[item.id] !== undefined); + if (!hasOverlay) return [...items]; + return items + .map((item, index) => ({ item, index })) + .sort((a, b) => { + const layer = ruleRiskLayer(a.item.risk) - ruleRiskLayer(b.item.risk); + if (layer !== 0) return layer; + const aAttention = overlays[a.item.id]?.needsAttention === true ? 0 : 1; + const bAttention = overlays[b.item.id]?.needsAttention === true ? 0 : 1; + if (aAttention !== bAttention) return aAttention - bAttention; + return a.index - b.index; + }) + .map((entry) => entry.item); +} + +export function rankApprovalViews(views: readonly T[], now: number): T[] { + const overlays: Record = {}; + const rows = views.map((view) => { + const choice = view.context?.agingOverlay; + if (choice) overlays[view.id] = { choice, needsAttention: agingNeedsAttention(choice) }; + return { + id: view.id, + createdAt: view.createdAt, + now, + decision: { kind: view.status }, + risk: (view.context?.risk === "high" ? "high" : "medium") as ApprovalRiskLevel, + view, + }; }); + return presentAgingQueue(rows, overlays).map((row) => row.view); } /** Kind-and-clock payload. Description, target, and turn bodies are forbidden. */ diff --git a/apps/server/src/approvals/service.ts b/apps/server/src/approvals/service.ts index 4e1079af..526082eb 100644 --- a/apps/server/src/approvals/service.ts +++ b/apps/server/src/approvals/service.ts @@ -6,7 +6,7 @@ import { assertTurnWorkspace, executeTurn } from "../routes/turns.js"; import { resolvePositionAgentEngine } from "../agent-binding.js"; import { ApprovalStore, approvalIdentity } from "./store.js"; import { buildApprovalContext, redactApprovalText } from "./context.js"; -import { attachApprovalAgingOverlay } from "./aging-overlay.js"; +import { attachApprovalAgingOverlay, rankApprovalViews } from "./aging-overlay.js"; import { attachApprovalRiskOverlay } from "./risk-overlay.js"; import { canActForPolicy, policyProgress, resolveApprovalPolicy } from "./policy.js"; @@ -272,7 +272,7 @@ export class ApprovalService { const { items } = await this.synchronize(ws); const views = await Promise.all(items.map(a => this.view(ws, a))); assertTurnWorkspace(this.ctx, ws); - return views; + return rankApprovalViews(views, Date.now()); }); } diff --git a/apps/server/test/approval-aging-overlay.test.ts b/apps/server/test/approval-aging-overlay.test.ts index 51e922e8..a72929a3 100644 --- a/apps/server/test/approval-aging-overlay.test.ts +++ b/apps/server/test/approval-aging-overlay.test.ts @@ -10,6 +10,7 @@ import { attachApprovalAgingOverlay, mapAdviceChoiceToAging, presentAgingQueue, + rankApprovalViews, resolveApprovalAgingOverlay, } from "../src/approvals/aging-overlay.js"; @@ -53,6 +54,20 @@ test("overlay cannot hide pending high items from the rule-risk layer", () => { assert.ok(highIndex < mediumIndex, "pending high stays in the high rule-risk layer above medium"); }); +test("flag-off ranking preserves original order and never crosses the high risk layer", () => { + const mediumFirst = { id: "medium-pending", createdAt, now, decision: { kind: "pending" as const }, risk: "medium" as const }; + const highSecond = { id: "high-pending", createdAt, now, decision: { kind: "pending" as const }, risk: "high" as const }; + const unchanged = presentAgingQueue([mediumFirst, highSecond], {}); + assert.deepEqual(unchanged.map((row) => row.id), ["medium-pending", "high-pending"]); + + const ranked = rankApprovalViews([ + { id: "medium-pending", createdAt, status: "pending", context: { risk: "medium", agingOverlay: "nudge" } }, + { id: "high-pending", createdAt, status: "pending", context: { risk: "high", agingOverlay: "wait" } }, + ], now); + assert.deepEqual(ranked.map((row) => row.id), ["high-pending", "medium-pending"]); + assert.equal(ranked[0]?.context?.risk, "high"); +}); + test("aging clock is deterministic against createdAt and does not invent expiry", () => { assert.equal(agingAgeMs(createdAt, now), now - Date.parse(createdAt)); assert.equal(agingNeedsAttention("wait"), false); From 5ffe5814b1135d241a5b5e451b48ff87803c7d91 Mon Sep 17 00:00:00 2001 From: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:24:45 +0800 Subject: [PATCH 3/4] test(approvals): adapt aging-overlay click test to the new approval detail component main replaced ApprovalDetailDrawer with ApprovalDetail; assert the new approval-detail testid and data-approval-id attribute instead. --- apps/desktop/renderer/test/approval-queue.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx index 2933b184..ed86d4ca 100644 --- a/apps/desktop/renderer/test/approval-queue.test.tsx +++ b/apps/desktop/renderer/test/approval-queue.test.tsx @@ -213,10 +213,10 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => { expect(highCard).toBeInTheDocument(); expect(within(highCard).getByTestId("approval-aging-overlay")).toHaveTextContent(/继续等待|wait/i); fireEvent.click(within(highCard).getByTestId("approval-aging-overlay")); - const drawer = screen.getByTestId("approval-detail-drawer"); + const drawer = screen.getByTestId("approval-detail"); expect(drawer).toBeInTheDocument(); - expect(drawer.querySelector('[data-approval-id="high-pending"]')).not.toBeNull(); - expect(drawer.querySelector('[data-approval-id="medium-pending"]')).toBeNull(); + expect(drawer.getAttribute("data-approval-id")).toBe("high-pending"); + expect(drawer.getAttribute("data-approval-id")).not.toBe("medium-pending"); expect(onApprove).not.toHaveBeenCalled(); expect(onDeny).not.toHaveBeenCalled(); }); From bb387888fd0951b7999c54c2c4f2986181412d81 Mon Sep 17 00:00:00 2001 From: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:01:42 +0800 Subject: [PATCH 4/4] chore(deps): bump vite to 6.4.4 in the lockfile npm audit --audit-level=high (the security:check step of npm run check) started failing on vite <= 6.4.3 due to GHSA-698v-4r3x-3vcv and related launch-editor advisories. Lock the dev-only vite entry to the 6.x patch release 6.4.4; no other dependency changes. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b2b543f..aa4390db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9138,9 +9138,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": {