Changeset 0.29.0 (#195)
This commit is contained in:
126
server/main.go
126
server/main.go
@@ -20,6 +20,8 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/filters"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
@@ -37,6 +39,7 @@ import (
|
||||
sqliteStore "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/workspace"
|
||||
)
|
||||
|
||||
@@ -103,6 +106,10 @@ func main() {
|
||||
stores = postgres.NewStores(database.DB)
|
||||
}
|
||||
|
||||
// v0.29.0: Wire store layer into treepath for backward compat.
|
||||
// New code should call stores.Messages.* directly.
|
||||
treepath.Stores = &stores
|
||||
|
||||
// Provider health accumulator (v0.22.0)
|
||||
if database.IsSQLite() {
|
||||
healthStore = sqliteStore.NewHealthStore()
|
||||
@@ -171,43 +178,24 @@ func main() {
|
||||
|
||||
// Staleness: mark idle active instances as stale
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_status = 'stale'
|
||||
WHERE type = 'workflow'
|
||||
AND workflow_status = 'active'
|
||||
AND last_activity_at < $1
|
||||
`), cutoff)
|
||||
n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("⚠ workflow staleness sweep failed: %v", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
} else if n > 0 {
|
||||
log.Printf("🧹 workflows: marked %d instances as stale", n)
|
||||
}
|
||||
|
||||
// v0.27.0: Retention enforcement — delete completed workflow channels
|
||||
// where the parent workflow has retention.mode="delete" and
|
||||
// retention.delete_after_days has elapsed since completion.
|
||||
res2, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM channels
|
||||
WHERE type = 'workflow'
|
||||
AND workflow_status IN ('completed', 'archived')
|
||||
AND workflow_id IS NOT NULL
|
||||
AND last_activity_at < $1
|
||||
AND workflow_id IN (
|
||||
SELECT id FROM workflows
|
||||
WHERE retention IS NOT NULL
|
||||
AND retention->>'mode' = 'delete'
|
||||
AND (retention->>'delete_after_days')::int > 0
|
||||
AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval
|
||||
)
|
||||
`), cutoff)
|
||||
n2, err := stores.Channels.EnforceWorkflowRetention(ctx)
|
||||
if err != nil {
|
||||
// SQLite doesn't support JSON operators — skip retention on SQLite
|
||||
if !database.IsSQLite() {
|
||||
log.Printf("⚠ workflow retention enforcement failed: %v", err)
|
||||
}
|
||||
} else if n, _ := res2.RowsAffected(); n > 0 {
|
||||
log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n)
|
||||
} else if n2 > 0 {
|
||||
log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2)
|
||||
}
|
||||
|
||||
cancel()
|
||||
@@ -367,6 +355,26 @@ func main() {
|
||||
memScanner.Start()
|
||||
defer memScanner.Stop()
|
||||
|
||||
// ── Pre-completion filter chain (v0.29.0) ──
|
||||
// Built-in filters register here. Starlark extension filters will
|
||||
// register at package install time (CS3).
|
||||
filterChain := filters.NewChain()
|
||||
filterChain.Register(filters.NewKBInjectFilter(stores))
|
||||
|
||||
// ── Starlark Runner (v0.29.0 CS3) ──
|
||||
// Sandboxed interpreter for extension scripts. Runner assembles
|
||||
// modules based on granted permissions. Notifier attached below
|
||||
// after notification service init.
|
||||
starlarkRunner := sandbox.NewRunner(
|
||||
sandbox.New(sandbox.DefaultConfig()),
|
||||
stores,
|
||||
)
|
||||
|
||||
// Discover and register active Starlark pre-completion filters
|
||||
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
|
||||
|
||||
log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len())
|
||||
|
||||
r := gin.Default()
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
r.Use(middleware.CORS(cfg))
|
||||
@@ -404,6 +412,7 @@ func main() {
|
||||
}
|
||||
notifSvc.StartCleanup()
|
||||
notifications.SetDefault(notifSvc)
|
||||
starlarkRunner.SetNotifier(notifSvc)
|
||||
|
||||
// Subscribe to role.fallback events → generate notifications for admins
|
||||
bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores))
|
||||
@@ -416,6 +425,7 @@ func main() {
|
||||
scheduler.RegisterBuiltins()
|
||||
|
||||
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
|
||||
exec.SetRunner(starlarkRunner)
|
||||
taskSched := scheduler.New(stores, exec)
|
||||
go taskSched.Run()
|
||||
log.Println(" ⏰ Task scheduler started (with executor)")
|
||||
@@ -533,7 +543,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Channels
|
||||
channels := handlers.NewChannelHandler()
|
||||
channels := handlers.NewChannelHandler(stores)
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
@@ -547,19 +557,19 @@ func main() {
|
||||
channelID := c.Param("id")
|
||||
// Resolve display name
|
||||
var displayName string
|
||||
_ = database.DB.QueryRow(database.Q(`
|
||||
SELECT COALESCE(display_name, username) FROM users WHERE id = $1
|
||||
`), userID).Scan(&displayName)
|
||||
user, err := stores.Users.GetByID(c.Request.Context(), userID)
|
||||
if err == nil && user != nil {
|
||||
displayName = user.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = user.Username
|
||||
}
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = userID[:8]
|
||||
}
|
||||
// Broadcast to other user participants
|
||||
pRows, err := database.DB.Query(database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||
`), channelID, userID)
|
||||
pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID)
|
||||
if err == nil {
|
||||
defer pRows.Close()
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": channelID,
|
||||
"user_id": userID,
|
||||
@@ -570,32 +580,30 @@ func main() {
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
for pRows.Next() {
|
||||
var pid string
|
||||
if pRows.Scan(&pid) == nil {
|
||||
hub.SendToUser(pid, evt)
|
||||
}
|
||||
for _, pid := range pids {
|
||||
hub.SendToUser(pid, evt)
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Chat Folders (v0.23.1)
|
||||
folders := handlers.NewFolderHandler()
|
||||
folders := handlers.NewFolderHandler(stores)
|
||||
protected.GET("/folders", folders.List)
|
||||
protected.POST("/folders", folders.Create)
|
||||
protected.PUT("/folders/:id", folders.Update)
|
||||
protected.DELETE("/folders/:id", folders.Delete)
|
||||
|
||||
// Presence (v0.23.1)
|
||||
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
|
||||
protected.GET("/presence", handlers.PresenceQuery)
|
||||
presence := handlers.NewPresenceHandler(stores)
|
||||
protected.POST("/presence/heartbeat", presence.Heartbeat)
|
||||
protected.GET("/presence", presence.Query)
|
||||
|
||||
// User search (v0.23.2 — DM user picker)
|
||||
protected.GET("/users/search", handlers.SearchUsers)
|
||||
protected.GET("/users/search", presence.SearchUsers)
|
||||
|
||||
// Persona groups (v0.23.2 — roster templates for group chats)
|
||||
pgH := handlers.NewPersonaGroupHandler()
|
||||
pgH := handlers.NewPersonaGroupHandler(stores)
|
||||
protected.GET("/persona-groups", pgH.List)
|
||||
protected.POST("/persona-groups", pgH.Create)
|
||||
protected.GET("/persona-groups/:id", pgH.Get)
|
||||
@@ -627,7 +635,7 @@ func main() {
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
@@ -676,6 +684,7 @@ func main() {
|
||||
comp.SetHealthStore(healthStore)
|
||||
}
|
||||
comp.SetRoutingEvaluator(routing.NewEvaluator())
|
||||
comp.SetFilterChain(filterChain)
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
protected.GET("/tools", comp.ListTools)
|
||||
|
||||
@@ -685,7 +694,7 @@ func main() {
|
||||
|
||||
// Summarize & Continue (backed by compaction service)
|
||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||
summarize := handlers.NewSummarizeHandler(compactionSvc)
|
||||
summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
|
||||
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
||||
|
||||
// Auto-title generation (utility role)
|
||||
@@ -716,7 +725,7 @@ func main() {
|
||||
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||
|
||||
// User Settings & Profile
|
||||
settings := handlers.NewSettingsHandler(uekCache)
|
||||
settings := handlers.NewSettingsHandler(stores, uekCache)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
protected.PUT("/profile", settings.UpdateProfile)
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
@@ -892,7 +901,7 @@ func main() {
|
||||
|
||||
// Team admin self-service
|
||||
teamScoped := protected.Group("/teams/:teamId")
|
||||
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||
teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams))
|
||||
{
|
||||
teamScoped.GET("/members", teams.ListMembers)
|
||||
teamScoped.POST("/members", teams.AddMember)
|
||||
@@ -938,7 +947,7 @@ func main() {
|
||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||
|
||||
// Team workflow assignments (v0.26.4)
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler(hub)
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
@@ -952,7 +961,7 @@ func main() {
|
||||
|
||||
// Team task viewing for all members (v0.27.5)
|
||||
teamMemberRoutes := protected.Group("/teams/:teamId")
|
||||
teamMemberRoutes.Use(middleware.RequireTeamMember())
|
||||
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
|
||||
{
|
||||
teamMemberTaskH := handlers.NewTaskHandler(stores)
|
||||
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
|
||||
@@ -1015,8 +1024,8 @@ func main() {
|
||||
admin.POST("/personas", personaAdm.CreateAdminPersona)
|
||||
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
|
||||
admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
|
||||
admin.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
|
||||
admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
admin.POST("/personas/:id/avatar", func(c *gin.Context) { handlers.UploadPersonaAvatar(stores.Personas, c) })
|
||||
admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { handlers.DeletePersonaAvatar(stores.Personas, c) })
|
||||
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
|
||||
@@ -1116,6 +1125,20 @@ func main() {
|
||||
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
|
||||
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
|
||||
|
||||
// Extension permissions (admin — v0.29.0)
|
||||
extPermH := handlers.NewExtPermHandler(stores)
|
||||
admin.GET("/extensions/:id/permissions", extPermH.ListPackagePermissions)
|
||||
admin.GET("/extensions/:id/review", extPermH.ReviewPackage)
|
||||
admin.POST("/extensions/:id/permissions/:perm/grant", extPermH.GrantPermission)
|
||||
admin.POST("/extensions/:id/permissions/:perm/revoke", extPermH.RevokePermission)
|
||||
admin.POST("/extensions/:id/permissions/grant-all", extPermH.GrantAllPermissions)
|
||||
|
||||
// Extension secrets (admin — v0.29.0 CS3)
|
||||
extSecH := handlers.NewExtSecretsHandler(stores)
|
||||
admin.GET("/extensions/:id/secrets", extSecH.GetSecrets)
|
||||
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
|
||||
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
|
||||
|
||||
// Provider Health (admin — v0.22.0)
|
||||
healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
|
||||
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
|
||||
@@ -1222,6 +1245,7 @@ func main() {
|
||||
wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
|
||||
|
||||
wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
|
||||
wfComp.SetFilterChain(filterChain)
|
||||
wfAPI.POST("/:id/completions", wfComp.Complete)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user