已合并
SESSION-01: define canonical session and event contracts #18
urandon创建于 8月4日
SESSION-01: define canonical session and event contracts #18
已合并
共 30 个文件变更+956-307
| @@ -3,13 +3,13 @@ | |||
| 3 | Sessionless is a serverless, cloud-hosted control plane for routing | 3 | Sessionless is a serverless, cloud-hosted control plane for routing |
| 4 | conversation-backed work to isolated AI-agent workers. | 4 | conversation-backed work to isolated AI-agent workers. |
| 5 | 5 | ||
| 6 | -The core is frontend-aware but not frontend-specific. Telegram is the first | 6 | +Sessionless owns the canonical conversation model: an append-only, strictly |
| 7 | -frontend and its chat history provides the authoritative conversation context | 7 | +ordered `SessionEvent` stream with immutable snapshots as optional context |
| 8 | -for the initial product slice. Additional frontends can be added through | 8 | +materializations. Telegram is the first frontend adapter, and WebUI is the next; |
| 9 | -transport adapters without redefining runs, scheduling, quota accounting, or | 9 | +both bind external conversations to canonical sessions without redefining runs, |
| 10 | -worker isolation. A new clean context is always an explicit frontend action | 10 | +scheduling, quota accounting, or worker isolation. A `/new` action creates a |
| 11 | -that advances a context epoch; it does not create a hidden primary session | 11 | +new session and switches the frontend binding atomically. Existing sessions and |
| 12 | -model or delete the frontend's history. | 12 | +their history remain intact. |
| 13 | 13 | ||
| 14 | The control plane is also harness-neutral. Codex, OpenCode, Claude, and | 14 | The control plane is also harness-neutral. Codex, OpenCode, Claude, and |
| 15 | Hermes-style runtimes are candidates for isolated worker adapters, not | 15 | Hermes-style runtimes are candidates for isolated worker adapters, not |
| @@ -24,18 +24,22 @@ and MCP access. | |||
| 24 | 24 | ||
| 25 | The repository currently contains the Go component boundaries, harness-neutral | 25 | The repository currently contains the Go component boundaries, harness-neutral |
| 26 | domain/runtime contracts, the authoritative YDB state store and migrations, | 26 | domain/runtime contracts, the authoritative YDB state store and migrations, |
| 27 | -authenticated Telegram webhook ingestion, durable Telegram delivery, bounded | 27 | +authenticated Telegram webhook ingestion, durable Telegram delivery, canonical |
| 28 | +session domain and port contracts, bounded | ||
| 28 | subscription-aware admission and dispatch, a reproducible local development | 29 | subscription-aware admission and dispatch, a reproducible local development |
| 29 | stand, isolated worker packaging, pinned developer tools, subscription state | 30 | stand, isolated worker packaging, pinned developer tools, subscription state |
| 30 | -commands, explicit clean-context epochs, and GitHub Actions CI fed by the | 31 | +commands, and GitHub Actions CI fed by the |
| 31 | GitCode mirror. It also contains the complete isolated worker lifecycle with a | 32 | GitCode mirror. It also contains the complete isolated worker lifecycle with a |
| 32 | credential-free deterministic harness: durable job materialization, fenced | 33 | credential-free deterministic harness: durable job materialization, fenced |
| 33 | lease renewal, bounded scratch space, checkpoint/resume, usage events, | 34 | lease renewal, bounded scratch space, checkpoint/resume, usage events, |
| 34 | content-addressed artifacts, cancellation/timeout handling, and atomic terminal | 35 | content-addressed artifacts, cancellation/timeout handling, and atomic terminal |
| 35 | delivery. A credential-free two-tenant black-box suite now composes the full | 36 | delivery. A credential-free two-tenant black-box suite now composes the full |
| 36 | local Telegram-to-worker-to-Telegram path and its recovery cases. Provider | 37 | local Telegram-to-worker-to-Telegram path and its recovery cases. Provider |
| 37 | -authorization and subscription-backed Codex, OpenCode, Claude, or Hermes | 38 | +authorization, canonical-session persistence, frontend projection, and |
| 38 | -adapters remain later implementation slices. | 39 | +subscription-backed Codex, OpenCode, Claude, or Hermes adapters remain later |
| 40 | +implementation slices. The current Telegram/YDB adapter still carries a private | ||
| 41 | +legacy revision until those persistence and adapter migrations land; it is not | ||
| 42 | +part of the product contract. | ||
| 39 | 43 | ||
| 40 | ## Components | 44 | ## Components |
| 41 | 45 | ||
| @@ -52,8 +56,8 @@ adapters remain later implementation slices. | |||
| 52 | - `internal/worker`: durable materialize/execute/checkpoint/finalize lifecycle; | 56 | - `internal/worker`: durable materialize/execute/checkpoint/finalize lifecycle; |
| 53 | - `internal/deterministicharness`: credential-free adapter that proves the | 57 | - `internal/deterministicharness`: credential-free adapter that proves the |
| 54 | worker contract before a subscription CLI is selected; | 58 | worker contract before a subscription CLI is selected; |
| 55 | -- `internal/domain`: tenant-scoped identities, state machines, quota/usage | 59 | +- `internal/domain`: canonical sessions/events/bindings/snapshots, tenant-scoped |
| 56 | - semantics, outboxes, artifacts, and explicit context epochs; | 60 | + identities, state machines, quota/usage semantics, outboxes, and artifacts; |
| 57 | - `internal/ports`: YDB/queue/blob/frontend/credential/harness-neutral runtime | 61 | - `internal/ports`: YDB/queue/blob/frontend/credential/harness-neutral runtime |
| 58 | interfaces; | 62 | interfaces; |
| 59 | - `internal/portlog`: process-boundary structured correlation logs without | 63 | - `internal/portlog`: process-boundary structured correlation logs without |
| @@ -73,7 +77,7 @@ adapters remain later implementation slices. | |||
| 73 | reservation enforcement, durable queue publication, and quota expiry; | 77 | reservation enforcement, durable queue publication, and quota expiry; |
| 74 | - `internal/telegramingress`: webhook authentication, opaque deterministic | 78 | - `internal/telegramingress`: webhook authentication, opaque deterministic |
| 75 | identity resolution, normalized input/blob handling, durable subscription | 79 | identity resolution, normalized input/blob handling, durable subscription |
| 76 | - commands, explicit clean-context transitions, and idempotent run creation; | 80 | + commands, transitional binding compatibility, and idempotent run creation; |
| 77 | - `internal/telegramdelivery`: bounded YDB-ready traversal, transactional delivery | 81 | - `internal/telegramdelivery`: bounded YDB-ready traversal, transactional delivery |
| 78 | claims, retry policy, and Telegram Bot API sending; | 82 | claims, retry policy, and Telegram Bot API sending; |
| 79 | - `internal/queuecontract`: versioned queue envelopes containing opaque IDs only. | 83 | - `internal/queuecontract`: versioned queue envelopes containing opaque IDs only. |
| @@ -1,80 +0,0 @@ | |||
| 1 | -package domain | ||
| 2 | - | ||
| 3 | -import "time" | ||
| 4 | - | ||
| 5 | -// ContextEpoch identifies an explicit frontend-visible context generation. | ||
| 6 | -// Epoch 1 is the initial context; it changes only through a clean-context event. | ||
| 7 | -type ContextEpoch uint64 | ||
| 8 | - | ||
| 9 | -const InitialContextEpoch ContextEpoch = 1 | ||
| 10 | - | ||
| 11 | -func (epoch ContextEpoch) Validate() error { | ||
| 12 | - if epoch < InitialContextEpoch { | ||
| 13 | - return ValidationError{Field: "context_epoch", Reason: "must be at least 1"} | ||
| 14 | - } | ||
| 15 | - return nil | ||
| 16 | -} | ||
| 17 | - | ||
| 18 | -func (epoch ContextEpoch) Next() (ContextEpoch, error) { | ||
| 19 | - if err := epoch.Validate(); err != nil { | ||
| 20 | - return 0, err | ||
| 21 | - } | ||
| 22 | - if epoch == ^ContextEpoch(0) { | ||
| 23 | - return 0, ValidationError{Field: "context_epoch", Reason: "cannot overflow"} | ||
| 24 | - } | ||
| 25 | - return epoch + 1, nil | ||
| 26 | -} | ||
| 27 | - | ||
| 28 | -// CleanContextEvent records the explicit user action that moves a | ||
| 29 | -// conversation to a fresh epoch. It does not delete frontend history. | ||
| 30 | -type CleanContextEvent struct { | ||
| 31 | - TenantID TenantID `json:"tenant_id"` | ||
| 32 | - Conversation ConversationRef `json:"conversation"` | ||
| 33 | - RequestedBy ActorRef `json:"requested_by"` | ||
| 34 | - PreviousEpoch ContextEpoch `json:"previous_epoch"` | ||
| 35 | - NewEpoch ContextEpoch `json:"new_epoch"` | ||
| 36 | - TriggerMessageID string `json:"trigger_message_id"` | ||
| 37 | - IdempotencyKey IdempotencyKey `json:"idempotency_key"` | ||
| 38 | - RequestedAt time.Time `json:"requested_at"` | ||
| 39 | -} | ||
| 40 | - | ||
| 41 | -func (event CleanContextEvent) Validate() error { | ||
| 42 | - if err := event.TenantID.Validate(); err != nil { | ||
| 43 | - return err | ||
| 44 | - } | ||
| 45 | - if err := event.Conversation.Validate(); err != nil { | ||
| 46 | - return err | ||
| 47 | - } | ||
| 48 | - if err := EnsureSameTenant(event.TenantID, event.Conversation.TenantID); err != nil { | ||
| 49 | - return err | ||
| 50 | - } | ||
| 51 | - if err := event.RequestedBy.Validate(); err != nil { | ||
| 52 | - return err | ||
| 53 | - } | ||
| 54 | - if err := EnsureSameTenant(event.TenantID, event.RequestedBy.TenantID); err != nil { | ||
| 55 | - return err | ||
| 56 | - } | ||
| 57 | - if event.Conversation.Frontend != event.RequestedBy.Frontend { | ||
| 58 | - return ValidationError{Field: "requested_by.frontend", Reason: "must match the conversation frontend"} | ||
| 59 | - } | ||
| 60 | - if err := event.PreviousEpoch.Validate(); err != nil { | ||
| 61 | - return err | ||
| 62 | - } | ||
| 63 | - next, err := event.PreviousEpoch.Next() | ||
| 64 | - if err != nil { | ||
| 65 | - return err | ||
| 66 | - } | ||
| 67 | - if event.NewEpoch != next { | ||
| 68 | - return ValidationError{Field: "new_epoch", Reason: "must increment previous_epoch by exactly one"} | ||
| 69 | - } | ||
| 70 | - if event.TriggerMessageID == "" { | ||
| 71 | - return ValidationError{Field: "trigger_message_id", Reason: "must not be empty"} | ||
| 72 | - } | ||
| 73 | - if err := event.IdempotencyKey.Validate(); err != nil { | ||
| 74 | - return err | ||
| 75 | - } | ||
| 76 | - if event.RequestedAt.IsZero() { | ||
| 77 | - return ValidationError{Field: "requested_at", Reason: "must not be zero"} | ||
| 78 | - } | ||
| 79 | - return nil | ||
| 80 | -} | ||
| @@ -11,18 +11,12 @@ import ( | |||
| 11 | var testTime = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) | 11 | var testTime = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) |
| 12 | 12 | ||
| 13 | func validRun() domain.Run { | 13 | func validRun() domain.Run { |
| 14 | - conversation := domain.ConversationRef{ | ||
| 15 | - TenantID: "tenant-a", | ||
| 16 | - Frontend: domain.FrontendTelegram, | ||
| 17 | - ExternalID: "-1000123", | ||
| 18 | - ID: "conversation-1", | ||
| 19 | - } | ||
| 20 | return domain.Run{ | 14 | return domain.Run{ |
| 21 | ID: "run-1", | 15 | ID: "run-1", |
| 22 | TenantID: "tenant-a", | 16 | TenantID: "tenant-a", |
| 23 | - Conversation: conversation, | 17 | + SessionID: "session-1", |
| 18 | + TriggerEventID: "event-1", | ||
| 24 | SubscriptionConnectionID: "subscription-1", | 19 | SubscriptionConnectionID: "subscription-1", |
| 25 | - ContextEpoch: domain.InitialContextEpoch, | ||
| 26 | Status: domain.RunCreated, | 20 | Status: domain.RunCreated, |
| 27 | IdempotencyKey: "telegram-update-1", | 21 | IdempotencyKey: "telegram-update-1", |
| 28 | CreatedAt: testTime, | 22 | CreatedAt: testTime, |
| @@ -75,45 +69,6 @@ func TestBlobMustRemainInsideTenantPrefix(t *testing.T) { | |||
| 75 | } | 69 | } |
| 76 | } | 70 | } |
| 77 | 71 | ||
| 78 | -func TestCleanContextRequiresExplicitNextEpochAndMatchingTenant(t *testing.T) { | ||
| 79 | - t.Parallel() | ||
| 80 | - | ||
| 81 | - event := domain.CleanContextEvent{ | ||
| 82 | - TenantID: "tenant-a", | ||
| 83 | - Conversation: domain.ConversationRef{ | ||
| 84 | - TenantID: "tenant-a", | ||
| 85 | - Frontend: domain.FrontendTelegram, | ||
| 86 | - ExternalID: "-1000123", | ||
| 87 | - ID: "conversation-1", | ||
| 88 | - }, | ||
| 89 | - RequestedBy: domain.ActorRef{ | ||
| 90 | - TenantID: "tenant-a", | ||
| 91 | - Frontend: domain.FrontendTelegram, | ||
| 92 | - ExternalID: "1234", | ||
| 93 | - ID: "actor-1", | ||
| 94 | - }, | ||
| 95 | - PreviousEpoch: 1, | ||
| 96 | - NewEpoch: 2, | ||
| 97 | - TriggerMessageID: "telegram-message-7", | ||
| 98 | - IdempotencyKey: "telegram-update-7", | ||
| 99 | - RequestedAt: testTime, | ||
| 100 | - } | ||
| 101 | - if err := event.Validate(); err != nil { | ||
| 102 | - t.Fatalf("valid CleanContextEvent rejected: %v", err) | ||
| 103 | - } | ||
| 104 | - | ||
| 105 | - event.NewEpoch = 3 | ||
| 106 | - if err := event.Validate(); err == nil { | ||
| 107 | - t.Fatal("CleanContextEvent.Validate() accepted a skipped epoch") | ||
| 108 | - } | ||
| 109 | - | ||
| 110 | - event.NewEpoch = 2 | ||
| 111 | - event.RequestedBy.TenantID = "tenant-b" | ||
| 112 | - if err := event.Validate(); err == nil { | ||
| 113 | - t.Fatal("CleanContextEvent.Validate() accepted a cross-tenant actor") | ||
| 114 | - } | ||
| 115 | -} | ||
| 116 | - | ||
| 117 | func TestUnknownQuotaCannotFabricateRemainingValue(t *testing.T) { | 72 | func TestUnknownQuotaCannotFabricateRemainingValue(t *testing.T) { |
| 118 | t.Parallel() | 73 | t.Parallel() |
| 119 | 74 | ||
| @@ -57,9 +57,9 @@ func CanTransitionRun(from, to RunStatus) bool { | |||
| 57 | type Run struct { | 57 | type Run struct { |
| 58 | ID RunID `json:"id"` | 58 | ID RunID `json:"id"` |
| 59 | TenantID TenantID `json:"tenant_id"` | 59 | TenantID TenantID `json:"tenant_id"` |
| 60 | - Conversation ConversationRef `json:"conversation"` | 60 | + SessionID SessionID `json:"session_id"` |
| 61 | + TriggerEventID SessionEventID `json:"trigger_event_id"` | ||
| 61 | SubscriptionConnectionID SubscriptionConnectionID `json:"subscription_connection_id"` | 62 | SubscriptionConnectionID SubscriptionConnectionID `json:"subscription_connection_id"` |
| 62 | - ContextEpoch ContextEpoch `json:"context_epoch"` | ||
| 63 | Status RunStatus `json:"status"` | 63 | Status RunStatus `json:"status"` |
| 64 | IdempotencyKey IdempotencyKey `json:"idempotency_key"` | 64 | IdempotencyKey IdempotencyKey `json:"idempotency_key"` |
| 65 | CancellationRequestedAt *time.Time `json:"cancellation_requested_at,omitempty"` | 65 | CancellationRequestedAt *time.Time `json:"cancellation_requested_at,omitempty"` |
| @@ -76,18 +76,15 @@ func (run Run) Validate() error { | |||
| 76 | if err := run.TenantID.Validate(); err != nil { | 76 | if err := run.TenantID.Validate(); err != nil { |
| 77 | return err | 77 | return err |
| 78 | } | 78 | } |
| 79 | - if err := run.Conversation.Validate(); err != nil { | 79 | + if err := run.SessionID.Validate(); err != nil { |
| 80 | return err | 80 | return err |
| 81 | } | 81 | } |
| 82 | - if err := EnsureSameTenant(run.TenantID, run.Conversation.TenantID); err != nil { | 82 | + if err := run.TriggerEventID.Validate(); err != nil { |
| 83 | return err | 83 | return err |
| 84 | } | 84 | } |
| 85 | if err := run.SubscriptionConnectionID.Validate(); err != nil { | 85 | if err := run.SubscriptionConnectionID.Validate(); err != nil { |
| 86 | return err | 86 | return err |
| 87 | } | 87 | } |
| 88 | - if err := run.ContextEpoch.Validate(); err != nil { | ||
| 89 | - return err | ||
| 90 | - } | ||
| 91 | if !run.Status.Valid() { | 88 | if !run.Status.Valid() { |
| 92 | return ValidationError{Field: "run.status", Reason: "is unknown"} | 89 | return ValidationError{Field: "run.status", Reason: "is unknown"} |
| 93 | } | 90 | } |