mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* perf(db): mint UUIDv7 primary keys for the append-heavy tables
Random v4 primary keys scatter every insert across the whole primary-key
B-tree, so each row dirties a different page. On the tables that grow
continuously — the task queue, messages, comments, activity and audit rows —
that is pure write amplification. UUIDv7 puts a big-endian millisecond
timestamp in the leading 48 bits, so consecutive inserts share a page.
The 12 tables converted here are the append-only hot paths:
agent_task_queue, activity_log, inbox_item, comment, issue, chat_session,
chat_message, autopilot_run, webhook_delivery, task_token,
channel_inbound_audit, sys_cron_executions
Ids come from the new pkg/dbid, which returns a pgtype.UUID ready to assign to
a sqlc params struct. Upsert-dominated tables (task_usage, agent_runtime, ...)
are deliberately left alone: v7 only buys locality for rows that are actually
inserted. Lease and claim tokens keep gen_random_uuid() — they must stay
unpredictable and carry no timestamp.
No migration, and no backfill. Every converted INSERT binds its id as
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()), so the column default stays
reachable: a caller that passes no id inserts exactly as it does today. Tables
will hold a mix of v4 and v7 ids permanently, which the uuid type and every
parser in the repo already accept.
Verification: - pkg/dbid: version/variant, uniqueness, and a non-decreasing timestamp
prefix; a shape test asserting every converted query keeps the COALESCE
fallback; and a source scan asserting every production params literal mints
an id, so a call site added later cannot silently fall back to v4.
- internal/handler: round-trips issue, comment, activity_log, inbox_item,
chat_session, chat_message, agent_task_queue, task_token and
channel_inbound_audit against Postgres and asserts the stored id is v7,
plus one case proving an unset id still inserts (as v4).
- internal/scheduler: covers the one hand-written INSERT, asserting the
execution id is v7 while lease_token stays v4.
Co-authored-by: multica-agent <[email protected]>
* fix(db): address UUIDv7 review feedback
Co-authored-by: multica-agent <[email protected]>
* fix(db): cover Telegram audit UUIDv7
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
412 lines
15 KiB
Go
412 lines
15 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/multica-ai/multica/server/internal/service"
|
||
|
||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||
"github.com/multica-ai/multica/server/pkg/dbid"
|
||
)
|
||
|
||
// ── Response types ──────────────────────────────────────────────────────────
|
||
|
||
// WebhookDeliveryResponse is the authenticated-API view of webhook_delivery.
|
||
// The list endpoint returns these without `RawBody` / `SelectedHeaders`
|
||
// populated; the detail endpoint includes both for debugging. We never echo
|
||
// the signing secret or token through this surface.
|
||
type WebhookDeliveryResponse struct {
|
||
ID string `json:"id"`
|
||
WorkspaceID string `json:"workspace_id"`
|
||
AutopilotID string `json:"autopilot_id"`
|
||
TriggerID string `json:"trigger_id"`
|
||
Provider string `json:"provider"`
|
||
Event string `json:"event"`
|
||
DedupeKey *string `json:"dedupe_key"`
|
||
DedupeSource *string `json:"dedupe_source"`
|
||
SignatureStatus string `json:"signature_status"`
|
||
Status string `json:"status"`
|
||
AttemptCount int32 `json:"attempt_count"`
|
||
DispatchAttempts int32 `json:"dispatch_attempts"`
|
||
AvailableAt string `json:"available_at"`
|
||
ContentType *string `json:"content_type"`
|
||
ResponseStatus *int32 `json:"response_status"`
|
||
AutopilotRunID *string `json:"autopilot_run_id"`
|
||
ReplayedFromDeliveryID *string `json:"replayed_from_delivery_id"`
|
||
Error *string `json:"error"`
|
||
ReasonCode *string `json:"reason_code"`
|
||
ReplayIdempotencyKey *string `json:"replay_idempotency_key"`
|
||
ReceivedAt string `json:"received_at"`
|
||
LastAttemptAt string `json:"last_attempt_at"`
|
||
CreatedAt string `json:"created_at"`
|
||
|
||
// Detail-only fields. List responses leave these nil/empty so a page
|
||
// of N deliveries never serialises ~N × 256 KiB of raw bodies. Detail
|
||
// requests opt in by hitting GET /deliveries/{deliveryId}.
|
||
SelectedHeaders json.RawMessage `json:"selected_headers,omitempty"`
|
||
RawBody *string `json:"raw_body,omitempty"`
|
||
ResponseBody *string `json:"response_body,omitempty"`
|
||
}
|
||
|
||
// slimDeliveryToResponse maps the projected list row (no raw_body /
|
||
// selected_headers / response_body) into the wire response shape.
|
||
func slimDeliveryToResponse(d db.ListWebhookDeliveriesByAutopilotRow) WebhookDeliveryResponse {
|
||
resp := WebhookDeliveryResponse{
|
||
ID: uuidToString(d.ID),
|
||
WorkspaceID: uuidToString(d.WorkspaceID),
|
||
AutopilotID: uuidToString(d.AutopilotID),
|
||
TriggerID: uuidToString(d.TriggerID),
|
||
Provider: d.Provider,
|
||
Event: d.Event,
|
||
DedupeKey: textToPtr(d.DedupeKey),
|
||
DedupeSource: textToPtr(d.DedupeSource),
|
||
SignatureStatus: d.SignatureStatus,
|
||
Status: d.Status,
|
||
AttemptCount: d.AttemptCount,
|
||
DispatchAttempts: d.DispatchAttempts,
|
||
AvailableAt: timestampToString(d.AvailableAt),
|
||
ContentType: textToPtr(d.ContentType),
|
||
ReceivedAt: timestampToString(d.ReceivedAt),
|
||
LastAttemptAt: timestampToString(d.LastAttemptAt),
|
||
CreatedAt: timestampToString(d.CreatedAt),
|
||
ReasonCode: textToPtr(d.ReasonCode),
|
||
ReplayIdempotencyKey: textToPtr(d.ReplayIdempotencyKey),
|
||
}
|
||
if d.ResponseStatus.Valid {
|
||
v := d.ResponseStatus.Int32
|
||
resp.ResponseStatus = &v
|
||
}
|
||
if d.AutopilotRunID.Valid {
|
||
v := uuidToString(d.AutopilotRunID)
|
||
resp.AutopilotRunID = &v
|
||
}
|
||
if d.ReplayedFromDeliveryID.Valid {
|
||
v := uuidToString(d.ReplayedFromDeliveryID)
|
||
resp.ReplayedFromDeliveryID = &v
|
||
}
|
||
if d.Error.Valid {
|
||
v := d.Error.String
|
||
resp.Error = &v
|
||
}
|
||
return resp
|
||
}
|
||
|
||
func deliveryToResponse(d db.WebhookDelivery, detail bool) WebhookDeliveryResponse {
|
||
resp := WebhookDeliveryResponse{
|
||
ID: uuidToString(d.ID),
|
||
WorkspaceID: uuidToString(d.WorkspaceID),
|
||
AutopilotID: uuidToString(d.AutopilotID),
|
||
TriggerID: uuidToString(d.TriggerID),
|
||
Provider: d.Provider,
|
||
Event: d.Event,
|
||
DedupeKey: textToPtr(d.DedupeKey),
|
||
DedupeSource: textToPtr(d.DedupeSource),
|
||
SignatureStatus: d.SignatureStatus,
|
||
Status: d.Status,
|
||
AttemptCount: d.AttemptCount,
|
||
DispatchAttempts: d.DispatchAttempts,
|
||
AvailableAt: timestampToString(d.AvailableAt),
|
||
ContentType: textToPtr(d.ContentType),
|
||
ReceivedAt: timestampToString(d.ReceivedAt),
|
||
LastAttemptAt: timestampToString(d.LastAttemptAt),
|
||
CreatedAt: timestampToString(d.CreatedAt),
|
||
ReasonCode: textToPtr(d.ReasonCode),
|
||
ReplayIdempotencyKey: textToPtr(d.ReplayIdempotencyKey),
|
||
}
|
||
if d.ResponseStatus.Valid {
|
||
v := d.ResponseStatus.Int32
|
||
resp.ResponseStatus = &v
|
||
}
|
||
if d.AutopilotRunID.Valid {
|
||
v := uuidToString(d.AutopilotRunID)
|
||
resp.AutopilotRunID = &v
|
||
}
|
||
if d.ReplayedFromDeliveryID.Valid {
|
||
v := uuidToString(d.ReplayedFromDeliveryID)
|
||
resp.ReplayedFromDeliveryID = &v
|
||
}
|
||
if d.Error.Valid {
|
||
v := d.Error.String
|
||
resp.Error = &v
|
||
}
|
||
if detail {
|
||
if len(d.SelectedHeaders) > 0 {
|
||
resp.SelectedHeaders = json.RawMessage(d.SelectedHeaders)
|
||
}
|
||
if len(d.RawBody) > 0 {
|
||
s := string(d.RawBody)
|
||
resp.RawBody = &s
|
||
}
|
||
if d.ResponseBody.Valid {
|
||
v := d.ResponseBody.String
|
||
resp.ResponseBody = &v
|
||
}
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||
|
||
// ListAutopilotDeliveries returns recent deliveries for an autopilot. Slim
|
||
// projection — selected_headers / raw_body / response_body are omitted to
|
||
// keep list responses small. Use GetAutopilotDelivery for the full payload.
|
||
func (h *Handler) ListAutopilotDeliveries(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
limit := int32(20)
|
||
offset := int32(0)
|
||
if l := r.URL.Query().Get("limit"); l != "" {
|
||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||
limit = int32(v)
|
||
}
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
if o := r.URL.Query().Get("offset"); o != "" {
|
||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||
offset = int32(v)
|
||
}
|
||
}
|
||
|
||
rows, err := h.Queries.ListWebhookDeliveriesByAutopilot(r.Context(), db.ListWebhookDeliveriesByAutopilotParams{
|
||
AutopilotID: autopilot.ID,
|
||
WorkspaceID: autopilot.WorkspaceID,
|
||
Limit: limit,
|
||
Offset: offset,
|
||
})
|
||
if err != nil {
|
||
slog.Error("list deliveries failed", "error", err, "autopilot_id", autopilotID)
|
||
writeError(w, http.StatusInternalServerError, "failed to list deliveries")
|
||
return
|
||
}
|
||
|
||
resp := make([]WebhookDeliveryResponse, len(rows))
|
||
for i, row := range rows {
|
||
resp[i] = slimDeliveryToResponse(row)
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"deliveries": resp, "total": len(resp)})
|
||
}
|
||
|
||
// GetAutopilotDelivery returns one delivery in full, including the raw body
|
||
// and headers subset. Workspace-scoped via the autopilot lookup; the
|
||
// delivery is then re-checked to belong to that autopilot so a guessed
|
||
// delivery id from another workspace cannot leak data.
|
||
func (h *Handler) GetAutopilotDelivery(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
deliveryID := chi.URLParam(r, "deliveryId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
delivery, ok := h.loadDeliveryForAutopilot(w, r, autopilot, deliveryID)
|
||
if !ok {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, deliveryToResponse(delivery, true))
|
||
}
|
||
|
||
// ReplayAutopilotDelivery creates a NEW queued delivery row from a prior one;
|
||
// the existing durable worker owns dispatch. The new row carries
|
||
// `replayed_from_delivery_id` so the operator can correlate. Replay is
|
||
// rejected for deliveries that originally failed signature verification —
|
||
// re-running an attack payload against the autopilot would defeat the
|
||
// rejection in the first place.
|
||
//
|
||
// Replays bypass provider dedupe by inserting with a NULL dedupe_key:
|
||
// reusing the original key would silently collapse the replay onto the prior
|
||
// delivery (the partial unique index would fire). This is the intended
|
||
// behaviour — a replay is explicitly "run this again". API retries are
|
||
// independently deduplicated by (original delivery, Idempotency-Key).
|
||
func (h *Handler) ReplayAutopilotDelivery(w http.ResponseWriter, r *http.Request) {
|
||
autopilotID := chi.URLParam(r, "id")
|
||
deliveryID := chi.URLParam(r, "deliveryId")
|
||
workspaceID := h.resolveWorkspaceID(r)
|
||
|
||
autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !h.requireAutopilotWrite(w, r, autopilot, workspaceID) {
|
||
return
|
||
}
|
||
original, ok := h.loadDeliveryForAutopilot(w, r, autopilot, deliveryID)
|
||
if !ok {
|
||
return
|
||
}
|
||
if original.Status == deliveryStatusRejected || original.SignatureStatus == sigStatusInvalid {
|
||
writeError(w, http.StatusBadRequest, "cannot replay a delivery that failed signature verification")
|
||
return
|
||
}
|
||
if len(original.RawBody) == 0 {
|
||
writeError(w, http.StatusBadRequest, "original delivery has no raw body to replay")
|
||
return
|
||
}
|
||
|
||
if autopilot.Status != "active" {
|
||
writeError(w, http.StatusBadRequest, "autopilot is not active")
|
||
return
|
||
}
|
||
|
||
trigRow, err := h.Queries.GetAutopilotTrigger(r.Context(), original.TriggerID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "trigger not found")
|
||
return
|
||
}
|
||
if !trigRow.Enabled {
|
||
writeError(w, http.StatusBadRequest, "trigger is disabled")
|
||
return
|
||
}
|
||
|
||
// Build the envelope from the stored raw body using the original headers
|
||
// subset for event inference. SelectedHeaders is small + JSON-shaped, so
|
||
// we decode it back into a header map to reuse the same normalize path.
|
||
headers := headersFromSelected(original.SelectedHeaders)
|
||
envelope, err := normalizeWebhookPayload(original.RawBody, headers)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "stored body no longer parses: "+err.Error())
|
||
return
|
||
}
|
||
_ = envelope // the durable worker normalizes the stored payload again
|
||
|
||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||
if len(idempotencyKey) > 255 {
|
||
writeError(w, http.StatusBadRequest, "Idempotency-Key is too long")
|
||
return
|
||
}
|
||
if idempotencyKey == "" {
|
||
idempotencyKey = service.NewRequestIdempotencyKey()
|
||
}
|
||
keyText := pgtype.Text{String: idempotencyKey, Valid: true}
|
||
if existing, lookupErr := h.Queries.GetWebhookReplayByIdempotencyKey(r.Context(), db.GetWebhookReplayByIdempotencyKeyParams{
|
||
ReplayedFromDeliveryID: original.ID, ReplayIdempotencyKey: keyText,
|
||
}); lookupErr == nil {
|
||
writeJSON(w, http.StatusAccepted, deliveryToResponse(existing, true))
|
||
return
|
||
} else if !errors.Is(lookupErr, pgx.ErrNoRows) {
|
||
writeError(w, http.StatusInternalServerError, "failed to inspect replay request")
|
||
return
|
||
}
|
||
|
||
contentType := ""
|
||
if original.ContentType.Valid {
|
||
contentType = original.ContentType.String
|
||
}
|
||
replay, err := h.Queries.CreateWebhookDelivery(r.Context(), db.CreateWebhookDeliveryParams{
|
||
ID: dbid.NewV7(),
|
||
WorkspaceID: autopilot.WorkspaceID,
|
||
AutopilotID: autopilot.ID,
|
||
TriggerID: original.TriggerID,
|
||
Provider: original.Provider,
|
||
Event: envelope.Event,
|
||
SignatureStatus: sigStatusNotRequired,
|
||
Status: deliveryStatusQueued,
|
||
SelectedHeaders: original.SelectedHeaders,
|
||
ContentType: pgtype.Text{String: contentType, Valid: contentType != ""},
|
||
RawBody: original.RawBody,
|
||
ReplayedFromDeliveryID: original.ID,
|
||
ReplayIdempotencyKey: keyText,
|
||
})
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
existing, lookupErr := h.Queries.GetWebhookReplayByIdempotencyKey(r.Context(), db.GetWebhookReplayByIdempotencyKeyParams{
|
||
ReplayedFromDeliveryID: original.ID, ReplayIdempotencyKey: keyText,
|
||
})
|
||
if lookupErr == nil {
|
||
writeJSON(w, http.StatusAccepted, deliveryToResponse(existing, true))
|
||
return
|
||
}
|
||
}
|
||
slog.Error("replay: insert delivery failed",
|
||
"error", err,
|
||
"original_delivery_id", uuidToString(original.ID),
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "failed to create replay delivery")
|
||
return
|
||
}
|
||
|
||
if h.WebhookDeliveryWorker != nil {
|
||
h.WebhookDeliveryWorker.Notify()
|
||
}
|
||
writeJSON(w, http.StatusAccepted, deliveryToResponse(replay, true))
|
||
}
|
||
|
||
// loadDeliveryForAutopilot returns the delivery row when it exists in the
|
||
// same workspace AND belongs to the given autopilot. Cross-autopilot or
|
||
// cross-workspace IDs are returned as 404 — defense in depth against ID
|
||
// guessing.
|
||
func (h *Handler) loadDeliveryForAutopilot(w http.ResponseWriter, r *http.Request, autopilot db.Autopilot, deliveryID string) (db.WebhookDelivery, bool) {
|
||
deliveryUUID, ok := parseUUIDOrBadRequest(w, deliveryID, "delivery id")
|
||
if !ok {
|
||
return db.WebhookDelivery{}, false
|
||
}
|
||
delivery, err := h.Queries.GetWebhookDeliveryInWorkspace(r.Context(), db.GetWebhookDeliveryInWorkspaceParams{
|
||
ID: deliveryUUID,
|
||
WorkspaceID: autopilot.WorkspaceID,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeError(w, http.StatusNotFound, "delivery not found")
|
||
return db.WebhookDelivery{}, false
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "failed to load delivery")
|
||
return db.WebhookDelivery{}, false
|
||
}
|
||
if uuidToString(delivery.AutopilotID) != uuidToString(autopilot.ID) {
|
||
writeError(w, http.StatusNotFound, "delivery not found")
|
||
return db.WebhookDelivery{}, false
|
||
}
|
||
return delivery, true
|
||
}
|
||
|
||
// headersFromSelected decodes the small headers-subset blob back into an
|
||
// http.Header. Only used by the replay path — fields we did not capture at
|
||
// ingress time are simply absent, which matches what would have happened if
|
||
// the original request had not sent them either.
|
||
func headersFromSelected(raw []byte) http.Header {
|
||
out := http.Header{}
|
||
if len(raw) == 0 {
|
||
return out
|
||
}
|
||
var m map[string]any
|
||
if err := json.Unmarshal(raw, &m); err != nil {
|
||
return out
|
||
}
|
||
canonical := map[string]string{
|
||
"user-agent": "User-Agent",
|
||
"x-github-event": "X-GitHub-Event",
|
||
"x-github-delivery": "X-GitHub-Delivery",
|
||
"x-gitlab-event": "X-Gitlab-Event",
|
||
"x-event-type": "X-Event-Type",
|
||
"idempotency-key": "Idempotency-Key",
|
||
}
|
||
for k, v := range m {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
header := canonical[k]
|
||
if header == "" {
|
||
continue
|
||
}
|
||
out.Set(header, s)
|
||
}
|
||
return out
|
||
}
|