mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 10:13:16 +00:00
Cron job execution set the tenant ID on its context but not the tenant slug. Tenant-scoped filesystem paths (skills-store, workspace, media via config.TenantScopedDir) key off the slug and fall back to an id-based path when it is absent — a different directory than where HTTP/WS skill upload materialized the files (which sets the slug). As a result a cron agent turn in a non-master tenant saw NONE of its tenant's managed skills: skill_search returned 0 results and the agent, unable to run the skill, produced an ungrounded answer. Add cronTenantContext() which resolves the tenant slug via TenantStore and sets both WithTenantID and WithTenantSlug. Master tenant and nil-store/lookup-failure paths fall back to id-only (prior behavior). Thread TenantStore into makeCronJobHandler and runCommandCronJob. Tested: added unit tests for cronTenantContext (slug injected for non-master; master skips lookup; nil store and lookup error fall back to id-only). Verified end-to-end on a live tenant: before, a daily-agenda cron guessed an empty day; after, it read the real event from the DB. Note: other background executors that build a context from a tenant ID (e.g. heartbeat) likely share this gap and are worth an audit.
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
//go:build !windows
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func commandCronConfig(enabled bool) *config.Config {
|
|
c := &config.Config{}
|
|
c.Cron.CommandEnabled = enabled
|
|
return c
|
|
}
|
|
|
|
func commandCronJob(spec *store.CronCommandSpec, deliver bool) *store.CronJob {
|
|
job := &store.CronJob{
|
|
ID: uuid.NewString(),
|
|
TenantID: uuid.New(),
|
|
Name: "probe",
|
|
AgentID: "ops",
|
|
UserID: "user-1",
|
|
Payload: store.CronPayload{Kind: store.CronPayloadKindCommand, Command: spec},
|
|
}
|
|
if deliver {
|
|
job.Deliver = true
|
|
job.DeliverChannel = "telegram"
|
|
job.DeliverTo = "chat-1"
|
|
}
|
|
return job
|
|
}
|
|
|
|
// A command payload must be refused unless cron.command_enabled is set.
|
|
func TestCronJobHandler_CommandDisabled(t *testing.T) {
|
|
handler := makeCronJobHandler(nil, nil, commandCronConfig(false), nil, nil, nil, nil, nil, nil)
|
|
if _, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo hi"}}, false)); err == nil {
|
|
t.Fatal("expected error when cron.command_enabled is false")
|
|
}
|
|
}
|
|
|
|
// A successful command runs with zero model tokens and its stdout is delivered.
|
|
func TestCronJobHandler_CommandSuccessDelivers(t *testing.T) {
|
|
mb := bus.New()
|
|
defer mb.Close()
|
|
|
|
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
|
|
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "printf hello"}}, true))
|
|
if err != nil {
|
|
t.Fatalf("command cron returned error: %v", err)
|
|
}
|
|
if result == nil || result.Content != "hello" {
|
|
t.Fatalf("result = %#v, want content hello", result)
|
|
}
|
|
if result.InputTokens != 0 || result.OutputTokens != 0 {
|
|
t.Errorf("command cron must report zero tokens, got in=%d out=%d", result.InputTokens, result.OutputTokens)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
got, ok := mb.SubscribeOutbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected outbound delivery of command output")
|
|
}
|
|
if got.Content != "hello" || got.Channel != "telegram" || got.ChatID != "chat-1" {
|
|
t.Fatalf("outbound = %#v, want telegram/chat-1/hello", got)
|
|
}
|
|
}
|
|
|
|
// A non-zero exit returns an error (recorded as a failed run) and is NOT
|
|
// delivered — only successful output is announced.
|
|
func TestCronJobHandler_CommandFailureNotDelivered(t *testing.T) {
|
|
mb := bus.New()
|
|
defer mb.Close()
|
|
|
|
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
|
|
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo boom 1>&2; exit 3"}}, true))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero command exit")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("failed command should return nil result, got %#v", result)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
if got, ok := mb.SubscribeOutbound(ctx); ok {
|
|
t.Fatalf("failed command must not deliver, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// An empty argv is rejected before execution.
|
|
func TestCronJobHandler_CommandInvalidSpec(t *testing.T) {
|
|
handler := makeCronJobHandler(nil, nil, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
|
|
if _, err := handler(commandCronJob(&store.CronCommandSpec{}, false)); err == nil {
|
|
t.Fatal("expected error for empty argv")
|
|
}
|
|
}
|