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
71 changes: 71 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,3 +1390,74 @@ describe('ApprovalService — quorum & per_group (#3266)', () => {
expect(act.attachments).toEqual(['file_1', 'file_2']);
});
});

// ── Decision progress + notification deep links (#2678 P1.5) ──────────
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let svc: ApprovalService;

beforeEach(() => {
engine = makeFakeEngine();
let n = 0;
const base = new Date('2026-09-01T09:00:00Z').getTime();
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
});

const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
...openInput([]),
config: { approvers, behavior, lockRecord: true, ...extra },
});
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });

it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
let row: any = await svc.getRequest(req.id, SYS);
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
expect(row.decision_progress.groups).toEqual([
{ group: 'finance', got: 0, need: 1, satisfied: false },
{ group: 'legal', got: 0, need: 1, satisfied: false },
]);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
row = await svc.getRequest(req.id, SYS);
expect(row.decision_progress.got).toBe(1);
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
});

it('quorum: progress reports approvals against the clamped threshold', async () => {
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
const row: any = await svc.getRequest(req.id, SYS);
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
});

it('first_response: no decision_progress', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
const row: any = await svc.getRequest(req.id, SYS);
expect(row.decision_progress).toBeUndefined();
});

it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
const emitted: any[] = [];
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
const note = emitted.find(e => e.topic === 'approval.reassigned');
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
});
});

// listActions must surface decision attachments through the contract mapping
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
it('returns the attachments recorded on a decision', async () => {
const engine = makeFakeEngine();
let n = 0;
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
const acts = await svc.listActions(req.id, SYS);
const approve = acts.find(a => a.action === 'approve');
expect(approve?.attachments).toEqual(['file_a']);
});
});
65 changes: 64 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ function rowFromAction(row: any): ApprovalActionRow {
action: row.action,
actor_id: row.actor_id ?? undefined,
comment: row.comment ?? undefined,
// Decision attachments (#3266). The column shipped in #3268 but this
// contract mapping didn't — the raw engine row carried the fileIds while
// every consumer of listActions saw none (caught by browser verification).
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
created_at: row.created_at ?? undefined,
};
}
Expand Down Expand Up @@ -268,8 +272,20 @@ export class ApprovalService implements IApprovalService {
}): Promise<number> {
const audience = input.audience.filter(a => a && !a.includes(':'));
if (!this.messaging || !audience.length) return 0;
// Deep-link the inbox (#2678 P1.5): a notification about one request should
// land on that request, not the bare inbox. Rewritten centrally so every
// call site — and any future one — inherits it; the query param is read by
// the console inbox to auto-open the drawer.
let payload = input.payload;
if (
payload?.actionUrl === '/system/approvals'
&& input.source?.object === 'sys_approval_request'
&& input.source.id
) {
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
}
try {
await this.messaging.emit({ severity: 'info', ...input, audience });
await this.messaging.emit({ severity: 'info', ...input, payload, audience });
return audience.length;
} catch (err: any) {
this.logger?.warn?.('[approvals] notification failed', {
Expand Down Expand Up @@ -2204,9 +2220,56 @@ export class ApprovalService implements IApprovalService {
const row = rowFromRequest(rows[0]);
await this.enrichRows([row]);
await this.attachFlowSteps(row);
await this.attachDecisionProgress(row, rows[0]);
return row;
}

/**
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
* for a PENDING request whose behavior aggregates multiple approvals
* (`unanimous` / `quorum` / `per_group`), expose
* `decision_progress: { behavior, got, need, groups? }` so any client renders
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
* `first_response` requests carry no progress (one approval finalizes).
* Display-only and best-effort — errors leave the row untouched.
*/
private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
try {
if (row.status !== 'pending') return;
const cfg = parseJson<any>(raw.node_config_json, undefined);
const behavior = cfg?.behavior ?? 'first_response';
if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;

const acts = await this.engine.find('sys_approval_action', {
where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
});
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));

const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
const total = slate.length || 1;

const progress: any = { behavior, got: approved.size, need: total };
if (behavior === 'quorum') {
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
} else if (behavior === 'per_group' && snapshot) {
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
const size: Record<string, number> = {};
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
const got: Record<string, number> = {};
for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
progress.groups = Object.keys(size).sort().map(g => {
const need = Math.min(perGroupNeed, size[g]);
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
});
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
progress.need = progress.groups.length;
}
(row as any).decision_progress = progress;
} catch { /* display-only enrichment */ }
}

/**
* Derive approval-step progress from the owning flow's graph (single-read
* enrichment only — list reads skip it). Walks from the start node
Expand Down
17 changes: 17 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ export interface ApprovalRequestRow {
* `node_config_json` snapshot (`__round`), so no schema migration.
*/
round?: number;
/**
* Server-computed decision aggregation progress (#3266, single-request reads
* of PENDING requests only). Present when the node's behavior aggregates
* multiple approvals: `unanimous` (got/need = approvals of total),
* `quorum` (got/need = approvals of the M threshold), `per_group`
* (got/need = satisfied groups of total groups, plus per-group detail).
* Absent for `first_response`. Display-only — the engine's finalization
* tally in decideNode stays authoritative.
*/
decision_progress?: {
behavior: 'unanimous' | 'quorum' | 'per_group';
got: number;
need: number;
groups?: Array<{ group: string; got: number; need: number; satisfied: boolean }>;
};
}

/** Kinds of entries on a request's audit trail. */
Expand Down Expand Up @@ -130,6 +145,8 @@ export interface ApprovalActionRow {
action: ApprovalActionKind;
actor_id?: string;
comment?: string;
/** File references attached to this action (decision attachments, #3266). */
attachments?: string[];
created_at?: string;
/** Display name of the actor (`sys_user.name`), when resolvable. */
actor_name?: string;
Expand Down