Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
### Added

- #615: Show per-task execution progress in project cards, schedule rows, and the task editor. Reuse live stage events and exact persisted snapshots, recover on reconnect, and keep completed execution separate from delivery acceptance.
- #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

### Fixed

Expand Down Expand Up @@ -72,6 +73,7 @@
- #559:新增协作区人工验收契约,验收通过必须完整覆盖计划中的全部验收项;限制标识符、时间戳和产物路径,拒绝遗漏或越界的裁决。

- #469:可选 Laya 接力停止建议。仅在 `ROLEWEAVE_LAYA_ENABLED` 开启、已完成步骤得到正向建议且仍有未执行步骤时暂停;桌面端由 owner 显式选择“停止剩余步骤”或“继续接力”。30 秒超时、SSE 断连、无效或失败建议均继续原 mention order;Laya 只接收 `status`、`errorCode`、`hasOutput`,不接收输入、输出或 handoff 文本。已接受的消息与 spawn 清单保持不可变。Refs #422。
>>>>>>> origin/main

- #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work/<positionId>/`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work/<positionId>/`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work/<owner>/`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,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/delivery.js",
"dist/src/approvals/policy.js",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/renderer/src/approvals/ApprovalDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ function SelectedApprovalDetail({
{t("apr.riskOverlay")}: {t(`apr.risk.${context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")}
</Tag>
) : null}
{context.agingOverlay ? (
<Tag data-testid="approval-aging-overlay">
{t("apr.agingOverlay")}: {t(`apr.aging.${context.agingOverlay}`)}
</Tag>
) : null}
</>
) : null}
{permissionMode ? (
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/renderer/src/approvals/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,11 @@ function ApprovalCard({ item, now, onOpen, selected, batchSelected, batchDisable
{t("apr.riskOverlay")}: {t(`apr.risk.${item.context.riskOverlay}`)} · {t("apr.suggestionNotAdopted")}
</Tag>
) : null}
{item.context?.agingOverlay ? (
<Tag data-testid="approval-aging-overlay">
{t("apr.agingOverlay")}: {t(`apr.aging.${item.context.agingOverlay}`)}
</Tag>
) : null}
{overreach ? (
<Tag color="red" data-testid="approval-overreach-tag">
{t("apr.overreach")}
Expand Down
64 changes: 63 additions & 1 deletion apps/desktop/renderer/test/approval-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { enterPickerDate } from "./date-picker-helper";
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> = {}): ApprovalQueueItem {
Expand Down Expand Up @@ -178,6 +178,68 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => {
expect(within(screen.getByTestId("approval-card-appr-abc")).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(<ApprovalQueue items={[mediumPending, highPending]} onApprove={onApprove} onDeny={onDeny} />);
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"));
const drawer = screen.getByTestId("approval-detail");
expect(drawer).toBeInTheDocument();
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();
});

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(
<ApprovalQueue items={[makeItem()]} onApprove={noop} onDeny={noop} />,
Expand Down
179 changes: 179 additions & 0 deletions apps/server/src/approvals/aging-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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<AgingChoice>(["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.
* With no overlay, the original order is preserved so flag-off lists stay byte-stable. */
export function presentAgingQueue<T extends AgingQueueRow>(
items: readonly T[],
overlays: Readonly<Record<string, AgingSuggestion | undefined>>,
): T[] {
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<T extends {
id: string;
createdAt: string;
status: string;
context?: { risk?: ApprovalRiskLevel; agingOverlay?: AgingChoice };
}>(views: readonly T[], now: number): T[] {
const overlays: Record<string, AgingSuggestion | undefined> = {};
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. */
export function assertAgingAdvicePayload(payload: Record<string, unknown>): 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<string>;
ask?: LayaAsk;
now?: number;
}

export function agingAdviceState(input: {
createdAt: string;
now: number;
decisionKind: string;
risk: ApprovalRiskLevel;
}): Record<string, unknown> {
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<AgingChoice | undefined> {
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<ApprovalContext> {
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;
}
8 changes: 6 additions & 2 deletions apps/server/src/approvals/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, rankApprovalViews } from "./aging-overlay.js";
import { attachApprovalRiskOverlay } from "./risk-overlay.js";
import { canActForPolicy, policyProgress, resolveApprovalPolicy } from "./policy.js";

Expand Down Expand Up @@ -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,
Expand All @@ -268,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());
});
}

Expand Down
Loading
Loading