Skip to content
Closed
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: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ require (
go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8
go.lumeweb.com/oauth v0.1.6
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9
go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0
go.lumeweb.com/portal-sdk v0.1.73
go.lumeweb.com/queryutil v0.3.19
go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,8 @@ go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743 h1:R3GNQ7ENa2IGbaHJWvB5
go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb h1:Cw0gjgj5auBFpEhnLj0YWRhNOoHCLiTQFMrqur74Wlw=
go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 h1:QsMQcXyrRXZoO0E5PPbFaDLMJMpfh6B5K30f35hxZ9M=
go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8=
go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk=
go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c=
Expand Down
57 changes: 51 additions & 6 deletions internal/cli/catalog_workspaces_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"strconv"

"github.com/urfave/cli/v3"
ipfs "go.lumeweb.com/ipfs-sdk"
Expand Down Expand Up @@ -47,9 +48,17 @@ func catalogWorkspacesDeps(factory ...ConfigManagerFactory) catalogops.Workspace
}
return GetSecureSetting(nil, cfgMgr)
},
ServiceFactory: workspaces.DefaultFactory,
// Services are wrapped so single-workspace operations accept the
// workspace's label slug as the id (see workspaces_label.go).
ServiceFactory: func(cfgMgr config.Manager, secure bool, opts ...workspaces.Option) workspaces.Service {
return wrapLabelResolvingWorkspaces(workspaces.DefaultFactory(cfgMgr, secure, opts...))
},
NewAuthenticated: func(cfgMgr config.Manager, secure bool, token string) (workspaces.Service, error) {
return workspaces.NewAuthenticated(cfgMgr, token, secure)
svc, err := workspaces.NewAuthenticated(cfgMgr, token, secure)
if err != nil {
return nil, err
}
return wrapLabelResolvingWorkspaces(svc), nil
},
GetAuthToken: func() string {
cfgMgr, err := cfgFactory()
Expand Down Expand Up @@ -146,7 +155,7 @@ func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operati

switch r := result.(type) {
case catalogops.ListResult:
return renderListResult(output, r)
return renderWorkspacesListResult(output, r)

case *ipfs.WorkspaceResponse:
// workspaces get/create/attach/suspend/resume all return a workspace.
Expand Down Expand Up @@ -182,15 +191,51 @@ func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operati
}
}

// renderWorkspacesListResult renders a workspaces list page. The human table
// is rebuilt here (rather than using the catalogops rows) so the workspace's
// user-facing ID is its label slug and the LABEL column stays out of the
// table — the slug IS the label, so the column would repeat the ID.
func renderWorkspacesListResult(output Output, r catalogops.ListResult) error {
if ws, ok := r.ListItems().([]ipfs.WorkspaceResponse); ok {
rows := make([][]string, 0, len(ws))
for i := range ws {
rows = append(rows, workspaceListRow(&ws[i]))
}
r = catalogops.NewListResult(ws, catalogops.ListResultMeta{
Noun: "workspace(s)",
Headers: []string{"ID", "DOMAIN", "STATUS", "WEBSITE ID", "CREATED"},
Rows: rows,
Total: r.ListTotal(),
})
Comment on lines +204 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

renderWorkspacesListResult rebuilds ListResult with only Noun/Headers/Rows/Total, dropping the original Truncated state, so truncated workspace lists print "Found N workspace(s)" instead of "Showing N of M workspace(s)". Preserve the backend's Truncated/Count fields in the rebuilt ListResultMeta to keep the partial-count render.

r = catalogops.NewListResult(ws, catalogops.ListResultMeta{
    Noun:    "workspace(s)",
    Headers: []string{"ID", "DOMAIN", "STATUS", "WEBSITE ID", "CREATED"},
    Rows:    rows,
    Total:   r.ListTotal(),
    // preserve whether the backend reported more pages beyond this page
    // (Truncated / Count) so renderListResult still emits "Showing N of M"
})
Prompt for LLM

File internal/cli/catalog_workspaces_wiring.go:

Line 204 to 209:

renderWorkspacesListResult rebuilds ListResult with only Noun/Headers/Rows/Total, dropping the original Truncated state, so truncated workspace lists print "Found N workspace(s)" instead of "Showing N of M workspace(s)". Preserve the backend's Truncated/Count fields in the rebuilt ListResultMeta to keep the partial-count render.

Suggested Code:

r = catalogops.NewListResult(ws, catalogops.ListResultMeta{
    Noun:    "workspace(s)",
    Headers: []string{"ID", "DOMAIN", "STATUS", "WEBSITE ID", "CREATED"},
    Rows:    rows,
    Total:   r.ListTotal(),
    // preserve whether the backend reported more pages beyond this page
    // (Truncated / Count) so renderListResult still emits "Showing N of M"
})

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

}
return renderListResult(output, r)
}

// workspaceListRow is one row of the workspaces list table, keyed by the
// label-slug ID.
func workspaceListRow(w *ipfs.WorkspaceResponse) []string {
websiteID := "-"
if w.WebsiteId != nil {
websiteID = strconv.Itoa(*w.WebsiteId)
}
return []string{
workspaceSlugID(w),
w.Domain,
w.Status,
websiteID,
w.Created.Format("2006-01-02 15:04:05"),
}
}

// renderWorkspaceHuman renders the fields of a single workspace (used by get,
// create, attach, suspend, resume).
// create, attach, suspend, resume). The ID is the label slug; the label itself
// is not shown separately (it would only repeat the ID).
func renderWorkspaceHuman(output Output, w *ipfs.WorkspaceResponse) {
output.Printfln("Workspace Details")

fields := []Field{
{"ID", fmt.Sprintf("%d", w.Id)},
{"ID", workspaceSlugID(w)},
{"Domain", w.Domain},
{"Label", w.Label},
{"Status", w.Status},
}
if w.WebsiteId != nil {
Expand Down
159 changes: 159 additions & 0 deletions internal/cli/workspaces_label.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cli

import (
"context"
"fmt"
"strconv"
"strings"

ipfs "go.lumeweb.com/ipfs-sdk"
"go.lumeweb.com/pinner"
"go.lumeweb.com/pinner/core/workspaces"
)

// workspaces_label.go maps the workspace's opaque URL label (its user-facing
// identity) onto its sequential numeric ID. Generated labels settled on the
// bare slug (portal-plugin-ipfs#1040, e.g. "ugki684o" -> <slug>.build.<root>);
// legacy labels carry a "ws-" prefix (e.g. "ws-ugki684o"). The strip-fallback
// logic here accepts both, so present-day and post-#1040 workspaces answer to
// the same short form.

// workspaceLabelLegacyPrefix is the prefix stripped from legacy generated
// workspace labels ("ws-<slug>") to produce the short user-facing ID.
const workspaceLabelLegacyPrefix = "ws-"

// workspaceSlugFromLabel is the user-facing workspace ID implied by a label:
// the label with any legacy "ws-" prefix removed.
func workspaceSlugFromLabel(label string) string {
return strings.TrimPrefix(label, workspaceLabelLegacyPrefix)
}

// workspaceSlugID is the user-facing ID for a workspace response: the slug
// form of its label, falling back to the numeric ID when the label is empty.
func workspaceSlugID(w *ipfs.WorkspaceResponse) string {
if w == nil {
return ""
}
if w.Label == "" {
return strconv.Itoa(w.Id)
}
return workspaceSlugFromLabel(w.Label)
}

// workspaceScanPageSize is the page size used when scanning the user's
// workspace list to map a label onto its numeric ID.
const workspaceScanPageSize = 100

// labelResolvingWorkspaces decorates a workspaces.Service so the
// single-workspace operations (get/attach/suspend/resume/access/delete)
// accept the workspace's label slug (or its full legacy "ws-<slug>" label) as
// the `id` argument, resolving it to the numeric ID by scanning the paged
// list. Label matching runs first for every input — including numeric-looking
// ones — so a purely numeric label slug is never shadowed by the numeric
// interpretation; an unmatched numeric id falls back to the numeric form the
// backend expects. Resolve failure says so explicitly rather than letting the
// backend 404 an unresolvable input.
//
// Both CLI and MCP surfaces build their workspaces catalog deps through this
// wrapper, so label-based control is consistent across frontends.
type labelResolvingWorkspaces struct {
inner workspaces.Service
}

// wrapLabelResolvingWorkspaces returns svc wrapped by labelResolvingWorkspaces
// (a nil inner is returned unchanged; catalogops rejects it downstream).
func wrapLabelResolvingWorkspaces(svc workspaces.Service) workspaces.Service {
if svc == nil {
return nil
}
return &labelResolvingWorkspaces{inner: svc}
}

func (s *labelResolvingWorkspaces) RequireAuthenticated() error {
return s.inner.RequireAuthenticated()
}

func (s *labelResolvingWorkspaces) SetAuthToken(token string) {
s.inner.SetAuthToken(token)
}

func (s *labelResolvingWorkspaces) List(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) {
return s.inner.List(ctx, opts)
}

func (s *labelResolvingWorkspaces) Create(ctx context.Context, req ipfs.WorkspaceRequest) (*ipfs.WorkspaceResponse, error) {
return s.inner.Create(ctx, req)
}

func (s *labelResolvingWorkspaces) Get(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Get(ctx, resolved)
}

func (s *labelResolvingWorkspaces) Delete(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Delete(ctx, resolved)
}

func (s *labelResolvingWorkspaces) Access(ctx context.Context, id string, rotate bool) (*ipfs.WorkspaceAccessResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Access(ctx, resolved, rotate)
}

func (s *labelResolvingWorkspaces) Attach(ctx context.Context, id string, websiteID int) (*ipfs.WorkspaceResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Attach(ctx, resolved, websiteID)
}

func (s *labelResolvingWorkspaces) Resume(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Resume(ctx, resolved)
}

func (s *labelResolvingWorkspaces) Suspend(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) {
resolved, err := s.resolveID(ctx, id)
if err != nil {
return nil, err
}
return s.inner.Suspend(ctx, resolved)
}

// resolveID maps a workspace id argument to the numeric ID the backend
// expects. It matches the workspace's label slug (tolerating the legacy
// "ws-<slug>" form and the full label) by scanning the user's paged list —
// labels are matched before the numeric interpretation so a purely numeric
// label slug is reachable — and passes an unmatched numeric id through.
func (s *labelResolvingWorkspaces) resolveID(ctx context.Context, id string) (string, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Performance high

resolveID runs a full paged scan of the user's workspace list for every single-workspace operation, even when a plain numeric ID has no label match, turning one backend call into N sequential list HTTP calls per command with no caching. Short-circuit the scan when strconv.Atoi(id) succeeds and no workspace label could shadow the numeric ID, or return the numeric ID immediately on the first page without a label match.

if _, err := strconv.Atoi(id); err == nil {
    // numeric id: only scan if a workspace label could shadow it
    if !s.labelMightShadowNumeric(id) {
        return id, nil
    }
}
ws, found, err := pinner.ScanPagesWithOptions(ctx, s, matchesLabel, nil, workspaceScanPageSize)
if err != nil {
    return "", err
}
if found {
    return strconv.Itoa(ws.Id), nil
}
if _, err := strconv.Atoi(id); err == nil {
    return id, nil
}
Prompt for LLM

File internal/cli/workspaces_label.go:

Line 141:

resolveID runs a full paged scan of the user's workspace list for every single-workspace operation, even when a plain numeric ID has no label match, turning one backend call into N sequential list HTTP calls per command with no caching. Short-circuit the scan when strconv.Atoi(id) succeeds and no workspace label could shadow the numeric ID, or return the numeric ID immediately on the first page without a label match.

Suggested Code:

if _, err := strconv.Atoi(id); err == nil {
    // numeric id: only scan if a workspace label could shadow it
    if !s.labelMightShadowNumeric(id) {
        return id, nil
    }
}
ws, found, err := pinner.ScanPagesWithOptions(ctx, s, matchesLabel, nil, workspaceScanPageSize)
if err != nil {
    return "", err
}
if found {
    return strconv.Itoa(ws.Id), nil
}
if _, err := strconv.Atoi(id); err == nil {
    return id, nil
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

if id == "" {
return id, nil
}
matchesLabel := func(w ipfs.WorkspaceResponse) (bool, error) {
return workspaceSlugFromLabel(w.Label) == id || w.Label == id, nil
}
ws, found, err := pinner.ScanPagesWithOptions(ctx, s, matchesLabel, nil, workspaceScanPageSize)
if err != nil {
return "", err
}
if found {
return strconv.Itoa(ws.Id), nil
}
if _, err := strconv.Atoi(id); err == nil {
return id, nil
}
return "", fmt.Errorf("workspace %q not found", id)
}
Loading
Loading