Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -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)
}