- ControlPlane 라우터에 워커/에이전트 구독 채널을 추가한다 - ProtoSocket 기반 실시간 양방향 채널( dispatcher, envelope, server)을 구현한다 - Forgejo push 이벤트를 provider 어댑터로 처리하도록 연동한다 - PostgreSQL 스토리지 백엔드를 분리하여 postgres.go로 신설한다 - Git engine command에 diff/checkout/merge 연쇄 연산을 추가한다 - 런타임 모델에 agentSession, runtimeChannel 스키마를 추가한다 - 데이터베이스 마이그레이션에 agent_session, runtime_channel, lease 테이블을 추가한다 - agent-contract에 Forgejo branch events 계약 문서를 작성한다 - 로드맵 마일스톤과 아카이브 작업을 갱신한다
293 lines
9.2 KiB
Go
293 lines
9.2 KiB
Go
package controlplane
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/gito/services/core/internal/config"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/core"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/events"
|
|
"git.toki-labs.com/toki/gito/services/core/internal/storage"
|
|
)
|
|
|
|
// fakeStore is a shared in-memory implementation of storage.Store for tests.
|
|
type fakeStore struct {
|
|
watches *fakeBranchWatchStore
|
|
cursors *fakeRevisionCursorStore
|
|
deliveries *fakeProviderDeliveryStore
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
watches: &fakeBranchWatchStore{},
|
|
cursors: &fakeRevisionCursorStore{},
|
|
deliveries: &fakeProviderDeliveryStore{records: make(map[string]core.ProviderDelivery)},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) Ping(_ context.Context) error { return nil }
|
|
func (f *fakeStore) Repos() storage.RepoStore { return nil }
|
|
func (f *fakeStore) WorkspaceLeases() storage.WorkspaceLeaseStore { return nil }
|
|
func (f *fakeStore) Operations() storage.OperationStore { return nil }
|
|
func (f *fakeStore) OperationEvents() storage.OperationEventStore { return nil }
|
|
func (f *fakeStore) BranchWatches() storage.BranchWatchStore { return f.watches }
|
|
func (f *fakeStore) RevisionCursors() storage.RevisionCursorStore { return f.cursors }
|
|
func (f *fakeStore) ProviderDeliveries() storage.ProviderDeliveryStore { return f.deliveries }
|
|
|
|
var _ storage.Store = (*fakeStore)(nil)
|
|
|
|
// fakeBranchWatchStore
|
|
|
|
type fakeBranchWatchStore struct {
|
|
mu sync.Mutex
|
|
watches []core.BranchWatch
|
|
}
|
|
|
|
func (f *fakeBranchWatchStore) UpsertBranchWatch(_ context.Context, watch core.BranchWatch) (core.BranchWatch, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, w := range f.watches {
|
|
if w.Provider == watch.Provider && w.RepoID == watch.RepoID && w.Branch == watch.Branch {
|
|
return w, nil
|
|
}
|
|
}
|
|
f.watches = append(f.watches, watch)
|
|
return watch, nil
|
|
}
|
|
|
|
func (f *fakeBranchWatchStore) FindBranchWatch(_ context.Context, provider, repoID, branch string) (core.BranchWatch, bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, w := range f.watches {
|
|
if w.Provider == provider && w.RepoID == repoID && w.Branch == branch {
|
|
return w, true, nil
|
|
}
|
|
}
|
|
return core.BranchWatch{}, false, nil
|
|
}
|
|
|
|
func (f *fakeBranchWatchStore) ListBranchWatches(_ context.Context) ([]core.BranchWatch, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]core.BranchWatch(nil), f.watches...), nil
|
|
}
|
|
|
|
// fakeRevisionCursorStore
|
|
|
|
type fakeRevisionCursorStore struct {
|
|
mu sync.Mutex
|
|
cursors map[string]core.RevisionCursor
|
|
}
|
|
|
|
func (f *fakeRevisionCursorStore) UpsertRevisionCursor(_ context.Context, cursor core.RevisionCursor) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.cursors == nil {
|
|
f.cursors = make(map[string]core.RevisionCursor)
|
|
}
|
|
f.cursors[cursor.RepoID+":"+cursor.Branch] = cursor
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRevisionCursorStore) GetRevisionCursor(_ context.Context, repoID, branch string) (core.RevisionCursor, bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
c, ok := f.cursors[repoID+":"+branch]
|
|
return c, ok, nil
|
|
}
|
|
|
|
// fakeProviderDeliveryStore
|
|
|
|
type fakeProviderDeliveryStore struct {
|
|
mu sync.Mutex
|
|
records map[string]core.ProviderDelivery
|
|
}
|
|
|
|
func (f *fakeProviderDeliveryStore) RecordOnce(_ context.Context, delivery core.ProviderDelivery) (storage.DeliveryResult, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if existing, ok := f.records[delivery.DedupeKey]; ok {
|
|
return storage.DeliveryResult{
|
|
First: false,
|
|
ExistingEventID: existing.EventID,
|
|
ExistingCreatedAt: existing.CreatedAt.Truncate(time.Microsecond),
|
|
}, nil
|
|
}
|
|
f.records[delivery.DedupeKey] = delivery
|
|
return storage.DeliveryResult{First: true}, nil
|
|
}
|
|
|
|
// Tests
|
|
|
|
func TestRuntimeRestartPersistsWatchAndCursor(t *testing.T) {
|
|
store := newFakeStore()
|
|
|
|
// Runtime A: register a watch
|
|
runtimeA := NewRuntimeWithStore(nil, store)
|
|
_, err := runtimeA.RegisterBranchWatch("nomadcode", "develop", "forgejo")
|
|
if err != nil {
|
|
t.Fatalf("register watch: %v", err)
|
|
}
|
|
|
|
// Runtime B: same store, simulates process restart
|
|
runtimeB := NewRuntimeWithStore(nil, store)
|
|
|
|
revision := core.RevisionEvent{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
Before: "aaa",
|
|
After: "bbb",
|
|
ObservedAt: time.Now().UTC(),
|
|
}
|
|
record, matched, err := runtimeB.HandleRevision(context.Background(), "forgejo", "delivery-restart-1", revision)
|
|
if err != nil {
|
|
t.Fatalf("handle revision: %v", err)
|
|
}
|
|
if !matched {
|
|
t.Fatalf("expected matched=true after restart, got false; record=%+v", record)
|
|
}
|
|
if record.Duplicate {
|
|
t.Fatalf("expected first delivery not to be duplicate")
|
|
}
|
|
|
|
// cursor must be persisted
|
|
cursor, ok, err := store.cursors.GetRevisionCursor(context.Background(), "nomadcode", "develop")
|
|
if err != nil {
|
|
t.Fatalf("get cursor: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected revision cursor to be stored after first delivery")
|
|
}
|
|
if cursor.Revision != "bbb" {
|
|
t.Fatalf("cursor revision: got %q want %q", cursor.Revision, "bbb")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeDuplicateDeliveryIsIdempotent(t *testing.T) {
|
|
store := newFakeStore()
|
|
runtime := NewRuntimeWithStore(nil, store)
|
|
|
|
_, err := runtime.RegisterBranchWatch("nomadcode", "develop", "forgejo")
|
|
if err != nil {
|
|
t.Fatalf("register watch: %v", err)
|
|
}
|
|
|
|
revision := core.RevisionEvent{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
Before: "aaa",
|
|
After: "bbb",
|
|
ObservedAt: time.Now().UTC(),
|
|
}
|
|
|
|
record1, matched1, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
|
|
if err != nil {
|
|
t.Fatalf("first handle: %v", err)
|
|
}
|
|
if !matched1 || record1.Duplicate {
|
|
t.Fatalf("first: matched=%v duplicate=%v", matched1, record1.Duplicate)
|
|
}
|
|
|
|
record2, matched2, err := runtime.HandleRevision(context.Background(), "forgejo", "delivery-dup-1", revision)
|
|
if err != nil {
|
|
t.Fatalf("second handle: %v", err)
|
|
}
|
|
if !matched2 {
|
|
t.Fatalf("duplicate delivery should still return matched=true")
|
|
}
|
|
if !record2.Duplicate {
|
|
t.Fatalf("expected second delivery to be marked as duplicate")
|
|
}
|
|
|
|
// duplicate response must return same event id and created_at as the first
|
|
if record2.ID != record1.ID {
|
|
t.Fatalf("duplicate event id: got %q want %q", record2.ID, record1.ID)
|
|
}
|
|
if !record2.CreatedAt.Equal(record1.CreatedAt) {
|
|
t.Fatalf("duplicate created_at: got %v want %v", record2.CreatedAt, record1.CreatedAt)
|
|
}
|
|
|
|
// in-memory event log must have exactly one record
|
|
evList := runtime.ListEvents()
|
|
if len(evList) != 1 {
|
|
t.Fatalf("event list count: got %d want 1", len(evList))
|
|
}
|
|
if evList[0].Type != events.BranchUpdated {
|
|
t.Fatalf("event type: got %q", evList[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestForgejoPushDuplicateDeliveryIsIdempotent(t *testing.T) {
|
|
store := newFakeStore()
|
|
router := newRouterWithStore(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, nil, store)
|
|
|
|
registerBranchListener(t, router, `{"repo_id":"nomadcode","branch":"develop","provider":"forgejo"}`)
|
|
|
|
pushBody := `{
|
|
"ref": "refs/heads/develop",
|
|
"before": "111",
|
|
"after": "222",
|
|
"repository": {"name": "nomadcode", "full_name": "toki/nomadcode"},
|
|
"commits": [{"id": "222", "modified": ["README.md"]}]
|
|
}`
|
|
|
|
sendPush := func(deliveryID string) map[string]any {
|
|
req := httptest.NewRequest(http.MethodPost, "/callbacks/forgejo/push?repo_id=nomadcode", bytes.NewBufferString(pushBody))
|
|
req.Header.Set("X-Forgejo-Event", "push")
|
|
req.Header.Set("X-Forgejo-Delivery", deliveryID)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
first := sendPush("dup-delivery-1")
|
|
if first["matched"] != true || first["duplicate"] == true {
|
|
t.Fatalf("first push: matched=%v duplicate=%v", first["matched"], first["duplicate"])
|
|
}
|
|
|
|
second := sendPush("dup-delivery-1")
|
|
if second["matched"] != true {
|
|
t.Fatalf("second push: expected matched=true")
|
|
}
|
|
if second["duplicate"] != true {
|
|
t.Fatalf("second push: expected duplicate=true, got %v", second["duplicate"])
|
|
}
|
|
|
|
// duplicate response event id and created_at must match the first delivery
|
|
firstEvent, _ := first["event"].(map[string]any)
|
|
secondEvent, _ := second["event"].(map[string]any)
|
|
if firstEvent == nil || secondEvent == nil {
|
|
t.Fatalf("event missing: first=%v second=%v", first["event"], second["event"])
|
|
}
|
|
if firstEvent["id"] != secondEvent["id"] {
|
|
t.Fatalf("duplicate event id mismatch: first=%v second=%v", firstEvent["id"], secondEvent["id"])
|
|
}
|
|
if firstEvent["created_at"] != secondEvent["created_at"] {
|
|
t.Fatalf("duplicate created_at mismatch: first=%v second=%v", firstEvent["created_at"], secondEvent["created_at"])
|
|
}
|
|
|
|
// /api/events count must not increase on duplicate
|
|
evReq := httptest.NewRequest(http.MethodGet, "/api/events", nil)
|
|
evRec := httptest.NewRecorder()
|
|
router.ServeHTTP(evRec, evReq)
|
|
var evBody map[string][]map[string]any
|
|
if err := json.NewDecoder(evRec.Body).Decode(&evBody); err != nil {
|
|
t.Fatalf("decode events: %v", err)
|
|
}
|
|
if len(evBody["events"]) != 1 {
|
|
t.Fatalf("events count: got %d want 1", len(evBody["events"]))
|
|
}
|
|
}
|