carlos / platform Public

cli: a replayed run whose output is gone says so, and the open-run line names who started it once (PRO-6214)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a333a94331e3936672d468d5066033faa153fa92 parent 95bb8e9
5 files changed, +68 −6
  • docs/site/cli.md +2 −2
  • internal/console/apiruns.go +3 −3
  • internal/console/apiruns_service_test.go +1 −1
  • run.go +21 −0
  • run_test.go +41 −0
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 64ad50590..d1f367084 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -598,7 +598,7 @@ carlos run exits with the command's own status, 128 plus the signal number when
carlos logs shows when each run started, who asked for it and how it ended. The command's output is not kept there. A service's run reads "started by service ci", with the person it named, if any, marked as the service's statement.
-If you cannot tell whether a run started (the connection dropped as you sent it), start it with `--idempotency-key <key>`. The key is a string you choose: 1 to 128 characters of letters, digits, dots, underscores, colons and hyphens. Run the same command again with the same key and the same files, and carlos says `already started: run <id>` and follows that run, instead of starting a second. Each `--file` is hashed first, because a key only matches a request that is the same in every byte. The same key with a different command, timeout or file is refused with `idempotency_key_reused`. A key is yours alone: no other person or service can see it or collide with it. It is honoured for 24 hours, or until you have started 32 newer runs with keys on that instance, whichever comes first. After that the same key starts a new run.
+If you cannot tell whether a run started (the connection dropped as you sent it), start it with `--idempotency-key <key>`. The key is a string you choose: 1 to 128 characters of letters, digits, dots, underscores, colons and hyphens. Run the same command again with the same key and the same files, and carlos says `already started: run <id>` and follows that run, instead of starting a second. Each `--file` is hashed first, because a key only matches a request that is the same in every byte. The same key with a different command, timeout or file is refused with `idempotency_key_reused`. A key is yours alone: no other person or service can see it or collide with it. It is honoured for 24 hours, or until you have made 32 newer keyed admissions on that instance, whichever comes first. After that the same key starts a new run. If the run has since finished and its output was read, or has been cleared away, carlos says `run <id> was already started with this key, and its output is no longer kept` and exits 2: the run happened, but there is nothing left to follow.
Arguments are kept in the account's audit log, with who ran them. Never put a secret in an argument. Pass it in a --file, or keep it in the instance's environment.
@@ -654,7 +654,7 @@ Every refusal a run route makes has a `code` beside its `error` sentence, for a
- `idempotency_key_invalid`: the key is not 1 to 128 characters of letters, digits, dots, underscores, colons and hyphens.
- `idempotency_key_reused`: the key was used for a different request.
- `keys_not_ready`: this console does not take keys yet. See below.
-- `file_hash_mismatch`: the bytes of a file are not the sha256 `meta` declared for it.
+- `file_hash_mismatch`: the bytes of a file are not the sha256 `meta` declared for it. This is a 400.
A create with an `Idempotency-Key` header is admitted at most once. A retry with the same key and the same request answers `202` with the same body and `"replayed": true`, and starts nothing. Keyed creates carry three rules:
diff --git a/internal/console/apiruns.go b/internal/console/apiruns.go
index 02a91d28a..c126528ca 100644
--- a/internal/console/apiruns.go
+++ b/internal/console/apiruns.go
@@ -141,11 +141,11 @@ const runTooManyMsg = "too many run requests. wait a minute and try again"
// runOpenMsg is refusal 7 (RUN-C24), at the door and at admission. req is
// the open run's request when it could be read: a service's run is named in
-// the house line (runs.Request.ServiceLine), never by its audit address.
+// the house wording (runs.Request.ServiceBy), never by its audit address.
func runOpenMsg(host string, run provision.RunState, req *runs.Request) string {
if req != nil {
- if line := req.ServiceLine(); line != "" {
- return fmt.Sprintf("%s already has a run open: %s, %s", host, run.ID, line)
+ if by := req.ServiceBy(); by != "" {
+ return fmt.Sprintf("%s already has a run open: %s, started %s", host, run.ID, by)
}
}
return fmt.Sprintf("%s already has a run open: %s, started by %s", host, run.ID, run.By)
diff --git a/internal/console/apiruns_service_test.go b/internal/console/apiruns_service_test.go
index eb58715d0..9c9aa0ba0 100644
--- a/internal/console/apiruns_service_test.go
+++ b/internal/console/apiruns_service_test.go
@@ -358,7 +358,7 @@ func TestRunOpenRefusalRedactedForServices(t *testing.T) {
// The member is told whose it is, in the house line: the service, and
// the person only as the service's statement.
status, body = postRun(t, fx.runsURL(runTestHost), fx.member, okMeta(), nil)
- if want := runTestHost + " already has a run open: " + own + ", run by service ci for " + runServiceFor + " (as stated by the service)"; status != http.StatusConflict || errorOf(t, body) != want {
+ if want := runTestHost + " already has a run open: " + own + ", started by service ci for " + runServiceFor + " (as stated by the service)"; status != http.StatusConflict || errorOf(t, body) != want {
t.Fatalf("member over a service's run: %d %s, want %q", status, body, want)
}
}
diff --git a/run.go b/run.go
index 01058e491..0da9a1bc9 100644
--- a/run.go
+++ b/run.go
@@ -795,6 +795,12 @@ func startRun(ctx context.Context, c runClient, t runTerm, o runOpts, app string
if created.Replayed {
// An earlier attempt with this key started the run: this one starts
// nothing, and follows it.
+ if created.State == "" && runGone(ctx, c, created.ID) {
+ // The record no longer names the run and its objects are gone: it
+ // was acknowledged or swept. The key proves it started.
+ t.say(runReplayGone(created.ID))
+ return 2
+ }
t.say(fmt.Sprintf("already started: run %s on %s. to follow it again: carlos run --app %s --instance %s --follow %s", created.ID, o.instance, app, o.instance, created.ID))
} else {
t.say(fmt.Sprintf("run %s on %s. to follow it again: carlos run --app %s --instance %s --follow %s", created.ID, o.instance, app, o.instance, created.ID))
@@ -802,6 +808,21 @@ func startRun(ctx context.Context, c runClient, t runTerm, o runOpts, app string
return followRun(ctx, c, t, o.instance, created.ID, t.now().Add(runs.PickupWindow))
}
+// runReplayGone is what a retry says when its key started a run that is no
+// longer there to follow.
+func runReplayGone(id string) string {
+ return fmt.Sprintf("run %s was already started with this key, and its output is no longer kept", id)
+}
+
+// runGone reports whether the console says it has no run id (a 404 on the
+// first page). Any other answer, an error included, is not "gone": the
+// follow that comes next meets it again and deals with it.
+func runGone(ctx context.Context, c runClient, id string) bool {
+ _, err := c.page(ctx, id, 0)
+ var ce *runConsoleError
+ return errors.As(err, &ce) && ce.status == http.StatusNotFound
+}
+
// runRun is `carlos run` (run spec §3).
func runRun(args []string) {
o, err := parseRunArgs(args, os.Stderr)
diff --git a/run_test.go b/run_test.go
index 3c76f2463..0caf9cabe 100644
--- a/run_test.go
+++ b/run_test.go
@@ -1421,3 +1421,44 @@ func TestRunCLIRetryFollowsTheReplayedRun(t *testing.T) {
t.Fatalf("followed %v, want %v: the retry follows the same run", k.pages, want)
}
}
+
+// replayedRun is a console whose create always answers a replay of run_a
+// with the given state.
+type replayedRun struct {
+ *scriptedRun
+ state string
+}
+
+func (r replayedRun) create(context.Context, runs.Meta, []string) (runs.Created, error) {
+ return runs.Created{ID: "run_a", Host: "shop.example.com", State: r.state, Replayed: true}, nil
+}
+
+// A retry whose key started a run that was since acknowledged or swept has
+// nothing to follow: it says so, rather than the generic "has no run".
+func TestRunCLIRetryOfARunWhoseOutputIsGone(t *testing.T) {
+ o := runOpts{instance: "shop.example.com", argv: []string{"import"}, timeout: time.Minute, idempotencyKey: "k"}
+ f := newTermFx()
+ s := replayedRun{scriptedRun: &scriptedRun{pageErr: &runConsoleError{status: http.StatusNotFound, msg: "no such run"}}}
+ if code := startRun(context.Background(), s, f.term(), o, "shop"); code != 2 {
+ t.Fatalf("exit %d, stderr %q", code, f.err.String())
+ }
+ got := f.err.String()
+ if !strings.Contains(got, "run run_a was already started with this key, and its output is no longer kept") ||
+ strings.Contains(got, "has no run") || strings.Contains(got, "to follow it again") {
+ t.Fatalf("stderr %q", got)
+ }
+
+ // A replay the record still names is followed, with no probe.
+ f = newTermFx()
+ s = replayedRun{scriptedRun: &scriptedRun{pages: []runs.Page{{State: "done", Result: &runs.Result{Exit: 0}}}}, state: "done"}
+ if code := startRun(context.Background(), s, f.term(), o, "shop"); code != 0 || s.pagesRead() != 1 {
+ t.Fatalf("named replay: exit %d, %d pages, stderr %q", code, s.pagesRead(), f.err.String())
+ }
+ // A replay the record no longer names, whose output is still there, is
+ // followed too.
+ f = newTermFx()
+ s = replayedRun{scriptedRun: &scriptedRun{pages: []runs.Page{{State: "done", Result: &runs.Result{Exit: 0}}}}}
+ if code := startRun(context.Background(), s, f.term(), o, "shop"); code != 0 || !strings.Contains(f.err.String(), "already started: run run_a") {
+ t.Fatalf("unnamed replay: exit %d, stderr %q", code, f.err.String())
+ }
+}