Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@ pad item deps <ref> Show dependencies
pad item unblock <src> <target> Remove dependency
pad item related <ref> Show direct relationships for an item
pad item implemented-by <ref> Show incoming implementers for an item
pad item claim <ref> Atomically claim an item for execution (--holder, --ttl; 409 names the live holder)
pad item release <ref> Release your execution lease (idempotent)
pad item bulk-update --status X Batch update multiple items

pad collection list List collections with item counts
Expand Down
116 changes: 116 additions & 0 deletions cmd/pad/cmd_item_lease.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"fmt"
"time"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/PerpetualSoftware/pad/internal/cli"
)

// `pad item claim` / `pad item release` (#1221): the execution lease. A
// claim answers "may I be the one executing this right now" atomically —
// two pollers that both read "unclaimed" get one winner and one
// structured refusal naming the live holder, instead of two winners.

func itemClaimCmd() *cobra.Command {
var (
holderFlag string
ttlFlag string
)

cmd := &cobra.Command{
Use: "claim <ref>",
Short: "Atomically claim an item for execution (lease with expiry)",
Long: `Acquire the execution lease on an item, or refresh it if you already
hold it (a re-claim by the live holder extends the expiry — heartbeat).

Fails with the live holder and expiry when someone else holds the item, so
the loser can log who won and skip instead of double-working. The lease
expires on its own; a crashed holder blocks nobody past the TTL.

Examples:
pad item claim TASK-5
pad item claim TASK-5 --holder sweep-runner --ttl 30m`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ttlSeconds := 0
if ttlFlag != "" {
d, err := time.ParseDuration(ttlFlag)
if err != nil {
return fmt.Errorf("invalid --ttl %q: %w (use Go durations, e.g. 15m, 1h)", ttlFlag, err)
}
if d <= 0 {
return fmt.Errorf("--ttl must be positive, got %s", d)
}
ttlSeconds = int(d.Seconds())
}

client, _ := getClient()
ws := getWorkspace()

lease, err := client.ClaimItem(ws, args[0], holderFlag, ttlSeconds)
if err != nil {
return err
}

if formatFlag == "json" {
return cli.PrintJSON(lease)
}

green := color.New(color.FgGreen)
fmt.Printf("%s Lease acquired on %s\n", green.Sprint("✓"), args[0])
fmt.Printf(" Holder: %s\n", lease.Holder)
fmt.Printf(" Expires: %s (%s)\n",
lease.ExpiresAt.Local().Format("2006-01-02 15:04:05"),
cli.LeaseCountdown(lease.ExpiresAt))
return nil
},
}

cmd.Flags().StringVar(&holderFlag, "holder", "", "lease holder identity (default: the authenticated user)")
cmd.Flags().StringVar(&ttlFlag, "ttl", "", "lease duration, e.g. 15m, 1h (default: 15m; max 24h)")

return cmd
}

func itemReleaseCmd() *cobra.Command {
var holderFlag string

cmd := &cobra.Command{
Use: "release <ref>",
Short: "Release an item's execution lease (idempotent)",
Long: `Release the execution lease you hold on an item. Releasing an absent or
already-expired lease is a no-op, not an error — cleanup code never needs
to check whether it still holds the lease first. Releasing another
holder's LIVE lease is refused.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()

released, err := client.ReleaseItem(ws, args[0], holderFlag)
if err != nil {
return err
}

if formatFlag == "json" {
return cli.PrintJSON(map[string]any{"ref": args[0], "released": released})
}

green := color.New(color.FgGreen)
if released {
fmt.Printf("%s Lease released on %s\n", green.Sprint("✓"), args[0])
} else {
fmt.Printf("%s No live lease to release on %s (already expired or never held)\n", green.Sprint("✓"), args[0])
}
return nil
},
}

cmd.Flags().StringVar(&holderFlag, "holder", "", "lease holder identity (default: the authenticated user)")

return cmd
}
199 changes: 199 additions & 0 deletions cmd/pad/cmd_item_lease_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

// Tests for `pad item claim` / `pad item release` (#1221) — the CLI face
// of the execution lease. The stub answers the two endpoints; the tests
// drive the real commands and pin what the automation-facing output and
// exit behaviour look like, because sweep scripts branch on them.

type stubLeaseServer struct {
*httptest.Server
claimBodies []map[string]any
releaseBodies []map[string]any
holdConflict bool // when true, claim answers 409 lease_held
}

func newStubLeaseServer(t *testing.T) *stubLeaseServer {
t.Helper()
s := &stubLeaseServer{}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/workspaces/ws1/items/TASK-5/claim", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
s.claimBodies = append(s.claimBodies, body)
if s.holdConflict {
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": "lease_held",
"message": `item is leased to "other-runner" until 2026-09-02T22:00:00Z`,
"details": map[string]any{
"ref": "TASK-5", "holder": "other-runner",
"acquired_at": "2026-09-02T21:45:00Z",
"expires_at": "2026-09-02T22:00:00Z",
},
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ref": "TASK-5",
"lease": map[string]any{
"holder": "sweep-runner",
"acquired_at": "2026-09-02T21:30:00Z",
"expires_at": "2026-09-02T21:45:00Z",
},
})
})
mux.HandleFunc("/api/v1/workspaces/ws1/items/TASK-5/release", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
s.releaseBodies = append(s.releaseBodies, body)
released := len(s.claimBodies) > 0 // released=true only if something was claimed
_ = json.NewEncoder(w).Encode(map[string]any{"ref": "TASK-5", "released": released})
})
s.Server = httptest.NewServer(mux)
t.Cleanup(s.Close)
return s
}

func setupLeaseCLI(t *testing.T) *stubLeaseServer {
t.Helper()
setTempHomeMain(t)
srv := newStubLeaseServer(t)
t.Setenv("PAD_URL", srv.URL)
t.Setenv("PAD_TOKEN", "pad_envtoken")
// Workspace detection walks up from CWD for a .pad.toml —
// setTempHomeMain already chdir'd to a fresh temp dir, so a minimal
// pin there routes getWorkspace() to the stub's workspace.
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.WriteFile(filepath.Join(cwd, ".pad.toml"), []byte("workspace = \"ws1\"\n"), 0644); err != nil {
t.Fatalf("write .pad.toml: %v", err)
}
return srv
}

// claim sends holder + ttl (converted to ttl_seconds) and confirms the
// acquired lease with holder and expiry — the sweep's success signal.
func TestItemClaim_SendsTTLAndPrintsLease(t *testing.T) {
srv := setupLeaseCLI(t)

cmd := itemClaimCmd()
cmd.SetArgs([]string{"TASK-5", "--holder", "sweep-runner", "--ttl", "10m"})
var runErr error
out := captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr != nil {
t.Fatalf("claim: %v", runErr)
}
if len(srv.claimBodies) != 1 {
t.Fatalf("expected one claim POST, got %d", len(srv.claimBodies))
}
body := srv.claimBodies[0]
if body["holder"] != "sweep-runner" {
t.Errorf("posted holder = %v, want sweep-runner", body["holder"])
}
if n, _ := body["ttl_seconds"].(float64); int(n) != 600 {
t.Errorf("posted ttl_seconds = %v, want 600", body["ttl_seconds"])
}
if !strings.Contains(out, "sweep-runner") || !strings.Contains(strings.ToLower(out), "lease") {
t.Errorf("output should confirm the lease and holder:\n%s", out)
}
}

// A contended claim exits non-zero with the holder and expiry in the
// error — the loser must be able to log WHO holds the item without a
// second call.
func TestItemClaim_ContendedExitsWithHolder(t *testing.T) {
srv := setupLeaseCLI(t)
srv.holdConflict = true

cmd := itemClaimCmd()
cmd.SetArgs([]string{"TASK-5"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var runErr error
captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr == nil {
t.Fatal("contended claim must exit with an error")
}
if !strings.Contains(runErr.Error(), "other-runner") {
t.Errorf("error should name the live holder, got %q", runErr.Error())
}
}

// An unparseable --ttl fails locally; the server is never asked.
func TestItemClaim_BadTTLFailsLocally(t *testing.T) {
srv := setupLeaseCLI(t)

cmd := itemClaimCmd()
cmd.SetArgs([]string{"TASK-5", "--ttl", "banana"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var runErr error
captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr == nil {
t.Fatal("expected a parse error for --ttl banana")
}
if len(srv.claimBodies) != 0 {
t.Errorf("no claim should reach the server on a bad ttl, got %d", len(srv.claimBodies))
}
}

// release distinguishes a real release from the idempotent no-op — both
// succeed (exit 0), but the words differ so a human reading sweep logs
// can tell them apart.
func TestItemRelease_ReportsReleasedVsNoop(t *testing.T) {
srv := setupLeaseCLI(t)

// No claim yet: the stub answers released=false.
cmd := itemReleaseCmd()
cmd.SetArgs([]string{"TASK-5"})
var runErr error
out := captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr != nil {
t.Fatalf("no-op release must still exit 0: %v", runErr)
}
if !strings.Contains(strings.ToLower(out), "no live lease") {
t.Errorf("no-op release should say nothing was held:\n%s", out)
}

// After a claim: released=true.
claim := itemClaimCmd()
claim.SetArgs([]string{"TASK-5"})
captureStdout(t, func() { _ = claim.Execute() })

cmd = itemReleaseCmd()
cmd.SetArgs([]string{"TASK-5", "--holder", "sweep-runner"})
out = captureStdout(t, func() {
runErr = cmd.Execute()
})
if runErr != nil {
t.Fatalf("release: %v", runErr)
}
if len(srv.releaseBodies) != 2 || srv.releaseBodies[1]["holder"] != "sweep-runner" {
t.Errorf("release bodies = %v, want second with holder sweep-runner", srv.releaseBodies)
}
if !strings.Contains(strings.ToLower(out), "released") {
t.Errorf("real release should say released:\n%s", out)
}
}
2 changes: 2 additions & 0 deletions cmd/pad/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ func itemCmd() *cobra.Command {
commentsCmd(),
noteCmd(),
decideCmd(),
itemClaimCmd(),
itemReleaseCmd(),
blocksCmd(),
blockedByCmd(),
depsCmd(),
Expand Down
53 changes: 53 additions & 0 deletions internal/cli/client_lease.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package cli

import (
"net/url"

"github.com/PerpetualSoftware/pad/internal/models"
)

// Item execution lease endpoints (#1221):
// POST /workspaces/{ws}/items/{ref}/claim and .../release. A 409 with
// code "lease_held" comes back as *APIError whose message names the live
// holder and expiry; callers surface it rather than retrying blindly.

// leaseRequest is the wire body for claim and release. Zero values are
// omitted so the server applies its defaults (authenticated identity as
// holder; 15-minute TTL).
type leaseRequest struct {
Holder string `json:"holder,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
}

// claimResponse mirrors the structured success bodies: ref plus the lease
// (claim) or the released verdict (release).
type leaseResponse struct {
Ref string `json:"ref"`
Lease *models.ItemLease `json:"lease,omitempty"`
Released *bool `json:"released,omitempty"`
}

// ClaimItem acquires (or refreshes, for the live holder) the execution
// lease on an item. holder and ttlSeconds may be zero for the server
// defaults.
func (c *Client) ClaimItem(wsSlug, ref, holder string, ttlSeconds int) (*models.ItemLease, error) {
var out leaseResponse
err := c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(ref)+"/claim",
leaseRequest{Holder: holder, TTLSeconds: ttlSeconds}, &out)
if err != nil {
return nil, err
}
return out.Lease, nil
}

// ReleaseItem clears the holder's lease on an item. released=false means
// there was nothing live to release — a success, not an error.
func (c *Client) ReleaseItem(wsSlug, ref, holder string) (bool, error) {
var out leaseResponse
err := c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(ref)+"/release",
leaseRequest{Holder: holder}, &out)
if err != nil {
return false, err
}
return out.Released != nil && *out.Released, nil
}
Loading