package workbenchruntime import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "strings" ) const ( realtimeSyncContractVersion = "workbench-runtime-sync-v1" realtimeSyncDefaultLimit = 100 realtimeSyncMaxLimit = 500 realtimeSyncMaxIdentityLen = 512 ) type realtimeSyncQuery struct { SessionID string `json:"sessionId"` TraceID string `json:"traceId"` AfterOutboxSeq int64 `json:"afterOutboxSeq"` Limit int `json:"limit"` SnapshotOnly bool `json:"snapshotOnly,omitempty"` DeltaOnly bool `json:"deltaOnly,omitempty"` Actor sessionActor `json:"actor"` } type realtimeSyncSnapshot struct { Facts map[string][]any `json:"facts"` Events []realtimeSyncEvent `json:"events"` CutoffOutboxSeq int64 `json:"cutoffOutboxSeq"` CursorOutboxSeq int64 `json:"cursorOutboxSeq"` HasMore bool `json:"hasMore"` } type realtimeSyncEvent struct { OutboxSeq int64 `json:"outboxSeq"` OutboxEventID string `json:"outboxEventId"` EntityFamily string `json:"entityFamily"` EntityID string `json:"entityId"` EventSeq any `json:"eventSeq"` AggregateID any `json:"aggregateId"` AggregateSeq int64 `json:"aggregateSeq"` ProjectionRevision int64 `json:"projectionRevision"` TraceID string `json:"traceId"` SessionID any `json:"sessionId"` TurnID any `json:"turnId"` MessageID any `json:"messageId"` ProjectedSeq int64 `json:"projectedSeq"` SourceSeq int64 `json:"sourceSeq"` SourceEventID any `json:"sourceEventId"` CommitType string `json:"commitType"` Terminal bool `json:"terminal"` Sealed bool `json:"sealed"` Payload any `json:"payload"` CreatedAt string `json:"createdAt"` ValuesRedacted bool `json:"valuesRedacted"` } type realtimeSyncFactSpec struct { Family string Table string JSONColumn string OrderBy string } var realtimeSyncFactSpecs = []realtimeSyncFactSpec{ {Family: "sessions", Table: "workbench_sessions", JSONColumn: "session_json", OrderBy: "updated_at ASC, session_id ASC"}, {Family: "messages", Table: "workbench_messages", JSONColumn: "message_json", OrderBy: "updated_at ASC, message_id ASC"}, {Family: "parts", Table: "workbench_parts", JSONColumn: "part_json", OrderBy: "updated_at ASC, part_index ASC, part_id ASC"}, {Family: "turns", Table: "workbench_turns", JSONColumn: "turn_json", OrderBy: "updated_at ASC, turn_id ASC"}, {Family: "traceEvents", Table: "workbench_trace_events", JSONColumn: "event_json", OrderBy: "projected_seq ASC, id ASC"}, {Family: "checkpoints", Table: "workbench_projection_checkpoints", JSONColumn: "checkpoint_json", OrderBy: "updated_at ASC, trace_id ASC"}, } var realtimeSyncSnapshotFactSpecs = []realtimeSyncFactSpec{ realtimeSyncFactSpecs[0], realtimeSyncFactSpecs[1], realtimeSyncFactSpecs[3], } const realtimeSyncResolveScopeSQL = ` SELECT sessions.session_id FROM workbench_sessions AS sessions WHERE ($1 = '' OR sessions.session_id = $1) AND ( $2 = '' OR sessions.last_trace_id = $2 OR EXISTS ( SELECT 1 FROM workbench_projection_outbox AS scoped_outbox WHERE scoped_outbox.session_id = sessions.session_id AND scoped_outbox.trace_id = $2 ) ) AND ($3 OR sessions.owner_user_id = $4) ORDER BY CASE WHEN sessions.session_id = NULLIF($1, '') THEN 0 ELSE 1 END, sessions.updated_at DESC, sessions.session_id ASC LIMIT 1` const realtimeSyncCutoffSQL = ` SELECT COALESCE(MAX(outbox_seq), 0)::bigint FROM workbench_projection_outbox WHERE session_id = $1 AND ($2 = '' OR trace_id = $2)` const realtimeSyncEventsSQL = ` SELECT outbox_seq, outbox_event_id, entity_family, entity_id, event_seq, aggregate_id, aggregate_seq, projection_revision, trace_id, session_id, turn_id, message_id, projected_seq, source_seq, source_event_id, commit_type, terminal, sealed, payload_json, created_at FROM workbench_projection_outbox WHERE session_id = $1 AND ($2 = '' OR trace_id = $2) AND outbox_seq > $3 AND outbox_seq <= $4 ORDER BY outbox_seq ASC LIMIT $5` func (s *Server) handleRealtimeSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w, "POST") return } query, ok := decodeRealtimeSyncQuery(w, r) if !ok { return } query, validation := normalizeRealtimeSyncQuery(query) if validation != nil { writeAPIError(w, validation.Status, validation.Code, validation.Message) return } ctx, cancel := context.WithTimeout(r.Context(), s.config.QueryTimeout) defer cancel() snapshot, err := s.readRealtimeSync(ctx, query) if errors.Is(err, sql.ErrNoRows) { writeAPIError(w, http.StatusNotFound, "workbench_sync_scope_not_found", "workbench sync scope was not found") return } if err != nil { writeQueryError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "status": "succeeded", "contractVersion": realtimeSyncContractVersion, "facts": snapshot.Facts, "events": snapshot.Events, "cutoffOutboxSeq": snapshot.CutoffOutboxSeq, "cursorOutboxSeq": snapshot.CursorOutboxSeq, "hasMore": snapshot.HasMore, "persistence": s.persistenceSummary(), "servedBy": serviceID, "valuesRedacted": true, "secretMaterialStored": false, }) } type realtimeSyncValidationError struct { Status int Code string Message string } func decodeRealtimeSyncQuery(w http.ResponseWriter, r *http.Request) (realtimeSyncQuery, bool) { defer r.Body.Close() decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) decoder.DisallowUnknownFields() var query realtimeSyncQuery if err := decoder.Decode(&query); err != nil { writeAPIError(w, http.StatusBadRequest, "invalid_json", "request body must be a closed workbench sync JSON object") return realtimeSyncQuery{}, false } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { writeAPIError(w, http.StatusBadRequest, "invalid_json", "request body must contain exactly one JSON object") return realtimeSyncQuery{}, false } return query, true } func normalizeRealtimeSyncQuery(query realtimeSyncQuery) (realtimeSyncQuery, *realtimeSyncValidationError) { query.SessionID = strings.TrimSpace(query.SessionID) query.TraceID = strings.TrimSpace(query.TraceID) query.Actor.ID = strings.TrimSpace(query.Actor.ID) query.Actor.Role = strings.ToLower(strings.TrimSpace(query.Actor.Role)) if query.SessionID == "" && query.TraceID == "" { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_scope_required", Message: "sessionId or traceId is required"} } if len(query.SessionID) > realtimeSyncMaxIdentityLen || len(query.TraceID) > realtimeSyncMaxIdentityLen || len(query.Actor.ID) > realtimeSyncMaxIdentityLen { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_identity_invalid", Message: "workbench sync identifiers are too long"} } if query.AfterOutboxSeq < 0 { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_cursor_invalid", Message: "afterOutboxSeq must be non-negative"} } if query.Limit < 0 { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_limit_invalid", Message: "limit must be non-negative"} } if query.SnapshotOnly && query.DeltaOnly { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_mode_invalid", Message: "snapshotOnly and deltaOnly are mutually exclusive"} } query.Limit = boundedLimit(query.Limit, realtimeSyncDefaultLimit, realtimeSyncMaxLimit) if query.Actor.Role != "admin" && query.Actor.Role != "user" { return query, &realtimeSyncValidationError{Status: http.StatusBadRequest, Code: "workbench_sync_actor_invalid", Message: "actor.role must be user or admin"} } if query.Actor.Role != "admin" && query.Actor.ID == "" { return query, &realtimeSyncValidationError{Status: http.StatusForbidden, Code: "workbench_sync_actor_required", Message: "ordinary users require an actor id"} } return query, nil } func realtimeSyncTxOptions() *sql.TxOptions { return &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true} } func (s *Server) readRealtimeSync(ctx context.Context, query realtimeSyncQuery) (realtimeSyncSnapshot, error) { tx, err := s.db.BeginTx(ctx, realtimeSyncTxOptions()) if err != nil { return realtimeSyncSnapshot{}, wrapQueryStage("workbench_runtime.db.sync.begin", err) } committed := false defer func() { if !committed { _ = tx.Rollback() } }() snapshot, err := readRealtimeSyncSnapshot(ctx, sqlRealtimeSyncQueryer{tx: tx}, query) if err != nil { return realtimeSyncSnapshot{}, err } if err := tx.Commit(); err != nil { return realtimeSyncSnapshot{}, wrapQueryStage("workbench_runtime.db.sync.commit", err) } committed = true return snapshot, nil } type realtimeSyncRow interface { Scan(...any) error } type realtimeSyncRows interface { Close() error Err() error Next() bool Scan(...any) error } type realtimeSyncQueryer interface { QueryContext(context.Context, string, ...any) (realtimeSyncRows, error) QueryRowContext(context.Context, string, ...any) realtimeSyncRow } type sqlRealtimeSyncQueryer struct { tx *sql.Tx } func (queryer sqlRealtimeSyncQueryer) QueryContext(ctx context.Context, query string, args ...any) (realtimeSyncRows, error) { return queryer.tx.QueryContext(ctx, query, args...) } func (queryer sqlRealtimeSyncQueryer) QueryRowContext(ctx context.Context, query string, args ...any) realtimeSyncRow { return queryer.tx.QueryRowContext(ctx, query, args...) } func readRealtimeSyncSnapshot(ctx context.Context, tx realtimeSyncQueryer, query realtimeSyncQuery) (realtimeSyncSnapshot, error) { sessionID, err := resolveRealtimeSyncSession(ctx, tx, query) if err != nil { return realtimeSyncSnapshot{}, err } cutoff, err := readRealtimeSyncCutoff(ctx, tx, sessionID, query.TraceID) if err != nil { return realtimeSyncSnapshot{}, err } events := make([]realtimeSyncEvent, 0) hasMore := false if !query.SnapshotOnly { allEvents, readErr := readRealtimeSyncEvents(ctx, tx, sessionID, query.TraceID, query.AfterOutboxSeq, cutoff, query.Limit+1) if readErr != nil { return realtimeSyncSnapshot{}, readErr } hasMore = len(allEvents) > query.Limit events = allEvents if hasMore { events = allEvents[:query.Limit] } } facts := make(map[string][]any) if !query.DeltaOnly { specs := realtimeSyncFactSpecs if query.SnapshotOnly { specs = realtimeSyncSnapshotFactSpecs } facts, err = readRealtimeSyncFacts(ctx, tx, sessionID, query.TraceID, specs) if err != nil { return realtimeSyncSnapshot{}, err } } cursor := query.AfterOutboxSeq if query.SnapshotOnly || cursor > cutoff { cursor = cutoff } if len(events) > 0 { cursor = events[len(events)-1].OutboxSeq } return realtimeSyncSnapshot{Facts: facts, Events: events, CutoffOutboxSeq: cutoff, CursorOutboxSeq: cursor, HasMore: hasMore}, nil } func resolveRealtimeSyncSession(ctx context.Context, tx realtimeSyncQueryer, query realtimeSyncQuery) (string, error) { var sessionID string err := tx.QueryRowContext(ctx, realtimeSyncResolveScopeSQL, query.SessionID, query.TraceID, query.Actor.Role == "admin", query.Actor.ID).Scan(&sessionID) if err != nil { return "", wrapQueryStage("workbench_runtime.db.sync.scope", err) } return sessionID, nil } func readRealtimeSyncCutoff(ctx context.Context, tx realtimeSyncQueryer, sessionID string, traceID string) (int64, error) { var cutoff int64 if err := tx.QueryRowContext(ctx, realtimeSyncCutoffSQL, sessionID, traceID).Scan(&cutoff); err != nil { return 0, wrapQueryStage("workbench_runtime.db.sync.cutoff", err) } return cutoff, nil } func readRealtimeSyncEvents(ctx context.Context, tx realtimeSyncQueryer, sessionID string, traceID string, after int64, cutoff int64, limit int) ([]realtimeSyncEvent, error) { rows, err := tx.QueryContext(ctx, realtimeSyncEventsSQL, sessionID, traceID, after, cutoff, limit) if err != nil { return nil, wrapQueryStage("workbench_runtime.db.sync.events", err) } defer rows.Close() events := make([]realtimeSyncEvent, 0) for rows.Next() { event, err := scanRealtimeSyncEvent(rows) if err != nil { return nil, wrapQueryStage("workbench_runtime.db.sync.events", err) } events = append(events, event) } if err := rows.Err(); err != nil { return nil, wrapQueryStage("workbench_runtime.db.sync.events", err) } return events, nil } type realtimeSyncScanner interface { Scan(...any) error } func scanRealtimeSyncEvent(scanner realtimeSyncScanner) (realtimeSyncEvent, error) { var event realtimeSyncEvent var outboxEventID, entityFamily, entityID, traceID, commitType, payloadJSON, createdAt sql.NullString var eventSeq, aggregateSeq, projectionRevision, projectedSeq, sourceSeq sql.NullInt64 var aggregateID, sessionID, turnID, messageID, sourceEventID sql.NullString var terminal, sealed sql.NullBool if err := scanner.Scan( &event.OutboxSeq, &outboxEventID, &entityFamily, &entityID, &eventSeq, &aggregateID, &aggregateSeq, &projectionRevision, &traceID, &sessionID, &turnID, &messageID, &projectedSeq, &sourceSeq, &sourceEventID, &commitType, &terminal, &sealed, &payloadJSON, &createdAt, ); err != nil { return realtimeSyncEvent{}, err } payload := any(map[string]any{}) if payloadJSON.Valid && strings.TrimSpace(payloadJSON.String) != "" { if err := json.Unmarshal([]byte(payloadJSON.String), &payload); err != nil { return realtimeSyncEvent{}, fmt.Errorf("decode projection outbox payload: %w", err) } } event.OutboxEventID = nullableStringValue(outboxEventID) event.EntityFamily = nullableStringValue(entityFamily) event.EntityID = nullableStringValue(entityID) event.EventSeq = realtimeSyncNullableInt(eventSeq) event.AggregateID = nullableString(aggregateID) event.AggregateSeq = realtimeSyncInt(aggregateSeq) event.ProjectionRevision = realtimeSyncInt(projectionRevision) event.TraceID = nullableStringValue(traceID) event.SessionID = nullableString(sessionID) event.TurnID = nullableString(turnID) event.MessageID = nullableString(messageID) event.ProjectedSeq = realtimeSyncInt(projectedSeq) event.SourceSeq = realtimeSyncInt(sourceSeq) event.SourceEventID = nullableString(sourceEventID) event.CommitType = nullableStringValue(commitType) event.Terminal = nullableBool(terminal) event.Sealed = nullableBool(sealed) event.Payload = payload event.CreatedAt = nullableTimeText(createdAt) event.ValuesRedacted = true return event, nil } func readRealtimeSyncFacts(ctx context.Context, tx realtimeSyncQueryer, sessionID string, traceID string, specs []realtimeSyncFactSpec) (map[string][]any, error) { facts := make(map[string][]any, len(specs)) for _, spec := range specs { args := []any{sessionID} where := "session_id = $1" if spec.Family != "sessions" { args = append(args, traceID) where += " AND ($2 = '' OR trace_id = $2)" } sqlText := fmt.Sprintf("SELECT %s FROM %s WHERE %s ORDER BY %s", spec.JSONColumn, spec.Table, where, spec.OrderBy) rows, err := tx.QueryContext(ctx, sqlText, args...) if err != nil { return nil, wrapQueryStage("workbench_runtime.db.sync.facts."+spec.Family, err) } items, err := scanRealtimeSyncFacts(rows) if err != nil { return nil, wrapQueryStage("workbench_runtime.db.sync.facts."+spec.Family, err) } facts[spec.Family] = items } return facts, nil } func scanRealtimeSyncFacts(rows realtimeSyncRows) ([]any, error) { defer rows.Close() items := make([]any, 0) for rows.Next() { var raw sql.NullString if err := rows.Scan(&raw); err != nil { return nil, err } if !raw.Valid || strings.TrimSpace(raw.String) == "" { continue } var item any if err := json.Unmarshal([]byte(raw.String), &item); err != nil { return nil, fmt.Errorf("decode workbench fact: %w", err) } if item != nil { items = append(items, item) } } if err := rows.Err(); err != nil { return nil, err } return items, nil } func realtimeSyncNullableInt(value sql.NullInt64) any { if !value.Valid { return nil } return value.Int64 } func realtimeSyncInt(value sql.NullInt64) int64 { if !value.Valid { return 0 } return value.Int64 }