fix(cron): send silent edit value as bool so management API accepts it (#915)

`cc-connect cron edit <id> silent true` is documented in
printCronEditUsage as a supported bool edit, but runCronEdit's
type-switch only coerced `enabled`, `mute`, and `timeout_mins` from
strings — `silent` fell through to the string default.

That meant the JSON body shipped to the management API had
`{"value": "true"}` (string) for silent. The server-side
updateJobField (core/cron.go:333) does:

    case "silent":
        if v, ok := value.(bool); ok { ... }

so the type-assert failed, and the field then took the string
fallback path that does `reflect.FieldByName(...).Kind() ==
reflect.String`. CronJob.Silent is `*bool`, not String, so the
fallback also failed and the user got back the misleading error:

    Error: failed to update field "silent" (may be read-only or invalid type)

Reproducer (with /tmp test store, before the fix):

    cs.UpdateJob("edit-test", "silent", "true")
      -> err = failed to update field "silent" (...)
    cs.UpdateJob("edit-test", "silent", true)
      -> err = <nil>

Fix: add `silent` to the bool case alongside `enabled` and `mute`.
While here, extract the parse logic into a small helper
parseCronEditValue so it can be unit-tested without a fake socket
server.

Add cmd/cc-connect/cron_edit_test.go covering the three bool fields,
timeout_mins, the string fallthrough, and an invalid-bool case.
Verified that removing `silent` from the bool case fails the two
silent regression tests with the original symptom — string returned
where bool was expected.
This commit is contained in:
Shawn
2026-05-18 10:13:28 +08:00
committed by GitHub
parent 9c78b04566
commit 7055ecd9f4
2 changed files with 101 additions and 20 deletions

View File

@@ -449,26 +449,10 @@ func runCronEdit(args []string) {
os.Exit(1)
}
// Parse value based on field type
var value any
switch field {
case "enabled", "mute":
v, err := strconv.ParseBool(valueStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s must be true or false\n", field)
os.Exit(1)
}
value = v
case "timeout_mins":
v, err := strconv.Atoi(valueStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: timeout_mins must be an integer\n")
os.Exit(1)
}
value = v
default:
// String fields: project, session_key, cron_expr, prompt, exec, work_dir, description, session_mode
value = valueStr
value, err := parseCronEditValue(field, valueStr)
if err != nil {
fmt.Fprintln(os.Stderr, "Error: "+err.Error())
os.Exit(1)
}
sockPath := resolveSocketPath(dataDir)
@@ -500,6 +484,39 @@ func runCronEdit(args []string) {
fmt.Printf("Updated job %s:\n%s\n", id, prettyJSON.String())
}
// parseCronEditValue converts the user-supplied <value> argument into the
// concrete Go type the management API's cron-edit handler expects on the wire.
// Bool/int fields must be sent as the matching JSON type so the server's
// updateJobField type-switch (core/cron.go) matches — otherwise the server
// falls through to its string-via-reflection path, which doesn't apply to
// *bool / *int fields and returns the misleading error
// "unknown or invalid field: <field>".
//
// `silent` was previously missing from the bool case here even though it's
// documented as a bool in printCronEditUsage, so `cc-connect cron edit <id>
// silent true` failed with "unknown or invalid field: silent" — see the
// regression test in cron_edit_test.go.
func parseCronEditValue(field, valueStr string) (any, error) {
switch field {
case "enabled", "mute", "silent":
v, err := strconv.ParseBool(valueStr)
if err != nil {
return nil, fmt.Errorf("%s must be true or false", field)
}
return v, nil
case "timeout_mins":
v, err := strconv.Atoi(valueStr)
if err != nil {
return nil, fmt.Errorf("timeout_mins must be an integer")
}
return v, nil
default:
// String fields: project, session_key, cron_expr, prompt, exec,
// work_dir, description, session_mode, mode
return valueStr, nil
}
}
func apiPost(sockPath, path string, payload []byte) (*http.Response, error) {
client := &http.Client{
Transport: &http.Transport{

View File

@@ -0,0 +1,64 @@
package main
import (
"testing"
)
// TestParseCronEditValue covers the value-coercion that runCronEdit applies
// before sending the JSON body to the management API. The server's
// updateJobField type-switch (core/cron.go) requires bools as JSON booleans
// and timeout_mins as a JSON number — sending a string falls through to a
// reflection-based string-field setter that doesn't apply to *bool / *int,
// and the user gets back "unknown or invalid field: <field>".
//
// Pre-fix bug: `silent` was missing from the bool case, so
// `cc-connect cron edit <id> silent true` shipped {"value": "true"} and the
// server rejected it. printCronEditUsage already documents `silent` as a
// bool, so the CLI parsing has to match.
func TestParseCronEditValue(t *testing.T) {
tests := []struct {
name string
field string
input string
want any
wantError bool
}{
// Bool fields — all three must round-trip as bool, not string.
{"enabled true", "enabled", "true", true, false},
{"enabled false", "enabled", "false", false, false},
{"mute true", "mute", "true", true, false},
{"silent true (regression)", "silent", "true", true, false},
{"silent false (regression)", "silent", "false", false, false},
{"silent invalid", "silent", "yes please", nil, true},
// Numeric field.
{"timeout_mins zero", "timeout_mins", "0", 0, false},
{"timeout_mins positive", "timeout_mins", "60", 60, false},
{"timeout_mins invalid", "timeout_mins", "soon", nil, true},
// String fields fall through unchanged.
{"cron_expr string", "cron_expr", "0 6 * * *", "0 6 * * *", false},
{"description string", "description", "Daily standup", "Daily standup", false},
{"session_mode string", "session_mode", "reuse", "reuse", false},
{"unknown field defaults to string", "made_up_field", "anything", "anything", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseCronEditValue(tt.field, tt.input)
if tt.wantError {
if err == nil {
t.Fatalf("parseCronEditValue(%q, %q) = (%v, nil), want error", tt.field, tt.input, got)
}
return
}
if err != nil {
t.Fatalf("parseCronEditValue(%q, %q) returned unexpected error: %v", tt.field, tt.input, err)
}
if got != tt.want {
t.Errorf("parseCronEditValue(%q, %q) = %v (%T), want %v (%T)",
tt.field, tt.input, got, got, tt.want, tt.want)
}
})
}
}