Repository Public
1/**2 * Durable waits the owner can act on (approvals, questions). Listing is plain SQL; resolving goes3 * through the kernel (`resolveWait` marks it resolved, emits `wait.resolved`, re-queues the run).4 * An approval with `remember: true` is passed through in the resolution: the ENGINE turns it into5 * an allow rule (it owns the matcher semantics, CONTRACT §6) — the server does not insert rules.6 */7import type { FastifyInstance } from 'fastify';8import { z } from 'zod';9import { Id, ResolveWait, WaitDto } from '@aos/shared';10import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';1112const IdParams = z.object({ id: Id });13const ListQuery = z.object({14 status: z.enum(['pending', 'resolved', 'expired', 'cancelled', 'all']).default('pending'),15 /** Default: what the owner can act on. `all` includes kernel-internal timers / child joins. */16 kind: z.enum(['approval', 'question', 'timer', 'child', 'all']).optional(),17 missionId: Id.optional(),18 limit: z.coerce.number().int().min(1).max(500).default(200),19});2021interface Row {22 id: string; run_id: string; mission_id: string; mission_title: string; step_seq: number; kind: string; payload: Record<string, unknown> | null;23 status: string; resolution: Record<string, unknown> | null; resolve_by: Date | null; created_at: Date; resolved_at: Date | null;24}25const SELECT = `SELECT w.id, w.run_id, w.mission_id, m.title AS mission_title, w.step_seq, w.kind, w.payload, w.status, w.resolution,26 w.resolve_by, w.created_at, w.resolved_at27 FROM waits w JOIN missions m ON m.id = w.mission_id`;28const dto = (r: Row): WaitDto => ({29 id: r.id, runId: r.run_id, missionId: r.mission_id, missionTitle: r.mission_title, stepSeq: r.step_seq, kind: r.kind as WaitDto['kind'],30 payload: r.payload ?? {}, status: r.status as WaitDto['status'], resolution: r.resolution,31 resolveBy: r.resolve_by?.toISOString() ?? null, createdAt: r.created_at.toISOString(), resolvedAt: r.resolved_at?.toISOString() ?? null,32});3334export default async function waitsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {35 const { db } = ctx;3637 app.get('/waits', { config: { doc: { summary: 'Waits (default: pending approvals and questions, oldest first)', query: ListQuery, response: z.array(WaitDto), responseName: 'WaitList' } } }, async (req) => {38 const a = authOf(req);39 const q = parse(ListQuery, req.query);40 const kinds = q.kind === 'all' ? null : q.kind ? [q.kind] : ['approval', 'question'];41 const rows = await db.q<Row>(42 `${SELECT} WHERE m.owner_id = $1 AND ($2::text IS NULL OR w.status = $2) AND ($3::text[] IS NULL OR w.kind = ANY($3::text[]))43 AND ($4::uuid IS NULL OR w.mission_id = $4::uuid)44 ORDER BY w.created_at, w.id LIMIT $5`,45 [a.userId, q.status === 'all' ? null : q.status, kinds, q.missionId ?? null, q.limit],46 );47 return rows.map(dto);48 });4950 app.post('/waits/:id/resolve', { config: { doc: { summary: 'Approve / deny an approval, or answer a question', body: ResolveWait, bodyName: 'ResolveWait', response: WaitDto, responseName: 'Wait' } } }, async (req) => {51 const a = authOf(req);52 const { id } = parse(IdParams, req.params);53 const body = parse(ResolveWait, req.body);54 const one = (): Promise<Row | null> => db.one<Row>(`${SELECT} WHERE w.id = $1 AND m.owner_id = $2`, [id, a.userId]);55 const row = await one();56 if (!row) throw notFound('wait');57 if (row.kind !== 'approval' && row.kind !== 'question') throw new HttpError(409, 'not_resolvable', 'this wait is not resolved by the owner');58 if (row.kind !== body.kind) throw new HttpError(400, 'kind_mismatch', `this wait is a${row.kind === 'approval' ? 'n approval' : ' question'}`);59 if (row.status !== 'pending') throw new HttpError(409, 'wait_not_pending', `this wait is already ${row.status}`);60 const resolution: Record<string, unknown> =61 body.kind === 'approval'62 ? { decision: body.decision, remember: body.decision === 'approve' && body.remember, ...(body.note ? { note: body.note } : {}) }63 : { answer: body.answer };64 const { resolved } = await ctx.kernel.resolveWait(id, resolution, a.username);65 if (!resolved) {66 // Lost a race with expiry / cancellation / another tab: report the state, nothing was changed.67 const now = await one();68 throw new HttpError(409, 'wait_not_pending', `this wait is already ${now?.status ?? 'settled'}`);69 }70 await ctx.audit.req(req, 'wait.resolve', {71 target: id,72 detail: body.kind === 'approval' ? { kind: 'approval', decision: body.decision, remember: resolution.remember } : { kind: 'question' }, // never the answer text73 });74 const after = await one();75 return dto(after ?? row);76 });77}78