SPB Git

Repository Public

index.ts 4.7 KB 78 lines typescript

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
Files main
Menu

Theme