mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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:
@@ -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{
|
||||
|
||||
64
cmd/cc-connect/cron_edit_test.go
Normal file
64
cmd/cc-connect/cron_edit_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user