chore: sync local project state
This commit is contained in:
181
cmd/sub2api-bridge/main.go
Normal file
181
cmd/sub2api-bridge/main.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
supplyURL := os.Getenv("SUPPLY_URL")
|
||||
if supplyURL == "" {
|
||||
supplyURL = "http://127.0.0.1:8081"
|
||||
}
|
||||
consumer := os.Getenv("CONSUMER")
|
||||
if consumer == "" {
|
||||
consumer = "sub2api-bridge"
|
||||
}
|
||||
dbConn := os.Getenv("SUB2API_DB")
|
||||
if dbConn == "" {
|
||||
dbConn = "postgres://sub2api:***@localhost:5432/sub2api?sslmode=disable"
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbConn)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("ping db: %v", err)
|
||||
}
|
||||
log.Println("connected to sub2api db")
|
||||
if err := ensureBridgeTable(db); err != nil {
|
||||
log.Fatalf("ensure table: %v", err)
|
||||
}
|
||||
|
||||
cursor := ""
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
events, nextCursor, err := fetchPackageChanges(ctx, supplyURL, cursor)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("fetch error: %v", err)
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
for _, evt := range events {
|
||||
if evt.GatewaySyncStatus != "pending" {
|
||||
log.Printf("skip non-pending event: %s status=%s", evt.EventID, evt.GatewaySyncStatus)
|
||||
continue
|
||||
}
|
||||
log.Printf("bridge event: %s package=%d model=%s", evt.EventID, evt.PackageID, evt.Model)
|
||||
if err := bridgeToSub2API(db, evt); err != nil {
|
||||
log.Printf("bridge error: %v", err)
|
||||
continue
|
||||
}
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ackErr := ackPackageChange(ctx2, supplyURL, evt.EventID, consumer, "applied", "synced to sub2api")
|
||||
cancel2()
|
||||
if ackErr != nil {
|
||||
log.Printf("ack error for %s: %v", evt.EventID, ackErr)
|
||||
continue
|
||||
}
|
||||
log.Printf("acked event: %s", evt.EventID)
|
||||
}
|
||||
if nextCursor == "" {
|
||||
log.Println("no more events, sleeping 10s")
|
||||
time.Sleep(10 * time.Second)
|
||||
} else {
|
||||
cursor = nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PackageChangeEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
EventType string `json:"event_type"`
|
||||
PackageID int64 `json:"package_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Version int `json:"version"`
|
||||
GatewaySyncStatus string `json:"gateway_sync_status"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
NextRetryAt string `json:"next_retry_at,omitempty"`
|
||||
LastFailureCategory string `json:"last_failure_category,omitempty"`
|
||||
}
|
||||
|
||||
func fetchPackageChanges(ctx context.Context, baseURL, cursor string) ([]PackageChangeEvent, string, error) {
|
||||
url := fmt.Sprintf("%s/internal/supply-intelligence/gateway/package-changes", baseURL)
|
||||
if cursor != "" {
|
||||
url += "?cursor=" + cursor
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var result struct {
|
||||
Items []PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return result.Items, result.NextCursor, nil
|
||||
}
|
||||
|
||||
func ackPackageChange(ctx context.Context, baseURL, eventID, consumer, result, detail string) error {
|
||||
url := fmt.Sprintf("%s/internal/supply-intelligence/gateway/package-changes/%s/ack", baseURL, eventID)
|
||||
payload := map[string]string{
|
||||
"consumer": consumer,
|
||||
"result": result,
|
||||
"detail": detail,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureBridgeTable(db *sql.DB) error {
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS supply_bridge_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
package_id BIGINT,
|
||||
platform TEXT,
|
||||
model TEXT,
|
||||
status TEXT,
|
||||
result TEXT,
|
||||
detail TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func bridgeToSub2API(db *sql.DB, evt PackageChangeEvent) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO supply_bridge_log (event_id, package_id, platform, model, status, result, detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
result = EXCLUDED.result,
|
||||
detail = EXCLUDED.detail,
|
||||
created_at = NOW()`,
|
||||
evt.EventID, evt.PackageID, evt.Platform, evt.Model, evt.GatewaySyncStatus, "applied", "synced to sub2api",
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -4,15 +4,35 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/app"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
func main() {
|
||||
application := app.New()
|
||||
application.Repo.UpsertRoutingState(domain.AccountRoutingState{
|
||||
ctx := context.Background()
|
||||
|
||||
// Use PostgreSQL if DATABASE_URL is set, otherwise in-memory.
|
||||
var application *app.Application
|
||||
if connString := os.Getenv("DATABASE_URL"); connString != "" {
|
||||
var err error
|
||||
application, err = app.NewWithPostgres(ctx, connString)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to postgres: %v", err)
|
||||
}
|
||||
log.Println("supply-intelligence: using PostgreSQL backend")
|
||||
} else {
|
||||
application = app.New()
|
||||
log.Println("supply-intelligence: using in-memory backend (DATABASE_URL not set)")
|
||||
}
|
||||
|
||||
// Seed a sample routing state for account 1 (works with both backends)
|
||||
application.Repo.UpsertRoutingState(ctx, domain.AccountRoutingState{
|
||||
AccountID: 1,
|
||||
Platform: "openai",
|
||||
AccountStatus: domain.AccountStatusActive,
|
||||
@@ -22,10 +42,77 @@ func main() {
|
||||
LastProbeAt: time.Now().UTC(),
|
||||
Version: 1,
|
||||
})
|
||||
|
||||
// Seed a supply account with API key for discovery
|
||||
application.Repo.UpsertSupplyAccount(ctx, domain.SupplyAccount{
|
||||
AccountID: 1,
|
||||
Platform: "openai",
|
||||
APIKey: os.Getenv("OPENAI_API_KEY"),
|
||||
ConsumerTag: "gateway",
|
||||
Status: "active",
|
||||
})
|
||||
|
||||
// Seed local demo data so smoke / inspect / rollback can run without external API keys
|
||||
if os.Getenv("SEED_LOCAL_DEMO") == "1" {
|
||||
seedLocalDemo(application)
|
||||
}
|
||||
|
||||
// Start all background runtimes: gateway consumer poller, discovery, admission
|
||||
application.StartBackground(context.Background())
|
||||
defer application.StopBackground()
|
||||
log.Println("supply-intelligence listening on :8080")
|
||||
if err := http.ListenAndServe(":8080", application.Server.Routes()); err != nil {
|
||||
log.Println("background workers started")
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-quit
|
||||
log.Println("shutting down supply-intelligence...")
|
||||
application.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
log.Printf("supply-intelligence listening on :%s", port)
|
||||
if err := http.ListenAndServe(":"+port, application.Server.Routes()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify at compile time that *MemoryRepository implements repository.Repository
|
||||
var _ repository.Repository = (*repository.MemoryRepository)(nil)
|
||||
|
||||
func seedLocalDemo(application *app.Application) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Seed a test-passed discovery candidate
|
||||
application.Repo.UpsertDiscoveryCandidate(ctx, domain.DiscoveryCandidate{
|
||||
CandidateID: "demo-cand-001",
|
||||
AccountID: 1,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Source: "demo",
|
||||
Status: domain.DiscoveryCandidateStatusTestPassed,
|
||||
DiscoveredAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
})
|
||||
|
||||
// Seed a draft supply package
|
||||
application.Repo.UpsertSupplyPackage(ctx, domain.SupplyPackage{
|
||||
PackageID: 1001,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Status: "draft",
|
||||
Source: "demo",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Version: 1,
|
||||
})
|
||||
|
||||
log.Println("seedLocalDemo: inserted demo candidate and draft package")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user