diff --git a/AGENTS.md b/AGENTS.md index 807902a..3fd3005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ Current top-level commands are: - `claw inspect` - `claw doctor` - `claw init` +- `claw api` (`schedule` subcommands) - `claw agent add` - `claw compose` (use this liberally instead of invoking docker directly) diff --git a/cmd/claw-api/client.go b/cmd/claw-api/client.go new file mode 100644 index 0000000..8f3e456 --- /dev/null +++ b/cmd/claw-api/client.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/mostlydev/clawdapus/internal/clawapi" +) + +func runLocalRequest(cfg config, stdout io.Writer, method, requestPath, requestBody, principalName string, timeout time.Duration) error { + store, err := clawapi.LoadStore(cfg.PrincipalsPath) + if err != nil { + return err + } + token, err := principalTokenByName(store, principalName) + if err != nil { + return err + } + if timeout <= 0 { + timeout = 10 * time.Second + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + bodyReader := io.Reader(nil) + trimmedBody := strings.TrimSpace(requestBody) + if trimmedBody != "" { + bodyReader = strings.NewReader(trimmedBody) + } + req, err := http.NewRequestWithContext(ctx, strings.ToUpper(strings.TrimSpace(method)), localAPIURL(cfg.Addr, requestPath), bodyReader) + if err != nil { + return fmt.Errorf("build local request: %w", err) + } + if trimmedBody != "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return fmt.Errorf("execute local request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read local response: %w", err) + } + if err := writeFormattedResponse(stdout, body); err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return quietExitError{} + } + return nil +} + +func principalTokenByName(store *clawapi.Store, principalName string) (string, error) { + if store == nil { + return "", fmt.Errorf("principal store not configured") + } + principalName = strings.TrimSpace(principalName) + if principalName == "" { + return "", fmt.Errorf("principal name must not be empty") + } + for _, principal := range store.Principals { + if strings.TrimSpace(principal.Name) == principalName { + return principal.Token, nil + } + } + return "", fmt.Errorf("principal %q not found", principalName) +} + +func localAPIURL(addr, requestPath string) string { + base := strings.TrimSuffix(healthcheckURL(addr), "/health") + requestPath = strings.TrimSpace(requestPath) + if requestPath == "" { + requestPath = "/" + } else if !strings.HasPrefix(requestPath, "/") { + requestPath = "/" + requestPath + } + return base + requestPath +} + +func writeFormattedResponse(stdout io.Writer, body []byte) error { + if stdout == nil || len(body) == 0 { + return nil + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return nil + } + var pretty bytes.Buffer + if json.Indent(&pretty, trimmed, "", " ") == nil { + pretty.WriteByte('\n') + _, err := stdout.Write(pretty.Bytes()) + return err + } + if !bytes.HasSuffix(body, []byte{'\n'}) { + body = append(body, '\n') + } + _, err := stdout.Write(body) + return err +} diff --git a/cmd/claw-api/client_test.go b/cmd/claw-api/client_test.go new file mode 100644 index 0000000..9f599ac --- /dev/null +++ b/cmd/claw-api/client_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunLocalRequestUsesNamedPrincipal(t *testing.T) { + var gotAuth string + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + gotBody = string(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`) + cfg := config{ + Addr: strings.TrimPrefix(srv.URL, "http://"), + PrincipalsPath: principalsPath, + } + + var stdout bytes.Buffer + err := runLocalRequest(cfg, &stdout, http.MethodPost, "/schedule/test/fire", `{"bypass_when":true}`, "claw-scheduler", time.Second) + if err != nil { + t.Fatalf("runLocalRequest: %v", err) + } + if gotAuth != "Bearer capi_sched" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } + if gotBody != `{"bypass_when":true}` { + t.Fatalf("unexpected request body: %q", gotBody) + } + if !strings.Contains(stdout.String(), "\n \"ok\": true\n") { + t.Fatalf("expected pretty JSON output, got %q", stdout.String()) + } +} + +func TestRunLocalRequestRejectsUnknownPrincipal(t *testing.T) { + principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`) + cfg := config{Addr: "127.0.0.1:8080", PrincipalsPath: principalsPath} + err := runLocalRequest(cfg, io.Discard, http.MethodGet, "/schedule", "", "missing", time.Second) + if err == nil || !strings.Contains(err.Error(), `principal "missing" not found`) { + t.Fatalf("expected missing principal error, got %v", err) + } +} + +func TestRunRequestModeSkipsManifestLoad(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`) + t.Setenv("CLAW_API_ADDR", strings.TrimPrefix(srv.URL, "http://")) + t.Setenv("CLAW_API_PRINCIPALS", principalsPath) + t.Setenv("CLAW_API_MANIFEST", filepath.Join(t.TempDir(), "missing-manifest.json")) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run([]string{"-request-method", "GET", "-request-path", "/schedule", "-request-principal", "claw-scheduler"}, &stdout, &stderr) + if err != nil { + t.Fatalf("run request mode: %v stderr=%s", err, stderr.String()) + } + if gotAuth != "Bearer capi_sched" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } +} + +func writePrincipalsFixture(t *testing.T, raw string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "principals.json") + if err := os.WriteFile(path, []byte(raw), 0o644); err != nil { + t.Fatalf("write principals fixture: %v", err) + } + return path +} diff --git a/cmd/claw-api/handler.go b/cmd/claw-api/handler.go index 8de4af4..3275fa1 100644 --- a/cmd/claw-api/handler.go +++ b/cmd/claw-api/handler.go @@ -267,6 +267,8 @@ func (h *apiHandler) handleScheduleControl(w http.ResponseWriter, r *http.Reques h.handleScheduleResume(w, r, view) case "skip-next": h.handleScheduleSkipNext(w, r, view) + case "clear-skip-next": + h.handleScheduleClearSkipNext(w, r, view) case "fire": h.handleScheduleFire(w, r, view) default: @@ -398,6 +400,25 @@ func (h *apiHandler) handleScheduleSkipNext(w http.ResponseWriter, r *http.Reque h.writeScheduleInvocation(w, view.ID) } +func (h *apiHandler) handleScheduleClearSkipNext(w http.ResponseWriter, r *http.Request, view scheduleInvocationView) { + if h.scheduleState == nil { + writeJSONError(w, http.StatusServiceUnavailable, "schedule state unavailable") + return + } + if err := requireEmptyOrJSONBody(r); err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := h.scheduleState.UpdateInvocation(view.ID, func(state *schedulepkg.InvocationState) error { + state.SkipNext = false + return nil + }); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + h.writeScheduleInvocation(w, view.ID) +} + func (h *apiHandler) handleScheduleFire(w http.ResponseWriter, r *http.Request, view scheduleInvocationView) { if h.scheduler == nil { writeJSONError(w, http.StatusServiceUnavailable, "scheduler unavailable") @@ -441,6 +462,8 @@ func decodeOptionalJSONBody(r *http.Request, dst any) error { return fmt.Errorf("invalid request body") } var extra any + // A second successful decode means the stream contained multiple JSON + // documents (for example "{}{}"), which we reject as an invalid body. if err := dec.Decode(&extra); err != nil { if errors.Is(err, io.EOF) { return nil diff --git a/cmd/claw-api/handler_test.go b/cmd/claw-api/handler_test.go index 1c5f213..dfc62a9 100644 --- a/cmd/claw-api/handler_test.go +++ b/cmd/claw-api/handler_test.go @@ -497,6 +497,55 @@ func TestHandlerScheduleSkipNextSetsFlag(t *testing.T) { } } +func TestHandlerScheduleClearSkipNextClearsFlagIdempotently(t *testing.T) { + manifest := sampleScheduleManifest() + state := newTestScheduleStateStore(t, manifest) + if _, err := state.UpdateInvocation("westin-open", func(state *schedulepkg.InvocationState) error { + state.SkipNext = true + return nil + }); err != nil { + t.Fatalf("seed state: %v", err) + } + h := newScheduleTestHandler(t, manifest, state, nil, clawapi.Principal{ + Name: "westin-ops", + Token: "capi_westin_ops", + Verbs: []string{clawapi.VerbScheduleControl}, + Services: []string{"westin"}, + }) + + first := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_westin_ops") + if first.Code != http.StatusOK { + t.Fatalf("expected 200 on first clear, got %d body=%s", first.Code, first.Body.String()) + } + if inv := decodeScheduleInvocationResponse(t, first); inv.State.SkipNext { + t.Fatalf("expected skip_next cleared on first call, got %+v", inv.State) + } + + second := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_westin_ops") + if second.Code != http.StatusOK { + t.Fatalf("expected 200 on second clear, got %d body=%s", second.Code, second.Body.String()) + } + if inv := decodeScheduleInvocationResponse(t, second); inv.State.SkipNext { + t.Fatalf("expected skip_next to remain cleared, got %+v", inv.State) + } +} + +func TestHandlerScheduleClearSkipNextHonorsScope(t *testing.T) { + manifest := sampleScheduleManifest() + state := newTestScheduleStateStore(t, manifest) + h := newScheduleTestHandler(t, manifest, state, nil, clawapi.Principal{ + Name: "analyst-ops", + Token: "capi_analyst_ops", + Verbs: []string{clawapi.VerbScheduleControl}, + Services: []string{"analyst"}, + }) + + w := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_analyst_ops") + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for out-of-scope clear, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestHandlerScheduleFireRespectsPauseWithoutBypass(t *testing.T) { manifest := sampleScheduleManifest() state := newTestScheduleStateStore(t, manifest) diff --git a/cmd/claw-api/main.go b/cmd/claw-api/main.go index 2566f54..e77846d 100644 --- a/cmd/claw-api/main.go +++ b/cmd/claw-api/main.go @@ -15,6 +15,7 @@ import ( "strings" "syscall" "time" + _ "time/tzdata" "github.com/docker/docker/client" @@ -31,8 +32,15 @@ type config struct { GovernanceDir string } +type quietExitError struct{} + +func (quietExitError) Error() string { return "" } + func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + if _, ok := err.(quietExitError); ok { + os.Exit(1) + } log.Fatalf("claw-api: %v", err) } } @@ -41,6 +49,11 @@ func run(args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("claw-api", flag.ContinueOnError) fs.SetOutput(stderr) healthcheck := fs.Bool("healthcheck", false, "check HTTP server health and exit") + requestMethod := fs.String("request-method", "", "issue a local authenticated request instead of serving HTTP") + requestPath := fs.String("request-path", "", "path to request in local client mode") + requestBody := fs.String("request-body", "", "raw JSON request body for local client mode") + requestPrincipal := fs.String("request-principal", "claw-scheduler", "principal name to use for local client mode") + requestTimeout := fs.Duration("request-timeout", 10*time.Second, "timeout for local client mode requests") if err := fs.Parse(args); err != nil { return err } @@ -49,6 +62,12 @@ func run(args []string, stdout, stderr io.Writer) error { if *healthcheck { return runHealthcheck(cfg.Addr) } + if strings.TrimSpace(*requestMethod) != "" || strings.TrimSpace(*requestPath) != "" || strings.TrimSpace(*requestBody) != "" { + if strings.TrimSpace(*requestMethod) == "" || strings.TrimSpace(*requestPath) == "" { + return fmt.Errorf("request-method and request-path are both required for local client mode") + } + return runLocalRequest(cfg, stdout, *requestMethod, *requestPath, *requestBody, *requestPrincipal, *requestTimeout) + } manifest, err := loadManifest(cfg.ManifestPath) if err != nil { diff --git a/cmd/claw/api.go b/cmd/claw/api.go new file mode 100644 index 0000000..8d283be --- /dev/null +++ b/cmd/claw/api.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + apiPrincipalName string + apiExecTimeout time.Duration + + schedulePauseUntil string + schedulePauseReason string + scheduleFireBypassWhen bool + scheduleFireBypassPause bool + + runClawAPIComposeCommand = runClawAPIComposeCommandDefault +) + +type composeServiceIndex struct { + Services map[string]map[string]any `yaml:"services"` +} + +const ( + httpMethodGet = "GET" + httpMethodPost = "POST" + clawAPIServiceName = "claw-api" +) + +var apiCmd = &cobra.Command{ + Use: "api", + Short: "Call the in-pod governance API through docker compose exec", + Long: "Calls claw-api from the host by tunneling through `docker compose exec` into the in-pod claw-api container.\n\n" + + "Security model: Docker access is pod-admin access. The `--principal` flag selects which in-container principal to use from claw-api's principals.json; it is not a host-side access boundary.", +} + +var apiScheduleCmd = &cobra.Command{ + Use: "schedule", + Short: "Inspect and control scheduled invocations through claw-api", +} + +var apiScheduleListCmd = &cobra.Command{ + Use: "list", + Short: "List scheduled invocations and current state", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runScheduleRequest(cmd.OutOrStdout(), httpMethodGet, "/schedule", nil) + }, +} + +var apiScheduleGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show one scheduled invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runScheduleRequest(cmd.OutOrStdout(), httpMethodGet, "/schedule/"+strings.TrimSpace(args[0]), nil) + }, +} + +var apiSchedulePauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause one scheduled invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body := map[string]any{} + if until := strings.TrimSpace(schedulePauseUntil); until != "" { + body["until"] = until + } + if reason := strings.TrimSpace(schedulePauseReason); reason != "" { + body["reason"] = reason + } + return runScheduleRequest(cmd.OutOrStdout(), httpMethodPost, "/schedule/"+strings.TrimSpace(args[0])+"/pause", body) + }, +} + +var apiScheduleResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume one scheduled invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runScheduleRequest(cmd.OutOrStdout(), httpMethodPost, "/schedule/"+strings.TrimSpace(args[0])+"/resume", nil) + }, +} + +var apiScheduleSkipNextCmd = &cobra.Command{ + Use: "skip-next ", + Short: "Skip the next scheduled fire for one invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runScheduleRequest(cmd.OutOrStdout(), httpMethodPost, "/schedule/"+strings.TrimSpace(args[0])+"/skip-next", nil) + }, +} + +var apiScheduleClearSkipNextCmd = &cobra.Command{ + Use: "clear-skip-next ", + Short: "Clear a pending skip-next flag for one invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runScheduleRequest(cmd.OutOrStdout(), httpMethodPost, "/schedule/"+strings.TrimSpace(args[0])+"/clear-skip-next", nil) + }, +} + +var apiScheduleFireCmd = &cobra.Command{ + Use: "fire ", + Short: "Trigger an immediate fire for one invocation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body := map[string]any{} + if scheduleFireBypassWhen { + body["bypass_when"] = true + } + if scheduleFireBypassPause { + body["bypass_pause"] = true + } + return runScheduleRequest(cmd.OutOrStdout(), httpMethodPost, "/schedule/"+strings.TrimSpace(args[0])+"/fire", body) + }, +} + +func runScheduleRequest(w io.Writer, method, requestPath string, body any) error { + generatedPath, err := resolveComposeGeneratedPath() + if err != nil { + return err + } + out, err := callClawAPICompose(generatedPath, apiPrincipalName, method, requestPath, body) + if err != nil { + return err + } + if w == nil { + w = os.Stdout + } + _, err = w.Write(out) + return err +} + +func callClawAPICompose(composePath, principalName, method, requestPath string, body any) ([]byte, error) { + if err := ensureComposeService(composePath, clawAPIServiceName); err != nil { + return nil, err + } + args := []string{ + "compose", "-f", composePath, + "exec", "-T", clawAPIServiceName, + "/claw-api", + "-request-method", strings.ToUpper(strings.TrimSpace(method)), + "-request-path", strings.TrimSpace(requestPath), + "-request-principal", defaultAPIPrincipal(principalName), + } + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + if string(raw) != "null" && string(raw) != "{}" { + args = append(args, "-request-body", string(raw)) + } + } + out, err := runClawAPIComposeCommand(args...) + if err != nil { + return nil, formatComposeOutputError("docker compose exec "+clawAPIServiceName, err, out) + } + return out, nil +} + +func defaultAPIPrincipal(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "claw-scheduler" + } + return name +} + +func ensureComposeService(composePath, service string) error { + raw, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("read compose file: %w", err) + } + var parsed composeServiceIndex + if err := yaml.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("parse compose file: %w", err) + } + if _, ok := parsed.Services[service]; ok { + return nil + } + if service == clawAPIServiceName { + return fmt.Errorf("pod does not include %s (run 'claw up' with x-claw.master or pod-level x-claw.invoke)", clawAPIServiceName) + } + return fmt.Errorf("service %q not found in compose.generated.yml", service) +} + +func runClawAPIComposeCommandDefault(args ...string) ([]byte, error) { + timeout := apiExecTimeout + if timeout <= 0 { + timeout = 15 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", args...) + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return out, fmt.Errorf("timed out after %s", timeout) + } + return out, err +} + +func init() { + apiCmd.PersistentFlags().StringVar(&apiPrincipalName, "principal", "claw-scheduler", "Principal name inside claw-api principals.json to use for the request; not a host-side access boundary") + apiCmd.PersistentFlags().DurationVar(&apiExecTimeout, "exec-timeout", 15*time.Second, "Maximum time to wait for the docker compose exec transport") + + apiSchedulePauseCmd.Flags().StringVar(&schedulePauseUntil, "until", "", "Pause until this RFC3339 timestamp instead of indefinitely") + apiSchedulePauseCmd.Flags().StringVar(&schedulePauseReason, "reason", "", "Optional operator reason recorded with the pause") + apiScheduleFireCmd.Flags().BoolVar(&scheduleFireBypassWhen, "bypass-when", false, "Fire even if the calendar gate is closed") + apiScheduleFireCmd.Flags().BoolVar(&scheduleFireBypassPause, "bypass-pause", false, "Fire even if the invocation is paused") + + apiScheduleCmd.AddCommand( + apiScheduleListCmd, + apiScheduleGetCmd, + apiSchedulePauseCmd, + apiScheduleResumeCmd, + apiScheduleSkipNextCmd, + apiScheduleClearSkipNextCmd, + apiScheduleFireCmd, + ) + apiCmd.AddCommand(apiScheduleCmd) + rootCmd.AddCommand(apiCmd) +} diff --git a/cmd/claw/api_test.go b/cmd/claw/api_test.go new file mode 100644 index 0000000..ed1b2b4 --- /dev/null +++ b/cmd/claw/api_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestCallClawAPIComposeBuildsExecCommand(t *testing.T) { + composePath := writeComposeFixture(t, ` +services: + claw-api: + image: ghcr.io/mostlydev/claw-api:latest +`) + + prev := runClawAPIComposeCommand + var gotArgs []string + runClawAPIComposeCommand = func(args ...string) ([]byte, error) { + gotArgs = append([]string(nil), args...) + return []byte("{\"ok\":true}\n"), nil + } + defer func() { runClawAPIComposeCommand = prev }() + + out, err := callClawAPICompose(composePath, "", "get", "/schedule", nil) + if err != nil { + t.Fatalf("callClawAPICompose: %v", err) + } + if string(out) != "{\"ok\":true}\n" { + t.Fatalf("unexpected output: %q", string(out)) + } + want := []string{ + "compose", "-f", composePath, + "exec", "-T", "claw-api", + "/claw-api", + "-request-method", "GET", + "-request-path", "/schedule", + "-request-principal", "claw-scheduler", + } + if !reflect.DeepEqual(gotArgs, want) { + t.Fatalf("unexpected args:\n got: %#v\nwant: %#v", gotArgs, want) + } +} + +func TestCallClawAPIComposeMarshalsBody(t *testing.T) { + composePath := writeComposeFixture(t, ` +services: + claw-api: + image: ghcr.io/mostlydev/claw-api:latest +`) + + prev := runClawAPIComposeCommand + var gotArgs []string + runClawAPIComposeCommand = func(args ...string) ([]byte, error) { + gotArgs = append([]string(nil), args...) + return []byte("{}\n"), nil + } + defer func() { runClawAPIComposeCommand = prev }() + + _, err := callClawAPICompose(composePath, "ops-admin", "POST", "/schedule/westin/fire", map[string]any{"bypass_when": true}) + if err != nil { + t.Fatalf("callClawAPICompose: %v", err) + } + joined := strings.Join(gotArgs, " ") + if !strings.Contains(joined, `-request-principal ops-admin`) { + t.Fatalf("expected principal in args, got %#v", gotArgs) + } + if !strings.Contains(joined, `-request-body {"bypass_when":true}`) { + t.Fatalf("expected request body in args, got %#v", gotArgs) + } +} + +func TestCallClawAPIComposeRejectsMissingClawAPIService(t *testing.T) { + composePath := writeComposeFixture(t, ` +services: + analyst: + image: ghcr.io/mostlydev/openclaw:latest +`) + + _, err := callClawAPICompose(composePath, "", "GET", "/schedule", nil) + if err == nil || !strings.Contains(err.Error(), "pod does not include claw-api") { + t.Fatalf("expected missing claw-api error, got %v", err) + } +} + +func TestAPICmdRegistered(t *testing.T) { + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "api" { + return + } + } + t.Fatal("expected 'api' command to be registered on rootCmd") +} + +func TestAPIScheduleClearSkipNextCmdRegistered(t *testing.T) { + for _, cmd := range apiScheduleCmd.Commands() { + if cmd.Name() == "clear-skip-next" { + return + } + } + t.Fatal("expected 'clear-skip-next' command under 'claw api schedule'") +} + +func writeComposeFixture(t *testing.T, raw string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "compose.generated.yml") + if err := os.WriteFile(path, []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil { + t.Fatalf("write compose fixture: %v", err) + } + return path +} diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index 54d839a..8033704 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -657,6 +657,16 @@ func runComposeUp(podFile string) error { CllamaCostsURL: firstIf(cllamaEnabled, fmt.Sprintf("http://localhost:%s", cllamaDashboardPort)), PodName: p.Name, } + if p.ClawAPI != nil && hasPodInvokeEntries(p) { + schedulerToken, err := lookupClawAPIPrincipalToken(p.ClawAPI.PrincipalsHostPath, "claw-scheduler") + if err != nil { + return err + } + p.Clawdash.Environment = map[string]string{ + "CLAW_API_URL": fmt.Sprintf("http://claw-api:%s", clawAPIInternalPort(p.ClawAPI.Addr)), + "CLAW_API_TOKEN": schedulerToken, + } + } // Pass 2: materialize after cllama tokens/context are resolved. for _, name := range sortedResolvedClawNames(resolvedClaws) { @@ -1626,7 +1636,9 @@ func prepareClawAPIRuntime(runtimeDir string, p *pod.Pod, resolvedClaws map[stri return nil, err } auto = append(auto, masterPrincipal) - } else if hasPodInvokeEntries(p) { + } + + if hasPodInvokeEntries(p) { schedulerPrincipal, err := clawapi.BuildSchedulerPrincipal(p.Name) if err != nil { return nil, err @@ -1765,6 +1777,22 @@ func writeClawAPIPrincipalStore(runtimeDir, hostPath string, store clawapi.Store return nil } +func lookupClawAPIPrincipalToken(storePath, principalName string) (string, error) { + store, err := clawapi.LoadStore(storePath) + if err != nil { + return "", err + } + for _, principal := range store.Principals { + if strings.TrimSpace(principal.Name) == strings.TrimSpace(principalName) { + if strings.TrimSpace(principal.Token) == "" { + return "", fmt.Errorf("principal %q has empty token", principalName) + } + return principal.Token, nil + } + } + return "", fmt.Errorf("principal %q not found in %s", principalName, storePath) +} + func prepareHistoryReplayRuntime(p *pod.Pod, resolvedClaws map[string]*driver.ResolvedClaw, resolvedMemory map[string]*resolvedMemorySubscription) (map[string]cllama.ServiceAuthEntry, error) { if p == nil || len(resolvedMemory) == 0 { return nil, nil @@ -3187,15 +3215,6 @@ func inspectServiceMetadata(podDir string, p *pod.Pod, serviceName string, svc * } } - // Build-only services without an explicit image tag: fall back to the - // default image name that docker compose would assign, so we can still - // inspect the locally built image for `claw.describe` metadata. - if imageRef == "" && svc.Compose["build"] != nil { - if derived := defaultComposeImageName(podDir, serviceName); derived != "" && imageExistsLocally(derived) { - imageRef = derived - } - } - var info *inspect.ClawInfo if imageRef != "" && imageExistsLocally(imageRef) { var err error @@ -3216,33 +3235,6 @@ func inspectServiceMetadata(podDir string, p *pod.Pod, serviceName string, svc * return imageRef, info, nil } -// defaultComposeImageName mirrors docker compose's default image naming for -// build-only services: `-`, where `` is the -// normalized basename of the pod directory. Returns an empty string if the -// project name normalizes to nothing. -func defaultComposeImageName(podDir, serviceName string) string { - project := normalizeComposeProjectName(filepath.Base(podDir)) - if project == "" { - return "" - } - return project + "-" + strings.ToLower(serviceName) -} - -// normalizeComposeProjectName replicates docker compose's project-name -// normalization: lowercase, strip any characters outside [a-z0-9_-], and trim -// leading non-alphanumeric characters. -func normalizeComposeProjectName(name string) string { - lower := strings.ToLower(name) - var b strings.Builder - b.Grow(len(lower)) - for _, r := range lower { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - b.WriteRune(r) - } - } - return strings.TrimLeft(b.String(), "_-") -} - func inspectBuildMetadata(podDir string, buildRaw interface{}) (*inspect.ClawInfo, error) { if buildRaw == nil { return nil, nil diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index 933547e..ec82a12 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -763,6 +763,48 @@ func TestPrepareClawAPIRuntimeWithoutMasterWritesSchedulerPrincipal(t *testing.T } } +func TestPrepareClawAPIRuntimeWithMasterAndInvokeAlsoWritesSchedulerPrincipal(t *testing.T) { + runtimeDir := t.TempDir() + p := &pod.Pod{ + Name: "ops", + Master: "octopus", + Services: map[string]*pod.Service{ + "octopus": { + Environment: map[string]string{}, + Claw: &pod.ClawBlock{}, + }, + "westin": { + Claw: &pod.ClawBlock{ + Invoke: []pod.InvokeEntry{{ + Schedule: "0 9 * * 1-5", + Message: "Open the market.", + }}, + }, + }, + }, + ClawAPI: &pod.ClawAPIConfig{ + Addr: ":8080", + PrincipalsHostPath: filepath.Join(runtimeDir, "claw-api", "principals.json"), + }, + } + + _, err := prepareClawAPIRuntime(runtimeDir, p, map[string]*driver.ResolvedClaw{ + "octopus": {Count: 1}, + "westin": {Count: 1}, + }) + if err != nil { + t.Fatalf("prepareClawAPIRuntime: %v", err) + } + + raw, err := os.ReadFile(p.ClawAPI.PrincipalsHostPath) + if err != nil { + t.Fatalf("read principals: %v", err) + } + if !strings.Contains(string(raw), "claw-scheduler") { + t.Fatalf("expected scheduler principal in principals.json, got %s", string(raw)) + } +} + func TestPrepareClawAPIRuntimeRejectsInjectIntoReservedMasterService(t *testing.T) { runtimeDir := t.TempDir() p := &pod.Pod{ @@ -1762,94 +1804,6 @@ func TestResolveServiceMetadataIgnoresMissingImplicitImageDescriptor(t *testing. } } -func TestResolveServiceMetadataFallsBackToDefaultComposeImageForBuildOnlyService(t *testing.T) { - prevExists := imageExistsLocally - prevInspect := inspectClawImage - prevLoadDescriptor := loadDescriptorFromImage - prevLoadBuildCtx := loadDescriptorFromBuildCtx - defer func() { - imageExistsLocally = prevExists - inspectClawImage = prevInspect - loadDescriptorFromImage = prevLoadDescriptor - loadDescriptorFromBuildCtx = prevLoadBuildCtx - }() - - podDir := filepath.Join(t.TempDir(), "tiverton-house") - if err := os.MkdirAll(podDir, 0o755); err != nil { - t.Fatal(err) - } - - const expectedImage = "tiverton-house-trading-api" - imageExistsLocally = func(ref string) bool { return ref == expectedImage } - inspectClawImage = func(ref string) (*inspect.ClawInfo, error) { - if ref != expectedImage { - t.Fatalf("unexpected image ref for inspect: %q", ref) - } - return &inspect.ClawInfo{DescribePath: "/rails/.claw-describe.json"}, nil - } - loadDescriptorFromImage = func(ref, descriptorPath string) (*describe.ServiceDescriptor, error) { - if ref != expectedImage { - t.Fatalf("unexpected image ref for descriptor load: %q", ref) - } - if descriptorPath != "/rails/.claw-describe.json" { - t.Fatalf("expected descriptor path from image label, got %q", descriptorPath) - } - return &describe.ServiceDescriptor{ - Version: 2, - Feeds: []describe.FeedDescriptor{ - {Name: "market-context", Path: "/feeds/market-context", TTL: 30}, - }, - }, nil - } - loadDescriptorFromBuildCtx = func(string, interface{}, string) (*describe.ServiceDescriptor, string, error) { - t.Fatal("should not fall through to build-context descriptor loader when local image carries the descriptor") - return nil, "", nil - } - - p := &pod.Pod{ - Services: map[string]*pod.Service{ - "trading-api": { - Compose: map[string]interface{}{ - "build": map[string]interface{}{ - "context": "./services/trading-api", - "dockerfile": "Dockerfile", - }, - }, - }, - }, - } - - imageRef, _, descriptor, err := resolveServiceMetadata(podDir, p, "trading-api", p.Services["trading-api"], map[string]string{}, map[string]*inspect.ClawInfo{}, map[string]*describe.ServiceDescriptor{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if imageRef != expectedImage { - t.Fatalf("expected derived image ref %q, got %q", expectedImage, imageRef) - } - if descriptor == nil || len(descriptor.Feeds) == 0 || descriptor.Feeds[0].Name != "market-context" { - t.Fatalf("expected descriptor with market-context feed, got %+v", descriptor) - } -} - -func TestNormalizeComposeProjectName(t *testing.T) { - cases := []struct { - in string - want string - }{ - {"rollcall", "rollcall"}, - {"tiverton-house", "tiverton-house"}, - {"Tiverton House", "tivertonhouse"}, - {"My.Project!", "myproject"}, - {"__leading", "leading"}, - {"---", ""}, - } - for _, tc := range cases { - if got := normalizeComposeProjectName(tc.in); got != tc.want { - t.Errorf("normalizeComposeProjectName(%q) = %q, want %q", tc.in, got, tc.want) - } - } -} - func testInvokeHandles() map[string]*driver.HandleInfo { return map[string]*driver.HandleInfo{ "discord": { diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 1093f76..795f480 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -37,6 +37,9 @@ claw audit [--since ] [--claw ] [--type ] [--json] # summarize cllama telemetry from container logs # types: request, response, error, intervention, # feed_fetch, provider_pool, tool_call +claw api schedule # inspect/control scheduled invocations via claw-api + # list | get | pause | resume | skip-next | + # clear-skip-next | fire # Session history & memory claw history export # export session history as NDJSON @@ -54,6 +57,14 @@ Lifecycle commands block if `claw-pod.yml` is newer than `compose.generated.yml` `-f` locates `compose.generated.yml` next to the pod file. Without `-f`, `claw up` uses `./claw-pod.yml`; other lifecycle commands look for `compose.generated.yml` in the current directory. +`claw api schedule ...` does not require a host-published claw-api port. It +tunnels through `docker compose exec -T claw-api /claw-api -request-*`, so the +pod must already be up and include an injected `claw-api` service. + +Trust boundary: if you can run `docker compose exec` against the pod, you can +select any principal present in claw-api's `principals.json`. The `--principal` +flag is a selector, not a security boundary. + ## Clawfile Reference A Clawfile is an extended Dockerfile. Every valid Dockerfile is a valid Clawfile. diff --git a/cmd/clawdash/handler.go b/cmd/clawdash/handler.go index c5683a3..816d8fd 100644 --- a/cmd/clawdash/handler.go +++ b/cmd/clawdash/handler.go @@ -38,14 +38,16 @@ type statusSource interface { type handler struct { manifest *manifestpkg.PodManifest statusSource statusSource + scheduleSource scheduleControlSource cllamaCostsURL string costLogFallback bool httpClient *http.Client + now func() time.Time tpl *template.Template static http.Handler } -func newHandler(manifest *manifestpkg.PodManifest, source statusSource, cllamaCostsURL string, costLogFallback bool) http.Handler { +func newHandler(manifest *manifestpkg.PodManifest, source statusSource, scheduleSource scheduleControlSource, cllamaCostsURL string, costLogFallback bool) http.Handler { funcs := template.FuncMap{ "statusClass": statusClass, "pathEscape": url.PathEscape, @@ -63,11 +65,13 @@ func newHandler(manifest *manifestpkg.PodManifest, source statusSource, cllamaCo return &handler{ manifest: manifest, statusSource: source, + scheduleSource: scheduleSource, cllamaCostsURL: strings.TrimSpace(cllamaCostsURL), costLogFallback: costLogFallback, httpClient: &http.Client{ Timeout: 2 * time.Second, }, + now: time.Now, tpl: tpl, static: http.StripPrefix("/static/", http.FileServerFS(staticFS)), } @@ -84,6 +88,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.Method == http.MethodGet && r.URL.Path == "/topology": h.renderTopology(w, r) return + case r.Method == http.MethodGet && r.URL.Path == "/schedule": + h.renderSchedule(w, r) + return + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/schedule/"): + h.handleScheduleAction(w, r) + return case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/detail/"): h.renderDetail(w, r) return @@ -103,6 +113,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type fleetPageData struct { PodName string ActiveTab string + HasSchedule bool Summary []dashStat Attention []dashAlert HasAttention bool @@ -248,6 +259,7 @@ func (h *handler) buildFleetPageData(ctx context.Context, statuses map[string]se return fleetPageData{ PodName: h.manifest.PodName, ActiveTab: "fleet", + HasSchedule: h.hasSchedule(), Summary: summary, Attention: attention, HasAttention: len(attention) > 0, @@ -268,6 +280,7 @@ func (h *handler) buildFleetPageData(ctx context.Context, statuses map[string]se type detailPageData struct { PodName string ActiveTab string + HasSchedule bool ServiceName string RoleBadge string RoleClass string @@ -415,6 +428,7 @@ func (h *handler) buildDetailPageData(name string, statuses map[string]serviceSt return detailPageData{ PodName: h.manifest.PodName, ActiveTab: "detail", + HasSchedule: h.hasSchedule(), ServiceName: name, RoleBadge: roleBadge, RoleClass: roleClass, @@ -442,10 +456,15 @@ func (h *handler) buildDetailPageData(name string, statuses map[string]serviceSt func (h *handler) renderTopology(w http.ResponseWriter, r *http.Request) { statuses, statusErr := h.snapshot(r.Context()) data := buildTopologyPageData(h.manifest, statuses, statusErr) + data.HasSchedule = h.hasSchedule() w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = h.tpl.ExecuteTemplate(w, "topology.html", data) } +func (h *handler) hasSchedule() bool { + return h != nil && h.scheduleSource != nil +} + type apiStatusResponse struct { GeneratedAt string `json:"generatedAt"` Services map[string]serviceStatus `json:"services"` diff --git a/cmd/clawdash/handler_test.go b/cmd/clawdash/handler_test.go index dcae406..0b476e4 100644 --- a/cmd/clawdash/handler_test.go +++ b/cmd/clawdash/handler_test.go @@ -13,6 +13,7 @@ import ( manifestpkg "github.com/mostlydev/clawdapus/internal/clawdash" "github.com/mostlydev/clawdapus/internal/driver" + schedulepkg "github.com/mostlydev/clawdapus/internal/schedule" ) type fakeStatusSource struct { @@ -27,6 +28,56 @@ func (f fakeStatusSource) Snapshot(_ context.Context, _ []string) (map[string]se return f.statuses, nil } +type fakeScheduleSource struct { + invocations []scheduleInvocationView + err error + actions []string +} + +func (f *fakeScheduleSource) List(_ context.Context) ([]scheduleInvocationView, error) { + if f.err != nil { + return nil, f.err + } + return f.invocations, nil +} + +func (f *fakeScheduleSource) Get(_ context.Context, id string) (scheduleInvocationView, error) { + if f.err != nil { + return scheduleInvocationView{}, f.err + } + for _, inv := range f.invocations { + if inv.ID == id { + return inv, nil + } + } + return scheduleInvocationView{}, fmt.Errorf("schedule %q not found", id) +} + +func (f *fakeScheduleSource) Pause(_ context.Context, id, until, reason string) error { + f.actions = append(f.actions, fmt.Sprintf("pause:%s:%s:%s", id, until, reason)) + return f.err +} + +func (f *fakeScheduleSource) Resume(_ context.Context, id string) error { + f.actions = append(f.actions, "resume:"+id) + return f.err +} + +func (f *fakeScheduleSource) SkipNext(_ context.Context, id string) error { + f.actions = append(f.actions, "skip-next:"+id) + return f.err +} + +func (f *fakeScheduleSource) ClearSkipNext(_ context.Context, id string) error { + f.actions = append(f.actions, "clear-skip-next:"+id) + return f.err +} + +func (f *fakeScheduleSource) Fire(_ context.Context, id string, bypassWhen, bypassPause bool) error { + f.actions = append(f.actions, fmt.Sprintf("fire:%s:%t:%t", id, bypassWhen, bypassPause)) + return f.err +} + func testManifest() *manifestpkg.PodManifest { return &manifestpkg.PodManifest{ PodName: "fleet", @@ -85,8 +136,121 @@ func testStatuses() map[string]serviceStatus { } } +func testScheduleViews() []scheduleInvocationView { + openingBell := time.Date(2026, 4, 6, 13, 30, 0, 0, time.UTC) + openingLast := openingBell.Add(-24 * time.Hour) + skipSlot := time.Date(2026, 4, 5, 15, 0, 0, 0, time.UTC) + skipLast := skipSlot.Add(-2 * time.Hour) + pausedSlot := time.Date(2026, 4, 5, 16, 0, 0, 0, time.UTC) + pausedUntil := time.Date(2026, 4, 5, 15, 20, 0, 0, time.UTC) + degradedSlot := time.Date(2026, 4, 5, 17, 30, 0, 0, time.UTC) + degradedLast := degradedSlot.Add(-30 * time.Minute) + return []scheduleInvocationView{ + { + ManifestInvocation: schedulepkg.ManifestInvocation{ + ID: "opening-bell", + Service: "bot", + AgentID: "bot", + Schedule: "30 9 * * 1-5", + Timezone: "America/New_York", + Message: "Open the market.", + Name: "Opening Bell", + When: &schedulepkg.When{ + Calendar: "us-equities", + Session: schedulepkg.SessionRegular, + }, + Wake: schedulepkg.Wake{ + Adapter: "openclaw-exec", + Target: "bot", + Command: []string{"openclaw", "cron", "run", "opening-bell"}, + }, + }, + State: schedulepkg.InvocationState{ + NextFireAt: &openingBell, + LastFiredAt: &openingLast, + LastStatus: "fired", + }, + }, + { + ManifestInvocation: schedulepkg.ManifestInvocation{ + ID: "research-pulse", + Service: "bot", + AgentID: "bot", + Schedule: "0 11 * * 1-5", + Timezone: "America/New_York", + Message: "Post a research pulse.", + Name: "Research Pulse", + Wake: schedulepkg.Wake{ + Adapter: "openclaw-exec", + Target: "bot", + Command: []string{"openclaw", "cron", "run", "research-pulse"}, + }, + }, + State: schedulepkg.InvocationState{ + NextFireAt: &skipSlot, + LastSkippedAt: &skipLast, + LastStatus: "skipped", + LastDetail: "skip-next", + SkipNext: true, + }, + }, + { + ManifestInvocation: schedulepkg.ManifestInvocation{ + ID: "midday-review", + Service: "bot", + AgentID: "bot", + Schedule: "0 12 * * 1-5", + Timezone: "America/New_York", + Message: "Review the mid-session state.", + Name: "Midday Review", + Wake: schedulepkg.Wake{ + Adapter: "openclaw-exec", + Target: "bot", + Command: []string{"openclaw", "cron", "run", "midday-review"}, + }, + }, + State: schedulepkg.InvocationState{ + NextFireAt: &pausedSlot, + LastStatus: "scheduled", + Paused: true, + PausedUntil: &pausedUntil, + PauseReason: "operator hold", + LastEvaluatedAt: &skipLast, + }, + }, + { + ManifestInvocation: schedulepkg.ManifestInvocation{ + ID: "close-watch", + Service: "bot", + AgentID: "bot", + Schedule: "30 13 * * 1-5", + Timezone: "America/New_York", + Message: "Watch the close setup.", + Name: "Close Watch", + When: &schedulepkg.When{ + Calendar: "us-equities", + Session: schedulepkg.SessionRegular, + }, + Wake: schedulepkg.Wake{ + Adapter: "openclaw-exec", + Target: "bot", + Command: []string{"openclaw", "cron", "run", "close-watch"}, + }, + }, + State: schedulepkg.InvocationState{ + NextFireAt: °radedSlot, + LastAttemptedAt: °radedLast, + LastStatus: "wake-error", + LastDetail: "docker exec timeout", + Degraded: true, + ConsecutiveFailures: 3, + }, + }, + } +} + func TestFleetPageRenders(t *testing.T) { - h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, "http://localhost:8181", false) + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -116,7 +280,7 @@ func TestFleetPageRenders(t *testing.T) { } func TestFleetPageShowsCostLinkWhenCostAPIAvailable(t *testing.T) { - raw := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, "http://localhost:8181", false) + raw := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) h, ok := raw.(*handler) if !ok { t.Fatal("expected *handler") @@ -155,7 +319,7 @@ func TestFleetPageShowsCostLinkWhenCostAPIAvailable(t *testing.T) { } func TestTopologyPageRenders(t *testing.T) { - h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, "http://localhost:8181", false) + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) req := httptest.NewRequest(http.MethodGet, "/topology", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -173,7 +337,7 @@ func TestTopologyPageRenders(t *testing.T) { } func TestAPIStatusJSON(t *testing.T) { - h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, "http://localhost:8181", false) + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) req := httptest.NewRequest(http.MethodGet, "/api/status", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -193,7 +357,7 @@ func TestAPIStatusJSON(t *testing.T) { } func TestDetailMissingServiceNotFound(t *testing.T) { - h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, "http://localhost:8181", false) + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) req := httptest.NewRequest(http.MethodGet, "/detail/missing", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -203,6 +367,138 @@ func TestDetailMissingServiceNotFound(t *testing.T) { } } +func TestSchedulePageRenders(t *testing.T) { + raw := newHandler( + testManifest(), + fakeStatusSource{statuses: testStatuses()}, + &fakeScheduleSource{invocations: testScheduleViews()}, + "http://localhost:8181", + false, + ) + h, ok := raw.(*handler) + if !ok { + t.Fatal("expected *handler") + } + h.now = func() time.Time { + return time.Date(2026, 4, 5, 14, 0, 0, 0, time.UTC) + } + req := httptest.NewRequest(http.MethodGet, "/schedule", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "Schedule Control") { + t.Fatalf("expected schedule heading in body") + } + if !strings.Contains(body, "Opening Bell") { + t.Fatalf("expected invocation name in body") + } + if !strings.Contains(body, "Next slot") || !strings.Contains(body, "skip-next armed") { + t.Fatalf("expected slot-centric card copy in body:\n%s", body) + } + if !strings.Contains(body, "Midday Review") || !strings.Contains(body, "Resume") { + t.Fatalf("expected paused card controls in body:\n%s", body) + } + if !strings.Contains(body, "Clear skip-next") || !strings.Contains(body, "Force fire") { + t.Fatalf("expected overflow actions in body:\n%s", body) + } +} + +func TestScheduleActionPostsAndRedirects(t *testing.T) { + scheduleSource := &fakeScheduleSource{invocations: testScheduleViews()} + h := newHandler( + testManifest(), + fakeStatusSource{statuses: testStatuses()}, + scheduleSource, + "http://localhost:8181", + false, + ) + req := httptest.NewRequest(http.MethodPost, "/schedule/opening-bell/skip-next", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + if got := w.Header().Get("Location"); !strings.Contains(got, "/schedule?notice=") { + t.Fatalf("expected redirect back to /schedule with notice, got %q", got) + } + if len(scheduleSource.actions) != 1 || scheduleSource.actions[0] != "skip-next:opening-bell" { + t.Fatalf("expected skip-next action, got %v", scheduleSource.actions) + } +} + +func TestScheduleActionClearSkipNextPostsAndRedirects(t *testing.T) { + scheduleSource := &fakeScheduleSource{invocations: testScheduleViews()} + h := newHandler( + testManifest(), + fakeStatusSource{statuses: testStatuses()}, + scheduleSource, + "http://localhost:8181", + false, + ) + req := httptest.NewRequest(http.MethodPost, "/schedule/research-pulse/clear-skip-next", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + if len(scheduleSource.actions) != 1 || scheduleSource.actions[0] != "clear-skip-next:research-pulse" { + t.Fatalf("expected clear-skip-next action, got %v", scheduleSource.actions) + } +} + +func TestSchedulePauseConvertsDatetimeLocalToUTC(t *testing.T) { + scheduleSource := &fakeScheduleSource{invocations: testScheduleViews()} + h := newHandler( + testManifest(), + fakeStatusSource{statuses: testStatuses()}, + scheduleSource, + "http://localhost:8181", + false, + ) + + req := httptest.NewRequest( + http.MethodPost, + "/schedule/opening-bell/pause", + strings.NewReader("until_local=2026-04-05T10%3A30&reason=market+holiday"), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + if len(scheduleSource.actions) != 1 { + t.Fatalf("expected one action, got %v", scheduleSource.actions) + } + if got, want := scheduleSource.actions[0], "pause:opening-bell:2026-04-05T14:30:00Z:market holiday"; got != want { + t.Fatalf("unexpected pause action:\n got: %s\nwant: %s", got, want) + } +} + +func TestBuildSchedulePageDataPinsPausedAndDegraded(t *testing.T) { + now := time.Date(2026, 4, 5, 14, 0, 0, 0, time.UTC) + data := buildSchedulePageData("fleet", testScheduleViews(), "", "", now) + if len(data.Cards) < 4 { + t.Fatalf("expected cards, got %d", len(data.Cards)) + } + if got := data.Cards[0].Name; got != "Midday Review" { + t.Fatalf("expected paused card pinned first, got %q", got) + } + if got := data.Cards[1].Name; got != "Close Watch" { + t.Fatalf("expected degraded card pinned next, got %q", got) + } + if data.Summary[3].Label != "Next slot" { + t.Fatalf("expected next-slot summary label, got %+v", data.Summary[3]) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/cmd/clawdash/main.go b/cmd/clawdash/main.go index 4df53a4..1cb72ab 100644 --- a/cmd/clawdash/main.go +++ b/cmd/clawdash/main.go @@ -10,6 +10,7 @@ import ( "strings" "syscall" "time" + _ "time/tzdata" ) func main() { @@ -34,6 +35,8 @@ type config struct { ManifestPath string CllamaCostsURL string CostLogFallback bool + ClawAPIURL string + ClawAPIToken string } func loadConfig() config { @@ -44,6 +47,8 @@ func loadConfig() config { CostLogFallback: envBool( "CLAWDASH_COST_LOG_FALLBACK", ), + ClawAPIURL: strings.TrimSpace(os.Getenv("CLAW_API_URL")), + ClawAPIToken: strings.TrimSpace(os.Getenv("CLAW_API_TOKEN")), } } @@ -59,7 +64,8 @@ func run(cfg config) error { } defer source.Close() - h := newHandler(manifest, source, cfg.CllamaCostsURL, cfg.CostLogFallback) + scheduleSource := newScheduleHTTPClient(cfg.ClawAPIURL, cfg.ClawAPIToken) + h := newHandler(manifest, source, scheduleSource, cfg.CllamaCostsURL, cfg.CostLogFallback) srv := &http.Server{ Addr: cfg.Addr, Handler: h, diff --git a/cmd/clawdash/schedule.go b/cmd/clawdash/schedule.go new file mode 100644 index 0000000..d598087 --- /dev/null +++ b/cmd/clawdash/schedule.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + schedulepkg "github.com/mostlydev/clawdapus/internal/schedule" +) + +type scheduleControlSource interface { + List(ctx context.Context) ([]scheduleInvocationView, error) + Get(ctx context.Context, id string) (scheduleInvocationView, error) + Pause(ctx context.Context, id, until, reason string) error + Resume(ctx context.Context, id string) error + SkipNext(ctx context.Context, id string) error + ClearSkipNext(ctx context.Context, id string) error + Fire(ctx context.Context, id string, bypassWhen, bypassPause bool) error +} + +type scheduleInvocationView struct { + schedulepkg.ManifestInvocation + State schedulepkg.InvocationState `json:"state"` +} + +type scheduleHTTPClient struct { + baseURL string + token string + client *http.Client +} + +type scheduleListResponse struct { + Invocations []scheduleInvocationView `json:"invocations"` +} + +type schedulePauseRequest struct { + Until string `json:"until,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type scheduleFireRequest struct { + BypassWhen bool `json:"bypass_when,omitempty"` + BypassPause bool `json:"bypass_pause,omitempty"` +} + +func newScheduleHTTPClient(baseURL, token string) scheduleControlSource { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + token = strings.TrimSpace(token) + if baseURL == "" || token == "" { + return nil + } + return &scheduleHTTPClient{ + baseURL: baseURL, + token: token, + client: &http.Client{ + Timeout: 4 * time.Second, + }, + } +} + +func (c *scheduleHTTPClient) List(ctx context.Context) ([]scheduleInvocationView, error) { + var payload scheduleListResponse + if err := c.do(ctx, http.MethodGet, "/schedule", nil, &payload); err != nil { + return nil, err + } + return payload.Invocations, nil +} + +func (c *scheduleHTTPClient) Get(ctx context.Context, id string) (scheduleInvocationView, error) { + var payload struct { + Invocation scheduleInvocationView `json:"invocation"` + } + if err := c.do(ctx, http.MethodGet, scheduleInvocationPath(id, ""), nil, &payload); err != nil { + return scheduleInvocationView{}, err + } + return payload.Invocation, nil +} + +func (c *scheduleHTTPClient) Pause(ctx context.Context, id, until, reason string) error { + return c.do(ctx, http.MethodPost, scheduleInvocationPath(id, "pause"), schedulePauseRequest{ + Until: strings.TrimSpace(until), + Reason: strings.TrimSpace(reason), + }, nil) +} + +func (c *scheduleHTTPClient) Resume(ctx context.Context, id string) error { + return c.do(ctx, http.MethodPost, scheduleInvocationPath(id, "resume"), nil, nil) +} + +func (c *scheduleHTTPClient) SkipNext(ctx context.Context, id string) error { + return c.do(ctx, http.MethodPost, scheduleInvocationPath(id, "skip-next"), nil, nil) +} + +func (c *scheduleHTTPClient) ClearSkipNext(ctx context.Context, id string) error { + return c.do(ctx, http.MethodPost, scheduleInvocationPath(id, "clear-skip-next"), nil, nil) +} + +func (c *scheduleHTTPClient) Fire(ctx context.Context, id string, bypassWhen, bypassPause bool) error { + var body any + if bypassWhen || bypassPause { + body = scheduleFireRequest{ + BypassWhen: bypassWhen, + BypassPause: bypassPause, + } + } + return c.do(ctx, http.MethodPost, scheduleInvocationPath(id, "fire"), body, nil) +} + +func (c *scheduleHTTPClient) do(ctx context.Context, method, path string, body any, dst any) error { + if c == nil { + return fmt.Errorf("schedule client unavailable") + } + var bodyReader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal schedule request: %w", err) + } + bodyReader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return decodeScheduleAPIError(resp) + } + if dst == nil { + return nil + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("decode schedule response: %w", err) + } + return nil +} + +func scheduleInvocationPath(id, action string) string { + path := "/schedule/" + url.PathEscape(strings.TrimSpace(id)) + if strings.TrimSpace(action) != "" { + path += "/" + action + } + return path +} + +func decodeScheduleAPIError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + var payload struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &payload); err == nil && strings.TrimSpace(payload.Error) != "" { + return fmt.Errorf("%s", payload.Error) + } + if message := strings.TrimSpace(string(body)); message != "" { + return fmt.Errorf("schedule api %s: %s", resp.Status, message) + } + return fmt.Errorf("schedule api %s", resp.Status) +} diff --git a/cmd/clawdash/schedule_page.go b/cmd/clawdash/schedule_page.go new file mode 100644 index 0000000..3da1262 --- /dev/null +++ b/cmd/clawdash/schedule_page.go @@ -0,0 +1,699 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + schedulepkg "github.com/mostlydev/clawdapus/internal/schedule" + "github.com/robfig/cron/v3" +) + +type schedulePageData struct { + PodName string + ActiveTab string + HasSchedule bool + Summary []dashStat + Cards []scheduleCard + HasCards bool + Notice string + Error string + HasNotice bool + HasError bool + HasStatusErrors bool +} + +type scheduleCard struct { + ID string + Name string + ServiceAgent string + StateAccent string + LifecyclePill scheduleBadge + HealthPill *scheduleBadge + SkipNextChip *scheduleBadge + NextSlot nextSlotDisplay + LastEvent lastEventDisplay + Details scheduleDetails + Primary schedulePrimaryAction + Overflow []scheduleAction + BypassFire scheduleAction + PauseForm pauseFormFields + SortKey time.Time + Pinned bool +} + +type scheduleBadge struct { + Label string + Tone string +} + +type nextSlotDisplay struct { + HeroLabel string + SlotRelative string + SlotAbsolute string + CronExpr string + GateSubtitle string + Modifier string + FollowupLabel string + FollowupValue string + Notes []string + Dimmed bool +} + +type lastEventDisplay struct { + StatusLabel string + StatusTone string + Relative string + Absolute string + Detail string + Tooltip string + HasTimestamp bool +} + +type scheduleDetails struct { + ID string + Service string + AgentID string + Message string + Target string + WakeAdapter string + WakeTarget string + WakeCommand string +} + +type schedulePrimaryAction struct { + Label string + ActionPath string + ButtonClass string + TogglesPauseForm bool +} + +type scheduleAction struct { + Label string + ActionPath string + ButtonClass string + Fields []scheduleField +} + +type scheduleField struct { + Name string + Value string +} + +type pauseFormFields struct { + ActionPath string + UntilLocal string + Reason string + Timezone string +} + +func (h *handler) renderSchedule(w http.ResponseWriter, r *http.Request) { + if !h.hasSchedule() { + http.NotFound(w, r) + return + } + + invocations, err := h.scheduleSource.List(r.Context()) + now := time.Now().UTC() + if h != nil && h.now != nil { + now = h.now().UTC() + } + data := buildSchedulePageData( + h.manifest.PodName, + invocations, + strings.TrimSpace(r.URL.Query().Get("notice")), + firstNonEmpty(strings.TrimSpace(r.URL.Query().Get("error")), errString(err)), + now, + ) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = h.tpl.ExecuteTemplate(w, "schedule.html", data) +} + +func (h *handler) handleScheduleAction(w http.ResponseWriter, r *http.Request) { + if !h.hasSchedule() { + http.NotFound(w, r) + return + } + id, action, ok := parseDashSchedulePath(r.URL.Path) + if !ok || action == "" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + redirectSchedule(w, r, "", "invalid form body") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + var ( + err error + notice string + ) + switch action { + case "pause": + until, parseErr := h.resolvePauseUntil(ctx, id, r.FormValue("until_local"), r.FormValue("until")) + if parseErr != nil { + redirectSchedule(w, r, "", parseErr.Error()) + return + } + err = h.scheduleSource.Pause(ctx, id, until, r.FormValue("reason")) + notice = fmt.Sprintf("Paused %s.", id) + case "resume": + err = h.scheduleSource.Resume(ctx, id) + notice = fmt.Sprintf("Resumed %s.", id) + case "skip-next": + err = h.scheduleSource.SkipNext(ctx, id) + notice = fmt.Sprintf("Marked %s to skip the next fire.", id) + case "clear-skip-next": + err = h.scheduleSource.ClearSkipNext(ctx, id) + notice = fmt.Sprintf("Cleared skip-next for %s.", id) + case "fire": + bypassWhen := formBool(r.FormValue("bypass_when")) + bypassPause := formBool(r.FormValue("bypass_pause")) + err = h.scheduleSource.Fire(ctx, id, bypassWhen, bypassPause) + notice = fmt.Sprintf("Triggered %s.", id) + if bypassWhen || bypassPause { + notice = fmt.Sprintf("Force-fired %s.", id) + } + default: + http.NotFound(w, r) + return + } + if err != nil { + redirectSchedule(w, r, "", err.Error()) + return + } + redirectSchedule(w, r, notice, "") +} + +func (h *handler) resolvePauseUntil(ctx context.Context, id, localValue, fallback string) (string, error) { + localValue = strings.TrimSpace(localValue) + if localValue == "" { + return strings.TrimSpace(fallback), nil + } + view, err := h.scheduleSource.Get(ctx, id) + if err != nil { + return "", err + } + return convertLocalPauseUntil(localValue, view.Timezone) +} + +func buildSchedulePageData(podName string, invocations []scheduleInvocationView, notice, errMsg string, now time.Time) schedulePageData { + cards := make([]scheduleCard, 0, len(invocations)) + paused := 0 + degraded := 0 + nextSlot := soonestScheduleSlot(invocations, now) + + for _, inv := range invocations { + if inv.State.Paused { + paused++ + } + if inv.State.Degraded { + degraded++ + } + cards = append(cards, buildScheduleCard(inv, now)) + } + + sort.Slice(cards, func(i, j int) bool { + return lessScheduleCard(cards[i], cards[j]) + }) + + summary := []dashStat{ + {Label: "Scheduled", Value: fmt.Sprintf("%d", len(invocations)), Hint: "scheduler-owned jobs in scope", Tone: "neutral"}, + {Label: "Paused", Value: fmt.Sprintf("%d", paused), Hint: "manually paused right now", Tone: toneForCount(paused)}, + {Label: "Degraded", Value: fmt.Sprintf("%d", degraded), Hint: "throttled after repeated failures", Tone: toneForCount(degraded)}, + {Label: "Next slot", Value: firstNonEmpty(formatFutureRelative(nextSlot, now), "—"), Hint: "soonest non-paused cron slot", Tone: "neutral"}, + } + + return schedulePageData{ + PodName: podName, + ActiveTab: "schedule", + HasSchedule: true, + Summary: summary, + Cards: cards, + HasCards: len(cards) > 0, + Notice: notice, + Error: errMsg, + HasNotice: strings.TrimSpace(notice) != "", + HasError: strings.TrimSpace(errMsg) != "", + } +} + +func buildScheduleCard(inv scheduleInvocationView, now time.Time) scheduleCard { + name := scheduleInvocationName(inv) + pinned := inv.State.Paused || inv.State.Degraded + + card := scheduleCard{ + ID: inv.ID, + Name: name, + ServiceAgent: strings.TrimSpace(inv.Service) + " \u2192 " + strings.TrimSpace(inv.AgentID), + StateAccent: scheduleAccentClass(inv.State), + LifecyclePill: scheduleLifecyclePill(inv.State), + HealthPill: scheduleHealthPill(inv.State), + SkipNextChip: scheduleSkipNextChip(inv.State), + NextSlot: buildNextSlotDisplay(inv, now), + LastEvent: buildLastEventDisplay(inv.State, inv.Timezone, now), + Details: scheduleDetails{ + ID: inv.ID, + Service: strings.TrimSpace(inv.Service), + AgentID: strings.TrimSpace(inv.AgentID), + Message: firstNonEmpty(strings.TrimSpace(inv.Message), "—"), + Target: firstNonEmpty(strings.TrimSpace(inv.To), "direct wake"), + WakeAdapter: firstNonEmpty(strings.TrimSpace(inv.Wake.Adapter), "—"), + WakeTarget: firstNonEmpty(strings.TrimSpace(inv.Wake.Target), "—"), + WakeCommand: firstNonEmpty(strings.Join(inv.Wake.Command, " "), "—"), + }, + Primary: schedulePrimaryAction{ + Label: "Pause", + ButtonClass: "dash-button", + TogglesPauseForm: true, + }, + Overflow: []scheduleAction{ + buildSkipNextAction(inv), + { + Label: "Fire now", + ActionPath: "/schedule/" + url.PathEscape(inv.ID) + "/fire", + ButtonClass: "dash-menu-item", + }, + }, + BypassFire: scheduleAction{ + Label: "Force fire", + ActionPath: "/schedule/" + url.PathEscape(inv.ID) + "/fire", + ButtonClass: "dash-button dash-button-danger", + Fields: []scheduleField{ + {Name: "bypass_when", Value: "true"}, + {Name: "bypass_pause", Value: "true"}, + }, + }, + PauseForm: pauseFormFields{ + ActionPath: "/schedule/" + url.PathEscape(inv.ID) + "/pause", + UntilLocal: formatLocalInputTime(inv.State.PausedUntil, inv.Timezone), + Reason: strings.TrimSpace(inv.State.PauseReason), + Timezone: firstNonEmpty(strings.TrimSpace(inv.Timezone), "UTC"), + }, + Pinned: pinned, + } + + if inv.State.NextFireAt != nil { + card.SortKey = inv.State.NextFireAt.UTC() + } + if inv.State.Paused { + card.Primary = schedulePrimaryAction{ + Label: "Resume", + ActionPath: "/schedule/" + url.PathEscape(inv.ID) + "/resume", + ButtonClass: "dash-button", + } + } + + return card +} + +func lessScheduleCard(left, right scheduleCard) bool { + if left.Pinned != right.Pinned { + return left.Pinned + } + if left.SortKey.IsZero() != right.SortKey.IsZero() { + return !left.SortKey.IsZero() + } + if !left.SortKey.Equal(right.SortKey) { + return left.SortKey.Before(right.SortKey) + } + if left.Name != right.Name { + return left.Name < right.Name + } + return left.ID < right.ID +} + +func buildSkipNextAction(inv scheduleInvocationView) scheduleAction { + action := scheduleAction{ + Label: "Skip next", + ActionPath: "/schedule/" + url.PathEscape(inv.ID) + "/skip-next", + ButtonClass: "dash-menu-item", + } + if inv.State.SkipNext { + action.Label = "Clear skip-next" + action.ActionPath = "/schedule/" + url.PathEscape(inv.ID) + "/clear-skip-next" + } + return action +} + +func scheduleAccentClass(state schedulepkg.InvocationState) string { + switch { + case state.Degraded: + return "dash-schedule-card-critical" + case state.Paused: + return "dash-schedule-card-warning" + default: + return "" + } +} + +func scheduleLifecyclePill(state schedulepkg.InvocationState) scheduleBadge { + if state.Paused { + return scheduleBadge{Label: "paused", Tone: "tone-warning"} + } + return scheduleBadge{Label: "scheduled", Tone: "tone-neutral"} +} + +func scheduleHealthPill(state schedulepkg.InvocationState) *scheduleBadge { + if !state.Degraded { + return nil + } + return &scheduleBadge{Label: "degraded", Tone: "tone-critical"} +} + +func scheduleSkipNextChip(state schedulepkg.InvocationState) *scheduleBadge { + if !state.SkipNext { + return nil + } + return &scheduleBadge{Label: "skip next", Tone: "tone-neutral"} +} + +func buildNextSlotDisplay(inv scheduleInvocationView, now time.Time) nextSlotDisplay { + display := nextSlotDisplay{ + HeroLabel: "Next fire", + SlotRelative: firstNonEmpty(formatFutureRelative(inv.State.NextFireAt, now), "not scheduled"), + SlotAbsolute: formatScheduleTime(inv.State.NextFireAt, inv.Timezone), + CronExpr: firstNonEmpty(strings.TrimSpace(inv.Schedule), "—"), + GateSubtitle: scheduleGateLabel(inv.When), + } + + if inv.State.NextFireAt == nil { + display.HeroLabel = "Next slot" + return display + } + + switch { + case inv.State.Paused: + display.HeroLabel = "Next slot" + display.Modifier = "(paused, will be skipped)" + display.Dimmed = true + if inv.State.PausedUntil != nil && inv.State.PausedUntil.Before(inv.State.NextFireAt.UTC()) { + display.FollowupLabel = "Resumes in" + display.FollowupValue = formatFutureRelative(inv.State.PausedUntil, now) + } + if inv.State.Degraded { + display.Notes = append(display.Notes, "Degraded after repeated failures; wake attempts stay throttled after resume.") + } + if inv.State.SkipNext { + display.Notes = append(display.Notes, "Skip-next is armed for the shown slot.") + } + case inv.State.SkipNext: + display.HeroLabel = "Next slot" + display.Modifier = "(skip-next armed, will be skipped)" + if nextFire := computeFollowingSlot(inv.Schedule, inv.Timezone, inv.State.NextFireAt.UTC()); nextFire != nil { + display.FollowupLabel = "Next fire" + display.FollowupValue = formatFutureRelative(nextFire, now) + } + if inv.State.Degraded { + display.Notes = append(display.Notes, "After the skip clears, degraded throttling still applies (~10% fire chance per slot).") + } + case inv.State.Degraded: + display.HeroLabel = "Next slot" + display.Modifier = "(degraded, ~10% fire chance)" + } + + return display +} + +func buildLastEventDisplay(state schedulepkg.InvocationState, timezone string, now time.Time) lastEventDisplay { + ts := latestScheduleEventTime(state) + statusLabel := displayScheduleStatus(state.LastStatus) + if statusLabel == "" { + statusLabel = "scheduled" + } + display := lastEventDisplay{ + StatusLabel: statusLabel, + StatusTone: scheduleOutcomeTone(state.LastStatus), + Detail: strings.TrimSpace(state.LastDetail), + } + if ts == nil { + display.Relative = "No recorded event yet" + display.Tooltip = display.Detail + return display + } + display.HasTimestamp = true + display.Relative = formatPastRelative(ts, now) + display.Absolute = formatScheduleTime(ts, timezone) + tooltipParts := make([]string, 0, 2) + if display.Absolute != "-" { + tooltipParts = append(tooltipParts, display.Absolute) + } + if display.Detail != "" { + tooltipParts = append(tooltipParts, display.Detail) + } + display.Tooltip = strings.Join(tooltipParts, " · ") + return display +} + +func scheduleInvocationName(inv scheduleInvocationView) string { + name := strings.TrimSpace(inv.Name) + if name == "" { + name = strings.TrimSpace(inv.ID) + } + return name +} + +func latestScheduleEventTime(state schedulepkg.InvocationState) *time.Time { + candidates := []*time.Time{ + state.LastFiredAt, + state.LastSkippedAt, + state.LastAttemptedAt, + state.LastEvaluatedAt, + } + var latest *time.Time + for _, candidate := range candidates { + if candidate == nil { + continue + } + if latest == nil || candidate.After(*latest) { + copy := candidate.UTC() + latest = © + } + } + return latest +} + +func soonestScheduleSlot(invocations []scheduleInvocationView, now time.Time) *time.Time { + var soonest *time.Time + for _, inv := range invocations { + if inv.State.Paused || inv.State.NextFireAt == nil { + continue + } + slot := inv.State.NextFireAt.UTC() + if slot.Before(now) { + continue + } + if soonest == nil || slot.Before(*soonest) { + copy := slot + soonest = © + } + } + return soonest +} + +func parseDashSchedulePath(path string) (id, action string, ok bool) { + if !strings.HasPrefix(path, "/schedule/") { + return "", "", false + } + trimmed := strings.TrimSpace(strings.TrimPrefix(path, "/schedule/")) + if trimmed == "" { + return "", "", false + } + parts := strings.Split(trimmed, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + id, err := url.PathUnescape(parts[0]) + if err != nil || strings.TrimSpace(id) == "" { + return "", "", false + } + return id, parts[1], true +} + +func redirectSchedule(w http.ResponseWriter, r *http.Request, notice, errMsg string) { + target := "/schedule" + query := url.Values{} + if strings.TrimSpace(notice) != "" { + query.Set("notice", notice) + } + if strings.TrimSpace(errMsg) != "" { + query.Set("error", errMsg) + } + if encoded := query.Encode(); encoded != "" { + target += "?" + encoded + } + http.Redirect(w, r, target, http.StatusSeeOther) +} + +func formBool(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func scheduleGateLabel(when *schedulepkg.When) string { + if when == nil { + return "cron only" + } + session := string(when.SessionOrDefault()) + return strings.TrimSpace(when.Calendar) + " / " + session +} + +func scheduleOutcomeTone(status string) string { + status = strings.ToLower(strings.TrimSpace(status)) + switch { + case status == "" || status == "scheduled": + return "tone-neutral" + case strings.HasPrefix(status, "fire") || strings.HasPrefix(status, "manual-fire"): + return "tone-good" + case strings.Contains(status, "skip") || strings.Contains(status, "pause"): + return "tone-warning" + default: + return "tone-critical" + } +} + +func displayScheduleStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "scheduled" + } + return strings.ReplaceAll(status, "-", " ") +} + +func formatScheduleTime(ts *time.Time, timezone string) string { + if ts == nil { + return "-" + } + location := loadScheduleLocation(timezone) + return ts.In(location).Format("Mon 2006-01-02 15:04 MST") +} + +func formatLocalInputTime(ts *time.Time, timezone string) string { + if ts == nil { + return "" + } + location := loadScheduleLocation(timezone) + return ts.In(location).Format("2006-01-02T15:04") +} + +func loadScheduleLocation(timezone string) *time.Location { + location := time.UTC + if tz := strings.TrimSpace(timezone); tz != "" { + if loaded, err := time.LoadLocation(tz); err == nil { + location = loaded + } + } + return location +} + +func formatFutureRelative(ts *time.Time, now time.Time) string { + if ts == nil { + return "" + } + diff := ts.Sub(now) + if diff <= 0 { + return "now" + } + return "in " + formatRelativeDuration(diff) +} + +func formatPastRelative(ts *time.Time, now time.Time) string { + if ts == nil { + return "" + } + diff := now.Sub(*ts) + if diff <= 0 { + return "just now" + } + return formatRelativeDuration(diff) + " ago" +} + +func formatRelativeDuration(diff time.Duration) string { + if diff < time.Minute { + seconds := int(diff.Round(time.Second) / time.Second) + if seconds < 1 { + seconds = 1 + } + return fmt.Sprintf("%ds", seconds) + } + if diff < time.Hour { + minutes := int(diff.Round(time.Minute) / time.Minute) + if minutes < 1 { + minutes = 1 + } + return fmt.Sprintf("%dm", minutes) + } + if diff < 24*time.Hour { + hours := diff / time.Hour + minutes := (diff % time.Hour).Round(time.Minute) / time.Minute + if minutes == 60 { + hours++ + minutes = 0 + } + if minutes == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh %dm", hours, minutes) + } + days := diff / (24 * time.Hour) + remainder := diff % (24 * time.Hour) + hours := remainder.Round(time.Hour) / time.Hour + if hours == 24 { + days++ + hours = 0 + } + if hours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd %dh", days, hours) +} + +func computeFollowingSlot(scheduleExpr, timezone string, after time.Time) *time.Time { + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + compiled, err := parser.Parse(strings.TrimSpace(scheduleExpr)) + if err != nil { + return nil + } + location := loadScheduleLocation(timezone) + next := compiled.Next(after.In(location)).UTC() + return &next +} + +func convertLocalPauseUntil(raw, timezone string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + location := loadScheduleLocation(timezone) + parsed, err := time.ParseInLocation("2006-01-02T15:04", raw, location) + if err != nil { + return "", fmt.Errorf("until must be a valid local date/time") + } + return parsed.UTC().Format(time.RFC3339), nil +} + +func toneForCount(count int) string { + if count <= 0 { + return "tone-good" + } + return "tone-warning" +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/cmd/clawdash/static/schedule.css b/cmd/clawdash/static/schedule.css new file mode 100644 index 0000000..944f709 --- /dev/null +++ b/cmd/clawdash/static/schedule.css @@ -0,0 +1,385 @@ +[x-cloak] { + display: none !important; +} + +.tone-critical { + border-color: rgba(239, 68, 68, 0.25); + background-color: rgba(239, 68, 68, 0.08); + color: rgb(239 68 68 / 1); +} + +.dash-banner-good { + border-color: rgba(52, 211, 153, 0.25); + background-color: rgba(52, 211, 153, 0.08); + color: rgb(52 211 153 / 1); +} + +.dash-stack-tight { + display: grid; + gap: 0.35rem; +} + +.dash-code { + font-family: Geist Mono, monospace; + font-size: 11px; + line-height: 1.4; + color: rgb(94 112 133 / 1); + word-break: break-word; +} + +.dash-card-list { + display: grid; + gap: 0.9rem; + padding: 1rem; +} + +.dash-schedule-card { + position: relative; + overflow: hidden; + border: 1px solid rgb(31 45 61 / 1); + border-radius: 0.55rem; + background: + radial-gradient(circle at top right, rgba(34, 211, 238, 0.08), transparent 34%), + linear-gradient(180deg, rgba(15, 22, 32, 0.94), rgba(12, 16, 23, 0.96)); + padding: 1.2rem 1.25rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.dash-schedule-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: rgba(34, 211, 238, 0.14); +} + +.dash-schedule-card-warning::before { + background: rgba(240, 165, 0, 0.55); +} + +.dash-schedule-card-critical::before { + background: rgba(239, 68, 68, 0.72); +} + +.dash-schedule-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.dash-schedule-pill-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.45rem; +} + +.dash-pill-outlined { + background-color: transparent; +} + +.dash-schedule-grid { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +@media (min-width: 860px) { + .dash-schedule-grid { + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + align-items: start; + } +} + +.dash-schedule-block { + border: 1px solid rgba(31, 45, 61, 0.92); + border-radius: 0.5rem; + background: rgba(12, 16, 23, 0.78); + padding: 0.95rem 1rem; +} + +.dash-schedule-label, +.dash-form-label { + font-family: Geist Mono, monospace; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(94 112 133 / 1); +} + +.dash-schedule-hero { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + margin-top: 0.45rem; + font-family: Geist Mono, monospace; + font-size: 1.4rem; + font-weight: 600; + line-height: 1.15; + color: rgb(237 242 247 / 1); +} + +.dash-schedule-hero-dimmed { + color: rgb(140 154 173 / 1); +} + +.dash-schedule-modifier { + font-size: 0.78rem; + font-weight: 500; + color: rgb(94 112 133 / 1); +} + +.dash-schedule-meta-line, +.dash-schedule-note { + margin-top: 0.35rem; + font-size: 12px; + line-height: 1.45; + color: rgb(94 112 133 / 1); +} + +.dash-schedule-followup { + margin-top: 0.6rem; + font-family: Geist Mono, monospace; + font-size: 11px; + color: rgb(34 211 238 / 1); +} + +.dash-schedule-note { + border-top: 1px dashed rgba(31, 45, 61, 0.9); + padding-top: 0.45rem; +} + +.dash-schedule-event { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.dash-schedule-event-copy { + font-size: 13px; + color: rgb(212 220 232 / 1); +} + +.dash-schedule-details { + margin-top: 0.85rem; +} + +.dash-schedule-details summary { + cursor: pointer; + font-family: Geist Mono, monospace; + font-size: 11px; + color: rgb(34 211 238 / 1); +} + +.dash-schedule-detail-grid { + display: grid; + gap: 0.65rem; + margin-top: 0.85rem; +} + +@media (min-width: 720px) { + .dash-schedule-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.dash-schedule-detail-wide { + grid-column: 1 / -1; +} + +.dash-schedule-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; +} + +.dash-inline-form { + display: inline-flex; +} + +.dash-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.4rem; + border: 1px solid rgba(34, 211, 238, 0.25); + background-color: rgba(34, 211, 238, 0.08); + padding: 0.48rem 0.82rem; + font-family: Geist Mono, monospace; + font-size: 11px; + font-weight: 600; + color: rgb(34 211 238 / 1); + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.dash-button:hover { + border-color: rgba(34, 211, 238, 0.4); + background-color: rgba(34, 211, 238, 0.14); + color: rgb(237 242 247 / 1); +} + +.dash-button-subtle { + border-color: rgb(31 45 61 / 1); + background-color: rgb(12 16 23 / 1); + color: rgb(94 112 133 / 1); +} + +.dash-button-subtle:hover { + border-color: rgba(34, 211, 238, 0.25); + background-color: rgba(34, 211, 238, 0.06); + color: rgb(212 220 232 / 1); +} + +.dash-button-danger { + border-color: rgba(239, 68, 68, 0.28); + background-color: rgba(239, 68, 68, 0.08); + color: rgb(248 113 113 / 1); +} + +.dash-button-danger:hover { + border-color: rgba(239, 68, 68, 0.45); + background-color: rgba(239, 68, 68, 0.14); + color: rgb(254 202 202 / 1); +} + +.dash-overflow-wrap { + position: relative; +} + +.dash-overflow-menu { + position: absolute; + top: calc(100% + 0.45rem); + right: 0; + z-index: 20; + display: grid; + gap: 0.25rem; + min-width: 180px; + border: 1px solid rgb(31 45 61 / 1); + border-radius: 0.45rem; + background: rgb(12 16 23 / 0.98); + padding: 0.35rem; + box-shadow: 0 18px 30px rgba(0, 0, 0, 0.32); +} + +.dash-menu-item { + width: 100%; + border: 0; + border-radius: 0.35rem; + padding: 0.55rem 0.7rem; + text-align: left; + font-family: Geist Mono, monospace; + font-size: 11px; + color: rgb(212 220 232 / 1); +} + +.dash-menu-item:hover { + background: rgba(34, 211, 238, 0.08); +} + +.dash-menu-item-danger { + color: rgb(248 113 113 / 1); +} + +.dash-menu-item-danger:hover { + background: rgba(239, 68, 68, 0.12); +} + +.dash-pause-form-shell { + margin-top: 1rem; + border-top: 1px solid rgba(31, 45, 61, 0.92); + padding-top: 0.95rem; +} + +.dash-pause-form { + display: grid; + gap: 0.8rem; +} + +@media (min-width: 820px) { + .dash-pause-form { + grid-template-columns: minmax(0, 220px) minmax(0, 1fr) auto; + align-items: end; + } +} + +.dash-form-field { + display: grid; + gap: 0.35rem; +} + +.dash-form-field-wide { + min-width: 0; +} + +.dash-pause-form-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.dash-confirm-modal { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.dash-confirm-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 8, 13, 0.72); + backdrop-filter: blur(2px); +} + +.dash-confirm-dialog { + position: relative; + width: min(420px, calc(100vw - 2rem)); + border: 1px solid rgb(31 45 61 / 1); + border-radius: 0.6rem; + background: linear-gradient(180deg, rgba(19, 26, 36, 0.98), rgba(12, 16, 23, 0.98)); + padding: 1.2rem; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.36); +} + +.dash-confirm-title { + margin-top: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.03em; + color: rgb(237 242 247 / 1); +} + +.dash-confirm-copy { + margin-top: 0.5rem; + font-size: 13px; + line-height: 1.55; + color: rgb(94 112 133 / 1); +} + +.dash-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +@media (max-width: 700px) { + .dash-schedule-card-head, + .dash-schedule-actions { + flex-direction: column; + align-items: flex-start; + } + + .dash-schedule-pill-row { + justify-content: flex-start; + } +} diff --git a/cmd/clawdash/templates/detail.html b/cmd/clawdash/templates/detail.html index 962108c..5c7b3ea 100644 --- a/cmd/clawdash/templates/detail.html +++ b/cmd/clawdash/templates/detail.html @@ -21,6 +21,7 @@ diff --git a/cmd/clawdash/templates/fleet.html b/cmd/clawdash/templates/fleet.html index 7ef8add..38b1fc9 100644 --- a/cmd/clawdash/templates/fleet.html +++ b/cmd/clawdash/templates/fleet.html @@ -21,6 +21,7 @@
diff --git a/cmd/clawdash/templates/schedule.html b/cmd/clawdash/templates/schedule.html new file mode 100644 index 0000000..b03278f --- /dev/null +++ b/cmd/clawdash/templates/schedule.html @@ -0,0 +1,227 @@ + + + + + + clawdapus dash - schedule + + + + + + + + + +
+
+ +
clawdash fleet
+
+ + + +
{{.PodName}}
+
+ +
+
{{.PodName}}
+

Schedule Control

+

Current scheduler state, next-slot timing, and operator controls for pod-origin invokes.

+
+ + {{if .HasNotice}} +
{{.Notice}}
+ {{end}} + {{if .HasError}} +
{{.Error}}
+ {{end}} + +
+
+ {{range .Summary}} +
+
{{.Label}}
+
{{.Value}}
+
{{.Hint}}
+
+ {{end}} +
+
+ +
+
+
+
Scheduler
+

Invocation registry

+
+ {{len .Cards}} visible +
+ + {{if .HasCards}} +
+ {{range .Cards}} +
+
+
+
{{.Name}}
+
{{.ServiceAgent}}
+
+
+ {{.LifecyclePill.Label}} + {{if .HealthPill}} + {{.HealthPill.Label}} + {{end}} + {{if .SkipNextChip}} + {{.SkipNextChip.Label}} + {{end}} +
+
+ +
+
+
{{.NextSlot.HeroLabel}}
+
+ {{.NextSlot.SlotRelative}} + {{if .NextSlot.Modifier}}{{.NextSlot.Modifier}}{{end}} +
+
{{.NextSlot.SlotAbsolute}}
+
{{.NextSlot.CronExpr}} · {{.NextSlot.GateSubtitle}}
+ {{if .NextSlot.FollowupLabel}} +
{{.NextSlot.FollowupLabel}} · {{.NextSlot.FollowupValue}}
+ {{end}} + {{range .NextSlot.Notes}} +
{{.}}
+ {{end}} +
+ +
+
Last event
+
+ {{.LastEvent.StatusLabel}} + {{.LastEvent.Relative}} +
+ {{if .LastEvent.HasTimestamp}} +
{{.LastEvent.Absolute}}
+ {{end}} + +
+ Details +
+
+
Invocation ID
+
{{.Details.ID}}
+
+
+
Service
+
{{.Details.Service}}
+
+
+
Agent
+
{{.Details.AgentID}}
+
+
+
Target
+
{{.Details.Target}}
+
+
+
Wake adapter
+
{{.Details.WakeAdapter}}
+
+
+
Wake target
+
{{.Details.WakeTarget}}
+
+
+
Message
+
{{.Details.Message}}
+
+
+
Wake command
+
{{.Details.WakeCommand}}
+
+
+
+
+
+ +
+ {{if .Primary.TogglesPauseForm}} + + {{else}} +
+ +
+ {{end}} + +
+ +
+ {{range .Overflow}} +
+ {{range .Fields}} + + {{end}} + +
+ {{end}} + +
+
+
+ + {{if .Primary.TogglesPauseForm}} +
+
+ + +
+ + +
+
+
`datetime-local` is interpreted in {{.PauseForm.Timezone}} and forwarded to claw-api as RFC3339 UTC.
+
+ {{end}} + +
+
+
+
Bypass fire
+

Fire {{.Name}} now?

+

This bypasses the calendar gate and pause state for a one-off wake.

+
+ +
+ {{range .BypassFire.Fields}} + + {{end}} + +
+
+
+
+
+ {{end}} +
+ {{else}} +
This scope has no pod-origin invocations. Pod-level `x-claw.invoke` entries appear here once `claw up` wires them.
+ {{end}} +
+
+ + diff --git a/cmd/clawdash/templates/topology.html b/cmd/clawdash/templates/topology.html index bbad66c..663c179 100644 --- a/cmd/clawdash/templates/topology.html +++ b/cmd/clawdash/templates/topology.html @@ -21,6 +21,7 @@
{{.PodName}}
diff --git a/cmd/clawdash/topology.go b/cmd/clawdash/topology.go index a032981..431f60e 100644 --- a/cmd/clawdash/topology.go +++ b/cmd/clawdash/topology.go @@ -13,6 +13,7 @@ import ( type topologyPageData struct { PodName string ActiveTab string + HasSchedule bool Summary []dashStat Lanes []topologyLane CanvasWidth int diff --git a/docs/plans/2026-04-04-conditional-invoke-scheduling.md b/docs/plans/2026-04-04-conditional-invoke-scheduling.md index 5860c92..27621a6 100644 --- a/docs/plans/2026-04-04-conditional-invoke-scheduling.md +++ b/docs/plans/2026-04-04-conditional-invoke-scheduling.md @@ -490,6 +490,12 @@ Other Slice 4 work: - `/schedule/:id/{pause,resume,skip-next,fire}` endpoints. - Principal scopes: `schedule.read`, `schedule.control`. - `claw api schedule {list,get,pause,resume,fire}` CLI subcommands. + Transport choice: the CLI tunnels through the existing compose/runtime path + with `docker compose exec -T claw-api /claw-api -request-*`; no host port is + published for claw-api in v1. + Trust boundary: this is operator/admin transport. Docker access implies pod + admin, so `--principal` is a selector for an in-container principal, not a + security boundary. - Clawdash schedule page + SSE stream. ### Slice 6 — Migration + docs diff --git a/docs/plans/2026-04-05-clawdash-schedule-ui-redesign.md b/docs/plans/2026-04-05-clawdash-schedule-ui-redesign.md new file mode 100644 index 0000000..07b3ccb --- /dev/null +++ b/docs/plans/2026-04-05-clawdash-schedule-ui-redesign.md @@ -0,0 +1,223 @@ +# Clawdash `/schedule` UI Redesign + +**Date:** 2026-04-05 +**Status:** Draft +**Related:** `docs/plans/2026-04-04-conditional-invoke-scheduling.md` (parent feature) +**Tracking issue:** TBD (link after filing) + +## Why + +The first-pass `/schedule` page ships an operator control plane that *works* but doesn't *read*. During local demo review the page was called out as "scattered and busy" with unclear intent. The specific problems: + +1. **Single 6-column table carries the whole page.** Each row stacks 15–20 lines of text across Invocation / Gate / Timing / Wake / State / Actions columns. Nothing anchors the eye. +2. **Technical fields leak into the hot path.** The "Wake" column renders the full `docker exec` command string. Operators never act on it — it belongs under a disclosure, not inline. +3. **Three columns cover one concept.** Gate, Timing, and Wake are all "when / how this fires." They should consolidate into a single "schedule" block with one hero answer: *when does this fire next?* +4. **Five action buttons per row, always visible.** Pause, Resume, Skip next, Fire now, Force fire. Pause + Resume are mutually exclusive. The red "Force fire" button sits inches from the green "Fire now" — one-click foot-gun. +5. **Summary strip duplicates the panel header.** The six-stat strip (Invocations / Services / Gated / Paused / Skip next / Degraded) competes with the `N visible` chip on the table header and doesn't lead anywhere. +6. **No focal point.** With one row the page is dense; with ten rows it becomes wall-of-text. No visual hierarchy tells the operator *which invocation needs attention right now*. + +## Operator Mental Model + +For one scheduled invocation, an operator needs to answer — in this order: + +1. **Is this healthy?** One glance: scheduled / paused / skip-armed / degraded / failing. +2. **When does it fire next?** Relative ("in 2h 14m") plus absolute ("Mon 14:30 ET") plus the cron + calendar gate. +3. **What did it do last?** Status + timestamp of the last event, with detail on demand. +4. **What do I need to do?** Context-sensitive primary action (Pause *or* Resume), with destructive actions tucked behind disclosure + confirm. + +For the pod as a whole: + +- How many scheduled jobs are there, and is anything off-nominal? (One health strip, not six.) + +## Proposed Shape + +### Layout + +Replace the monolithic table with a **stacked card list**, one card per invocation, plus a trimmed **health strip** above it. + +``` +┌─ Health strip (4 stats) ─────────────────────────────────┐ +│ 6 scheduled · 1 paused · 0 degraded · next 14:30 │ +└──────────────────────────────────────────────────────────┘ + +┌─ westin-open ───────────────────── [● SCHEDULED] ───────┐ +│ │ +│ Next fire in 2h 14m │ +│ Mon 14:30 America/New_York │ +│ ┌ 30 9 * * 1-5 · us-equities / regular │ +│ │ +│ Last event fired · 3m ago │ +│ │ +│ ▸ Details │ +│ │ +│ [ Pause ] ⋯ skip-next fire │ +└───────────────────────────────────────────────────────────┘ +``` + +### Card anatomy + +| Region | Content | +|---|---| +| **Header** | Invocation name (left, bold) · Status pills (right). State is **multi-dimensional** — render up to two primary pills plus one optional chip: **Lifecycle pill** (scheduled / paused) + **Health pill** (shown only when degraded) + **skip-next chip** (shown only when armed). An invocation that's both paused and degraded shows both pills — no information is collapsed. Colors: neutral=scheduled, amber=paused, red=degraded, outlined=skip-next. | +| **Primary line** | "Next slot" — relative time is the hero string (largest type). Absolute timestamp + timezone under it. Cron + `calendar / session` as a subtitle below. **This is the next cron slot, not a guaranteed fire.** The scheduler only tracks `next_fire_at` as the upcoming cron tick; whether it actually fires depends on current state. Rendering adapts:
• **scheduled, no gate issues** → hero reads "Next fire — in 2h 14m"
• **paused** → hero grays out and reads "Next slot — in 2h 14m *(paused, will be skipped)*"; if `paused_until` is set and earlier than the slot, show "resumes in …" beneath
• **skip-next armed** → hero reads "Next slot — in 2h 14m *(skip-next armed, will be skipped)*"; compute and show the slot *after* that as "Next fire — in …"
• **degraded** → hero reads "Next slot — in 2h 14m *(degraded, ~10% fire chance)*"
• **paused + degraded / paused + skip-armed** → paused takes precedence in the hero copy; other modifiers show as subtitle lines. | +| **Secondary line** | "Last event" — `last_status` pill + relative timestamp. Truncated `last_detail` tooltip on hover. This is *orthogonal* to the header pills — header answers "current state," last-event line answers "what happened last time." | +| **Details disclosure** | Collapsed by default. Contains: full ID hash, `service → agent-id`, message body, target container, wake adapter, wake command (monospace). | +| **Actions** | Primary button toggles by lifecycle state: `Pause` when scheduled, `Resume` when paused. Secondary actions behind `⋯` menu: Skip next, Clear skip-next (shown only when skip-next armed), Fire now, Force fire. | + +### Actions — safe by default + +- **Primary button** is state-aware: one button, not both. When `Paused=true` render `Resume`; otherwise `Pause`. When `SkipNext=true` the menu shows "Clear skip-next" instead of "Skip next." +- **Fire now** lives in the overflow menu, not as a top-level button. One click fires; no bypass. +- **Force fire (bypass)** requires a confirm dialog: a modal with the warning "This bypasses the calendar gate and pause state. Fire `` now?" — two explicit buttons. +- Pause opens a small inline form for optional `until` (datetime-local) + `reason`, not a bare POST. See "Control-plane changes" below — the clawdash controller converts `datetime-local` values against the invocation's timezone and forwards RFC3339 UTC to claw-api. + +### Health strip — 4 stats, not 6 + +- **Scheduled** (total) — neutral +- **Paused** — amber when > 0 +- **Degraded** — red when > 0 +- **Next slot** — relative time to the soonest upcoming cron slot across the pod ("in 2h 14m"). Label clarifies this is a slot, not a guaranteed fire, to match the card-level terminology. Computed only from non-paused invocations (paused ones would mislead this metric). + +Drop: "Services," "Gated," "Skip next" as summary stats. They're per-invocation state, not pod-health signals. Gated count is static config; skip-next is ephemeral per-row state. + +### Empty state + +Keep the existing empty card ("No scheduler-owned invocations in scope") but replace with a calmer message that names the likely cause: "This scope has no pod-origin invocations. Pod-level `x-claw.invoke` entries appear here once `claw up` wires them." + +### Sort order + +Cards sort by **next fire time ascending** (soonest first), with paused/degraded pinned to the top regardless of schedule. Currently rows sort by `service → name → id` which doesn't match the operator's scanning order. + +## Implementation Sketch + +### Template + +Replace `cmd/clawdash/templates/schedule.html` section `dash-panel > table` with a `dash-card-list` containing `scheduleCard` entries. Keep the existing `dash-shell`, topbar, nav, and banner structure. + +### Data shape + +Extend `scheduleRow` / rename to `scheduleCard`: + +```go +type scheduleCard struct { + ID string + Name string + + // Header pills — multi-dimensional, not a single enum. + LifecyclePill scheduleBadge // always present: "scheduled" or "paused" + HealthPill *scheduleBadge // present only when Degraded=true + SkipNextChip *scheduleBadge // present only when SkipNext=true + + NextSlot nextSlotDisplay // hero copy adapts to paused/skip-armed/degraded + LastEvent lastEventDisplay + Details scheduleDetails // collapsed content + + Primary scheduleAction // Pause OR Resume (lifecycle-driven) + Overflow []scheduleAction // Skip next / Clear skip-next / Fire now + BypassFire scheduleAction // gated behind confirm modal + PauseForm pauseFormFields // datetime-local + reason + + SortKey time.Time // NextFireAt UTC; zero for pinned rows + Pinned bool // paused or degraded pin to top +} + +type nextSlotDisplay struct { + SlotRelative string // "in 2h 14m" + SlotAbsolute string // "Mon 14:30 America/New_York" + CronExpr string + GateSubtitle string // "us-equities / regular" or "cron only" + HeroLabel string // "Next fire" | "Next slot" + Modifier string // "(paused, will be skipped)" | "(skip-next armed, will be skipped)" | "(degraded, ~10% fire chance)" | "" + FollowupLabel string // "Next fire" when skip-next armed; "resumes in" when paused-until; "" otherwise + FollowupValue string + Dimmed bool // true when paused (grays out the hero) +} +``` + +Computed on the server; template stays dumb. + +### Sort + +In `buildSchedulePageData`, sort by: +1. `Pinned` desc (paused/degraded first) +2. `SortKey` asc (soonest next-fire first) +3. `Name` asc (stable tiebreaker) + +### CSS + +Add `cmd/clawdash/static/schedule.css` classes: +- `.dash-card-list` — vertical gap 0.75rem +- `.dash-schedule-card` — panel styling, padding 1.25rem +- `.dash-schedule-hero` — next-fire relative string, `font-size: 1.5rem`, monospace +- `.dash-schedule-meta-line` — subtitle rows (absolute time, cron) +- `.dash-schedule-details` — `
` element; `summary` is clickable +- `.dash-overflow-menu` — Alpine.js dropdown trigger + panel (already have `alpine.js` in page) +- `.dash-confirm-modal` — bypass-fire dialog + +Drop `.dash-schedule-table`, `.dash-action-stack`, `.dash-action-row` — no longer used. + +### Relative time rendering + +Server-side "in 2h 14m" computed against a `now func() time.Time` injected into `buildSchedulePageData`. Production wires `time.Now`; tests inject a frozen clock. Without this injection the golden-file tests would churn on every run. Acceptable staleness for a refresh-on-action page; no client clock needed initially. If/when SSE lands, the relative string can be re-rendered client-side. + +### Confirm modal + +Alpine.js local state on the bypass-fire button. One modal element per card. Modal POSTs the same form; cancel closes it. + +## Control-plane changes + +This is **not** a template-only change. Two small backend additions are required for the UX to work correctly: + +### 1. `POST /schedule/:id/clear-skip-next` (new endpoint, claw-api) + +The current contract only sets `skip_next = true`; it clears implicitly when the scheduler consumes it on the next tick. The UI needs an explicit clear so an operator can disarm a skip they set by mistake or no longer want. + +- Verb: reuses `schedule.control` (no new verb). +- Semantics: idempotent — sets `state.SkipNext = false` and persists. +- Handler lives next to the existing `handleScheduleSkipNext` in `cmd/claw-api/handler.go`. +- Clawdash controller gains a matching `/schedule/:id/clear-skip-next` POST that forwards to the API. + +Alternative considered: overloading `/skip-next` with a JSON body `{clear: true}`. Rejected — a dedicated path is clearer in audit logs and matches the existing verb-per-endpoint pattern (pause/resume are separate endpoints for the same reason). + +### 2. Clawdash-side timezone conversion for `pause.until` + +`` submits `2026-04-05T14:30` (no seconds, no offset). claw-api's `handleSchedulePause` requires RFC3339 with timezone. + +Fix lives entirely in clawdash (`schedule_page.go` / `schedule.go`): + +- When the pause form submits, the clawdash controller parses the raw value as `2006-01-02T15:04` against the **invocation's declared timezone** (available on `scheduleInvocationView.Timezone`), converts to UTC, formats as RFC3339, and forwards to claw-api. +- If the invocation timezone is empty, fall back to UTC. +- Invalid values surface as a `?error=...` flash on the redirect, same as today. + +No change to `cmd/claw-api/handler.go` for this one — the API contract stays RFC3339, the clawdash controller does the translation. + +### 3. Controller CLI (`claw api`) unaffected + +`claw api schedule pause --until 2026-04-05T14:30:00Z ...` already takes RFC3339 directly. Only the browser form needs conversion. The new `clear-skip-next` endpoint gains a matching `claw api schedule clear-skip-next ` subcommand for parity. + +## Build Sequence + +Done on a feature branch — **direct replacement**, not an in-page A/B. The existing `/schedule` page is small enough that a query-param flag adds more complexity (redirect preservation, dual CSS maintenance) than it saves. + +1. **Control-plane additions** — add `POST /schedule/:id/clear-skip-next` to `cmd/claw-api/handler.go` (handler + authz wired to `schedule.control`); add matching `claw api schedule clear-skip-next` CLI subcommand; unit tests for the new endpoint including idempotency and scope enforcement. +2. **Clock injection** — thread a `now func() time.Time` into `buildSchedulePageData` and `renderSchedule`; default to `time.Now` in production wiring. Existing tests keep passing. +3. **Timezone-aware pause form** — clawdash controller parses `datetime-local` against the invocation's declared timezone, converts to RFC3339 UTC before forwarding. Flash an `?error=` on parse failure. Test with a non-UTC fixture. +4. **Data shape refactor** — introduce `scheduleCard` replacing `scheduleRow`, populated from `scheduleInvocationView`. Sort by next-fire ascending with paused/degraded pinned. Unit-test sort/pin logic with the frozen clock. +5. **New template + CSS** — write `schedule.html` card list, new CSS classes (`dash-card-list`, `dash-schedule-card`, `dash-schedule-hero`, `dash-overflow-menu`, `dash-confirm-modal`). Drop `dash-schedule-table`, `dash-action-stack`, `dash-action-row`. +6. **State-aware primary action + overflow menu** — Pause/Resume toggle; Skip next / Clear skip-next toggle (uses the new endpoint); Fire now plain; Force fire behind Alpine.js confirm modal. +7. **Health strip trim** — 4 stats (Scheduled / Paused / Degraded / Next slot), with the last one computed as the soonest upcoming cron slot across non-paused visible invocations. +8. **Snapshot tests** — render card list with a representative fixture (scheduled, paused, degraded, paused+degraded, skip-armed, recently-fired) under a frozen clock; golden-file the HTML. + +## Out of Scope + +- **Live updates (SSE).** Parent plan calls for SSE on the `/schedule` page; do that after the static layout lands. +- **Edit schedule** (cron string, calendar, timezone). Read/control only. +- **Audit trail view.** `last_event` summary only; no history list. +- **Multi-pod view.** Single-pod scope matches claw-api's reality. + +## Open Questions + +1. **Per-card color accents by status?** Would paused cards get an amber left border? Degraded cards a red one? Probably yes — cheap visual pre-attention cue. +2. **Stale-state indicator.** If `last_evaluated_at` is older than the expected cadence (scheduler hung, not firing), do we flag it? Requires knowing expected cadence from the cron — easy enough, but out of scope for this pass? +3. **Principal name surfaced on page.** Small footer line ("acting as `claw-scheduler`") useful for audit, or clutter? Lean toward small gray footer text. +4. **Demo fixtures from live session** — Codex mentioned two runtime gaps (OpenClaw fixture entrypoint under `/claw` tmpfs; claw-api image missing tzdata). Those get their own issue, not this one. diff --git a/internal/pod/compose_emit.go b/internal/pod/compose_emit.go index 2e8b05d..6961304 100644 --- a/internal/pod/compose_emit.go +++ b/internal/pod/compose_emit.go @@ -25,11 +25,12 @@ type CllamaProxyConfig struct { } type ClawdashConfig struct { - Image string // e.g. ghcr.io/mostlydev/clawdash:latest - Addr string // e.g. :8082 - ManifestHostPath string // host path to pod-manifest.json - DockerSockHostPath string // host path to docker socket - CllamaCostsURL string // external costs URL for operator browser + Image string // e.g. ghcr.io/mostlydev/clawdash:latest + Addr string // e.g. :8082 + ManifestHostPath string // host path to pod-manifest.json + DockerSockHostPath string // host path to docker socket + CllamaCostsURL string // external costs URL for operator browser + Environment map[string]string // extra env vars (e.g. schedule client auth) PodName string } @@ -421,6 +422,9 @@ func EmitCompose(p *Pod, results map[string]*driver.MaterializeResult, proxies . if strings.TrimSpace(p.Clawdash.CllamaCostsURL) != "" { env["CLAWDASH_CLLAMA_COSTS_URL"] = p.Clawdash.CllamaCostsURL } + for key, value := range p.Clawdash.Environment { + env[key] = value + } rootServices["clawdash"] = map[string]interface{}{ "image": p.Clawdash.Image, diff --git a/internal/pod/compose_emit_clawdash_test.go b/internal/pod/compose_emit_clawdash_test.go index a6c0d0f..8617be9 100644 --- a/internal/pod/compose_emit_clawdash_test.go +++ b/internal/pod/compose_emit_clawdash_test.go @@ -23,7 +23,11 @@ func TestEmitComposeInjectsClawdashDashboard(t *testing.T) { ManifestHostPath: "/tmp/.claw-runtime/pod-manifest.json", DockerSockHostPath: "/var/run/docker.sock", CllamaCostsURL: "http://localhost:8181", - PodName: "ops-pod", + Environment: map[string]string{ + "CLAW_API_URL": "http://claw-api:8080", + "CLAW_API_TOKEN": "capi_sched", + }, + PodName: "ops-pod", }, } results := map[string]*driver.MaterializeResult{ @@ -81,6 +85,12 @@ func TestEmitComposeInjectsClawdashDashboard(t *testing.T) { if clawdashSvc.Environment["CLAWDASH_CLLAMA_COSTS_URL"] != "http://localhost:8181" { t.Fatalf("expected CLAWDASH_CLLAMA_COSTS_URL env, got %v", clawdashSvc.Environment["CLAWDASH_CLLAMA_COSTS_URL"]) } + if clawdashSvc.Environment["CLAW_API_URL"] != "http://claw-api:8080" { + t.Fatalf("expected CLAW_API_URL passthrough env, got %v", clawdashSvc.Environment["CLAW_API_URL"]) + } + if clawdashSvc.Environment["CLAW_API_TOKEN"] != "capi_sched" { + t.Fatalf("expected CLAW_API_TOKEN passthrough env, got %v", clawdashSvc.Environment["CLAW_API_TOKEN"]) + } if clawdashSvc.Labels["claw.role"] != "dashboard" { t.Fatalf("expected claw.role=dashboard, got %q", clawdashSvc.Labels["claw.role"]) } diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 1093f76..795f480 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -37,6 +37,9 @@ claw audit [--since ] [--claw ] [--type ] [--json] # summarize cllama telemetry from container logs # types: request, response, error, intervention, # feed_fetch, provider_pool, tool_call +claw api schedule # inspect/control scheduled invocations via claw-api + # list | get | pause | resume | skip-next | + # clear-skip-next | fire # Session history & memory claw history export # export session history as NDJSON @@ -54,6 +57,14 @@ Lifecycle commands block if `claw-pod.yml` is newer than `compose.generated.yml` `-f` locates `compose.generated.yml` next to the pod file. Without `-f`, `claw up` uses `./claw-pod.yml`; other lifecycle commands look for `compose.generated.yml` in the current directory. +`claw api schedule ...` does not require a host-published claw-api port. It +tunnels through `docker compose exec -T claw-api /claw-api -request-*`, so the +pod must already be up and include an injected `claw-api` service. + +Trust boundary: if you can run `docker compose exec` against the pod, you can +select any principal present in claw-api's `principals.json`. The `--principal` +flag is a selector, not a security boundary. + ## Clawfile Reference A Clawfile is an extended Dockerfile. Every valid Dockerfile is a valid Clawfile.