carlos / platform Public

cli: schedule — spec usage line; run prints the console's canonical names

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 4e2e7ef042337be77e3294a1ae31841810231a62 parent 7372698
4 files changed, +56 −26
  • apimode.go +26 −9
  • main.go +1 −3
  • schedule.go +6 −6
  • schedule_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)
}