diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index 088d6f86..d887d756 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -47490,6 +47490,68 @@ func (v *TeamAvailabilityTeamAvailabilityWorkersWorkerAvailabilityWorkerAgentTas return &retval, nil } +// TeamChannelLocationApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// A runtime caller identity owned by exactly one Organization or User. It owns +// long-lived App Keys and installs Agents through the AppAgent N:M join: one +// App may install many Agents, and one Agent may be installed in many Apps. +// The singular agentId / agent fields below are soft-deprecated convenience +// reads of the first install, not a direct foreign key. +type TeamChannelLocationApp struct { + Id string `json:"id"` + Urn string `json:"urn"` + // Spec 049 Phase 4: the App's default Channel — the row its team chat became (null until provisioned). + DefaultChannel *TeamChannelLocationAppDefaultChannel `json:"defaultChannel"` +} + +// GetId returns TeamChannelLocationApp.Id, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationApp) GetId() string { return v.Id } + +// GetUrn returns TeamChannelLocationApp.Urn, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationApp) GetUrn() string { return v.Urn } + +// GetDefaultChannel returns TeamChannelLocationApp.DefaultChannel, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationApp) GetDefaultChannel() *TeamChannelLocationAppDefaultChannel { + return v.DefaultChannel +} + +// TeamChannelLocationAppDefaultChannel includes the requested fields of the GraphQL type Channel. +// The GraphQL type's documentation follows. +// +// A CHANNEL (spec 049, D-2026-09-13-006): a durable, ordered message stream +// hosted in ONE memory at ONE reserved address, server-ordered and +// server-attributed — a platform entity, created one-to-one with its chat root. +// Creating it RESERVES and PROTECTS the address: generic node writes under it +// are refused (LOC_PROTECTED); its own operations (createTeamChatMessage, +// hadron_team_chat_post) are the only writers. The audience is the host +// memory's (cor:acl:030:01) — a Channel has no access layer of its own. +// `lastSeq` / `lastMessageAt` are the platform-maintained watermark. +type TeamChannelLocationAppDefaultChannel struct { + Id string `json:"id"` + // The chat root node this Channel IS the entity for (one-to-one). + ChatRootNodeId string `json:"chatRootNodeId"` +} + +// GetId returns TeamChannelLocationAppDefaultChannel.Id, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationAppDefaultChannel) GetId() string { return v.Id } + +// GetChatRootNodeId returns TeamChannelLocationAppDefaultChannel.ChatRootNodeId, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationAppDefaultChannel) GetChatRootNodeId() string { return v.ChatRootNodeId } + +// TeamChannelLocationResponse is returned by TeamChannelLocation on success. +type TeamChannelLocationResponse struct { + // Fetch an App (member of the App's org, or platform ADMIN — the myApps + // exposure bar; #473 relaxed this from org ADMIN for read parity with the + // list). + // + // 'ref' accepts the entity's ID or URN. + App *TeamChannelLocationApp `json:"app"` +} + +// GetApp returns TeamChannelLocationResponse.App, and is useful for accessing the field via an interface. +func (v *TeamChannelLocationResponse) GetApp() *TeamChannelLocationApp { return v.App } + // TeamChatMessageFields includes the GraphQL fields of TeamChatMessage requested by the fragment TeamChatMessageFields. // The GraphQL type's documentation follows. // @@ -64817,6 +64879,14 @@ type __TeamAvailabilityInput struct { // GetAppRef returns __TeamAvailabilityInput.AppRef, and is useful for accessing the field via an interface. func (v *__TeamAvailabilityInput) GetAppRef() string { return v.AppRef } +// __TeamChannelLocationInput is used internally by genqlient +type __TeamChannelLocationInput struct { + AppRef string `json:"appRef"` +} + +// GetAppRef returns __TeamChannelLocationInput.AppRef, and is useful for accessing the field via an interface. +func (v *__TeamChannelLocationInput) GetAppRef() string { return v.AppRef } + // __TeamChatMessagesAttributedInput is used internally by genqlient type __TeamChatMessagesAttributedInput struct { AppRef string `json:"appRef"` @@ -83126,6 +83196,46 @@ func TeamAvailability( return data_, err_ } +// The query executed by TeamChannelLocation. +const TeamChannelLocation_Operation = ` +query TeamChannelLocation ($appRef: ID!) { + app(ref: $appRef) { + id + urn + defaultChannel { + id + chatRootNodeId + } + } +} +` + +// Default-channel identity only, no transcript/cursor read or cursor mutation. +func TeamChannelLocation( + ctx_ context.Context, + client_ graphql.Client, + appRef string, +) (data_ *TeamChannelLocationResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TeamChannelLocation", + Query: TeamChannelLocation_Operation, + Variables: &__TeamChannelLocationInput{ + AppRef: appRef, + }, + } + + data_ = &TeamChannelLocationResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by TeamChatMessages. const TeamChatMessages_Operation = ` query TeamChatMessages ($appRef: ID!, $sinceSeq: Int, $mentionsRef: ID, $limit: Int, $offset: Int, $beforeSeq: Int) { diff --git a/internal/api/queries/team.graphql b/internal/api/queries/team.graphql index 37210820..336d370d 100644 --- a/internal/api/queries/team.graphql +++ b/internal/api/queries/team.graphql @@ -1400,3 +1400,15 @@ mutation TeamStopSignalLift($appRef: String!, $id: ID!) { signal { ...StopSignalFields } } } + +# Default-channel identity only, no transcript/cursor read or cursor mutation. +query TeamChannelLocation($appRef: ID!) { + app(ref: $appRef) { + id + urn + defaultChannel { + id + chatRootNodeId + } + } +} diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index 501486df..086fdceb 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -3460,3 +3460,17 @@ format delegates the provider default. No local output-file or remote document creation option. Server delegates format/binary/size guarantees to its tool; file_too_large and unknown failures retain existing error mapping/refusal text, with no retry/reconnect guess. No hidden discovery, writes or uploads. + +## Default team channel location (#489) + +`hadron team channel [--app ]` answers where the default team channel lives. +It uses the existing team App resolution (--app, configured context, binding), +reports that source, and copies the server-built URL verbatim. The destination +is the channel root node's own page and channel view, not an App conversation +selector. No route is composed. JSON is {app,appSource,channelId,portalUrl}; +portalUrl is present and nullable. Text prints URL: only when supplied, otherwise +says no channel URL is available. One metadata read and one best-effort authorized +root node read, no transcript fetch or cursor/binding write. The root read records +a read and may decrypt internally. A concealed/missing/failed root read leaves +the URL null; initial lookup failures remain errors. Existing channel get/list +commands are unchanged. diff --git a/internal/cmd/team/channel_location.go b/internal/cmd/team/channel_location.go new file mode 100644 index 00000000..e610f877 --- /dev/null +++ b/internal/cmd/team/channel_location.go @@ -0,0 +1,85 @@ +package team + +import ( + "fmt" + "io" + + "github.com/hadron-memory/hadron-cli/internal/api" + "github.com/hadron-memory/hadron-cli/internal/api/gen" + "github.com/hadron-memory/hadron-cli/internal/cmdutil" + "github.com/hadron-memory/hadron-cli/internal/exitcode" + "github.com/hadron-memory/hadron-cli/internal/output" + "github.com/spf13/cobra" +) + +type teamChannelLocationDTO struct { + App string `json:"app"` + AppSource string `json:"appSource"` + ChannelID string `json:"channelId"` + PortalURL *string `json:"portalUrl"` +} + +func newCmdTeamChannel(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "channel", Short: "Show the default team channel's server-built URL", + Long: `Find the default team channel without knowing its id or address. + +The team App resolves from --app, the configured App context, or the worktree +binding, just as other team commands do. Output names the App and scope source. +The server-built URL opens the channel root node's own page and channel view; +this does not select an App-level conversation or create a new portal route. + +One default-channel metadata read is followed by one authorized root-node read +for identity and URL only. That node read records a read and may decrypt +internally; no transcript is fetched, no cursor is advanced, and no binding +is started or changed. No URL is composed by the client. If the server supplies +no link, or the optional root read fails or is concealed, portalUrl is null in +JSON and text says no channel URL is available. The default-channel lookup +itself must succeed.`, + Example: " hadron team channel\n hadron team channel --app acme.com:team --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + b, err := readBindingOrNilWithApp(ctx, f) + if err != nil { + return err + } + scope, err := resolveTeamAppScope(ctx, f, b) + if err != nil { + return err + } + client, err := f.GraphQLClient() + if err != nil { + return err + } + resp, err := gen.TeamChannelLocation(ctx, client, scope.Ref) + if err != nil { + return api.MapError(err) + } + if resp.App == nil { + return exitcode.Newf(exitcode.NotFound, "no team App is readable here") + } + if resp.App.DefaultChannel == nil { + return exitcode.Newf(exitcode.NotFound, "the App has no default team channel") + } + d := teamChannelLocationDTO{App: resp.App.Urn, AppSource: scope.Source, ChannelID: resp.App.DefaultChannel.Id} + if ref := resp.App.DefaultChannel.ChatRootNodeId; ref != "" { + pointer, pointerErr := gen.NodePortal(ctx, client, ref) + if pointerErr == nil && pointer != nil && pointer.Node != nil { + d.PortalURL = pointer.Node.PortalUrl + } + } + return output.Write(f.IOStreams, f.JSON, d, func(w io.Writer) error { + if _, err := fmt.Fprintf(w, "app: %s (%s)\nchannel: %s\n", d.App, d.AppSource, d.ChannelID); err != nil { + return err + } + if d.PortalURL != nil { + _, err := fmt.Fprintf(w, "URL: %s\n", *d.PortalURL) + return err + } + _, err := fmt.Fprintln(w, "no channel URL is available") + return err + }) + }, + } +} diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go index b1120fc4..be35e635 100644 --- a/internal/cmd/team/team.go +++ b/internal/cmd/team/team.go @@ -97,6 +97,7 @@ and ` + "`hadron app agent list `" + ` (the installed cast pool).`, cmd.AddCommand(newCmdRole(f)) cmd.AddCommand(newCmdSession(f)) cmd.AddCommand(newCmdTeamChat(f)) + cmd.AddCommand(newCmdTeamChannel(f)) cmd.AddCommand(newCmdAttention(f)) cmd.AddCommand(newCmdTeamTask(f)) cmd.AddCommand(newCmdTeamRoster(f)) diff --git a/internal/cmd/team_channel_location_489_test.go b/internal/cmd/team_channel_location_489_test.go new file mode 100644 index 00000000..8f081335 --- /dev/null +++ b/internal/cmd/team_channel_location_489_test.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/parser" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTeamChannel489(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + url bool + }{ + {"link", `{"data":{"node":{"id":"root-id","urn":"server-address","portalUrl":"https://portal.test/opaque?q=a%2Fb&x=1"}}}`, 200, true}, + {"null", `{"data":{"node":{"portalUrl":null}}}`, 200, false}, + {"concealed", `{"data":{"node":null}}`, 200, false}, + {"denied", `{"errors":[{"message":"private-pointer-sentinel","extensions":{"code":"FORBIDDEN"}}]}`, 200, false}, + {"gateway", `bad gateway`, 502, false}, + } { + for _, j := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/json=%v", tc.name, j), func(t *testing.T) { + calls := []string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + OperationName, Query string + Variables map[string]any + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + } + calls = append(calls, req.OperationName) + w.Header().Set("Content-Type", "application/json") + doc, err := parser.ParseQuery(&ast.Source{Input: req.Query}) + if err != nil { + t.Error(err) + return + } + if len(doc.Operations) != 1 || doc.Operations[0].Operation != ast.Query { + t.Error("not one read query") + } + switch req.OperationName { + case "TeamChannelLocation": + if len(req.Variables) != 1 || req.Variables["appRef"] != "hrn:app:acme.com:team" { + t.Error(req.Variables) + } + fields := doc.Operations[0].SelectionSet + app := fields[0].(*ast.Field) + if len(fields) != 1 || app.Name != "app" || len(app.SelectionSet) != 3 { + t.Fatal(req.Query) + } + channel := app.SelectionSet[2].(*ast.Field) + if app.SelectionSet[0].(*ast.Field).Name != "id" || app.SelectionSet[1].(*ast.Field).Name != "urn" || channel.Name != "defaultChannel" || len(channel.SelectionSet) != 2 || channel.SelectionSet[0].(*ast.Field).Name != "id" || channel.SelectionSet[1].(*ast.Field).Name != "chatRootNodeId" { + t.Error(req.Query) + } + fmt.Fprint(w, `{"data":{"app":{"id":"actual-app","urn":"hrn:app:acme.com:resolved","defaultChannel":{"id":"channel-id","chatRootNodeId":"root-id","chatRootUrn":null}}}}`) + case "NodePortal": + if len(req.Variables) != 1 || req.Variables["ref"] != "root-id" { + t.Error(req.Variables) + } + node := doc.Operations[0].SelectionSet[0].(*ast.Field) + if node.Name != "node" || len(node.SelectionSet) != 3 { + t.Fatal(req.Query) + } + for i, n := range []string{"id", "urn", "portalUrl"} { + if node.SelectionSet[i].(*ast.Field).Name != n { + t.Error(req.Query) + } + } + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + default: + t.Error("unexpected operation", req.OperationName) + } + })) + defer srv.Close() + f, out := testFactory(t) + root := NewRootCmd(f) + args := []string{"team", "channel", "--app", "acme.com:team", "--server", srv.URL} + if j { + args = append(args, "--json") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if strings.Join(calls, ",") != "TeamChannelLocation,NodePortal" { + t.Fatal(calls) + } + if strings.Contains(out.String(), "private-pointer-sentinel") { + t.Fatal(out.String()) + } + if j { + var d map[string]any + if err := json.Unmarshal([]byte(out.String()), &d); err != nil { + t.Fatal(err) + } + if len(d) != 4 || d["app"] != "hrn:app:acme.com:resolved" || d["appSource"] != "from --app" || d["channelId"] != "channel-id" { + t.Fatal(d) + } + v, ok := d["portalUrl"] + if !ok { + t.Fatal("missing null key") + } + if tc.url { + if v != "https://portal.test/opaque?q=a%2Fb&x=1" { + t.Fatal(v) + } + } else if v != nil { + t.Fatal(v) + } + } else { + text := out.String() + if strings.Contains(strings.ToLower(text), "chat") { + t.Fatal(text) + } + if tc.url { + if !strings.Contains(text, "URL: https://portal.test/opaque?q=a%2Fb&x=1") { + t.Fatal(text) + } + } else if strings.Contains(text, "URL:") || !strings.Contains(text, "no channel URL is available") { + t.Fatal(text) + } + } + }) + } + } +} + +func TestTeamChannel489FailuresAndHelp(t *testing.T) { + for _, tc := range []struct { + name, body string + code int + }{ + {"no-app", `{"data":{"app":null}}`, 4}, + {"no-channel", `{"data":{"app":{"id":"a","defaultChannel":null}}}`, 4}, + {"forbidden", `{"errors":[{"message":"denied","extensions":{"code":"FORBIDDEN"}}]}`, 8}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, calls := captureGraphQL(t, map[string]string{"TeamChannelLocation": tc.body}) + f, out := testFactory(t) + root := NewRootCmd(f) + root.SetArgs([]string{"team", "channel", "--app", "acme.com:team", "--server", srv.URL}) + if got := exitCodeFor(root.Execute()); got != tc.code { + t.Fatal(got) + } + if out.Len() != 0 || len(calls) != 1 { + t.Fatalf("%v %s", calls, out.String()) + } + }) + } + f, out := testFactory(t) + root := NewRootCmd(f) + root.SetArgs([]string{"team", "channel", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(out.String()), "chat") || !strings.Contains(out.String(), "server-built") { + t.Fatal(out.String()) + } +} + +func TestTeamChannel489BindingAndContext(t *testing.T) { + for _, configured := range []bool{false, true} { + t.Run(fmt.Sprint(configured), func(t *testing.T) { + dir := teamGitDir(t) + if err := os.WriteFile(filepath.Join(dir, "hadron-team-session.json"), []byte(bindingFixture), 0600); err != nil { + t.Fatal(err) + } + srv, calls := captureGraphQL(t, map[string]string{"TeamChannelLocation": `{"data":{"app":{"id":"returned-app","urn":"hrn:app:acme.com:resolved","defaultChannel":{"id":"default-id","chatRootNodeId":"root-id"}}}}`, "NodePortal": `{"data":{"node":{"portalUrl":null}}}`}) + f, out := testFactory(t) + if configured { + cfg, err := f.Config() + if err != nil { + t.Fatal(err) + } + if err := cfg.Set("app", "acme.com:configured"); err != nil { + t.Fatal(err) + } + } + root := NewRootCmd(f) + root.SetArgs([]string{"team", "channel", "--json", "--server", srv.URL}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var vars map[string]any + if err := json.Unmarshal(calls["TeamChannelLocation"], &vars); err != nil { + t.Fatal(err) + } + expected := "capp100000000000000000000" + source := "from the worktree binding" + if configured { + expected = "hrn:app:acme.com:configured" + source = "from the App context" + } + if vars["appRef"] != expected || !strings.Contains(out.String(), source) { + t.Fatalf("%v %s", vars, out.String()) + } + data, err := os.ReadFile(filepath.Join(dir, "hadron-team-session.json")) + if err != nil || string(data) != bindingFixture { + t.Fatal("binding changed", err) + } + }) + } +}