cli: schedule — spec usage line; run prints the console's canonical names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+56
−26
apimode.go+26 −9main.go+1 −3schedule.go+6 −6schedule_test.go+23 −8
diff --git a/apimode.go b/apimode.go| index 41292f243..661fd08a3 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1111,25 +1111,42 @@ func (s *apiSession) scheduleRemove(ctx context.Context, a apiAcct, app, name st |
| return res, nil |
| } |
| +// apiScheduleRunResult is handleAPIScheduleRun's answer: |
| +// {"requested":true,"account","app","name"} — the console's CANONICAL |
| +// account sqid and app name, since the segment the caller typed may have |
| +// been a name resolved server-side (restart's apiRestartResult, verbatim |
| +// reasoning; review finding on 23966c9: printing the locally-resolved |
| +// segment here diverged from ls/set/rm, which all print what the console |
| +// echoed back). |
| +type apiScheduleRunResult struct { |
| + Requested bool `json:"requested"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Name string `json:"name"` |
| +} |
| + |
| // scheduleRun asks the console to touch name's run signal |
| // (handleAPIScheduleRun) — TouchRun's etag movement, never its content, is |
| // the signal every box's converge pass acts on, restart-gen's own |
| -// mechanism. The response carries the console's canonical account/app/name |
| -// too, but there is nothing more for a caller to learn from them than the |
| -// request already knew — this returns error-only, unlike ls/set/rm, which |
| -// return the fuller shape because their caller needs the FULL declaration. |
| -func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name string) error { |
| +// mechanism. The response's canonical account/app/name are decoded and |
| +// returned so the caller prints the SAME kind of canonical value |
| +// ls/set/rm do, never the segment this terminal happened to resolve. |
| +func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name string) (apiScheduleRunResult, error) { |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/run"), struct{}{}) |
| if err != nil { |
| - return err |
| + return apiScheduleRunResult{}, err |
| } |
| if status != http.StatusOK { |
| if status == http.StatusNotFound { |
| - return scheduleNameNotFoundErr() |
| + return apiScheduleRunResult{}, scheduleNameNotFoundErr() |
| } |
| - return apiErrorFromBody(status, body) |
| + return apiScheduleRunResult{}, apiErrorFromBody(status, body) |
| } |
| - return nil |
| + var res apiScheduleRunResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiScheduleRunResult{}, fmt.Errorf("schedule run: unreadable response: %w", err) |
| + } |
| + return res, nil |
| } |
| // apiRetentionResult mirrors the console's retention answer: the policy as |
diff --git a/main.go b/main.go| index 5a243494d..d6b3600c8 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -160,9 +160,7 @@ Your apps — ship and run software on a CARLOS deployment |
| (reaching stable cuts the release's semver tag) |
| deploy ship + promote + wait until the URL serves the new version |
| restart cycle an app's instances — no new version, no config change |
| - schedule recurring work on an app's own instances: ls | set | rm | |
| - run — a schedule POSTs to a path the app itself serves, |
| - on every instance, pull-based like restart |
| + schedule recurring or one-shot work per app: ls | set | rm | run |
| store object storage for an app: create | status | grant | rotate |
| email tenant email sending: enable | domains add | status | test | |
| credentials create/list/revoke | rotate | pause | resume |
diff --git a/schedule.go b/schedule.go| index fff78b7bf..251718d78 100644 |
| --- a/schedule.go |
| +++ b/schedule.go |
| @@ -205,15 +205,15 @@ func runScheduleRun(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| appName := resolvedApp(sess, acct, *app, scheduleRunUsage) |
| - if err := sess.scheduleRun(context.Background(), acct, appName, scheduleName); err != nil { |
| + res, err := sess.scheduleRun(context.Background(), acct, appName, scheduleName) |
| + if err != nil { |
| log.Fatalf("schedule run: %v", err) |
| } |
| - // scheduleRun's wire shape (unlike ls/set/rm) answers with no canonical |
| - // account/app to echo — see apimode.go's client method comment — so |
| - // this line reports the SEGMENT this terminal resolved and sent, the |
| - // same value the request itself carried. |
| + // The CANONICAL account/app the console echoed back, ls/set/rm's own |
| + // rule — the segment this terminal resolved and sent may have been a |
| + // name, not the sqid the console actually stores. |
| fmt.Fprintf(stdout, "%s for %s on %s/%s — fires on every instance within ~15s; watch it with carlos logs -app %s -f\n", |
| - colorFor(stdout).ok("run requested"), scheduleName, acct.Seg, appName, appName) |
| + colorFor(stdout).ok("run requested"), res.Name, res.Account, res.App, res.App) |
| } |
| // relFire renders one fire time (unix seconds, 0 meaning "never" — no run |
diff --git a/schedule_test.go b/schedule_test.go| index c3f30b0b7..d7b3c2f7a 100644 |
| --- a/schedule_test.go |
| +++ b/schedule_test.go |
| @@ -120,9 +120,12 @@ func TestAPISessionScheduleRemoveWireShape(t *testing.T) { |
| } |
| // TestAPISessionScheduleRunWireShape pins handleAPIScheduleRun's shape: a |
| -// bearer POST at /schedules/{name}/run. scheduleRun is error-only — the |
| -// response's canonical account/app/name are read by the console side, not |
| -// by this client — so this test asserts on the wire, not on a return value. |
| +// bearer POST at /schedules/{name}/run, with the account segment carried |
| +// verbatim (restart's own rule — the console resolves it, not this |
| +// client). The typed segment ("keymail-team") deliberately differs from |
| +// what the fake server answers ("bab"), so a passing canonical-name |
| +// assertion proves the response was actually decoded and returned, not |
| +// just echoed from the request. |
| func TestAPISessionScheduleRunWireShape(t *testing.T) { |
| var gotPath, gotMethod, gotAuth string |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| @@ -131,18 +134,24 @@ func TestAPISessionScheduleRunWireShape(t *testing.T) { |
| })) |
| defer ts.Close() |
| - if err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync"); err != nil { |
| + res, err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "sync") |
| + if err != nil { |
| t.Fatalf("scheduleRun: %v", err) |
| } |
| if gotMethod != http.MethodPost { |
| t.Fatalf("method = %s, want POST", gotMethod) |
| } |
| - if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/run" { |
| + if gotPath != "/api/cli/apps/keymail-team/keymail/schedules/sync/run" { |
| t.Fatalf("path = %s", gotPath) |
| } |
| if gotAuth != "Bearer cli-token" { |
| t.Fatalf("auth = %q", gotAuth) |
| } |
| + // The CANONICAL names come back from the console, not from what was |
| + // typed — restart's own assertion (TestAPISessionRestartWireShape). |
| + if !res.Requested || res.Account != "bab" || res.App != "keymail" || res.Name != "sync" { |
| + t.Fatalf("result = %+v, want the console's canonical bab/keymail/sync", res) |
| + } |
| } |
| // TestAPISessionScheduleDoorNotFound404 covers ls/set (no {name} segment): |
| @@ -362,14 +371,20 @@ func TestScheduleVerbRefusals(t *testing.T) { |
| t.Fatalf("schedule set both = %q, want the give-one-or-the-other refusal", out) |
| } |
| - // The usage line lands under "Your apps", after restart — carlos help |
| - // is the one place a member finds a verb exists at all. |
| + // The usage line lands under "Your apps", after restart, and reads |
| + // EXACTLY the spec's mandated string (spec §9) — carlos help is the |
| + // one place a member finds a verb exists at all, and a paraphrase |
| + // there is a spec violation even if every word in it is true. |
| out, err = run(nil, "help") |
| if err != nil { |
| t.Fatalf("carlos help exited nonzero:\n%s", out) |
| } |
| + const wantLine = " schedule recurring or one-shot work per app: ls | set | rm | run" |
| + if !strings.Contains(out, wantLine) { |
| + t.Fatalf("carlos help does not carry the exact spec usage line %q:\n%s", wantLine, out) |
| + } |
| restartAt := strings.Index(out, "\n restart ") |
| - scheduleAt := strings.Index(out, "\n schedule ") |
| + scheduleAt := strings.Index(out, "\n"+wantLine) |
| if restartAt == -1 || scheduleAt == -1 || scheduleAt < restartAt { |
| t.Fatalf("carlos help does not list schedule after restart:\n%s", out) |
| } |