Skip to content

Commit e53e059

Browse files
committed
feat(approvals): server-computed decision progress + notification deep links (#3266, objectui#2678 P1.5)
UX follow-ups on quorum/per_group, both SDUI-shaped (server computes, any client renders): - getRequest now attaches `decision_progress` for PENDING multi-approver requests: unanimous/quorum report approvals got/need (quorum against the clamped threshold); per_group reports satisfied-groups got/need plus a per-group {group, got, need, satisfied} breakdown computed from the open-time approver-group snapshot. Single-read enrichment only, display-only (decideNode's tally stays authoritative), best-effort. Typed on the ApprovalRequestRow contract. - Approval notifications now deep-link the inbox: `notify()` centrally rewrites the bare `/system/approvals` actionUrl to `/system/approvals?request=<id>` from the notification's source, so all twelve call sites — and any future one — land the recipient on the exact request (the console inbox consumes the param and auto-opens the drawer). Tests: 4 new (per-group progress updates per approval, quorum clamped threshold, first_response carries no progress, deep-link rewrite). 136 green in plugin-approvals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ypkQikZ55oWUHUnMebwXA
1 parent 86d30af commit e53e059

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,3 +1390,59 @@ describe('ApprovalService — quorum & per_group (#3266)', () => {
13901390
expect(act.attachments).toEqual(['file_1', 'file_2']);
13911391
});
13921392
});
1393+
1394+
// ── Decision progress + notification deep links (#2678 P1.5) ──────────
1395+
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
1396+
let engine: ReturnType<typeof makeFakeEngine>;
1397+
let svc: ApprovalService;
1398+
1399+
beforeEach(() => {
1400+
engine = makeFakeEngine();
1401+
let n = 0;
1402+
const base = new Date('2026-09-01T09:00:00Z').getTime();
1403+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
1404+
});
1405+
1406+
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
1407+
...openInput([]),
1408+
config: { approvers, behavior, lockRecord: true, ...extra },
1409+
});
1410+
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
1411+
1412+
it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
1413+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1414+
let row: any = await svc.getRequest(req.id, SYS);
1415+
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
1416+
expect(row.decision_progress.groups).toEqual([
1417+
{ group: 'finance', got: 0, need: 1, satisfied: false },
1418+
{ group: 'legal', got: 0, need: 1, satisfied: false },
1419+
]);
1420+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1421+
row = await svc.getRequest(req.id, SYS);
1422+
expect(row.decision_progress.got).toBe(1);
1423+
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
1424+
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
1425+
});
1426+
1427+
it('quorum: progress reports approvals against the clamped threshold', async () => {
1428+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1429+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1430+
const row: any = await svc.getRequest(req.id, SYS);
1431+
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
1432+
});
1433+
1434+
it('first_response: no decision_progress', async () => {
1435+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1436+
const row: any = await svc.getRequest(req.id, SYS);
1437+
expect(row.decision_progress).toBeUndefined();
1438+
});
1439+
1440+
it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
1441+
const emitted: any[] = [];
1442+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
1443+
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
1444+
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
1445+
const note = emitted.find(e => e.topic === 'approval.reassigned');
1446+
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
1447+
});
1448+
});

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,20 @@ export class ApprovalService implements IApprovalService {
268268
}): Promise<number> {
269269
const audience = input.audience.filter(a => a && !a.includes(':'));
270270
if (!this.messaging || !audience.length) return 0;
271+
// Deep-link the inbox (#2678 P1.5): a notification about one request should
272+
// land on that request, not the bare inbox. Rewritten centrally so every
273+
// call site — and any future one — inherits it; the query param is read by
274+
// the console inbox to auto-open the drawer.
275+
let payload = input.payload;
276+
if (
277+
payload?.actionUrl === '/system/approvals'
278+
&& input.source?.object === 'sys_approval_request'
279+
&& input.source.id
280+
) {
281+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
282+
}
271283
try {
272-
await this.messaging.emit({ severity: 'info', ...input, audience });
284+
await this.messaging.emit({ severity: 'info', ...input, payload, audience });
273285
return audience.length;
274286
} catch (err: any) {
275287
this.logger?.warn?.('[approvals] notification failed', {
@@ -2204,9 +2216,56 @@ export class ApprovalService implements IApprovalService {
22042216
const row = rowFromRequest(rows[0]);
22052217
await this.enrichRows([row]);
22062218
await this.attachFlowSteps(row);
2219+
await this.attachDecisionProgress(row, rows[0]);
22072220
return row;
22082221
}
22092222

2223+
/**
2224+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
2225+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
2226+
* for a PENDING request whose behavior aggregates multiple approvals
2227+
* (`unanimous` / `quorum` / `per_group`), expose
2228+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
2229+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
2230+
* `first_response` requests carry no progress (one approval finalizes).
2231+
* Display-only and best-effort — errors leave the row untouched.
2232+
*/
2233+
private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
2234+
try {
2235+
if (row.status !== 'pending') return;
2236+
const cfg = parseJson<any>(raw.node_config_json, undefined);
2237+
const behavior = cfg?.behavior ?? 'first_response';
2238+
if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;
2239+
2240+
const acts = await this.engine.find('sys_approval_action', {
2241+
where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
2242+
});
2243+
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
2244+
2245+
const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
2246+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
2247+
const total = slate.length || 1;
2248+
2249+
const progress: any = { behavior, got: approved.size, need: total };
2250+
if (behavior === 'quorum') {
2251+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
2252+
} else if (behavior === 'per_group' && snapshot) {
2253+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
2254+
const size: Record<string, number> = {};
2255+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
2256+
const got: Record<string, number> = {};
2257+
for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
2258+
progress.groups = Object.keys(size).sort().map(g => {
2259+
const need = Math.min(perGroupNeed, size[g]);
2260+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
2261+
});
2262+
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
2263+
progress.need = progress.groups.length;
2264+
}
2265+
(row as any).decision_progress = progress;
2266+
} catch { /* display-only enrichment */ }
2267+
}
2268+
22102269
/**
22112270
* Derive approval-step progress from the owning flow's graph (single-read
22122271
* enrichment only — list reads skip it). Walks from the start node

packages/spec/src/contracts/approval-service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ export interface ApprovalRequestRow {
9797
* `node_config_json` snapshot (`__round`), so no schema migration.
9898
*/
9999
round?: number;
100+
/**
101+
* Server-computed decision aggregation progress (#3266, single-request reads
102+
* of PENDING requests only). Present when the node's behavior aggregates
103+
* multiple approvals: `unanimous` (got/need = approvals of total),
104+
* `quorum` (got/need = approvals of the M threshold), `per_group`
105+
* (got/need = satisfied groups of total groups, plus per-group detail).
106+
* Absent for `first_response`. Display-only — the engine's finalization
107+
* tally in decideNode stays authoritative.
108+
*/
109+
decision_progress?: {
110+
behavior: 'unanimous' | 'quorum' | 'per_group';
111+
got: number;
112+
need: number;
113+
groups?: Array<{ group: string; got: number; need: number; satisfied: boolean }>;
114+
};
100115
}
101116

102117
/** Kinds of entries on a request's audit trail. */

0 commit comments

Comments
 (0)