Changeset 0.29.0 (#195)
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
@@ -34,6 +35,7 @@ type Executor struct {
|
||||
vault *crypto.KeyResolver
|
||||
hub *events.Hub
|
||||
health handlers.HealthRecorder
|
||||
runner *sandbox.Runner // v0.29.0: Starlark task execution
|
||||
}
|
||||
|
||||
// NewExecutor creates a task executor. All fields are optional except stores.
|
||||
@@ -46,6 +48,11 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub
|
||||
}
|
||||
}
|
||||
|
||||
// SetRunner attaches the Starlark sandbox runner for starlark task execution.
|
||||
func (e *Executor) SetRunner(r *sandbox.Runner) {
|
||||
e.runner = r
|
||||
}
|
||||
|
||||
// Execute runs a single task to completion.
|
||||
// Called from the scheduler's execute() goroutine.
|
||||
func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) {
|
||||
@@ -63,12 +70,18 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
||||
return
|
||||
}
|
||||
|
||||
// v0.29.0: Starlark tasks run a sandboxed extension script.
|
||||
if task.TaskType == "starlark" {
|
||||
e.executeStarlark(ctx, task, run, channelID, startTime)
|
||||
return
|
||||
}
|
||||
|
||||
// ── 1. Resolve provider ────────────────────
|
||||
providerConfigID := ""
|
||||
if task.ProviderConfigID != nil {
|
||||
providerConfigID = *task.ProviderConfigID
|
||||
}
|
||||
res, err := handlers.ResolveProviderConfig(e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID)
|
||||
res, err := handlers.ResolveProviderConfig(e.stores, e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID)
|
||||
if err != nil {
|
||||
e.failRun(ctx, task, run, "provider resolution failed: "+err.Error())
|
||||
return
|
||||
@@ -265,6 +278,83 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta
|
||||
}
|
||||
}
|
||||
|
||||
// executeStarlark runs a sandboxed Starlark extension script.
|
||||
// The task's system_function field holds the package ID.
|
||||
// The script's on_run(ctx) entry point is called with task context.
|
||||
func (e *Executor) executeStarlark(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) {
|
||||
if e.runner == nil {
|
||||
e.failRun(ctx, task, run, "starlark runner not configured")
|
||||
return
|
||||
}
|
||||
|
||||
packageID := task.SystemFunction
|
||||
if packageID == "" {
|
||||
e.failRun(ctx, task, run, "starlark task missing package_id (system_function field)")
|
||||
return
|
||||
}
|
||||
|
||||
// Load the package
|
||||
pkg, err := e.stores.Packages.Get(ctx, packageID)
|
||||
if err != nil || pkg == nil {
|
||||
e.failRun(ctx, task, run, "package not found: "+packageID)
|
||||
return
|
||||
}
|
||||
|
||||
// Run the script and call on_run entry point
|
||||
// Build a context dict with task info
|
||||
val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil)
|
||||
|
||||
wallClock := int(time.Since(startTime).Seconds())
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
result := ""
|
||||
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
errMsg = err.Error()
|
||||
} else if val != nil {
|
||||
result = val.String()
|
||||
}
|
||||
|
||||
// Append print output to result
|
||||
if output != "" {
|
||||
if result != "" {
|
||||
result = result + "\n---\n" + output
|
||||
} else {
|
||||
result = output
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to channel if output_mode == "channel"
|
||||
if status == "completed" && task.OutputMode == "channel" && e.stores.Messages != nil && result != "" {
|
||||
_ = e.stores.Messages.Create(ctx, &models.Message{
|
||||
ChannelID: channelID,
|
||||
Role: "system",
|
||||
Content: "Starlark task output:\n```\n" + result + "\n```",
|
||||
})
|
||||
}
|
||||
|
||||
_ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg)
|
||||
_ = e.stores.Tasks.IncrementRunCount(ctx, task.ID)
|
||||
|
||||
log.Printf("[executor] Starlark task %s (%s → %s) → %s (wall=%ds)", task.ID, task.Name, packageID, status, wallClock)
|
||||
|
||||
e.notifyOwner(ctx, task, status, errMsg)
|
||||
|
||||
if task.WebhookURL != "" {
|
||||
go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{
|
||||
TaskID: task.ID,
|
||||
RunID: run.ID,
|
||||
TaskName: task.Name,
|
||||
ChannelID: channelID,
|
||||
Status: status,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
Output: result,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// executeSystem runs a built-in Go function from the system registry.
|
||||
// No LLM, no provider resolution, no channel needed.
|
||||
func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Package scheduler — system_builtins.go
|
||||
//
|
||||
// v0.28.6: Built-in system functions registered at startup.
|
||||
// These mirror the background goroutines in main.go but run as visible,
|
||||
// scheduled, auditable tasks. The goroutines remain as fallback until
|
||||
// system tasks are validated in production.
|
||||
// v0.29.0: Raw SQL replaced with store methods.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
@@ -12,13 +10,11 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
)
|
||||
|
||||
// RegisterBuiltins registers all built-in system functions.
|
||||
// Called once from main.go at startup.
|
||||
func RegisterBuiltins() {
|
||||
taskutil.RegisterSystemFunc("session_cleanup",
|
||||
"Remove expired anonymous visitor sessions",
|
||||
@@ -43,7 +39,6 @@ func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) {
|
||||
if stores.Sessions == nil {
|
||||
return "skipped: session store not available", nil
|
||||
}
|
||||
// Default: expire sessions older than 7 days
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7)
|
||||
n, err := stores.Sessions.DeleteExpired(ctx, cutoff)
|
||||
if err != nil {
|
||||
@@ -59,19 +54,11 @@ func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) {
|
||||
// ── staleness_check ─────────────────────────
|
||||
|
||||
func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) {
|
||||
// Default: 48 hours idle → stale
|
||||
cutoff := time.Now().UTC().Add(-48 * 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 {
|
||||
return "", fmt.Errorf("staleness sweep failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339))
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
@@ -82,28 +69,10 @@ func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) {
|
||||
// ── retention_sweep ─────────────────────────
|
||||
|
||||
func retentionSweep(ctx context.Context, stores store.Stores) (string, error) {
|
||||
if database.IsSQLite() {
|
||||
return "skipped: retention enforcement requires PostgreSQL (JSON operators)", nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-48 * time.Hour)
|
||||
res, 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)
|
||||
n, err := stores.Channels.EnforceWorkflowRetention(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retention enforcement failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n)
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
@@ -114,14 +83,14 @@ func retentionSweep(ctx context.Context, stores store.Stores) (string, error) {
|
||||
// ── health_prune ────────────────────────────
|
||||
|
||||
func healthPrune(ctx context.Context, stores store.Stores) (string, error) {
|
||||
if stores.Health == nil {
|
||||
return "skipped: health store not available", nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM health_windows WHERE window_start < $1
|
||||
`), cutoff)
|
||||
n, err := stores.Health.Prune(ctx, cutoff)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("health prune failed: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339))
|
||||
if n > 0 {
|
||||
log.Printf("[system_task] %s", msg)
|
||||
|
||||
Reference in New Issue
Block a user