diff --git a/internal/config/config.go b/internal/config/config.go index d4ea7c5..d4e6750 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,24 +66,14 @@ type Config struct { OIDCIssuer string OIDCClientID string - // Fabric announce coupling. - // - // As of Phase 3.5: only the system api key stays in env. Guild base - // URL + announce channel ID are PER-TOPIC, supplied by the proposing - // agent at create time and stored on the topic row. Different topics - // can broadcast to different guilds/channels from the same backend. - // - // FabricSystemAPIKey: header value for x-fabric-system-key when - // POSTing to announce channels. Must match Fabric.Backend.Guild's - // FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty → announcer becomes - // a no-op (logs intent, skips post — useful for dev / opted-out - // environments). - FabricSystemAPIKey string - - // (removed Phase 3.5: FabricGuildBaseURL, FabricAnnounceChannelID, - // FabricBotBearerToken — guild/channel moved to per-topic config; - // bot bearer obsolete since Guild's ApiKeyGuard now accepts system - // key alone for announce posts.) + // (Removed Aug 2026: all Fabric coupling — FabricSystemAPIKey, + // FabricGuildBaseURL, FabricAnnounceChannelID, FabricBotBearerToken. + // Backend no longer broadcasts lifecycle events to Fabric. The + // proposing agent posts a single recruitment fabric-send-message + // after creating a topic; downstream agents book HF on_call slots + // covering the debate window via `hf calendar schedule` and HF + // wakes them naturally. The backend stays a pure data + state- + // machine service and doesn't know about Fabric.) // Orchestrator tick interval. 0 / unset → default 15s. OrchestratorTickInterval time.Duration @@ -105,7 +95,6 @@ func LoadFromEnv() (*Config, error) { DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), - FabricSystemAPIKey: os.Getenv("DIALECTIC_FABRIC_SYSTEM_API_KEY"), } if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { diff --git a/internal/db/migrations/003_drop_topic_announce_target.sql b/internal/db/migrations/003_drop_topic_announce_target.sql new file mode 100644 index 0000000..2911654 --- /dev/null +++ b/internal/db/migrations/003_drop_topic_announce_target.sql @@ -0,0 +1,10 @@ +-- Drop per-topic announce target columns. The backend no longer +-- broadcasts lifecycle events to Fabric; the proposing agent posts a +-- single recruitment fabric-send-message after topic creation, and +-- downstream agents book HF on_call slots covering the debate window +-- via `hf calendar schedule` so HF wakes them naturally. +-- +-- Counterpart of 002_topic_announce_target.sql (now obsolete). +ALTER TABLE topics + DROP COLUMN announce_guild_base_url, + DROP COLUMN announce_channel_id; diff --git a/internal/fabric/announce.go b/internal/fabric/announce.go deleted file mode 100644 index f7f6a28..0000000 --- a/internal/fabric/announce.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package fabric provides an HTTP client for posting system messages -// to a Fabric Guild's announce-type channel. -// -// As of Phase 3.5 the target (guildBaseUrl + channelID) is per-call -// rather than backend-env: each topic stores its own announce target, -// chosen by the proposing agent at create time. Only the system api -// key stays in backend env (DIALECTIC_FABRIC_SYSTEM_API_KEY) since it -// authorizes ANY announce POST regardless of channel. -// -// FABRIC_BOT_BEARER_TOKEN is gone — the upgraded Fabric.Backend.Guild -// ApiKeyGuard accepts x-fabric-system-key alone (no per-user Bearer -// required) for announce-channel posts. -package fabric - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" -) - -type Announcer struct { - systemAPIKey string - requestTimeout time.Duration - client *http.Client -} - -func NewAnnouncer(systemAPIKey string) *Announcer { - timeout := 5 * time.Second - return &Announcer{ - systemAPIKey: systemAPIKey, - requestTimeout: timeout, - client: &http.Client{Timeout: timeout}, - } -} - -// Enabled returns true iff the system api key is configured (without -// it, no announce POST can authenticate). -func (a *Announcer) Enabled() bool { return a.systemAPIKey != "" } - -// Target identifies one announce post destination. Both fields must -// be non-empty; either empty -> Post* returns a "skipped" no-op. -type Target struct { - GuildBaseURL string - ChannelID string -} - -// PostTopicAnnouncement: first signup-open broadcast. -// PostLifecycleEvent: subsequent state transitions. -// -// Both best-effort: log + nil on transport / target-misconfigured -// errors so the orchestrator transition isn't blocked by a Fabric -// outage or an opted-out topic. - -func (a *Announcer) PostTopicAnnouncement(ctx context.Context, t Target, - topicID, title, summary string, - signupOpen, signupClose, debateStart, debateEnd time.Time, - verdictSchemaID string) error { - - body := formatAnnouncement(topicID, title, summary, - signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID) - return a.post(ctx, t, topicID, "signup_open", body) -} - -func (a *Announcer) PostLifecycleEvent(ctx context.Context, t Target, - topicID, title, kind, summary string) error { - body := fmt.Sprintf("📣 **[%s]** %s [%s]\n%s", kind, title, topicID, summary) - return a.post(ctx, t, topicID, kind, body) -} - -func (a *Announcer) post(ctx context.Context, t Target, topicID, kind, content string) error { - if !a.Enabled() { - log.Printf("announce: skipped topic=%s kind=%s — DIALECTIC_FABRIC_SYSTEM_API_KEY unset", topicID, kind) - return nil - } - if t.GuildBaseURL == "" || t.ChannelID == "" { - log.Printf("announce: skipped topic=%s kind=%s — no announce target on topic (creator opted out)", topicID, kind) - return nil - } - - body, _ := json.Marshal(map[string]any{"content": content}) - url := fmt.Sprintf("%s/api/channels/%s/messages", - trimRightSlash(t.GuildBaseURL), t.ChannelID) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("content-type", "application/json") - req.Header.Set("x-fabric-system-key", a.systemAPIKey) - - resp, err := a.client.Do(req) - if err != nil { - log.Printf("announce: POST %s failed: %v", url, err) - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - log.Printf("announce: POST %s kind=%s -> %d body=%s", url, kind, resp.StatusCode, string(b)) - return fmt.Errorf("announce post: status %d", resp.StatusCode) - } - return nil -} - -func formatAnnouncement(id, title, summary string, - signupOpen, signupClose, debateStart, debateEnd time.Time, schema string) string { - const layout = "2006-01-02 15:04 UTC" - return fmt.Sprintf( - "🆕 **Debate signup open** [%s]\n\n**%s**\n%s\n\n"+ - "• Signup: %s → %s\n"+ - "• Debate: %s → %s\n"+ - "• Verdict schema: `%s`\n\n"+ - "To volunteer, use the `dialectic_signup` tool with this topic_id and your willing camp(s) — pro / con / judge. "+ - "You must have an `on_call` slot covering the debate window.", - id, title, summary, - signupOpen.UTC().Format(layout), signupClose.UTC().Format(layout), - debateStart.UTC().Format(layout), debateEnd.UTC().Format(layout), - schema, - ) -} - -func trimRightSlash(s string) string { - for len(s) > 0 && s[len(s)-1] == '/' { - s = s[:len(s)-1] - } - return s -} diff --git a/internal/httpapi/handlers/topics.go b/internal/httpapi/handlers/topics.go index fda0edb..187de07 100644 --- a/internal/httpapi/handlers/topics.go +++ b/internal/httpapi/handlers/topics.go @@ -109,12 +109,6 @@ type createTopicBody struct { SignupCloseAt string `json:"signup_close_at"` DebateStartAt string `json:"debate_start_at"` DebateEndAt string `json:"debate_end_at"` - // Optional: per-topic announce-channel target. Both must be set - // (or both omitted = no broadcasts). Creator picks based on the - // debate's intended audience (different guilds may host different - // communities, different channels may serve different categories). - AnnounceGuildBaseURL string `json:"announce_guild_base_url,omitempty"` - AnnounceChannelID string `json:"announce_channel_id,omitempty"` } // POST /api/topics @@ -147,31 +141,16 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - // Announce target: both fields required or both empty; one-of-two - // is a config error caught here rather than silently treated as - // "no broadcast". - var aGuild, aChannel *string - if body.AnnounceGuildBaseURL != "" || body.AnnounceChannelID != "" { - if body.AnnounceGuildBaseURL == "" || body.AnnounceChannelID == "" { - http.Error(w, "announce_guild_base_url and announce_channel_id must both be set (or both empty for no broadcasts)", http.StatusBadRequest) - return - } - g, c := body.AnnounceGuildBaseURL, body.AnnounceChannelID - aGuild, aChannel = &g, &c - } - created, err := h.store.Create(r.Context(), store.CreateTopicInput{ - Title: body.Title, - Summary: body.Summary, - Visibility: models.Visibility(body.Visibility), - VerdictSchemaID: body.VerdictSchemaID, - SignupOpenAt: parsed[0], - SignupCloseAt: parsed[1], - DebateStartAt: parsed[2], - DebateEndAt: parsed[3], - CreatorUserID: caller.ID, - AnnounceGuildBaseURL: aGuild, - AnnounceChannelID: aChannel, + Title: body.Title, + Summary: body.Summary, + Visibility: models.Visibility(body.Visibility), + VerdictSchemaID: body.VerdictSchemaID, + SignupOpenAt: parsed[0], + SignupCloseAt: parsed[1], + DebateStartAt: parsed[2], + DebateEndAt: parsed[3], + CreatorUserID: caller.ID, }) if err != nil { http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError) diff --git a/internal/httpapi/handlers/verdict.go b/internal/httpapi/handlers/verdict.go index 9465a19..4417c85 100644 --- a/internal/httpapi/handlers/verdict.go +++ b/internal/httpapi/handlers/verdict.go @@ -1,30 +1,26 @@ package handlers import ( - "context" "encoding/json" "errors" - "fmt" "net/http" "time" "github.com/go-chi/chi/v5" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" - "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/fabric" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) type VerdictHandler struct { - topics *store.TopicStore - camps *store.CampStore - verdicts *store.VerdictStore - announcer *fabric.Announcer // optional; nil-safe via Enabled() check + topics *store.TopicStore + camps *store.CampStore + verdicts *store.VerdictStore } -func NewVerdictHandler(t *store.TopicStore, c *store.CampStore, v *store.VerdictStore, ann *fabric.Announcer) *VerdictHandler { - return &VerdictHandler{topics: t, camps: c, verdicts: v, announcer: ann} +func NewVerdictHandler(t *store.TopicStore, c *store.CampStore, v *store.VerdictStore) *VerdictHandler { + return &VerdictHandler{topics: t, camps: c, verdicts: v} } type submitVerdictBody struct { @@ -117,24 +113,6 @@ func (h *VerdictHandler) Submit(w http.ResponseWriter, r *http.Request) { w.Header().Set("x-warn", "verdict saved but status update failed: "+err.Error()) } - // Lifecycle broadcast — completed event. Best-effort; runs async - // outside the request context so a slow Fabric doesn't slow the - // judge's response. Target is per-topic (nil announcer or nil - // target on topic → silently skipped by announcer). - if h.announcer != nil { - var tgt fabric.Target - if topic.AnnounceGuildBaseURL != nil && topic.AnnounceChannelID != nil { - tgt = fabric.Target{ - GuildBaseURL: *topic.AnnounceGuildBaseURL, - ChannelID: *topic.AnnounceChannelID, - } - } - go func(t *fabric.Announcer, tID, title, judge string, target fabric.Target) { - summary := fmt.Sprintf("verdict published by judge=%s. Use dialectic_view_verdict to see the structured result.", judge) - _ = t.PostLifecycleEvent(context.Background(), target, tID, title, "completed", summary) - }(h.announcer, verdict.TopicID, topic.Title, caller.ID, tgt) - } - writeJSON(w, http.StatusCreated, map[string]any{ "id": verdict.ID, "topic_id": verdict.TopicID, diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index ce19ca0..d45b0b6 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -11,7 +11,6 @@ import ( "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config" - "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/fabric" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/httpapi/handlers" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) @@ -63,8 +62,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler { topicsH := handlers.NewTopicsHandler(topicStore, campStore) signupsH := handlers.NewSignupsHandler(topicStore, signupStore) argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore) - announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey) - verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore, announcer) + verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore) adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey) // Routes. diff --git a/internal/models/topic.go b/internal/models/topic.go index c625415..c99e827 100644 --- a/internal/models/topic.go +++ b/internal/models/topic.go @@ -49,15 +49,8 @@ type Topic struct { VisibilityChangedBy *string `db:"visibility_changed_by" json:"visibility_changed_by,omitempty"` VisibilityChangedAt *time.Time `db:"visibility_changed_at" json:"visibility_changed_at,omitempty"` CancelledReason *string `db:"cancelled_reason" json:"cancelled_reason,omitempty"` - // AnnounceGuildBaseURL + AnnounceChannelID: per-topic broadcast - // target. Set by the proposing agent at create time (they pick which - // Fabric guild + announce channel reflects this topic's audience). - // Both nullable; null on either side disables lifecycle broadcasts - // for this topic (creator opted out). - AnnounceGuildBaseURL *string `db:"announce_guild_base_url" json:"announce_guild_base_url,omitempty"` - AnnounceChannelID *string `db:"announce_channel_id" json:"announce_channel_id,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // IsCampValid returns true iff c is one of pro|con|judge. diff --git a/internal/orchestrator/ticker.go b/internal/orchestrator/ticker.go index 4601fcf..d2ee351 100644 --- a/internal/orchestrator/ticker.go +++ b/internal/orchestrator/ticker.go @@ -2,14 +2,12 @@ package orchestrator import ( "context" - "fmt" "log" "math/rand" "time" "github.com/jmoiron/sqlx" - "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/fabric" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) @@ -21,7 +19,6 @@ import ( // State transitions handled by the ticker: // // created → signup_open (when now >= signup_open_at) -// + post Fabric announcement // signup_open → signup_closed (when now >= signup_close_at, allocator succeeded) // → cancelled (allocator returned CancelReason) // signup_closed → debating (when now >= debate_start_at; opens round 0) @@ -35,15 +32,21 @@ import ( // // Per-topic transitions use SELECT FOR UPDATE so concurrent ticker // instances (or future replicas) don't double-fire. +// +// Lifecycle broadcasting moved out-of-backend (Aug 2026): the proposing +// agent posts a single recruitment fabric-send-message after creating a +// topic; downstream agents book HF on_call slots covering the debate +// window via `hf calendar schedule`, and HF wakes them naturally. The +// backend stays a pure data + state-machine service and doesn't know +// about Fabric. type Ticker struct { - db *sqlx.DB - topics *store.TopicStore - signups *store.SignupStore - camps *store.CampStore - rounds *store.RoundStore - announcer *fabric.Announcer - interval time.Duration - rng *rand.Rand + db *sqlx.DB + topics *store.TopicStore + signups *store.SignupStore + camps *store.CampStore + rounds *store.RoundStore + interval time.Duration + rng *rand.Rand } func NewTicker( @@ -52,27 +55,25 @@ func NewTicker( signups *store.SignupStore, camps *store.CampStore, rounds *store.RoundStore, - announcer *fabric.Announcer, interval time.Duration, ) *Ticker { if interval <= 0 { interval = 15 * time.Second } return &Ticker{ - db: db, - topics: topics, - signups: signups, - camps: camps, - rounds: rounds, - announcer: announcer, - interval: interval, - rng: rand.New(rand.NewSource(time.Now().UnixNano())), + db: db, + topics: topics, + signups: signups, + camps: camps, + rounds: rounds, + interval: interval, + rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } // Run blocks until ctx is cancelled. Caller goroutines it. func (t *Ticker) Run(ctx context.Context) { - log.Printf("orchestrator: ticker started (interval=%s, announce=%v)", t.interval, t.announcer.Enabled()) + log.Printf("orchestrator: ticker started (interval=%s)", t.interval) tk := time.NewTicker(t.interval) defer tk.Stop() // First tick immediately so startup is responsive — don't wait @@ -81,7 +82,7 @@ func (t *Ticker) Run(ctx context.Context) { for { select { case <-ctx.Done(): - log.Printf("orchestrator: ticker stopped") + log.Printf("orchestrator: ticker stopping") return case <-tk.C: t.tickOnce(ctx) @@ -89,8 +90,6 @@ func (t *Ticker) Run(ctx context.Context) { } } -// tickOnce scans + applies. Errors are logged per topic; one topic -// failing doesn't stall the others. func (t *Ticker) tickOnce(ctx context.Context) { now := time.Now() @@ -98,17 +97,11 @@ func (t *Ticker) tickOnce(ctx context.Context) { if err := t.transitionByStatus(ctx, now, models.TopicStatusCreated, "signup_open_at", func(ctx context.Context, tx *sqlx.Tx, topicID string) error { - topic, err := t.topics.GetByID(ctx, topicID) - if err != nil { - return err - } if _, err := tx.ExecContext(ctx, `UPDATE topics SET status = ? WHERE id = ?`, models.TopicStatusSignupOpen, topicID); err != nil { return err } - // Announcement is best-effort, outside the tx (network call). - go t.broadcastAnnouncement(topic) return nil }); err != nil { log.Printf("orchestrator: created→signup_open scan: %v", err) @@ -118,10 +111,6 @@ func (t *Ticker) tickOnce(ctx context.Context) { if err := t.transitionByStatus(ctx, now, models.TopicStatusSignupOpen, "signup_close_at", func(ctx context.Context, tx *sqlx.Tx, topicID string) error { - topic, err := t.topics.GetByID(ctx, topicID) - if err != nil { - return err - } signups, err := t.signups.ListByTopic(ctx, topicID) if err != nil { return err @@ -133,10 +122,6 @@ func (t *Ticker) tickOnce(ctx context.Context) { models.TopicStatusCancelled, res.CancelReason, topicID) log.Printf("orchestrator: topic %s cancelled at signup_close: %s", topicID, res.CancelReason) - if err == nil { - go t.broadcastLifecycle(topic, "cancelled", - fmt.Sprintf("debate cancelled at signup close - %s", res.CancelReason)) - } return err } if err := t.camps.WriteAllocation(ctx, tx, topicID, res.Allocation); err != nil { @@ -148,14 +133,6 @@ func (t *Ticker) tickOnce(ctx context.Context) { log.Printf("orchestrator: topic %s allocated pro=%s con=%s judge=%s", topicID, res.Allocation[models.CampPro], res.Allocation[models.CampCon], res.Allocation[models.CampJudge]) - if err == nil { - go t.broadcastLifecycle(topic, "signup_closed", - fmt.Sprintf("camps allocated — pro=%s con=%s judge=%s. Debate starts at %s", - res.Allocation[models.CampPro], - res.Allocation[models.CampCon], - res.Allocation[models.CampJudge], - topic.DebateStartAt.UTC().Format("2006-01-02 15:04 UTC"))) - } return err }); err != nil { log.Printf("orchestrator: signup_open→signup_closed scan: %v", err) @@ -165,10 +142,6 @@ func (t *Ticker) tickOnce(ctx context.Context) { if err := t.transitionByStatus(ctx, now, models.TopicStatusSignupClosed, "debate_start_at", func(ctx context.Context, tx *sqlx.Tx, topicID string) error { - topic, err := t.topics.GetByID(ctx, topicID) - if err != nil { - return err - } if _, err := tx.ExecContext(ctx, `UPDATE topics SET status = ? WHERE id = ?`, models.TopicStatusDebating, topicID); err != nil { @@ -176,15 +149,10 @@ func (t *Ticker) tickOnce(ctx context.Context) { } // Round 0 inserted within the tx — if commit fails we don't // leak a half-state. - _, err = tx.ExecContext(ctx, + _, err := tx.ExecContext(ctx, `INSERT INTO rounds (id, topic_id, round_no) VALUES (UUID(), ?, 0)`, topicID) log.Printf("orchestrator: topic %s entered debating; round 0 opened", topicID) - if err == nil { - go t.broadcastLifecycle(topic, "debating", - fmt.Sprintf("debate is live — pro/con post arguments; judge stays mostly silent until debate_end_at (%s). Use participate-debate workflow.", - topic.DebateEndAt.UTC().Format("2006-01-02 15:04 UTC"))) - } return err }); err != nil { log.Printf("orchestrator: signup_closed→debating scan: %v", err) @@ -193,10 +161,7 @@ func (t *Ticker) tickOnce(ctx context.Context) { // Note: there's no explicit `debating → judging` transition in v1. // The verdict handler enforces "status==debating AND now>=debate_end_at" // as its preconditions; that's equivalent to a "judging" gate without - // adding a new enum value. Migration 002 will introduce the explicit - // 'judging' state when we want richer UI (e.g. "Awaiting verdict" - // distinct from "In debate"); until then this comment serves as the - // state-machine documentation for future maintainers. + // adding a new enum value. } // transitionByStatus is the shared "scan + per-row tx + apply" pattern. @@ -245,48 +210,3 @@ func (t *Ticker) applyOne(ctx context.Context, topicID string, } return tx.Commit() } - -// broadcastLifecycle wraps the announcer's lifecycle-event post with -// the standard signup_closed / cancelled / debating / completed -// formats. Best-effort; runs in its own goroutine outside any tx. -// Target is resolved from the topic's per-topic announce columns; -// null on either column → announcer skips with a log (creator opted -// out of broadcasts). -func (t *Ticker) broadcastLifecycle(topic *models.Topic, kind, summary string) { - if topic == nil { - return - } - if err := t.announcer.PostLifecycleEvent( - context.Background(), topicTarget(topic), topic.ID, topic.Title, kind, summary, - ); err != nil { - log.Printf("orchestrator: lifecycle broadcast topic=%s kind=%s failed: %v", topic.ID, kind, err) - } -} - -func (t *Ticker) broadcastAnnouncement(topic *models.Topic) { - if topic == nil { - return - } - if err := t.announcer.PostTopicAnnouncement( - context.Background(), topicTarget(topic), - topic.ID, topic.Title, topic.Summary, - topic.SignupOpenAt, topic.SignupCloseAt, - topic.DebateStartAt, topic.DebateEndAt, - topic.VerdictSchemaID, - ); err != nil { - log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err) - } -} - -// topicTarget extracts the per-topic announce target from the topic -// row; returns zero-value Target if either column is null (which the -// announcer treats as "skip"). -func topicTarget(topic *models.Topic) fabric.Target { - if topic.AnnounceGuildBaseURL == nil || topic.AnnounceChannelID == nil { - return fabric.Target{} - } - return fabric.Target{ - GuildBaseURL: *topic.AnnounceGuildBaseURL, - ChannelID: *topic.AnnounceChannelID, - } -} diff --git a/internal/store/topic_store.go b/internal/store/topic_store.go index 102f31a..1cef38d 100644 --- a/internal/store/topic_store.go +++ b/internal/store/topic_store.go @@ -23,17 +23,15 @@ type TopicStore struct { func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} } type CreateTopicInput struct { - Title string - Summary string - Visibility models.Visibility - VerdictSchemaID string - SignupOpenAt time.Time - SignupCloseAt time.Time - DebateStartAt time.Time - DebateEndAt time.Time - CreatorUserID string - AnnounceGuildBaseURL *string // optional; null = no broadcasts for this topic - AnnounceChannelID *string + Title string + Summary string + Visibility models.Visibility + VerdictSchemaID string + SignupOpenAt time.Time + SignupCloseAt time.Time + DebateStartAt time.Time + DebateEndAt time.Time + CreatorUserID string } func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.Topic, error) { @@ -41,11 +39,11 @@ func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.T _, err := s.db.ExecContext(ctx, ` INSERT INTO topics (id, title, summary, visibility, verdict_schema_id, signup_open_at, signup_close_at, debate_start_at, debate_end_at, - creator_user_id, announce_guild_base_url, announce_channel_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + creator_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, in.Title, in.Summary, in.Visibility, in.VerdictSchemaID, in.SignupOpenAt, in.SignupCloseAt, in.DebateStartAt, in.DebateEndAt, - in.CreatorUserID, in.AnnounceGuildBaseURL, in.AnnounceChannelID) + in.CreatorUserID) if err != nil { return nil, fmt.Errorf("insert topic: %w", err) } diff --git a/main.go b/main.go index aa751dd..f1158cd 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,6 @@ import ( "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/db" - "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/fabric" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/httpapi" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/orchestrator" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" @@ -51,15 +50,15 @@ func main() { } log.Printf("migrations: ok") - // Wire orchestrator + Fabric announcer + start the ticker. - // Per Phase 3.5: target is per-topic, only the system key stays in env. - announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey) + // Wire orchestrator + start the ticker. Backend no longer broadcasts + // to Fabric — proposers post a single recruitment fabric-send-message, + // downstream agents book HF on_call slots to be woken at debate time. topicStore := store.NewTopicStore(conn) signupStore := store.NewSignupStore(conn) campStore := store.NewCampStore(conn) roundStore := store.NewRoundStore(conn) ticker := orchestrator.NewTicker(conn, topicStore, signupStore, campStore, roundStore, - announcer, cfg.OrchestratorTickInterval) + cfg.OrchestratorTickInterval) go ticker.Run(ctx) srv := &http.Server{