zdd-6 final fix wave 1: W5 draft markers on the remaining new copy
zdd.settings.save on the Deploys card's Save button; zdd.apps-set.flag-app, .error and .unreadable for apps_swap.go's new flag help and error texts; the swap door's 429 gets its own em-dash-free draft, zdd.api.swap-limited. The table placeholder dash stays (ruling). Astra plan 6 R1 #9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+141
−6
apps_swap.go+10 −4apps_swap_markers_test.go+88 −0internal/console/apiswap.go+5 −1internal/console/apiswap_test.go+37 −0internal/console/templates/app_settings.html+1 −1
diff --git a/apps_swap.go b/apps_swap.go| index 9582af12b..1b6bed8d0 100644 |
| --- a/apps_swap.go |
| +++ b/apps_swap.go |
| @@ -32,6 +32,8 @@ Any member of the app may change these. They reach the app's boxes with its |
| config, within seconds.` |
| const ( |
| + // TODO-COPY zdd.apps-set.flag-app |
| + appsSetFlagApp = "app whose swap policy to set" |
| // TODO-COPY zdd.apps-set.flag-swap |
| appsSetFlagSwap = "overlap or hold: how a running instance takes a new build" |
| // TODO-COPY zdd.apps-set.flag-concurrency |
| @@ -54,6 +56,10 @@ const ( |
| appsSetHoldNote = " each instance stops before its new build starts, and requests wait until it is up" |
| // TODO-COPY zdd.apps-set.mismatch |
| appsSetMismatch = "apps set: the change did not stick (the console stored %s, %d at a time). Run it again" |
| + // TODO-COPY zdd.apps-set.error |
| + appsSetError = "apps set: %v" |
| + // TODO-COPY zdd.apps-set.unreadable |
| + appsSetUnreadable = "apps set: unreadable response: %w" |
| // TODO-COPY zdd.apps-set.old-console |
| appsSetOldConsole = "not found: either %s is not an app in this account, or this console predates carlos apps set (deploy a console built from current main)" |
| ) |
| @@ -134,7 +140,7 @@ func appsSetStuck(req swapPolicyReq, got apiSwapPolicy) error { |
| func runAppsSet(args []string) { |
| lead, args := leadingArg(args) |
| fs := flag.NewFlagSet("apps set", flag.ExitOnError) |
| - appF := appFlag(fs, "app whose swap policy to set") |
| + appF := appFlag(fs, appsSetFlagApp) |
| swap := fs.String("swap", "", appsSetFlagSwap) |
| concurrency := fs.Int("swap-concurrency", 0, appsSetFlagConcurrency) |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| @@ -147,7 +153,7 @@ func runAppsSet(args []string) { |
| log.Fatal(err) |
| } |
| if err := validAppName(app); err != nil { |
| - log.Fatalf("apps set: %v", err) |
| + log.Fatalf(appsSetError, err) |
| } |
| req, err := appsSetRequest(*swap, *concurrency, flagWasSet(fs, "swap"), flagWasSet(fs, "swap-concurrency")) |
| if err != nil { |
| @@ -163,7 +169,7 @@ func runAppsSet(args []string) { |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| got, err := sess.setSwapPolicy(context.Background(), acct, app, req) |
| if err != nil { |
| - log.Fatalf("apps set: %v", err) |
| + log.Fatalf(appsSetError, err) |
| } |
| if err := appsSetStuck(req, got); err != nil { |
| log.Fatal(err) |
| @@ -192,7 +198,7 @@ func (s *apiSession) setSwapPolicy(ctx context.Context, a apiAcct, app string, r |
| } |
| var out apiSwapPolicy |
| if err := json.Unmarshal(body, &out); err != nil { |
| - return apiSwapPolicy{}, fmt.Errorf("apps set: unreadable response: %w", err) |
| + return apiSwapPolicy{}, fmt.Errorf(appsSetUnreadable, err) |
| } |
| return out, nil |
| } |
diff --git a/apps_swap_markers_test.go b/apps_swap_markers_test.go| new file mode 100644 |
| index 000000000..428109905 |
| --- /dev/null |
| +++ b/apps_swap_markers_test.go |
| @@ -0,0 +1,88 @@ |
| +package main |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Astra plan 6 R1 #9: every member-facing string `carlos apps set` adds is a |
| +// marked draft. In apps_swap.go, the help text of a flag and the text of an |
| +// error it prints must be a constant whose declaration carries a |
| +// "TODO-COPY zdd." marker. The only literals allowed there are the help |
| +// strings shared, word for word, with the other verbs' --account, |
| +// --account-id and --console flags: existing copy, not new. |
| +func TestAppsSetCopyIsMarkedDrafts(t *testing.T) { |
| + shared := map[string]bool{ |
| + "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)": true, |
| + "account sqid, matched as a sqid ONLY — supersedes --account": true, |
| + "console to act through (default: the only logged-in one)": true, |
| + } |
| + fset := token.NewFileSet() |
| + f, err := parser.ParseFile(fset, "apps_swap.go", nil, parser.ParseComments) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + marked := map[string]bool{} |
| + for _, d := range f.Decls { |
| + g, ok := d.(*ast.GenDecl) |
| + if !ok || g.Tok != token.CONST { |
| + continue |
| + } |
| + for _, sp := range g.Specs { |
| + vs := sp.(*ast.ValueSpec) |
| + doc := "" |
| + if vs.Doc != nil { |
| + doc = vs.Doc.Text() |
| + } |
| + if len(g.Specs) == 1 && g.Doc != nil { |
| + doc += g.Doc.Text() |
| + } |
| + for _, n := range vs.Names { |
| + marked[n.Name] = strings.Contains(doc, "TODO-COPY zdd.") |
| + } |
| + } |
| + } |
| + // copyArg is which argument of each call is member-facing text. |
| + copyArg := map[string]int{"fs.String": 2, "fs.Int": 2, "appFlag": 1, "consoleOption": 1, |
| + "fmt.Errorf": 0, "errors.New": 0, "log.Fatal": 0, "log.Fatalf": 0} |
| + checked := 0 |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + name := "" |
| + switch fn := call.Fun.(type) { |
| + case *ast.Ident: |
| + name = fn.Name |
| + case *ast.SelectorExpr: |
| + if x, ok := fn.X.(*ast.Ident); ok { |
| + name = x.Name + "." + fn.Sel.Name |
| + } |
| + } |
| + i, ok := copyArg[name] |
| + if !ok || i >= len(call.Args) { |
| + return true |
| + } |
| + checked++ |
| + pos := fset.Position(call.Pos()) |
| + switch a := call.Args[i].(type) { |
| + case *ast.BasicLit: |
| + if s, _ := strconv.Unquote(a.Value); !shared[s] { |
| + t.Errorf("%s: %s(%s): new copy as a bare literal; make it a marked draft constant", pos, name, a.Value) |
| + } |
| + case *ast.Ident: |
| + if m, known := marked[a.Name]; known && !m { |
| + t.Errorf("%s: %s(%s): its constant has no TODO-COPY marker", pos, name, a.Name) |
| + } |
| + } |
| + return true |
| + }) |
| + if checked < 10 { |
| + t.Fatalf("only %d copy sites checked in apps_swap.go: the scan is broken", checked) |
| + } |
| +} |
diff --git a/internal/console/apiswap.go b/internal/console/apiswap.go| index 644ff4853..5e73d8a76 100644 |
| --- a/internal/console/apiswap.go |
| +++ b/internal/console/apiswap.go |
| @@ -25,6 +25,10 @@ const ( |
| apiSwapBad = `swap must be "overlap" or "hold"` |
| // TODO-COPY zdd.api.concurrency-bad |
| apiSwapConcurrencyBad = "swap_concurrency must be a whole number, 1 or more" |
| + // apiSwapLimited is this door's own draft: the sibling doors' sentence |
| + // carries an em dash (Astra plan 6 R1 #9). |
| + // TODO-COPY zdd.api.swap-limited |
| + apiSwapLimited = "too many requests. wait a minute and try again" |
| // apiSwapAudit is app, mode, number, then "cli" or "console". |
| // TODO-COPY zdd.api.swap-audit |
| apiSwapAudit = "set the swap policy for %s to %s, %d at a time (%s)" |
| @@ -37,7 +41,7 @@ func (s *Server) handleAPISwapPut(w http.ResponseWriter, r *http.Request) { |
| } |
| p, _ := principalFrom(r.Context()) |
| if !s.allowWrite(p, time.Now()) { |
| - jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + jsonError(w, http.StatusTooManyRequests, apiSwapLimited) |
| return |
| } |
| // Pointers, so an absent half ("leave it") differs from a present one. |
diff --git a/internal/console/apiswap_test.go b/internal/console/apiswap_test.go| index 80ff58e26..eb9ec97dd 100644 |
| --- a/internal/console/apiswap_test.go |
| +++ b/internal/console/apiswap_test.go |
| @@ -69,3 +69,40 @@ func TestAPISwapNamesAMisspeltKey(t *testing.T) { |
| t.Fatalf("typo = %d %s, want a 400 naming swap_conurrency", code, body) |
| } |
| } |
| + |
| +// Astra plan 6 R1 #9: the swap door's rate-limit answer is its own marked |
| +// draft (zdd.api.swap-limited), not the sibling doors' em-dash sentence. |
| +func TestAPISwapRateLimitIsAMarkedDraftWithNoEmDash(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-swap-limited") |
| + u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| + for i := 0; i < cliWriteBurst+3; i++ { |
| + code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"overlap"}`) |
| + if code != http.StatusTooManyRequests { |
| + continue |
| + } |
| + var out struct{ Error string } |
| + if err := json.Unmarshal([]byte(body), &out); err != nil || out.Error != "too many requests. wait a minute and try again" { |
| + t.Fatalf("429 = %s (%v)", body, err) |
| + } |
| + return |
| + } |
| + t.Fatalf("the swap door was never rate-limited in %d attempts", cliWriteBurst+3) |
| +} |
| + |
| +// Astra plan 6 R1 #9: the Deploys card's Save button is new copy, so it |
| +// carries its draft marker like the card's other strings. |
| +func TestDeploysCardSaveButtonIsMarked(t *testing.T) { |
| + raw, err := os.ReadFile("templates/app_settings.html") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, card, ok := strings.Cut(string(raw), "{{/* TODO-COPY zdd.settings.heading */}}") |
| + if !ok { |
| + t.Fatal("no Deploys card in app_settings.html") |
| + } |
| + card, _, _ = strings.Cut(card, "</form>") |
| + if !strings.Contains(card, `<button type="submit" class="btn-sm">{{/* TODO-COPY zdd.settings.save */}}Save</button>`) { |
| + t.Fatalf("the Deploys card's Save button has no zdd.settings.save marker:\n%s", card) |
| + } |
| +} |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 958e3ca07..58f2a42cb 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -377,7 +377,7 @@ is off until you turn it on.</p> |
| <label>{{/* TODO-COPY zdd.settings.concurrency-label */}}{{.SwapCopy.ConcurrencyLabel}} |
| <input type="number" name="swap_concurrency" min="1" step="1" value="{{.SwapPolicy.Concurrency}}" required> |
| </label> |
| - <button type="submit" class="btn-sm">Save</button> |
| + <button type="submit" class="btn-sm">{{/* TODO-COPY zdd.settings.save */}}Save</button> |
| </form> |
| <p class="small">{{/* TODO-COPY zdd.settings.migrations-note */}}{{.SwapCopy.MigrationsNote}}</p> |
| {{end}} |