Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion internal/driver/openclaw/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"

"github.com/mostlydev/clawdapus/internal/driver"
)

const defaultInvocationTimeoutSeconds = 300

type job struct {
ID string `json:"id"`
AgentID string `json:"agentId"`
Expand Down Expand Up @@ -61,6 +64,10 @@ type jobState struct {
// so re-running claw up is idempotent.
func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) {
now := time.Now().UnixMilli()
timeoutSeconds, err := resolvedInvocationTimeoutSeconds(rc)
if err != nil {
return nil, err
}
jobs := make([]job, 0, len(rc.Invocations))
timezone := strings.TrimSpace(rc.Timezone)
if timezone == "" {
Expand All @@ -85,7 +92,7 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) {
Schedule: jobSchedule{Expr: inv.Schedule, TZ: timezone, Kind: "cron"},
SessionTarget: "isolated",
WakeMode: "now",
Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: 300},
Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: timeoutSeconds},
Delivery: jobDelivery{Mode: "announce", BestEffort: true, To: inv.To},
State: jobState{},
}
Expand All @@ -97,6 +104,46 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) {
}, "", " ")
}

func resolvedInvocationTimeoutSeconds(rc *driver.ResolvedClaw) (int, error) {
timeoutSeconds := defaultInvocationTimeoutSeconds
if rc == nil {
return timeoutSeconds, nil
}
for _, cmd := range rc.Configures {
path, value, err := parseConfigSetCommand(cmd)
if err != nil {
return 0, fmt.Errorf("jobs generation: %w", err)
}
if path != "agents.defaults.timeoutSeconds" {
continue
}
timeoutSeconds, err = positiveIntConfigValue(path, value)
if err != nil {
return 0, fmt.Errorf("jobs generation: %w", err)
}
}
return timeoutSeconds, nil
}

func positiveIntConfigValue(path string, value any) (int, error) {
switch v := value.(type) {
case float64:
parsed := int(v)
if float64(parsed) != v || parsed <= 0 {
return 0, fmt.Errorf("%s must be a positive integer, got %v", path, value)
}
return parsed, nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s must be a positive integer, got %q", path, v)
}
return parsed, nil
default:
return 0, fmt.Errorf("%s must be a positive integer, got %T", path, value)
}
}

func deterministicJobID(parts ...string) string {
h := sha256.Sum256([]byte(strings.Join(parts, "|")))
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
Expand Down
46 changes: 46 additions & 0 deletions internal/driver/openclaw/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func TestGenerateJobsJSONSingleInvocation(t *testing.T) {
if payload["message"] != "Pre-market synthesis" {
t.Errorf("expected payload.message=%q, got %v", "Pre-market synthesis", payload["message"])
}
if payload["timeoutSeconds"] != float64(300) {
t.Errorf("expected payload.timeoutSeconds=300, got %v", payload["timeoutSeconds"])
}

delivery := j["delivery"].(map[string]interface{})
if delivery["to"] != "111222333444" {
Expand Down Expand Up @@ -187,6 +190,49 @@ func TestGenerateJobsJSONDisablesPodOriginJobs(t *testing.T) {
}
}

func TestGenerateJobsJSONUsesConfiguredAgentTimeout(t *testing.T) {
rc := &driver.ResolvedClaw{
ServiceName: "allen",
Configures: []string{
"openclaw config set agents.defaults.timeoutSeconds 900",
},
Invocations: []driver.Invocation{
{
Schedule: "0 7 * * 1-5",
Message: "Morning research scan",
},
},
}
data, err := GenerateJobsJSON(rc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

jobs := parseJobStore(t, data)
payload := jobs[0]["payload"].(map[string]interface{})
if payload["timeoutSeconds"] != float64(900) {
t.Fatalf("expected configured payload timeoutSeconds=900, got %v", payload["timeoutSeconds"])
}
}

func TestGenerateJobsJSONRejectsInvalidConfiguredAgentTimeout(t *testing.T) {
rc := &driver.ResolvedClaw{
ServiceName: "allen",
Configures: []string{
`openclaw config set agents.defaults.timeoutSeconds "slow"`,
},
Invocations: []driver.Invocation{
{
Schedule: "0 7 * * 1-5",
Message: "Morning research scan",
},
},
}
if _, err := GenerateJobsJSON(rc); err == nil {
t.Fatal("expected invalid timeoutSeconds configure to fail")
}
}

func TestGenerateJobsJSONUsesResolvedTimezone(t *testing.T) {
rc := &driver.ResolvedClaw{
ServiceName: "tiverton",
Expand Down
29 changes: 28 additions & 1 deletion internal/driver/shared/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,41 @@ func PreparePortableMemory(stateDir string, extraImportRoots ...string) (string,
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("stat portable memory file %q: %w", path, err)
}
if err := os.WriteFile(path, nil, 0o644); err != nil {
if err := os.WriteFile(path, nil, 0o666); err != nil {
return "", fmt.Errorf("seed portable memory file %q: %w", path, err)
}
}
if err := normalizePortableMemoryPermissions(memoryDir); err != nil {
return "", err
}

return memoryDir, nil
}

func normalizePortableMemoryPermissions(memoryDir string) error {
return filepath.Walk(memoryDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("walk portable memory %q: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
if info.IsDir() {
if err := os.Chmod(path, 0o777); err != nil {
return fmt.Errorf("chmod portable memory dir %q: %w", path, err)
}
return nil
}
if !info.Mode().IsRegular() {
return nil
}
if err := os.Chmod(path, 0o666); err != nil {
return fmt.Errorf("chmod portable memory file %q: %w", path, err)
}
return nil
})
}

func legacyPortableMemoryDirs(runtimeDir string) []string {
return []string{
filepath.Join(runtimeDir, "hermes-home", "memories"),
Expand Down
41 changes: 41 additions & 0 deletions internal/driver/shared/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,44 @@ func TestPreparePortableMemoryImportsFromExtraRuntimeRoot(t *testing.T) {
t.Fatalf("unexpected migrated memory: %q", string(data))
}
}

func TestPreparePortableMemoryNormalizesExistingPermissions(t *testing.T) {
stateDir := t.TempDir()
memoryDir := filepath.Join(stateDir, "memory")
nestedDir := filepath.Join(memoryDir, "notes")

if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(memoryDir, "MEMORY.md"), []byte("canonical"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nestedDir, "2026-04-10.md"), []byte("note"), 0o640); err != nil {
t.Fatal(err)
}

gotDir, err := PreparePortableMemory(stateDir)
if err != nil {
t.Fatalf("PreparePortableMemory returned error: %v", err)
}
if gotDir != memoryDir {
t.Fatalf("unexpected memory dir: %q", gotDir)
}

checks := map[string]os.FileMode{
memoryDir: 0o777,
filepath.Join(memoryDir, "MEMORY.md"): 0o666,
filepath.Join(memoryDir, "USER.md"): 0o666,
nestedDir: 0o777,
filepath.Join(nestedDir, "2026-04-10.md"): 0o666,
}
for path, want := range checks {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode=%o want %o", path, got, want)
}
}
}