From 7149f18b078b0301784be43a59d7a5385fd331f2 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 11:31:26 -0500 Subject: [PATCH 1/3] Add compact issue identity status --- cmd/kata/main.go | 1 + cmd/kata/show.go | 1 + cmd/kata/status.go | 229 +++++++++++++++++++++++++++++++++ cmd/kata/status_test.go | 83 ++++++++++++ docs/reference/agent-output.md | 18 +++ docs/reference/cli.md | 9 ++ 6 files changed, 341 insertions(+) create mode 100644 cmd/kata/status.go create mode 100644 cmd/kata/status_test.go diff --git a/cmd/kata/main.go b/cmd/kata/main.go index c41f3e97e..3db428290 100644 --- a/cmd/kata/main.go +++ b/cmd/kata/main.go @@ -109,6 +109,7 @@ func newRootCmd() *cobra.Command { newInitCmd(), newCreateCmd(), newShowCmd(), + newStatusCmd(), newListCmd(), newEditCmd(), newScheduleCmd(), diff --git a/cmd/kata/show.go b/cmd/kata/show.go index 9ea9c133e..449b44229 100644 --- a/cmd/kata/show.go +++ b/cmd/kata/show.go @@ -251,6 +251,7 @@ type showResponseForCLI struct { Author string `json:"author"` Owner *string `json:"owner"` Priority *int64 `json:"priority"` + Revision int64 `json:"revision"` Metadata map[string]json.RawMessage `json:"metadata"` } `json:"issue"` Comments []struct { diff --git a/cmd/kata/status.go b/cmd/kata/status.go new file mode 100644 index 000000000..0c1a25e7b --- /dev/null +++ b/cmd/kata/status.go @@ -0,0 +1,229 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/spf13/cobra" + "go.kenn.io/kata/internal/textsafe" +) + +type issueStatusProjection struct { + Issue string `json:"issue"` + Project string `json:"project"` + IssueStatus string `json:"issue_status"` + Revision int64 `json:"revision"` + Actor string `json:"actor"` + ActorSource string `json:"actor_source"` + Auth string `json:"auth"` + Instance string `json:"instance"` + Owner *string `json:"owner,omitempty"` + Claim string `json:"claim"` + Holder string `json:"holder,omitempty"` + HolderInstance string `json:"holder_instance,omitempty"` + LeaseKind string `json:"lease_kind,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + PendingLeaseCount int `json:"pending_lease_count,omitempty"` +} + +type instanceStatusForCLI struct { + InstanceUID string `json:"instance_uid"` + Auth struct { + Kind string `json:"kind"` + Actor string `json:"actor"` + } `json:"auth"` +} + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "show compact issue identity and claim status", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runIssueStatus(cmd, args[0]) + }, + } +} + +func runIssueStatus(cmd *cobra.Command, issueRef string) error { + ctx, baseURL, pid, ref, err := resolveIssueRefForCommand(cmd, issueRef) + if err != nil { + return err + } + client, err := httpClientFor(ctx, baseURL) + if err != nil { + return err + } + + var show showResponseForCLI + if err := getStatusPayload(ctx, client, + fmt.Sprintf("%s/api/v1/projects/%d/issues/%s", baseURL, pid, url.PathEscape(ref.RefForAPI)), + &show); err != nil { + return err + } + var instance instanceStatusForCLI + if err := getStatusPayload(ctx, client, baseURL+"/api/v1/instance", &instance); err != nil { + return err + } + + actor, source := resolveActor(ctx, flags.As, nil) + if instance.Auth.Actor != "" { + actor = instance.Auth.Actor + source = instance.Auth.Kind + } + authKind := instance.Auth.Kind + if authKind == "" { + authKind = "unknown" + } + now := time.Now().UTC() + if show.LeaseHubNow != nil && !show.LeaseHubNow.IsZero() { + now = show.LeaseHubNow.UTC() + } + projection := issueStatusProjection{ + Issue: show.Issue.ShortID, + Project: ref.ProjectName, + IssueStatus: show.Issue.Status, + Revision: show.Issue.Revision, + Actor: actor, + ActorSource: source, + Auth: authKind, + Instance: instance.InstanceUID, + Owner: show.Issue.Owner, + Claim: projectedClaimState(show.Issue.Status, show.Issue.Owner, show.Lease, show.PendingLeases, now), + PendingLeaseCount: len(show.PendingLeases), + } + if show.Lease != nil { + projection.Holder = show.Lease.Holder + projection.HolderInstance = show.Lease.HolderInstanceUID + projection.LeaseKind = show.Lease.ClaimKind + projection.ExpiresAt = show.Lease.ExpiresAt + } + return printIssueStatus(cmd, projection) +} + +func getStatusPayload(ctx context.Context, client *http.Client, target string, out any) error { + status, body, err := httpDoJSON(ctx, client, http.MethodGet, target, nil) + if err != nil { + return err + } + if status >= http.StatusBadRequest { + return apiErrFromBody(status, body) + } + return json.Unmarshal(body, out) +} + +func projectedClaimState( + issueStatus string, + owner *string, + lease *claimForShowCLI, + pending []pendingClaimForCLI, + now time.Time, +) string { + if issueStatus == "closed" { + return "closed" + } + if lease != nil { + if lease.ClaimKind == "timed" && lease.ExpiresAt != nil && !lease.ExpiresAt.After(now) { + return "expired" + } + return "active" + } + if len(pending) > 0 { + return "pending" + } + if owner != nil && *owner != "" { + return "assigned" + } + return "unassigned" +} + +func printIssueStatus(cmd *cobra.Command, status issueStatusProjection) error { + switch currentOutputMode() { + case outputJSON: + var out bytes.Buffer + if err := emitJSON(&out, status); err != nil { + return err + } + _, err := fmt.Fprint(cmd.OutOrStdout(), out.String()) + return err + case outputAgent: + return printIssueStatusAgent(cmd.OutOrStdout(), status) + default: + return printIssueStatusHuman(cmd.OutOrStdout(), status) + } +} + +func printIssueStatusAgent(out io.Writer, status issueStatusProjection) error { + fields := []agentField{ + agentRowField("issue", status.Issue), + agentRowField("issue_status", status.IssueStatus), + agentRowField("revision", fmt.Sprint(status.Revision)), + agentRowField("actor", status.Actor), + agentRowField("actor_source", status.ActorSource), + agentRowField("auth", status.Auth), + agentRowField("instance", status.Instance), + agentOptionalRowField("owner", status.Owner), + agentRowField("claim", status.Claim), + agentOptionalRowField("holder", optionalStatusString(status.Holder)), + agentOptionalRowField("holder_instance", optionalStatusString(status.HolderInstance)), + agentOptionalRowField("lease_kind", optionalStatusString(status.LeaseKind)), + } + if status.ExpiresAt != nil { + expires := status.ExpiresAt.UTC().Format(time.RFC3339Nano) + fields = append(fields, agentRowField("expires_at", expires)) + } + if status.PendingLeaseCount > 0 { + fields = append(fields, agentRowField("pending_leases", fmt.Sprint(status.PendingLeaseCount))) + } + if _, err := fmt.Fprint(out, "OK status"); err != nil { + return err + } + for _, field := range fields { + if field.value == nil { + continue + } + if _, err := fmt.Fprintf(out, " %s=%s", field.name, agentValue(*field.value)); err != nil { + return err + } + } + _, err := fmt.Fprintln(out) + return err +} + +func optionalStatusString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func printIssueStatusHuman(out io.Writer, status issueStatusProjection) error { + if _, err := fmt.Fprintf(out, "%s [%s] claim=%s\n", + textsafe.Line(status.Issue), textsafe.Line(status.IssueStatus), status.Claim); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "actor: %s (%s; auth=%s)\ninstance: %s\n", + textsafe.Line(status.Actor), textsafe.Line(status.ActorSource), + textsafe.Line(status.Auth), textsafe.Line(status.Instance)); err != nil { + return err + } + if status.Owner != nil && *status.Owner != "" { + if _, err := fmt.Fprintln(out, "owner:", textsafe.Line(*status.Owner)); err != nil { + return err + } + } + if status.Holder != "" { + if _, err := fmt.Fprintf(out, "lease: %s from instance %s (%s)\n", + textsafe.Line(status.Holder), textsafe.Line(status.HolderInstance), + textsafe.Line(status.LeaseKind)); err != nil { + return err + } + } + return nil +} diff --git a/cmd/kata/status_test.go b/cmd/kata/status_test.go new file mode 100644 index 000000000..b1c6e75c2 --- /dev/null +++ b/cmd/kata/status_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/kata/internal/db" + "go.kenn.io/kata/internal/testenv" +) + +func TestProjectedClaimStateDistinguishesLeaseAndAssignment(t *testing.T) { + now := time.Date(2026, time.September, 2, 12, 0, 0, 0, time.UTC) + before := now.Add(-time.Minute) + after := now.Add(time.Minute) + owner := "alice" + + for _, tc := range []struct { + name string + status string + owner *string + lease *claimForShowCLI + pending []pendingClaimForCLI + want string + }{ + {name: "active lease", status: "open", owner: &owner, lease: &claimForShowCLI{ClaimKind: "timed", ExpiresAt: &after}, want: "active"}, + {name: "expired lease", status: "open", owner: &owner, lease: &claimForShowCLI{ClaimKind: "timed", ExpiresAt: &before}, want: "expired"}, + {name: "pending lease", status: "open", owner: &owner, pending: []pendingClaimForCLI{{Holder: "alice"}}, want: "pending"}, + {name: "assignment only", status: "open", owner: &owner, want: "assigned"}, + {name: "unassigned", status: "open", want: "unassigned"}, + {name: "closed", status: "closed", owner: &owner, want: "closed"}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, projectedClaimState(tc.status, tc.owner, tc.lease, tc.pending, now)) + }) + } +} + +func TestStatusAgentReportsAuthenticatedIdentityAndAssignment(t *testing.T) { + env, dir, pid := setupCLIWorkspaceOptions(t, + testenv.WithAuthToken("bootstrap-token"), + testenv.WithRequireTokenIdentity(), + ) + const operatorToken = "operator-bearer" + _, _, err := env.DB.CreateAPIToken(context.Background(), db.CreateAPITokenParams{ //nolint:gosec // test-only bearer credential + PlaintextToken: operatorToken, + Actor: "operator", + AdminActor: db.BootstrapActor, + }) + require.NoError(t, err) + owner := "operator" + issue, _, err := env.DB.CreateIssue(context.Background(), db.CreateIssueParams{ + ProjectID: pid, + Title: "report assignment", + Author: "operator", + Owner: &owner, + }) + require.NoError(t, err) + t.Setenv("KATA_AUTH_TOKEN", operatorToken) + + out := runCLI(t, env, dir, "--agent", "status", issue.ShortID) + + assert.Contains(t, out, "OK status issue="+issue.ShortID+" issue_status=open revision=1") + assert.Contains(t, out, "actor=operator actor_source=db_token auth=db_token") + assert.Contains(t, out, "instance="+env.DB.InstanceUID()) + assert.Contains(t, out, "owner=operator claim=assigned") + assert.NotContains(t, out, operatorToken) +} + +func TestStatusAgentReportsActiveTimedLease(t *testing.T) { + env, dir, _, ref := setupFederatedHubIssue(t, "report active lease") + runCLIAs(t, env, dir, "alice", "federation", "lease", "acquire", ref, "--ttl", "30m") + + out := runCLIAs(t, env, dir, "alice", "--agent", "status", ref) + + assert.Contains(t, out, "claim=active") + assert.Contains(t, out, "holder=alice") + assert.Contains(t, out, "lease_kind=timed") + assert.True(t, strings.Contains(out, "expires_at="), out) +} diff --git a/docs/reference/agent-output.md b/docs/reference/agent-output.md index ce9f36b73..a1d8e478c 100644 --- a/docs/reference/agent-output.md +++ b/docs/reference/agent-output.md @@ -231,6 +231,24 @@ Priority: 1 The show sections for labels, body, metadata, links, comments, and leases are included when present. Their existing ordering and omission rules apply. +#### Compact issue status + +`kata status --agent` keeps the authenticated identity, assignment, and +lease state separate on one line: + +```text +OK status issue=abc4 issue_status=open revision=4 actor=agent-a actor_source=db_token auth=db_token instance=01HZNQ7VFPK1XGD8R5MABCD4AB owner=agent-a claim=active holder=agent-a holder_instance=01HZNQ7VFPK1XGD8R5MABCD4AB lease_kind=timed expires_at=2026-09-02T12:30:00Z +``` + +The fixed field order is `issue`, `issue_status`, `revision`, `actor`, +`actor_source`, `auth`, `instance`, optional `owner`, and `claim`. An active or +expired lease then appends optional `holder`, `holder_instance`, `lease_kind`, +and `expires_at`; pending leases append `pending_leases`. Claim state is one of +`active`, `expired`, `pending`, `assigned`, `unassigned`, or `closed`. +`assigned` identifies an open issue with an owner and without a live or pending +lease. Use `kata show --agent` when the body, comments, metadata, links, +or lease violations are needed. + ### Events Non-tail reads use a header plus rows; tail mode is stream-safe and emits exactly diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 026af0e37..8206f1e5e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -149,6 +149,7 @@ kata list --all [--status open|closed|all] [--limit N] [--owner NAME | --unowned] [--label LABEL] [--no-label LABEL] [--meta key[=value]] kata show [--render] +kata status kata search [--limit N] [--include-deleted] kata search [--lexical | --hybrid | --semantic] kata search [--label LABEL] [--no-label LABEL] @@ -168,6 +169,14 @@ pipelines, including `kata show --render | less -R`, intentionally remain plain text. This version has no force-render option for non-terminal output. +`kata status` gives agents a compact view of the daemon identity, effective +actor, issue owner, and federation lease. Its `claim` value is `active`, +`expired`, `pending`, `assigned`, `unassigned`, or `closed`. `assigned` means +the open issue has an owner without a live or pending lease. A cached timed +lease can report `expired` while its hub is unavailable; a successful hub read +releases the expired lease before returning current state. JSON output is a +flat projection of the same fields. Use `kata show` for the complete issue. + For `kata list`, `--meta` is repeatable. A bare key filters on presence, while `key=value` filters on string equality. Multiple filters combine with AND logic. From 496a6f320c0c5f30aa685b34f4262499566592f1 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 18:57:25 -0500 Subject: [PATCH 2/3] Keep agent status project-qualified --- cmd/kata/status.go | 1 + cmd/kata/status_test.go | 2 +- docs/reference/agent-output.md | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/kata/status.go b/cmd/kata/status.go index 0c1a25e7b..b8680ddaf 100644 --- a/cmd/kata/status.go +++ b/cmd/kata/status.go @@ -162,6 +162,7 @@ func printIssueStatus(cmd *cobra.Command, status issueStatusProjection) error { func printIssueStatusAgent(out io.Writer, status issueStatusProjection) error { fields := []agentField{ agentRowField("issue", status.Issue), + agentRowField("project", status.Project), agentRowField("issue_status", status.IssueStatus), agentRowField("revision", fmt.Sprint(status.Revision)), agentRowField("actor", status.Actor), diff --git a/cmd/kata/status_test.go b/cmd/kata/status_test.go index b1c6e75c2..60b562310 100644 --- a/cmd/kata/status_test.go +++ b/cmd/kata/status_test.go @@ -63,7 +63,7 @@ func TestStatusAgentReportsAuthenticatedIdentityAndAssignment(t *testing.T) { out := runCLI(t, env, dir, "--agent", "status", issue.ShortID) - assert.Contains(t, out, "OK status issue="+issue.ShortID+" issue_status=open revision=1") + assert.Contains(t, out, "OK status issue="+issue.ShortID+" project=kata issue_status=open revision=1") assert.Contains(t, out, "actor=operator actor_source=db_token auth=db_token") assert.Contains(t, out, "instance="+env.DB.InstanceUID()) assert.Contains(t, out, "owner=operator claim=assigned") diff --git a/docs/reference/agent-output.md b/docs/reference/agent-output.md index a1d8e478c..1778c4605 100644 --- a/docs/reference/agent-output.md +++ b/docs/reference/agent-output.md @@ -237,14 +237,14 @@ included when present. Their existing ordering and omission rules apply. lease state separate on one line: ```text -OK status issue=abc4 issue_status=open revision=4 actor=agent-a actor_source=db_token auth=db_token instance=01HZNQ7VFPK1XGD8R5MABCD4AB owner=agent-a claim=active holder=agent-a holder_instance=01HZNQ7VFPK1XGD8R5MABCD4AB lease_kind=timed expires_at=2026-09-02T12:30:00Z +OK status issue=abc4 project=kata issue_status=open revision=4 actor=agent-a actor_source=db_token auth=db_token instance=01HZNQ7VFPK1XGD8R5MABCD4AB owner=agent-a claim=active holder=agent-a holder_instance=01HZNQ7VFPK1XGD8R5MABCD4AB lease_kind=timed expires_at=2026-09-02T12:30:00Z ``` -The fixed field order is `issue`, `issue_status`, `revision`, `actor`, -`actor_source`, `auth`, `instance`, optional `owner`, and `claim`. An active or -expired lease then appends optional `holder`, `holder_instance`, `lease_kind`, -and `expires_at`; pending leases append `pending_leases`. Claim state is one of -`active`, `expired`, `pending`, `assigned`, `unassigned`, or `closed`. +The fixed field order is `issue`, `project`, `issue_status`, `revision`, +`actor`, `actor_source`, `auth`, `instance`, optional `owner`, and `claim`. An +active or expired lease then appends optional `holder`, `holder_instance`, +`lease_kind`, and `expires_at`; pending leases append `pending_leases`. Claim +state is one of `active`, `expired`, `pending`, `assigned`, `unassigned`, or `closed`. `assigned` identifies an open issue with an owner and without a live or pending lease. Use `kata show --agent` when the body, comments, metadata, links, or lease violations are needed. From 8eb428b11b810aaa09cfa742bf244e5e159931cc Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 21:14:21 -0500 Subject: [PATCH 3/3] Rename status claim field to hold and mark daemon-sourced actors `kata status` reported its lease-or-ownership state under a field named `claim`. That name collides with the `kata claim` command, which sets ownership: an agent that had just run `kata claim` would read `claim=assigned` and could not tell whether its claim had worked. The field is now `hold`, with the same six values, and the docs say that a successful `kata claim` outside federation yields `hold=assigned`. `actor_source` mixed two vocabularies. When the daemon authenticated the actor it copied the principal kind, such as `db_token`; otherwise it used the client-side `whoami` sources. Readers had to consult `auth` to know which one they were looking at. A daemon-authenticated actor now reports `actor_source=daemon`, and `auth` alone carries the principal kind. The human renderer omitted the lease expiry and pending lease count that the JSON and agent modes carried, so `hold=expired` or `hold=pending` gave no time or count. It now prints both when present. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- cmd/kata/status.go | 26 +++++++++++----- cmd/kata/status_test.go | 54 ++++++++++++++++++++++++++++++---- docs/reference/agent-output.md | 18 +++++++----- docs/reference/cli.md | 5 ++-- 4 files changed, 81 insertions(+), 22 deletions(-) diff --git a/cmd/kata/status.go b/cmd/kata/status.go index b8680ddaf..d91be3444 100644 --- a/cmd/kata/status.go +++ b/cmd/kata/status.go @@ -24,7 +24,7 @@ type issueStatusProjection struct { Auth string `json:"auth"` Instance string `json:"instance"` Owner *string `json:"owner,omitempty"` - Claim string `json:"claim"` + Hold string `json:"hold"` Holder string `json:"holder,omitempty"` HolderInstance string `json:"holder_instance,omitempty"` LeaseKind string `json:"lease_kind,omitempty"` @@ -43,7 +43,7 @@ type instanceStatusForCLI struct { func newStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status ", - Short: "show compact issue identity and claim status", + Short: "show compact issue identity and hold status", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runIssueStatus(cmd, args[0]) @@ -75,7 +75,7 @@ func runIssueStatus(cmd *cobra.Command, issueRef string) error { actor, source := resolveActor(ctx, flags.As, nil) if instance.Auth.Actor != "" { actor = instance.Auth.Actor - source = instance.Auth.Kind + source = "daemon" } authKind := instance.Auth.Kind if authKind == "" { @@ -95,7 +95,7 @@ func runIssueStatus(cmd *cobra.Command, issueRef string) error { Auth: authKind, Instance: instance.InstanceUID, Owner: show.Issue.Owner, - Claim: projectedClaimState(show.Issue.Status, show.Issue.Owner, show.Lease, show.PendingLeases, now), + Hold: projectedHoldState(show.Issue.Status, show.Issue.Owner, show.Lease, show.PendingLeases, now), PendingLeaseCount: len(show.PendingLeases), } if show.Lease != nil { @@ -118,7 +118,7 @@ func getStatusPayload(ctx context.Context, client *http.Client, target string, o return json.Unmarshal(body, out) } -func projectedClaimState( +func projectedHoldState( issueStatus string, owner *string, lease *claimForShowCLI, @@ -170,7 +170,7 @@ func printIssueStatusAgent(out io.Writer, status issueStatusProjection) error { agentRowField("auth", status.Auth), agentRowField("instance", status.Instance), agentOptionalRowField("owner", status.Owner), - agentRowField("claim", status.Claim), + agentRowField("hold", status.Hold), agentOptionalRowField("holder", optionalStatusString(status.Holder)), agentOptionalRowField("holder_instance", optionalStatusString(status.HolderInstance)), agentOptionalRowField("lease_kind", optionalStatusString(status.LeaseKind)), @@ -205,8 +205,8 @@ func optionalStatusString(value string) *string { } func printIssueStatusHuman(out io.Writer, status issueStatusProjection) error { - if _, err := fmt.Fprintf(out, "%s [%s] claim=%s\n", - textsafe.Line(status.Issue), textsafe.Line(status.IssueStatus), status.Claim); err != nil { + if _, err := fmt.Fprintf(out, "%s [%s] hold=%s\n", + textsafe.Line(status.Issue), textsafe.Line(status.IssueStatus), status.Hold); err != nil { return err } if _, err := fmt.Fprintf(out, "actor: %s (%s; auth=%s)\ninstance: %s\n", @@ -226,5 +226,15 @@ func printIssueStatusHuman(out io.Writer, status issueStatusProjection) error { return err } } + if status.ExpiresAt != nil { + if _, err := fmt.Fprintln(out, "expires:", status.ExpiresAt.UTC().Format(time.RFC3339)); err != nil { + return err + } + } + if status.PendingLeaseCount > 0 { + if _, err := fmt.Fprintln(out, "pending leases:", status.PendingLeaseCount); err != nil { + return err + } + } return nil } diff --git a/cmd/kata/status_test.go b/cmd/kata/status_test.go index 60b562310..33da175d1 100644 --- a/cmd/kata/status_test.go +++ b/cmd/kata/status_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "strings" "testing" "time" @@ -12,7 +13,7 @@ import ( "go.kenn.io/kata/internal/testenv" ) -func TestProjectedClaimStateDistinguishesLeaseAndAssignment(t *testing.T) { +func TestProjectedHoldStateDistinguishesLeaseAndAssignment(t *testing.T) { now := time.Date(2026, time.September, 2, 12, 0, 0, 0, time.UTC) before := now.Add(-time.Minute) after := now.Add(time.Minute) @@ -34,7 +35,7 @@ func TestProjectedClaimStateDistinguishesLeaseAndAssignment(t *testing.T) { {name: "closed", status: "closed", owner: &owner, want: "closed"}, } { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, projectedClaimState(tc.status, tc.owner, tc.lease, tc.pending, now)) + assert.Equal(t, tc.want, projectedHoldState(tc.status, tc.owner, tc.lease, tc.pending, now)) }) } } @@ -64,9 +65,9 @@ func TestStatusAgentReportsAuthenticatedIdentityAndAssignment(t *testing.T) { out := runCLI(t, env, dir, "--agent", "status", issue.ShortID) assert.Contains(t, out, "OK status issue="+issue.ShortID+" project=kata issue_status=open revision=1") - assert.Contains(t, out, "actor=operator actor_source=db_token auth=db_token") + assert.Contains(t, out, "actor=operator actor_source=daemon auth=db_token") assert.Contains(t, out, "instance="+env.DB.InstanceUID()) - assert.Contains(t, out, "owner=operator claim=assigned") + assert.Contains(t, out, "owner=operator hold=assigned") assert.NotContains(t, out, operatorToken) } @@ -76,8 +77,51 @@ func TestStatusAgentReportsActiveTimedLease(t *testing.T) { out := runCLIAs(t, env, dir, "alice", "--agent", "status", ref) - assert.Contains(t, out, "claim=active") + assert.Contains(t, out, "hold=active") assert.Contains(t, out, "holder=alice") assert.Contains(t, out, "lease_kind=timed") assert.True(t, strings.Contains(out, "expires_at="), out) } + +func TestStatusJSONReportsDaemonActorAndHold(t *testing.T) { + env, dir, pid := setupCLIWorkspaceOptions(t, + testenv.WithAuthToken("bootstrap-token"), + testenv.WithRequireTokenIdentity(), + ) + const operatorToken = "operator-bearer" + _, _, err := env.DB.CreateAPIToken(context.Background(), db.CreateAPITokenParams{ //nolint:gosec // test-only bearer credential + PlaintextToken: operatorToken, + Actor: "operator", + AdminActor: db.BootstrapActor, + }) + require.NoError(t, err) + issue, _, err := env.DB.CreateIssue(context.Background(), db.CreateIssueParams{ + ProjectID: pid, + Title: "report json status", + Author: "operator", + }) + require.NoError(t, err) + t.Setenv("KATA_AUTH_TOKEN", operatorToken) + + out := runCLI(t, env, dir, "--json", "status", issue.ShortID) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, "operator", got["actor"]) + assert.Equal(t, "daemon", got["actor_source"]) + assert.Equal(t, "db_token", got["auth"]) + assert.Equal(t, "unassigned", got["hold"]) + assert.NotContains(t, got, "claim") + assert.NotContains(t, out, operatorToken) +} + +func TestStatusHumanShowsLeaseExpiry(t *testing.T) { + env, dir, _, ref := setupFederatedHubIssue(t, "report lease expiry") + runCLIAs(t, env, dir, "alice", "federation", "lease", "acquire", ref, "--ttl", "30m") + + out := runCLIAs(t, env, dir, "alice", "status", ref) + + assert.Contains(t, out, "hold=active") + assert.Contains(t, out, "lease: alice from instance "+env.DB.InstanceUID()+" (timed)") + assert.Regexp(t, `(?m)^expires: \d{4}-\d{2}-\d{2}T`, out) +} diff --git a/docs/reference/agent-output.md b/docs/reference/agent-output.md index 1778c4605..8ebbbc50b 100644 --- a/docs/reference/agent-output.md +++ b/docs/reference/agent-output.md @@ -237,17 +237,21 @@ included when present. Their existing ordering and omission rules apply. lease state separate on one line: ```text -OK status issue=abc4 project=kata issue_status=open revision=4 actor=agent-a actor_source=db_token auth=db_token instance=01HZNQ7VFPK1XGD8R5MABCD4AB owner=agent-a claim=active holder=agent-a holder_instance=01HZNQ7VFPK1XGD8R5MABCD4AB lease_kind=timed expires_at=2026-09-02T12:30:00Z +OK status issue=abc4 project=kata issue_status=open revision=4 actor=agent-a actor_source=daemon auth=db_token instance=01HZNQ7VFPK1XGD8R5MABCD4AB owner=agent-a hold=active holder=agent-a holder_instance=01HZNQ7VFPK1XGD8R5MABCD4AB lease_kind=timed expires_at=2026-09-02T12:30:00Z ``` The fixed field order is `issue`, `project`, `issue_status`, `revision`, -`actor`, `actor_source`, `auth`, `instance`, optional `owner`, and `claim`. An +`actor`, `actor_source`, `auth`, `instance`, optional `owner`, and `hold`. An active or expired lease then appends optional `holder`, `holder_instance`, -`lease_kind`, and `expires_at`; pending leases append `pending_leases`. Claim -state is one of `active`, `expired`, `pending`, `assigned`, `unassigned`, or `closed`. -`assigned` identifies an open issue with an owner and without a live or pending -lease. Use `kata show --agent` when the body, comments, metadata, links, -or lease violations are needed. +`lease_kind`, and `expires_at`; pending leases append `pending_leases`. Hold +state is one of `active`, `expired`, `pending`, `assigned`, `unassigned`, or +`closed`. `assigned` identifies an open issue with an owner and without a live +or pending lease; a successful `kata claim` on a non-federated project yields +`hold=assigned`. +`actor_source` is `daemon` when the daemon authenticated the actor; otherwise it +is the client-side source reported by `kata whoami`. Use +`kata show --agent` when the body, comments, metadata, links, or lease +violations are needed. ### Events diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 209f6c701..548a0f778 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -170,9 +170,10 @@ remain plain text. This version has no force-render option for non-terminal output. `kata status` gives agents a compact view of the daemon identity, effective -actor, issue owner, and federation lease. Its `claim` value is `active`, +actor, issue owner, and federation lease. Its `hold` value is `active`, `expired`, `pending`, `assigned`, `unassigned`, or `closed`. `assigned` means -the open issue has an owner without a live or pending lease. A cached timed +the open issue has an owner without a live or pending lease, which is the +state `kata claim` produces outside federation. A cached timed lease can report `expired` while its hub is unavailable; a successful hub read releases the expired lease before returning current state. JSON output is a flat projection of the same fields. Use `kata show` for the complete issue.