494 lines
16 KiB
Go
494 lines
16 KiB
Go
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
|
|
}
|