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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions cmd/cc-connect/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func runCron(args []string) {
runCronEdit(args[1:])
case "info":
runCronInfo(args[1:])
case "run":
runCronRun(args[1:])
case "del", "delete", "rm", "remove":
runCronDel(args[1:])
case "--help", "-h", "help":
Expand Down Expand Up @@ -271,6 +273,54 @@ func runCronList(args []string) {
}
}

func runCronRun(args []string) {
var id, dataDir string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--data-dir":
if i+1 < len(args) {
i++
dataDir = args[i]
}
case "--help", "-h":
printCronRunUsage()
return
default:
if id == "" {
id = args[i]
}
}
}

if id == "" {
fmt.Fprintln(os.Stderr, "Error: job ID is required")
printCronRunUsage()
os.Exit(1)
}

sockPath := resolveSocketPath(dataDir)
if _, err := os.Stat(sockPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: cc-connect is not running (socket not found: %s)\n", sockPath)
os.Exit(1)
}

payload, _ := json.Marshal(map[string]any{"id": id})
resp, err := apiPost(sockPath, "/cron/run", payload)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
fmt.Fprintf(os.Stderr, "Error: %s\n", strings.TrimSpace(string(body)))
os.Exit(1)
}

fmt.Printf("Triggered cron job: %s\n", id)
}

func runCronDel(args []string) {
var dataDir string
var id string
Expand Down Expand Up @@ -517,6 +567,7 @@ func printCronUsage() {
Commands:
add Create a new scheduled task
list List all scheduled tasks
run <id> Trigger a scheduled task immediately
edit Edit a scheduled task field
info <id> [field] Show detailed info of a scheduled task
(optionally filter to a single field)
Expand Down Expand Up @@ -548,6 +599,19 @@ Examples:
cc-connect cron add 0 6 * * * Collect GitHub trending data and send me a summary`)
}

func printCronRunUsage() {
fmt.Println(`Usage: cc-connect cron run <id> [options]

Trigger an existing scheduled task immediately.

Options:
--data-dir <path> Data directory (default: ~/.cc-connect)
-h, --help Show this help

Example:
cc-connect cron run abc123`)
}

func printCronEditUsage() {
fmt.Println(`Usage: cc-connect cron edit <id> <field> <value> [options]

Expand Down
1 change: 1 addition & 0 deletions cmd/cc-connect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ Commands:
cron Manage scheduled tasks
add Create a scheduled task (-c <expr> --prompt <text>)
list List scheduled tasks
run Trigger a scheduled task immediately
del Delete a scheduled task by ID

sessions Browse session history
Expand Down
41 changes: 41 additions & 0 deletions cmd/cc-connect/main_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package main

import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/chenhg5/cc-connect/config"
Expand Down Expand Up @@ -248,3 +251,41 @@ func TestStartInitialRefresh_AfterProjectStateOverride(t *testing.T) {
t.Fatalf("agent workDir at refresh = %q, want %q", agent.workDir, overrideDir)
}
}

func captureStderr(t *testing.T, fn func()) string {
t.Helper()
old := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stderr = w
defer func() {
os.Stderr = old
}()

fn()

if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("copy stderr: %v", err)
}
if err := r.Close(); err != nil {
t.Fatalf("close reader: %v", err)
}
return buf.String()
}

func TestPrintUsage_ListsCronRunCommand(t *testing.T) {
out := captureStderr(t, printUsage)

if !strings.Contains(out, "Manage scheduled tasks") {
t.Fatalf("printUsage() output missing cron section:\n%s", out)
}
if !strings.Contains(out, "run Trigger a scheduled task immediately") {
t.Fatalf("printUsage() output missing cron run command:\n%s", out)
}
}
39 changes: 39 additions & 0 deletions core/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package core

import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
Expand Down Expand Up @@ -68,6 +69,7 @@ func NewAPIServer(dataDir string) (*APIServer, error) {
s.mux.HandleFunc("/cron/info", s.handleCronInfo)
s.mux.HandleFunc("/cron/edit", s.handleCronEdit)
s.mux.HandleFunc("/cron/del", s.handleCronDel)
s.mux.HandleFunc("/cron/run", s.handleCronRun)
s.mux.HandleFunc("/relay/send", s.handleRelaySend)
s.mux.HandleFunc("/relay/bind", s.handleRelayBind)
s.mux.HandleFunc("/relay/binding", s.handleRelayBinding)
Expand Down Expand Up @@ -354,6 +356,43 @@ func (s *APIServer) handleCronDel(w http.ResponseWriter, r *http.Request) {
}
}

func (s *APIServer) handleCronRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.cron == nil {
http.Error(w, "cron scheduler not available", http.StatusServiceUnavailable)
return
}

var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}

if err := s.cron.RunJobNow(req.ID); err != nil {
if errors.Is(err, ErrCronJobNotFound) {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

apiJSON(w, http.StatusAccepted, map[string]string{
"id": req.ID,
"status": "triggered",
})
}

func (s *APIServer) handleCronInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
Expand Down
88 changes: 88 additions & 0 deletions core/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestHandleSend_AllowsAttachmentOnly(t *testing.T) {
Expand Down Expand Up @@ -38,3 +39,90 @@ func TestHandleSend_AllowsAttachmentOnly(t *testing.T) {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
}

func TestHandleCronRun_TriggersJob(t *testing.T) {
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
scheduler := NewCronScheduler(store)

platform := &stubCronReplyTargetPlatform{
stubPlatformEngine: stubPlatformEngine{n: "discord"},
}
agentSession := newResultAgentSession("triggered from local api")
engine := NewEngine("test", &resultAgent{session: agentSession}, []Platform{platform}, "", LangEnglish)
defer engine.cancel()
engine.cronScheduler = scheduler
scheduler.RegisterEngine("test", engine)

job := &CronJob{
ID: "job-run-api",
Project: "test",
SessionKey: "discord:channel-1:user-1",
CronExpr: "0 6 * * *",
Prompt: "run now",
Description: "Run from API",
Enabled: false,
}
if err := store.Add(job); err != nil {
t.Fatal(err)
}

api := &APIServer{engines: map[string]*Engine{"test": engine}, cron: scheduler}
body, err := json.Marshal(map[string]any{"id": job.ID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}

req := httptest.NewRequest(http.MethodPost, "/cron/run", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.handleCronRun(rec, req)

if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}

deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(platform.getSent()) >= 2 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for local api trigger, sent=%v", platform.getSent())
}

func TestHandleCronRun_ProjectMissingIsBadRequest(t *testing.T) {
store, err := NewCronStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
scheduler := NewCronScheduler(store)

job := &CronJob{
ID: "job-run-missing-project",
Project: "ghost",
SessionKey: "discord:channel-1:user-1",
CronExpr: "0 6 * * *",
Prompt: "run now",
Enabled: true,
}
if err := store.Add(job); err != nil {
t.Fatal(err)
}

api := &APIServer{cron: scheduler}
body, err := json.Marshal(map[string]any{"id": job.ID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}

req := httptest.NewRequest(http.MethodPost, "/cron/run", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.handleCronRun(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
}
Loading
Loading