Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: tolerate legacy outbox records missing attempts

Lyric 2f337b60 6b5a5a05

+110 -2
+14 -2
contacts/file_store.go
··· 1274 1274 } 1275 1275 out := make([]BusOutboxRecord, 0, len(file.Records)) 1276 1276 for _, item := range file.Records { 1277 - normalized, normalizeErr := normalizeBusOutboxRecord(item) 1277 + normalized, normalizeErr := normalizeBusOutboxRecordForLoad(item) 1278 1278 if normalizeErr != nil { 1279 1279 return nil, normalizeErr 1280 1280 } ··· 1610 1610 } 1611 1611 1612 1612 func normalizeBusOutboxRecord(record BusOutboxRecord) (BusOutboxRecord, error) { 1613 + return normalizeBusOutboxRecordWithOptions(record, false) 1614 + } 1615 + 1616 + func normalizeBusOutboxRecordForLoad(record BusOutboxRecord) (BusOutboxRecord, error) { 1617 + return normalizeBusOutboxRecordWithOptions(record, true) 1618 + } 1619 + 1620 + func normalizeBusOutboxRecordWithOptions(record BusOutboxRecord, allowLegacyZeroAttempts bool) (BusOutboxRecord, error) { 1613 1621 channel, err := normalizeBusChannel(record.Channel) 1614 1622 if err != nil { 1615 1623 return BusOutboxRecord{}, err ··· 1623 1631 return BusOutboxRecord{}, err 1624 1632 } 1625 1633 if record.Attempts <= 0 { 1626 - return BusOutboxRecord{}, fmt.Errorf("attempts must be > 0") 1634 + if !allowLegacyZeroAttempts { 1635 + return BusOutboxRecord{}, fmt.Errorf("attempts must be > 0") 1636 + } 1637 + // Older v1 outbox files could omit attempts. Treat that legacy state as one attempt. 1638 + record.Attempts = 1 1627 1639 } 1628 1640 createdAt := record.CreatedAt.UTC() 1629 1641 updatedAt := record.UpdatedAt.UTC()
+48
contacts/file_store_test.go
··· 146 146 } 147 147 } 148 148 149 + func TestFileStoreBusOutboxLoadsLegacyRecordWithoutAttempts(t *testing.T) { 150 + ctx := context.Background() 151 + root := filepath.Join(t.TempDir(), "contacts") 152 + store := NewFileStore(root) 153 + if err := store.Ensure(ctx); err != nil { 154 + t.Fatalf("Ensure() error = %v", err) 155 + } 156 + if err := os.WriteFile( 157 + filepath.Join(root, "bus_outbox.json"), 158 + []byte("{\"version\":1,\"records\":[{\"channel\":\"telegram\",\"idempotency_key\":\"legacy:k1\",\"status\":\"sent\",\"created_at\":\"2026-02-08T12:00:00Z\",\"updated_at\":\"2026-02-08T12:00:00Z\",\"sent_at\":\"2026-02-08T12:00:00Z\"}]}\n"), 159 + 0o600, 160 + ); err != nil { 161 + t.Fatalf("WriteFile() error = %v", err) 162 + } 163 + 164 + record, ok, err := store.GetBusOutboxRecord(ctx, ChannelTelegram, "legacy:k1") 165 + if err != nil { 166 + t.Fatalf("GetBusOutboxRecord() error = %v", err) 167 + } 168 + if !ok { 169 + t.Fatalf("GetBusOutboxRecord() expected ok=true") 170 + } 171 + if record.Attempts != 1 { 172 + t.Fatalf("attempts mismatch: got %d want 1", record.Attempts) 173 + } 174 + } 175 + 176 + func TestFileStorePutBusOutboxRecordRejectsZeroAttempts(t *testing.T) { 177 + ctx := context.Background() 178 + root := filepath.Join(t.TempDir(), "contacts") 179 + store := NewFileStore(root) 180 + if err := store.Ensure(ctx); err != nil { 181 + t.Fatalf("Ensure() error = %v", err) 182 + } 183 + 184 + now := time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) 185 + if err := store.PutBusOutboxRecord(ctx, BusOutboxRecord{ 186 + Channel: ChannelTelegram, 187 + IdempotencyKey: "manual:k-zero", 188 + Status: BusDeliveryStatusPending, 189 + Attempts: 0, 190 + CreatedAt: now, 191 + UpdatedAt: now, 192 + }); err == nil { 193 + t.Fatalf("PutBusOutboxRecord() expected error for zero attempts") 194 + } 195 + } 196 + 149 197 func TestFileStoreParsesProfileMarkdownTemplate(t *testing.T) { 150 198 ctx := context.Background() 151 199 root := filepath.Join(t.TempDir(), "contacts")
+48
contacts/invariants_test.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/base64" 6 + "os" 6 7 "path/filepath" 7 8 "strings" 8 9 "testing" ··· 64 65 } 65 66 if !second.Deduped { 66 67 t.Fatalf("second send expected deduped outcome, got=%+v", second) 68 + } 69 + } 70 + 71 + func TestSendDecisionWithLegacyOutboxRecordMissingAttempts(t *testing.T) { 72 + ctx := context.Background() 73 + root := filepath.Join(t.TempDir(), "contacts") 74 + store := NewFileStore(root) 75 + svc := NewService(store) 76 + now := time.Date(2026, 2, 8, 21, 15, 0, 0, time.UTC) 77 + 78 + if err := store.Ensure(ctx); err != nil { 79 + t.Fatalf("Ensure() error = %v", err) 80 + } 81 + if err := os.WriteFile( 82 + filepath.Join(root, "bus_outbox.json"), 83 + []byte("{\"version\":1,\"records\":[{\"channel\":\"telegram\",\"idempotency_key\":\"legacy:k0\",\"status\":\"sent\",\"created_at\":\"2026-02-08T20:00:00Z\",\"updated_at\":\"2026-02-08T20:00:00Z\",\"sent_at\":\"2026-02-08T20:00:00Z\"}]}\n"), 84 + 0o600, 85 + ); err != nil { 86 + t.Fatalf("WriteFile() error = %v", err) 87 + } 88 + 89 + if _, err := svc.UpsertContact(ctx, Contact{ 90 + ContactID: "tg:10087", 91 + Kind: KindHuman, 92 + Channel: ChannelTelegram, 93 + TGPrivateChatID: 10087, 94 + }, now); err != nil { 95 + t.Fatalf("UpsertContact() error = %v", err) 96 + } 97 + 98 + sender := &mockSender{accepted: true} 99 + payload := base64.RawURLEncoding.EncodeToString([]byte("hello")) 100 + outcome, err := svc.SendDecision(ctx, now, ShareDecision{ 101 + ContactID: "tg:10087", 102 + ItemID: "manual_item_legacy", 103 + ContentType: "text/plain", 104 + PayloadBase64: payload, 105 + IdempotencyKey: "manual:key-legacy", 106 + }, sender) 107 + if err != nil { 108 + t.Fatalf("SendDecision() error = %v", err) 109 + } 110 + if sender.calls != 1 { 111 + t.Fatalf("sender calls mismatch: got %d want 1", sender.calls) 112 + } 113 + if !outcome.Accepted { 114 + t.Fatalf("outcome accepted mismatch: got %+v", outcome) 67 115 } 68 116 } 69 117