carlos / platform Public

cli: carlos schedule ls | set | rm | run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a2e4b50616e2673ad69bd489aec6a6f8c3e1d66d parent 3f9c658
4 files changed, +850 −0
  • apimode.go +119 −0
  • main.go +5 −0
  • schedule.go +350 −0
  • schedule_test.go +376 −0
diff --git a/apimode.go b/apimode.go
index ead8089ba..41292f243 100644
--- a/apimode.go
+++ b/apimode.go
@@ -36,6 +36,7 @@ import (
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/ledger"
+ "github.com/carlosframework/platform/internal/schedule"
)
// apiSession is the resolved API mode: which console, as whom. console and
@@ -1013,6 +1014,124 @@ func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRes
return res, nil
}
+// apiScheduleList mirrors handleAPIScheduleList's wire shape
+// (internal/console/apischedule.go's scheduleAnswer) byte for byte — this
+// binary never imports internal/console, so the shape is redeclared here
+// rather than shared. Three of the four schedule doors answer with this;
+// scheduleRun answers with its own small {"requested":...} shape instead,
+// since a run touches no declaration.
+type apiScheduleList struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Schedules []schedule.Schedule `json:"schedules"`
+ Instances []schedule.Status `json:"instances"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// scheduleDoorNotFoundErr is restart's own 404 widening, renamed: ls and
+// set carry no {name} segment, so a 404 from either can only be the
+// account/app membership gate (apiAccountApp) or a console that predates
+// this door at all.
+func scheduleDoorNotFoundErr() error {
+ return fmt.Errorf("%w — or this console predates `carlos schedule` "+
+ "(no schedule door on it at all), in which case redeploy the console first", notFoundErr())
+}
+
+// scheduleNameNotFoundErr widens scheduleDoorNotFoundErr for rm and run:
+// their {name} segment adds a THIRD cause behind the same byte-identical
+// 404 (declaredSchedule, internal/console/apischedule.go, answers an
+// undeclared name with the same http.NotFound apiAccountApp's membership
+// gate uses) — a probe must not be able to tell any of the three apart, so
+// the message names all of them, cheapest fix first.
+func scheduleNameNotFoundErr() error {
+ return fmt.Errorf("%w, or -name is not declared — or this console predates `carlos schedule` "+
+ "(no schedule door on it at all), in which case redeploy the console first", notFoundErr())
+}
+
+// scheduleList asks the console what app has declared plus what every
+// instance last reported (handleAPIScheduleList). A read: no write budget,
+// no audit line on the console side.
+func (s *apiSession) scheduleList(ctx context.Context, a apiAcct, app string) (apiScheduleList, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/schedules"))
+ if err != nil {
+ return apiScheduleList{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiScheduleList{}, scheduleDoorNotFoundErr()
+ }
+ return apiScheduleList{}, apiErrorFromBody(status, body)
+ }
+ var res apiScheduleList
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiScheduleList{}, fmt.Errorf("schedule ls: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
+// scheduleSet asks the console to declare or replace sc by name
+// (handleAPIScheduleSet). The console re-validates sc.Validate() itself —
+// this call's caller (schedule.go's runScheduleSet) has already run the
+// same check, so most bad requests never reach here at all, but the
+// console's answer is what actually lands, never assumed.
+func (s *apiSession) scheduleSet(ctx context.Context, a apiAcct, app string, sc schedule.Schedule) (apiScheduleList, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules"), sc)
+ if err != nil {
+ return apiScheduleList{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiScheduleList{}, scheduleDoorNotFoundErr()
+ }
+ return apiScheduleList{}, apiErrorFromBody(status, body)
+ }
+ var res apiScheduleList
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiScheduleList{}, fmt.Errorf("schedule set: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
+// scheduleRemove asks the console to drop name (handleAPIScheduleRemove).
+func (s *apiSession) scheduleRemove(ctx context.Context, a apiAcct, app, name string) (apiScheduleList, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/delete"), struct{}{})
+ if err != nil {
+ return apiScheduleList{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiScheduleList{}, scheduleNameNotFoundErr()
+ }
+ return apiScheduleList{}, apiErrorFromBody(status, body)
+ }
+ var res apiScheduleList
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiScheduleList{}, fmt.Errorf("schedule rm: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
+// 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 {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/run"), struct{}{})
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return scheduleNameNotFoundErr()
+ }
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
// apiRetentionResult mirrors the console's retention answer: the policy as
// recorded, plus the console's own count of currently-eligible versions —
// computed console-side because this terminal has no bucket. Eligible is a
diff --git a/main.go b/main.go
index 4899e6fc8..5a243494d 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,8 @@ func main() {
runRestart(args)
case "steering":
runSteering(args)
+ case "schedule":
+ runSchedule(args)
case "economics":
runEconomics(args)
case "store":
@@ -158,6 +160,9 @@ 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
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
new file mode 100644
index 000000000..fff78b7bf
--- /dev/null
+++ b/schedule.go
@@ -0,0 +1,350 @@
+// schedule.go is `carlos schedule ls | set | rm | run` (Task 9, the CLI
+// front over internal/console/apischedule.go). Pull-based, restart's shape
+// and reason: the console touches one small object (schedules.json, or the
+// run-signal touch object beside it), and every box serving the app notices
+// within seconds and acts on its own. Nothing is pushed at a box, no
+// hibernating instance is woken by any of these four verbs.
+//
+// The vocabulary follows restart's honesty rule on purpose: `set` says
+// "recorded", `rm` says "removed", `run` says "requested" — never "fired"
+// or "ran". This terminal is not watching the boxes; it wrote an object and
+// the fleet is free to act on it whenever it next looks.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+const scheduleUsage = `usage: carlos schedule <ls|set|rm|run> [flags]
+
+ ls -app <a> [flags]
+ this app's declared schedules, plus what each instance
+ reports it will do next and what happened last
+ set -app <a> -name <n> (-every <dur> | -cron "<5 fields>") -path </p> [flags]
+ declare a schedule, or replace one already declared under
+ the same name
+ rm -app <a> -name <n> [flags]
+ remove a declared schedule
+ run -app <a> -name <n> [flags]
+ request an out-of-band run on every instance now, on top
+ of its normal schedule
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login): a schedule is a console-authorized write,
+the same shape as carlos restart — boxes act on it within seconds, and a
+hibernating instance is never woken by any of these four verbs.
+`
+
+const scheduleLsUsage = `usage: carlos schedule ls -app <a> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+const scheduleSetUsage = `usage: carlos schedule set -app <a> -name <n> (-every <dur> | -cron "<5 fields>") -path </p> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+const scheduleRmUsage = `usage: carlos schedule rm -app <a> -name <n> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+const scheduleRunUsage = `usage: carlos schedule run -app <a> -name <n> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+// runSchedule dispatches the schedule sub-verbs. ls/set/rm/run are
+// positional words, not flags, and must never reach fs.Parse — the same
+// shape runDomains and runInstances use for their own sub-verbs.
+func runSchedule(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "ls":
+ runScheduleLs(args[1:])
+ return
+ case "set":
+ runScheduleSet(args[1:])
+ return
+ case "rm":
+ runScheduleRm(args[1:])
+ return
+ case "run":
+ runScheduleRun(args[1:])
+ return
+ }
+ }
+ log.Fatal(scheduleUsage)
+}
+
+// requireScheduleAPISession refuses direct-bucket mode with schedule's OWN
+// message, requireRestartAPISession's pattern and reason: a refusal that
+// names another verb's advice sends people somewhere that cannot help them.
+// There is no headless equivalent — the request is a console write with an
+// audit row, not a bucket object this CLI could write unaccompanied.
+func requireScheduleAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos schedule works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A schedule is a console-authorized\n" +
+ "write with an audit row and no direct-bucket equivalent. Unset the store env and run:\n" +
+ "carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+func runScheduleLs(args []string) {
+ fs := flag.NewFlagSet("schedule ls", flag.ExitOnError)
+ app := fs.String("app", "", "app whose schedules to list")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ fs.Parse(args)
+
+ sess := requireScheduleAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name := resolvedApp(sess, acct, *app, scheduleLsUsage)
+ res, err := sess.scheduleList(context.Background(), acct, name)
+ if err != nil {
+ log.Fatalf("schedule ls: %v", err)
+ }
+ renderScheduleList(stdout, res, time.Now())
+}
+
+func runScheduleSet(args []string) {
+ fs := flag.NewFlagSet("schedule set", flag.ExitOnError)
+ app := fs.String("app", "", "app the schedule belongs to")
+ name := fs.String("name", "", "schedule name — [a-z0-9][a-z0-9-]{0,31}")
+ every := fs.String("every", "", "fire every duration, whole minutes only (1m to 720h) — give this or -cron, not both")
+ cron := fs.String("cron", "", `fire on a 5-field cron expression ("min hour dom mon dow") — give this or -every, not both`)
+ path := fs.String("path", "", "absolute path on the app's own instance to POST at each fire")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)")
+ fs.Parse(args)
+
+ sc := schedule.Schedule{
+ Name: strings.TrimSpace(*name),
+ Every: strings.TrimSpace(*every),
+ Cron: strings.TrimSpace(*cron),
+ Path: strings.TrimSpace(*path),
+ }
+ // Client-side gate before the server's, restart's and apps create's
+ // pattern: a request the console is certain to refuse should cost a
+ // typo nothing, and the shared write budget (handleAPIScheduleSet's
+ // allowWrite) is spent per attempt whether or not this ever dials out.
+ // The console runs the SAME Validate() — most typos never reach it.
+ if err := sc.Validate(); err != nil {
+ log.Fatalf("schedule set: %v", err)
+ }
+ sp, err := sc.Spec() // Validate already confirmed this succeeds.
+ if err != nil {
+ log.Fatalf("schedule set: %v", err)
+ }
+
+ sess := requireScheduleAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ appName := resolvedApp(sess, acct, *app, scheduleSetUsage)
+ res, err := sess.scheduleSet(context.Background(), acct, appName, sc)
+ if err != nil {
+ log.Fatalf("schedule set: %v", err)
+ }
+ // "first fire" is computed from OUR OWN validated spec, not read back
+ // off the response: the console's answer carries every declared
+ // schedule (scheduleAnswer), not just the one just set, and this value
+ // is already known client-side the moment Validate() succeeded.
+ fmt.Fprintf(stdout, "%s %s for %s/%s — %s → POST %s; first fire at %s (UTC); boxes pick it up within "+
+ "seconds — `carlos schedule ls -app %s` shows each instance's next run\n",
+ colorFor(stdout).ok("recorded"), sc.Name, res.Account, res.App, sp.String(), sc.Path,
+ sp.Next(time.Now()).UTC().Format(time.RFC3339), res.App)
+}
+
+func runScheduleRm(args []string) {
+ fs := flag.NewFlagSet("schedule rm", flag.ExitOnError)
+ app := fs.String("app", "", "app the schedule belongs to")
+ name := fs.String("name", "", "schedule name to remove")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)")
+ fs.Parse(args)
+ scheduleName := strings.TrimSpace(*name)
+ if scheduleName == "" {
+ log.Fatal(scheduleRmUsage)
+ }
+
+ sess := requireScheduleAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ appName := resolvedApp(sess, acct, *app, scheduleRmUsage)
+ res, err := sess.scheduleRemove(context.Background(), acct, appName, scheduleName)
+ if err != nil {
+ log.Fatalf("schedule rm: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s from %s/%s\n", colorFor(stdout).ok("removed"), scheduleName, res.Account, res.App)
+}
+
+func runScheduleRun(args []string) {
+ fs := flag.NewFlagSet("schedule run", flag.ExitOnError)
+ app := fs.String("app", "", "app the schedule belongs to")
+ name := fs.String("name", "", "schedule name to run now")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console to run through (default: the only logged-in one)")
+ fs.Parse(args)
+ scheduleName := strings.TrimSpace(*name)
+ if scheduleName == "" {
+ log.Fatal(scheduleRunUsage)
+ }
+
+ sess := requireScheduleAPISession(*consoleFlag)
+ 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 {
+ 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.
+ 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)
+}
+
+// relFire renders one fire time (unix seconds, 0 meaning "never" — no run
+// yet, or a status report too old to carry one) relative to now: "—" for
+// the zero epoch, "in <dur>" for a future instant, "<dur> ago" for a past
+// one. Duration granularity is minutes once the gap passes an hour and
+// seconds below it — a minute of jitter on a 6-hour interval is noise a
+// member does not need; a second of it on a 90-second one is the whole
+// story.
+func relFire(epoch int64, now time.Time) string {
+ if epoch == 0 {
+ return "—"
+ }
+ d := time.Unix(epoch, 0).Sub(now)
+ if d < 0 {
+ return fmtDur(-d) + " ago"
+ }
+ return "in " + fmtDur(d)
+}
+
+// fmtDur is relFire's magnitude formatter — always given a non-negative
+// duration.
+func fmtDur(d time.Duration) string {
+ switch {
+ case d >= time.Hour:
+ h := int(d / time.Hour)
+ m := int((d % time.Hour) / time.Minute)
+ return fmt.Sprintf("%dh%02dm", h, m)
+ case d >= time.Minute:
+ m := int(d / time.Minute)
+ s := int((d % time.Minute) / time.Second)
+ return fmt.Sprintf("%dm%02ds", m, s)
+ default:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ }
+}
+
+// renderScheduleList is `carlos schedule ls`'s rendering half, split out so
+// it can be asserted on against a fixed clock without a console. It never
+// panics on a nil Schedules or Instances slice, or on a nil Timers slice
+// inside one Status — scheduleAnswer (internal/console/apischedule.go)
+// always fills `[]` for both top-level arrays, but this still ranges
+// safely over nil either way, the same defensiveness printLiveBoxes and
+// every other list renderer in this binary applies to a server answer it
+// does not fully control.
+func renderScheduleList(w io.Writer, res apiScheduleList, now time.Time) {
+ if len(res.Schedules) == 0 {
+ fmt.Fprintf(w, "no schedules declared for %s/%s — carlos schedule set -app %s -name <n> -every 6h -path /jobs/<n>\n",
+ res.Account, res.App, res.App)
+ } else {
+ fmt.Fprintf(w, "schedules for %s/%s (updated %s)\n", res.Account, res.App, res.UpdatedAt)
+ renderScheduleTable(w, res.Schedules)
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "instances")
+ if len(res.Instances) == 0 {
+ fmt.Fprintln(w, " no instance has reported yet — boxes report within seconds of a schedule landing")
+ return
+ }
+ for _, inst := range res.Instances {
+ renderScheduleInstance(w, inst, now)
+ }
+}
+
+// renderScheduleTable prints the NAME/WHEN/PATH block, column widths sized
+// to the widest cell in each column (header included) plus a 2-space gap —
+// text/tabwriter's own rule, applied by hand here because this table's
+// 2-space left margin has to be part of column one's measured width, not a
+// separate prefix tabwriter would pad independently.
+func renderScheduleTable(w io.Writer, scs []schedule.Schedule) {
+ nameW, whenW := len("NAME"), len("WHEN")
+ whens := make([]string, len(scs))
+ for i, sc := range scs {
+ if n := len(sc.Name); n > nameW {
+ nameW = n
+ }
+ sp, err := sc.Spec()
+ when := sp.String()
+ if err != nil {
+ when = "(invalid: " + err.Error() + ")"
+ }
+ whens[i] = when
+ if n := len(when); n > whenW {
+ whenW = n
+ }
+ }
+ fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, "NAME", whenW+2, "WHEN", "PATH")
+ for i, sc := range scs {
+ fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, sc.Name, whenW+2, whens[i], sc.Path)
+ }
+}
+
+// renderScheduleInstance prints one instance's block: the host line, then
+// one indented line per timer it reported. Unsupported (set only for a
+// unit-backed host — converge.go's UnsupportedUnit) short-circuits to a
+// single explanatory line with no timers, because a unit-backed instance
+// never gets far enough to have any.
+func renderScheduleInstance(w io.Writer, inst schedule.Status, now time.Time) {
+ if inst.Unsupported != "" {
+ fmt.Fprintf(w, " %s %s\n", inst.Host, inst.Unsupported)
+ return
+ }
+ fmt.Fprintf(w, " %s (box %s, reported %s ago)\n", inst.Host, inst.Box, reportedAgo(inst.ReportedAt, now))
+ nameW := 0
+ for _, t := range inst.Timers {
+ if n := len(t.Name); n > nameW {
+ nameW = n
+ }
+ }
+ for _, t := range inst.Timers {
+ next := relFire(t.NextAt, now)
+ last := relFire(t.LastAt, now)
+ if t.LastAt != 0 && t.LastStatus != "" {
+ last += fmt.Sprintf(" → %s in %.1fs", t.LastStatus, float64(t.LastMs)/1000)
+ }
+ fmt.Fprintf(w, " %-*snext %s last %s\n", nameW+2, t.Name, next, last)
+ }
+}
+
+// reportedAgo turns a Status.ReportedAt RFC3339 string into fmtDur's "X ago"
+// magnitude. A report this binary cannot parse (an old box's odd format, or
+// a corrupt object a bad actor wrote) prints the raw string rather than
+// panicking or hiding it — the caller still sees SOMETHING was reported.
+func reportedAgo(s string, now time.Time) string {
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ return s
+ }
+ d := now.Sub(t)
+ if d < 0 {
+ d = 0
+ }
+ return fmtDur(d)
+}
diff --git a/schedule_test.go b/schedule_test.go
new file mode 100644
index 000000000..c3f30b0b7
--- /dev/null
+++ b/schedule_test.go
@@ -0,0 +1,376 @@
+package main
+
+// The CLI half of `carlos schedule` (Task 9): the wire shape each of the
+// four doors requires, the renderer `carlos schedule ls` prints through
+// (asserted against a fixed clock so it needs no console at all), and the
+// one client-side refusal (`-every 30s`) that must never reach the network.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+// TestAPISessionScheduleListWireShape pins handleAPIScheduleList's shape: a
+// bearer GET at the app's /schedules path, the account segment carried
+// verbatim (the console resolves names server-side, never this client), and
+// the canonical account/app the console echoes back read straight through.
+func TestAPISessionScheduleListWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewEncoder(w).Encode(apiScheduleList{
+ Account: "bab", App: "keymail",
+ Schedules: []schedule.Schedule{{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}},
+ Instances: []schedule.Status{},
+ UpdatedAt: "2026-08-23T22:14:09Z",
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail")
+ if err != nil {
+ t.Fatalf("scheduleList: %v", err)
+ }
+ if gotMethod != http.MethodGet {
+ t.Fatalf("method = %s, want GET", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/keymail-team/keymail/schedules" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ if res.Account != "bab" || res.App != "keymail" {
+ t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
+ }
+ if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" {
+ t.Fatalf("schedules = %+v", res.Schedules)
+ }
+}
+
+// TestAPISessionScheduleSetWireShape pins handleAPIScheduleSet's shape: a
+// bearer POST at /schedules whose body is the schedule.Schedule JSON
+// verbatim — the console re-validates it, this client does not reshape it.
+func TestAPISessionScheduleSetWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ var gotBody schedule.Schedule
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{gotBody}, Instances: []schedule.Status{}})
+ }))
+ defer ts.Close()
+
+ sc := schedule.Schedule{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}
+ res, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", sc)
+ if err != nil {
+ t.Fatalf("scheduleSet: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/bab/keymail/schedules" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ if gotBody != sc {
+ t.Fatalf("body = %+v, want %+v sent verbatim", gotBody, sc)
+ }
+ if res.Account != "bab" || res.App != "keymail" {
+ t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
+ }
+}
+
+// TestAPISessionScheduleRemoveWireShape pins handleAPIScheduleRemove's
+// shape: a bearer POST at /schedules/{name}/delete.
+func TestAPISessionScheduleRemoveWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{}, Instances: []schedule.Status{}})
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync")
+ if err != nil {
+ t.Fatalf("scheduleRemove: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/delete" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ if res.Account != "bab" || res.App != "keymail" {
+ t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
+ }
+}
+
+// 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.
+func TestAPISessionScheduleRunWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail", "name": "sync"})
+ }))
+ defer ts.Close()
+
+ if err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync"); 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" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+}
+
+// TestAPISessionScheduleDoorNotFound404 covers ls/set (no {name} segment):
+// the byte-identical 404 apiAccountApp gives an unknown account, someone
+// else's account, an app that isn't there, OR an old console with no
+// schedule door at all — nothing in the response tells those apart, so the
+// message must name every one.
+func TestAPISessionScheduleDoorNotFound404(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "bab"}, "keymail")
+ if err == nil {
+ t.Fatal("a 404 must error")
+ }
+ if !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("error %q drops the membership explanation", err)
+ }
+ if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "carlos schedule") {
+ t.Fatalf("error %q does not name the old-console cause", err)
+ }
+}
+
+// TestAPISessionScheduleNameNotFound404 covers rm/run's {name} segment: the
+// SAME byte-identical 404 (declaredSchedule) also means "-name was never
+// declared" — a third cause the ls/set message cannot know to mention.
+func TestAPISessionScheduleNameNotFound404(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "ghost")
+ if err == nil {
+ t.Fatal("a 404 must error")
+ }
+ if !strings.Contains(err.Error(), "-name is not declared") {
+ t.Fatalf("error %q drops the undeclared-name explanation", err)
+ }
+ if !strings.Contains(err.Error(), "-account") || !strings.Contains(err.Error(), "predates") {
+ t.Fatalf("error %q drops the other two causes", err)
+ }
+}
+
+// TestAPISessionScheduleServerErrorSurfacesVerbatim: a door that answers
+// with a real diagnosis (the shared write budget, an over-cap declaration)
+// must reach the terminal in the console's own words.
+func TestAPISessionScheduleServerErrorSurfacesVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusTooManyRequests)
+ json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", schedule.Schedule{Name: "sync", Every: "1h", Path: "/jobs/sync"})
+ if err == nil || !strings.Contains(err.Error(), "wait a minute") {
+ t.Fatalf("schedule set 429 = %v, want the console's own text", err)
+ }
+}
+
+// TestRenderScheduleList pins the exact layout the brief specifies: the
+// declared-schedules table, a blank line, then one block per instance —
+// against a FIXED clock, so relative times ("next in 4h52m") are
+// deterministic instead of racing wall-clock time.
+func TestRenderScheduleList(t *testing.T) {
+ now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) // matches the brief's "reported 12s ago"
+ res := apiScheduleList{
+ Account: "bab", App: "app",
+ UpdatedAt: "2026-08-23T22:14:09Z",
+ Schedules: []schedule.Schedule{
+ {Name: "reminders", Cron: "0 8 * * *", Path: "/jobs/reminders"},
+ {Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"},
+ },
+ Instances: []schedule.Status{
+ {
+ Host: "a.bab.oncarlos.com", Box: "carlos-ie-1",
+ ReportedAt: now.Add(-12 * time.Second).Format(time.RFC3339),
+ Timers: []schedule.TimerStatus{
+ {
+ Name: "sync",
+ NextAt: now.Add(4*time.Hour + 52*time.Minute).Unix(),
+ LastAt: now.Add(-(1*time.Hour + 8*time.Minute)).Unix(),
+ LastStatus: "204",
+ LastMs: 3200,
+ },
+ {
+ Name: "reminders",
+ NextAt: now.Add(9*time.Hour + 53*time.Minute).Unix(),
+ },
+ },
+ },
+ {
+ Host: "u.bab.oncarlos.com",
+ Unsupported: schedule.UnsupportedUnit,
+ },
+ },
+ }
+
+ var buf strings.Builder
+ renderScheduleList(&buf, res, now)
+
+ want := "schedules for bab/app (updated 2026-08-23T22:14:09Z)\n" +
+ " NAME WHEN PATH\n" +
+ " reminders cron 0 8 * * * /jobs/reminders\n" +
+ " sync every 6h0m0s /jobs/sync\n" +
+ "\n" +
+ "instances\n" +
+ " a.bab.oncarlos.com (box carlos-ie-1, reported 12s ago)\n" +
+ " sync next in 4h52m last 1h08m ago → 204 in 3.2s\n" +
+ " reminders next in 9h53m last —\n" +
+ " u.bab.oncarlos.com unit-backed instance — scheduled work delivers to exec-backed instances in this release\n"
+ if got := buf.String(); got != want {
+ t.Fatalf("renderScheduleList =\n%s\nwant\n%s", got, want)
+ }
+}
+
+// TestRenderScheduleListEmpty pins the two empty-state sentences: no
+// schedules declared, and no instance has reported.
+func TestRenderScheduleListEmpty(t *testing.T) {
+ now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC)
+ res := apiScheduleList{Account: "bab", App: "app"}
+
+ var buf strings.Builder
+ renderScheduleList(&buf, res, now)
+
+ want := "no schedules declared for bab/app — carlos schedule set -app app -name <n> -every 6h -path /jobs/<n>\n" +
+ "\n" +
+ "instances\n" +
+ " no instance has reported yet — boxes report within seconds of a schedule landing\n"
+ if got := buf.String(); got != want {
+ t.Fatalf("renderScheduleList (empty) =\n%s\nwant\n%s", got, want)
+ }
+}
+
+// TestRenderScheduleListNilSlices proves the renderer never panics on a nil
+// Schedules/Instances/Timers — the shape a pre-scheduleAnswer console (or a
+// hand-built apiScheduleList{} in a test) can still hand it, even though
+// scheduleAnswer itself always fills `[]`.
+func TestRenderScheduleListNilSlices(t *testing.T) {
+ var buf strings.Builder
+ renderScheduleList(&buf, apiScheduleList{Account: "bab", App: "app"}, time.Now())
+ if buf.Len() == 0 {
+ t.Fatal("renderScheduleList wrote nothing")
+ }
+}
+
+// TestScheduleVerbRefusals drives the real binary for the ways `carlos
+// schedule` stops before it ever dials anything: direct bucket mode, and
+// -every 30s (schedule.ValidEvery's "whole minutes only" rule) — run with
+// NO console configured at all, so a passing test proves the validation
+// error is what stopped it, not a session failure that happened to look
+// similar. Both run through carlosTestEnv with a scratch cmd.Dir — the
+// repo rule — so a logged-in laptop's real credential and console can
+// never be what this test exercises. One buildCarlos call covers both,
+// restart_test.go's TestRestartVerbRefusals shape, because the build is
+// the expensive part and everything after it is a fast subprocess run.
+func TestScheduleVerbRefusals(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ run := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, env...)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // Direct bucket mode: refused in schedule's OWN words, naming the
+ // login that fixes it — never another verb's advice.
+ out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "schedule", "ls", "-app", "keymail")
+ if err == nil {
+ t.Fatalf("schedule ls in direct bucket mode exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos schedule works through a console") ||
+ !strings.Contains(out, "carlos auth login") {
+ t.Fatalf("direct-mode refusal = %q, want schedule's own message and the login fix", out)
+ }
+
+ // -every 30s fails schedule.ValidEvery ("shortest interval is 1m" — 30s
+ // is under MinEvery, checked before the whole-minutes rule) entirely
+ // client-side, in schedule.go's runScheduleSet, BEFORE
+ // requireScheduleAPISession ever runs — so this must fail on the
+ // validation message even with no console configured at all, never on
+ // "no deployment target configured" or a login prompt.
+ out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "30s", "-path", "/jobs/sync")
+ if err == nil {
+ t.Fatalf("schedule set -every 30s exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "shortest interval is 1m") {
+ t.Fatalf("schedule set -every 30s = %q, want the ValidEvery refusal, unreached network", out)
+ }
+ if strings.Contains(out, "no deployment target") || strings.Contains(out, "auth login") {
+ t.Fatalf("schedule set -every 30s = %q, reached session resolution before validating", out)
+ }
+
+ // -every 90s is >= the 1m floor but not a whole minute — the OTHER
+ // ValidEvery clause ("whole minutes only"), also unreached network.
+ out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "90s", "-path", "/jobs/sync")
+ if err == nil {
+ t.Fatalf("schedule set -every 90s exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "whole minutes only") {
+ t.Fatalf("schedule set -every 90s = %q, want the whole-minutes refusal", out)
+ }
+
+ // Giving both -every and -cron is the same client-side gate
+ // (Schedule.Spec's "give -every or -cron, not both"), also unreached
+ // network.
+ out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "1h", "-cron", "0 8 * * *", "-path", "/jobs/sync")
+ if err == nil {
+ t.Fatalf("schedule set with both -every and -cron exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "not both") {
+ 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.
+ out, err = run(nil, "help")
+ if err != nil {
+ t.Fatalf("carlos help exited nonzero:\n%s", out)
+ }
+ restartAt := strings.Index(out, "\n restart ")
+ scheduleAt := strings.Index(out, "\n schedule ")
+ if restartAt == -1 || scheduleAt == -1 || scheduleAt < restartAt {
+ t.Fatalf("carlos help does not list schedule after restart:\n%s", out)
+ }
+}