已合并
[SESSION-03] Implement frontend-neutral canonical user-event ingestion #23
urandon创建于 8月10日
[SESSION-03] Implement frontend-neutral canonical user-event ingestion #23
已合并
共 19 个文件变更+1932-77
| @@ -46,6 +46,19 @@ operations. `CreateAndSwitchSession` is the `/new` transaction boundary: a | |||
| 46 | failure cannot leave either an orphan product session or a half-switched | 46 | failure cannot leave either an orphan product session or a half-switched |
| 47 | frontend conversation. | 47 | frontend conversation. |
| 48 | 48 | ||
| 49 | +`CanonicalIngressStore` is the application persistence boundary shared by all | ||
| 50 | +frontends. An authenticated adapter supplies an internal user, opaque frontend | ||
| 51 | +and external conversation/event identifiers, and a stable delivery time. The | ||
| 52 | +store requires active tenant membership and a writable session participant, | ||
| 53 | +then commits the frontend deduplication fact, canonical user event, run, | ||
| 54 | +initial attempt, input manifest, and dispatch outbox in one serializable YDB | ||
| 55 | +transaction. No Telegram update, chat, or message type crosses this contract. | ||
| 56 | + | ||
| 57 | +Frontend deduplication is keyed by tenant, binding, and the normalized delivery | ||
| 58 | +key rather than the current session. Therefore a delayed duplicate received | ||
| 59 | +after a clean-context binding switch still resolves to the original event and | ||
| 60 | +run instead of being appended to the newer session. | ||
| 61 | + | ||
| 49 | `SessionParticipant` grants `owner`, `member`, or `viewer` access to an active | 62 | `SessionParticipant` grants `owner`, `member`, or `viewer` access to an active |
| 50 | tenant membership. Tenant, session, and user must all match before access is | 63 | tenant membership. Tenant, session, and user must all match before access is |
| 51 | allowed; viewers cannot append. Authentication establishes a user identity, | 64 | allowed; viewers cannot append. Authentication establishes a user identity, |
| @@ -134,9 +147,9 @@ the canonical event exists. Retrying or adding a frontend therefore cannot | |||
| 134 | duplicate or rewrite canonical history. | 147 | duplicate or rewrite canonical history. |
| 135 | 148 | ||
| 136 | Until #23 migrates worker finalization, the existing Telegram delivery outbox | 149 | Until #23 migrates worker finalization, the existing Telegram delivery outbox |
| 137 | -is a compatibility projection. Until #22/#36 migrate ingestion, the existing | 150 | +is a compatibility projection. Until #36 adapts Telegram to |
| 138 | -Telegram update transaction is a compatibility ingress path. Neither changes | 151 | +`CanonicalIngressStore`, the existing Telegram update transaction is a |
| 139 | -the canonical contract above. | 152 | +compatibility ingress path. Neither changes the canonical contract above. |
| 140 | 153 | ||
| 141 | ## Quota and usage | 154 | ## Quota and usage |
| 142 | 155 | ||
| @@ -23,21 +23,25 @@ func CanTransitionDispatch(from, to DispatchStatus) bool { | |||
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | type DispatchOutbox struct { | 25 | type DispatchOutbox struct { |
| 26 | - ID DispatchOutboxID `json:"id"` | 26 | + ID DispatchOutboxID `json:"id"` |
| 27 | - TenantID TenantID `json:"tenant_id"` | 27 | + TenantID TenantID `json:"tenant_id"` |
| 28 | - RunID RunID `json:"run_id"` | 28 | + RunID RunID `json:"run_id"` |
| 29 | - AttemptID AttemptID `json:"attempt_id"` | 29 | + AttemptID AttemptID `json:"attempt_id"` |
| 30 | - InputManifestID ArtifactManifestID `json:"input_manifest_id"` | 30 | + InputManifestID ArtifactManifestID `json:"input_manifest_id"` |
| 31 | - ContextSnapshot BlobRef `json:"context_snapshot"` | 31 | + ContextSnapshot BlobRef `json:"context_snapshot"` |
| 32 | - WorkspaceSnapshot *BlobRef `json:"workspace_snapshot,omitempty"` | 32 | + WorkspaceSnapshot *BlobRef `json:"workspace_snapshot,omitempty"` |
| 33 | - SkillBundle *BlobRef `json:"skill_bundle,omitempty"` | 33 | + SkillBundle *BlobRef `json:"skill_bundle,omitempty"` |
| 34 | - AllowedMCPServers []string `json:"allowed_mcp_servers,omitempty"` | 34 | + AllowedMCPServers []string `json:"allowed_mcp_servers,omitempty"` |
| 35 | - DeliveryChat TelegramChatRef `json:"delivery_chat"` | 35 | + Origin *FrontendEventOrigin `json:"origin,omitempty"` |
| 36 | - ReplyToMessageID int64 `json:"reply_to_message_id"` | 36 | + // DeliveryChat and ReplyToMessageID are the compatibility bridge for the |
| 37 | - Status DispatchStatus `json:"status"` | 37 | + // pre-canonical Telegram worker flow. New ingress paths use Origin; #36 |
| 38 | - IdempotencyKey IdempotencyKey `json:"idempotency_key"` | 38 | + // removes this bridge when Telegram projects canonical assistant events. |
| 39 | - CreatedAt time.Time `json:"created_at"` | 39 | + DeliveryChat TelegramChatRef `json:"delivery_chat"` |
| 40 | - UpdatedAt time.Time `json:"updated_at"` | 40 | + ReplyToMessageID int64 `json:"reply_to_message_id"` |
| 41 | + Status DispatchStatus `json:"status"` | ||
| 42 | + IdempotencyKey IdempotencyKey `json:"idempotency_key"` | ||
| 43 | + CreatedAt time.Time `json:"created_at"` | ||
| 44 | + UpdatedAt time.Time `json:"updated_at"` | ||
| 41 | } | 45 | } |
| 42 | 46 | ||
| 43 | func (outbox DispatchOutbox) ValidateForAttempt(run Run, attempt Attempt) error { | 47 | func (outbox DispatchOutbox) ValidateForAttempt(run Run, attempt Attempt) error { |
| @@ -69,14 +73,24 @@ func (outbox DispatchOutbox) ValidateForAttempt(run Run, attempt Attempt) error | |||
| 69 | return err | 73 | return err |
| 70 | } | 74 | } |
| 71 | } | 75 | } |
| 72 | - if err := outbox.DeliveryChat.Validate(); err != nil { | 76 | + if outbox.Origin == nil && outbox.DeliveryChat.ChatID == 0 { |
| 73 | - return err | 77 | + return ValidationError{Field: "dispatch_outbox.origin", Reason: "a frontend origin or legacy delivery target is required"} |
| 74 | } | 78 | } |
| 75 | - if err := EnsureSameTenant(run.TenantID, outbox.DeliveryChat.TenantID); err != nil { | 79 | + if outbox.Origin != nil { |
| 76 | - return err | 80 | + if err := outbox.Origin.Validate(); err != nil { |
| 81 | + return err | ||
| 82 | + } | ||
| 77 | } | 83 | } |
| 78 | - if outbox.ReplyToMessageID == 0 { | 84 | + if outbox.DeliveryChat.ChatID != 0 { |
| 79 | - return ValidationError{Field: "dispatch_outbox.reply_to_message_id", Reason: "must not be zero"} | 85 | + if err := outbox.DeliveryChat.Validate(); err != nil { |
| 86 | + return err | ||
| 87 | + } | ||
| 88 | + if err := EnsureSameTenant(run.TenantID, outbox.DeliveryChat.TenantID); err != nil { | ||
| 89 | + return err | ||
| 90 | + } | ||
| 91 | + if outbox.ReplyToMessageID == 0 { | ||
| 92 | + return ValidationError{Field: "dispatch_outbox.reply_to_message_id", Reason: "must not be zero for a legacy delivery target"} | ||
| 93 | + } | ||
| 80 | } | 94 | } |
| 81 | if !outbox.Status.Valid() { | 95 | if !outbox.Status.Valid() { |
| 82 | return ValidationError{Field: "dispatch_outbox.status", Reason: "is unknown"} | 96 | return ValidationError{Field: "dispatch_outbox.status", Reason: "is unknown"} |
| @@ -8,21 +8,24 @@ import ( | |||
| 8 | // WorkerJob is the durable, point-addressable materialization contract for one | 8 | // WorkerJob is the durable, point-addressable materialization contract for one |
| 9 | // admitted run. Queue messages carry only its tenant/run routing identity. | 9 | // admitted run. Queue messages carry only its tenant/run routing identity. |
| 10 | type WorkerJob struct { | 10 | type WorkerJob struct { |
| 11 | - TenantID TenantID `json:"tenant_id"` | 11 | + TenantID TenantID `json:"tenant_id"` |
| 12 | - RunID RunID `json:"run_id"` | 12 | + RunID RunID `json:"run_id"` |
| 13 | - SessionID SessionID `json:"session_id"` | 13 | + SessionID SessionID `json:"session_id"` |
| 14 | - TriggerEventID SessionEventID `json:"trigger_event_id"` | 14 | + TriggerEventID SessionEventID `json:"trigger_event_id"` |
| 15 | - AttemptID AttemptID `json:"attempt_id"` | 15 | + AttemptID AttemptID `json:"attempt_id"` |
| 16 | - ReservationID QuotaReservationID `json:"reservation_id"` | 16 | + ReservationID QuotaReservationID `json:"reservation_id"` |
| 17 | - InputManifestID ArtifactManifestID `json:"input_manifest_id"` | 17 | + InputManifestID ArtifactManifestID `json:"input_manifest_id"` |
| 18 | - ContextSnapshot BlobRef `json:"context_snapshot"` | 18 | + ContextSnapshot BlobRef `json:"context_snapshot"` |
| 19 | - WorkspaceSnapshot *BlobRef `json:"workspace_snapshot,omitempty"` | 19 | + WorkspaceSnapshot *BlobRef `json:"workspace_snapshot,omitempty"` |
| 20 | - SkillBundle *BlobRef `json:"skill_bundle,omitempty"` | 20 | + SkillBundle *BlobRef `json:"skill_bundle,omitempty"` |
| 21 | - AllowedMCPServers []string `json:"allowed_mcp_servers,omitempty"` | 21 | + AllowedMCPServers []string `json:"allowed_mcp_servers,omitempty"` |
| 22 | - Limits ProductLimits `json:"limits"` | 22 | + Limits ProductLimits `json:"limits"` |
| 23 | - DeliveryChat TelegramChatRef `json:"delivery_chat"` | 23 | + Origin *FrontendEventOrigin `json:"origin,omitempty"` |
| 24 | - ReplyToMessageID int64 `json:"reply_to_message_id"` | 24 | + // Compatibility bridge for Telegram until #36 projects results from the |
| 25 | - CreatedAt time.Time `json:"created_at"` | 25 | + // canonical session stream. |
| 26 | + DeliveryChat TelegramChatRef `json:"delivery_chat"` | ||
| 27 | + ReplyToMessageID int64 `json:"reply_to_message_id"` | ||
| 28 | + CreatedAt time.Time `json:"created_at"` | ||
| 26 | } | 29 | } |
| 27 | 30 | ||
| 28 | func (job WorkerJob) ValidateForRun(run Run) error { | 31 | func (job WorkerJob) ValidateForRun(run Run) error { |
| @@ -76,14 +79,24 @@ func (job WorkerJob) ValidateForRun(run Run) error { | |||
| 76 | if uint64(job.ContextSnapshot.Size) > job.Limits.MaxContextBytes { | 79 | if uint64(job.ContextSnapshot.Size) > job.Limits.MaxContextBytes { |
| 77 | return ValidationError{Field: "worker_job.context_snapshot", Reason: "exceeds the admitted context limit"} | 80 | return ValidationError{Field: "worker_job.context_snapshot", Reason: "exceeds the admitted context limit"} |
| 78 | } | 81 | } |
| 79 | - if err := job.DeliveryChat.Validate(); err != nil { | 82 | + if job.Origin == nil && job.DeliveryChat.ChatID == 0 { |
| 80 | - return err | 83 | + return ValidationError{Field: "worker_job.origin", Reason: "a frontend origin or legacy delivery target is required"} |
| 81 | } | 84 | } |
| 82 | - if err := EnsureSameTenant(run.TenantID, job.DeliveryChat.TenantID); err != nil { | 85 | + if job.Origin != nil { |
| 83 | - return err | 86 | + if err := job.Origin.Validate(); err != nil { |
| 87 | + return err | ||
| 88 | + } | ||
| 84 | } | 89 | } |
| 85 | - if job.ReplyToMessageID == 0 { | 90 | + if job.DeliveryChat.ChatID != 0 { |
| 86 | - return ValidationError{Field: "worker_job.reply_to_message_id", Reason: "must not be zero"} | 91 | + if err := job.DeliveryChat.Validate(); err != nil { |
| 92 | + return err | ||
| 93 | + } | ||
| 94 | + if err := EnsureSameTenant(run.TenantID, job.DeliveryChat.TenantID); err != nil { | ||
| 95 | + return err | ||
| 96 | + } | ||
| 97 | + if job.ReplyToMessageID == 0 { | ||
| 98 | + return ValidationError{Field: "worker_job.reply_to_message_id", Reason: "must not be zero for a legacy delivery target"} | ||
| 99 | + } | ||
| 87 | } | 100 | } |
| 88 | if job.CreatedAt.IsZero() || job.CreatedAt.Before(run.CreatedAt) { | 101 | if job.CreatedAt.IsZero() || job.CreatedAt.Before(run.CreatedAt) { |
| 89 | return ValidationError{Field: "worker_job.created_at", Reason: "must not precede the owning run"} | 102 | return ValidationError{Field: "worker_job.created_at", Reason: "must not precede the owning run"} |