diff --git a/AGENTS.md b/AGENTS.md index 0270caee12..78a6712181 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,8 @@ apps/backend/ │ │ ├── handlers/ # Task event handlers │ │ ├── models/ # Task, Session, Executor, Message models │ │ ├── repository/ # Database access (SQLite) -│ │ └── service/ # Task business logic +│ │ ├── service/ # Task business logic +│ │ └── share/ # Public share links: snapshot builder, gist backend, service, redaction │ ├── office/ # Autonomous agent management (Office feature) │ │ ├── agents/ # Agent instance CRUD + auth guards │ │ ├── approvals/ # Approval requests and decisions diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 70de787359..e15f342762 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -643,6 +643,10 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log) analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.log) agenthandlers.RegisterShellRoutes(p.router, p.lifecycleMgr, p.log) + if p.services.Share != nil { + p.services.Share.RegisterRoutes(p.router) + p.log.Debug("Registered Public Share Links handlers (HTTP)") + } p.log.Debug("Registered Task Service handlers (HTTP + WebSocket)") } diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f39e50cbfd..880b4b0ce8 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -279,7 +279,7 @@ func startServices( //nolint:cyclop return false } - services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry) + services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry, Version) if err != nil { log.Error("Failed to initialize services", zap.Error(err)) return false diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 47ccd924e1..23b4778c92 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -25,13 +25,14 @@ import ( "github.com/kandev/kandev/internal/slack" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/task/share" userservice "github.com/kandev/kandev/internal/user/service" utilityservice "github.com/kandev/kandev/internal/utility/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) -func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry) (*Services, *agentsettingscontroller.Controller, error) { +func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) { // Load custom TUI agents from DB into registry before discovery loadCustomTUIAgents(context.Background(), repos, agentRegistry, log) @@ -92,6 +93,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) slackSvc := initSlackService(dbPool, repos.Secrets, log) + shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) // Plumb GitHub branch listing into the task service so provider-backed // ("Remote") repos serve branches from the GitHub API rather than relying @@ -112,6 +114,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Jira: jiraSvc, Linear: linearSvc, Slack: slackSvc, + Share: shareHTTP, // Office is constructed later in initOfficeServices once all // of its dependencies (config loader, task integrations, etc.) are available. Office: nil, @@ -263,6 +266,32 @@ func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr return svc } +// initShareHandlers wires up the public-share-links HTTP surface. Failures +// are non-fatal: the rest of the backend boots without the share endpoints. +// The github.Client may be nil; CreateShare will fail at the IsAuthenticated +// probe with a 412 in that case. +func initShareHandlers( + dbPool *db.Pool, + taskRepo share.TaskReader, + githubSvc *github.Service, + log *logger.Logger, + version string, +) *share.HTTPHandlers { + var ghClient github.Client + if githubSvc != nil { + ghClient = githubSvc.Client() + } + if ghClient == nil { + ghClient = &github.NoopClient{} + } + h, _, err := share.Provide(dbPool.Writer(), dbPool.Reader(), taskRepo, ghClient, log, share.Config{KandevVersion: version}) + if err != nil { + log.Warn("Share handlers initialization failed (non-fatal)", zap.Error(err)) + return nil + } + return h +} + // initSlackService wires up the Slack integration. Failures are non-fatal. // The agent runner is wired post-construction by main.go once hostutility + // utility services exist. diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 5f4516891a..7015f43a4e 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -19,6 +19,7 @@ import ( "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/task/share" userservice "github.com/kandev/kandev/internal/user/service" userstore "github.com/kandev/kandev/internal/user/store" utilityservice "github.com/kandev/kandev/internal/utility/service" @@ -54,6 +55,7 @@ type Services struct { Jira *jira.Service Linear *linear.Service Slack *slack.Service + Share *share.HTTPHandlers Office *officeservice.Service OfficeSvcs *office.Services // OrchScheduler is the office SchedulerIntegration constructed by diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index 7b0e042356..4637f53f6c 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -90,4 +90,12 @@ type Client interface { // GetIssueState returns the state of a single issue ("open" or "closed"). GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) + + // CreateGist creates a new gist on the authenticated user's account. + // Set Public=false to create a secret (unlisted) gist. + CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) + + // DeleteGist deletes a gist by ID. A 404 is wrapped in *GitHubAPIError + // so callers can distinguish "already gone" from transport failures. + DeleteGist(ctx context.Context, gistID string) error } diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go index 205efc891c..f5a0b2a159 100644 --- a/apps/backend/internal/github/controller_test.go +++ b/apps/backend/internal/github/controller_test.go @@ -99,6 +99,10 @@ func (s *stubClient) GetIssueState(context.Context, string, string, int) (string func (s *stubClient) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) { return nil, nil } +func (s *stubClient) CreateGist(context.Context, CreateGistInput) (*GistResponse, error) { + return nil, nil +} +func (s *stubClient) DeleteGist(context.Context, string) error { return nil } func newControllerTestLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{ diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 3c8f22a118..e46a7bec2e 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -692,6 +692,65 @@ func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([] return branches, nil } +func (c *GHClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) { + payload := struct { + Description string `json:"description,omitempty"` + Public bool `json:"public"` + Files map[string]gistFileDTO `json:"files"` + }{ + Description: in.Description, + Public: in.Public, + Files: make(map[string]gistFileDTO, len(in.Files)), + } + for name, f := range in.Files { + payload.Files[name] = gistFileDTO(f) + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal gist payload: %w", err) + } + args := []string{"api", "gists", + "-X", "POST", + "-H", "Accept: " + githubAccept, + "--input", "-", + } + out, err := c.runWithStdin(ctx, body, args...) + if err != nil { + return nil, fmt.Errorf("create gist: %w", err) + } + var resp GistResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return nil, fmt.Errorf("decode gist response: %w", err) + } + return &resp, nil +} + +func (c *GHClient) DeleteGist(ctx context.Context, gistID string) error { + if gistID == "" { + return fmt.Errorf("delete gist: empty id") + } + _, err := c.run(ctx, "api", "gists/"+gistID, "-X", "DELETE") + if err != nil { + // gh exits non-zero for HTTP errors; stderr contains the status + // line (e.g. "HTTP 404: Not Found"). Promote 404 to *GitHubAPIError + // so share.IsAlreadyGone matches consistently — PATClient.delete() + // already returns a typed error for the same case, and the share + // service uses errors.As to detect "gist already revoked upstream" + // and treat it as a soft success rather than a 502. Use isNotFoundErr + // so every gh-CLI 404 format ("HTTP 404", "404 Not Found", "status: 404") + // is recognised, not just the most common one. + if isNotFoundErr(err) { + return &GitHubAPIError{ + StatusCode: http.StatusNotFound, + Endpoint: "/gists/" + gistID, + Body: err.Error(), + } + } + return fmt.Errorf("delete gist %s: %w", gistID, err) + } + return nil +} + const ghCLITimeout = 30 * time.Second // withDefaultGHTimeout applies a 30s timeout if the context has no deadline. @@ -719,6 +778,23 @@ func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { return stdout.String(), nil } +// runWithStdin is like run but pipes stdin into the gh CLI process. +// Useful for `gh api --input -` calls where the body comes from JSON. +func (c *GHClient) runWithStdin(ctx context.Context, stdin []byte, args ...string) (string, error) { + ctx, cancel := withDefaultGHTimeout(ctx) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", args...) + cmd.Stdin = bytes.NewReader(stdin) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + c.inspectRateStderr(args, stderr.String()) + return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String()) + } + return stdout.String(), nil +} + // ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed // the tracker on startup and after CLI failures without parsing prose. type ghRateLimitResponse struct { diff --git a/apps/backend/internal/github/gists.go b/apps/backend/internal/github/gists.go new file mode 100644 index 0000000000..325abc099e --- /dev/null +++ b/apps/backend/internal/github/gists.go @@ -0,0 +1,22 @@ +package github + +import "time" + +// CreateGistInput is the payload for CreateGist. +type CreateGistInput struct { + Description string + Public bool + Files map[string]GistFile +} + +// GistFile is a single file entry inside a gist. +type GistFile struct { + Content string +} + +// GistResponse is the subset of the GitHub gist API response we care about. +type GistResponse struct { + ID string `json:"id"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index 882051d898..15920a067b 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -74,6 +74,19 @@ type MockClient struct { submittedReviews []submittedReview mergedPRs []mergedPR mergeMethods map[repoKey]RepoMergeMethods + gists map[string]mockGist + deletedGists []string + nextGistID int +} + +// mockGist captures a gist that was created via the mock client so tests +// can inspect what would have been uploaded. +type mockGist struct { + ID string + Description string + Public bool + Files map[string]GistFile + HTMLURL string } // NewMockClient creates a new MockClient with default values. @@ -91,6 +104,7 @@ func NewMockClient() *MockClient { files: make(map[prKey][]PRFile), commits: make(map[prKey][]PRCommitInfo), mergeMethods: make(map[repoKey]RepoMergeMethods), + gists: make(map[string]mockGist), } } @@ -311,6 +325,65 @@ func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, return nil } +func (m *MockClient) CreateGist(_ context.Context, in CreateGistInput) (*GistResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextGistID++ + id := fmt.Sprintf("mock-gist-%d", m.nextGistID) + htmlURL := "https://gist.github.com/" + m.user + "/" + id + files := make(map[string]GistFile, len(in.Files)) + for k, v := range in.Files { + files[k] = v + } + m.gists[id] = mockGist{ + ID: id, + Description: in.Description, + Public: in.Public, + Files: files, + HTMLURL: htmlURL, + } + return &GistResponse{ + ID: id, + HTMLURL: htmlURL, + CreatedAt: time.Now().UTC(), + }, nil +} + +func (m *MockClient) DeleteGist(_ context.Context, gistID string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.gists[gistID]; !ok { + return &GitHubAPIError{ + StatusCode: 404, + Endpoint: "/gists/" + gistID, + Body: "gist not found", + } + } + delete(m.gists, gistID) + m.deletedGists = append(m.deletedGists, gistID) + return nil +} + +// Gists returns all currently-stored gists for test inspection. +func (m *MockClient) Gists() map[string]mockGist { + m.mu.RLock() + defer m.mu.RUnlock() + out := make(map[string]mockGist, len(m.gists)) + for k, v := range m.gists { + out[k] = v + } + return out +} + +// DeletedGists returns the IDs of gists that were deleted, in call order. +func (m *MockClient) DeletedGists() []string { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]string, len(m.deletedGists)) + copy(out, m.deletedGists) + return out +} + // --- Setter methods for HTTP control endpoints --- // SetUser sets the authenticated username. @@ -451,6 +524,9 @@ func (m *MockClient) Reset() { m.submittedReviews = nil m.mergedPRs = nil m.mergeMethods = make(map[repoKey]RepoMergeMethods) + m.gists = make(map[string]mockGist) + m.deletedGists = nil + m.nextGistID = 0 } // SubmittedReviews returns all recorded SubmitReview calls. diff --git a/apps/backend/internal/github/mock_client_test.go b/apps/backend/internal/github/mock_client_test.go index 6f677e8818..7bf3435fca 100644 --- a/apps/backend/internal/github/mock_client_test.go +++ b/apps/backend/internal/github/mock_client_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "testing" "time" ) @@ -193,6 +194,54 @@ func TestMockClient_SubmitReview(t *testing.T) { } } +func TestMockClient_CreateAndDeleteGist(t *testing.T) { + m := NewMockClient() + ctx := context.Background() + + resp, err := m.CreateGist(ctx, CreateGistInput{ + Description: "snapshot", + Public: false, + Files: map[string]GistFile{ + "snapshot.json": {Content: `{"x":1}`}, + "README.md": {Content: "hi"}, + }, + }) + if err != nil { + t.Fatalf("CreateGist error: %v", err) + } + if resp.ID == "" || resp.HTMLURL == "" { + t.Fatalf("expected non-empty id/url, got %+v", resp) + } + + gists := m.Gists() + g, ok := gists[resp.ID] + if !ok { + t.Fatalf("gist %s not stored", resp.ID) + } + if g.Public { + t.Fatal("gist should default to secret (Public=false)") + } + if _, ok := g.Files["snapshot.json"]; !ok { + t.Fatal("snapshot.json missing from stored gist") + } + + if err := m.DeleteGist(ctx, resp.ID); err != nil { + t.Fatalf("DeleteGist error: %v", err) + } + if _, ok := m.Gists()[resp.ID]; ok { + t.Fatal("gist still present after delete") + } + if got := m.DeletedGists(); len(got) != 1 || got[0] != resp.ID { + t.Fatalf("expected DeletedGists=[%q], got %v", resp.ID, got) + } + + err = m.DeleteGist(ctx, resp.ID) + var apiErr *GitHubAPIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 { + t.Fatalf("expected 404 GitHubAPIError on second delete, got %v", err) + } +} + func TestMockClient_Reset(t *testing.T) { m := NewMockClient() ctx := context.Background() diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go index 793a77f485..ee04b204c6 100644 --- a/apps/backend/internal/github/noop_client.go +++ b/apps/backend/internal/github/noop_client.go @@ -108,3 +108,11 @@ func (c *NoopClient) SearchPRsPaged(context.Context, string, string, int, int) ( func (c *NoopClient) GetIssueState(context.Context, string, string, int) (string, error) { return "", ErrNoClient } + +func (c *NoopClient) CreateGist(context.Context, CreateGistInput) (*GistResponse, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) DeleteGist(context.Context, string) error { + return ErrNoClient +} diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 68925261bd..bd342ebdb9 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -487,6 +487,42 @@ func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, return c.put(ctx, endpoint, jsonBody) } +func (c *PATClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) { + payload := struct { + Description string `json:"description,omitempty"` + Public bool `json:"public"` + Files map[string]gistFileDTO `json:"files"` + }{ + Description: in.Description, + Public: in.Public, + Files: make(map[string]gistFileDTO, len(in.Files)), + } + for name, f := range in.Files { + payload.Files[name] = gistFileDTO(f) + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal gist payload: %w", err) + } + var resp GistResponse + if err := c.postJSON(ctx, "/gists", body, &resp); err != nil { + return nil, fmt.Errorf("create gist: %w", err) + } + return &resp, nil +} + +func (c *PATClient) DeleteGist(ctx context.Context, gistID string) error { + if gistID == "" { + return fmt.Errorf("delete gist: empty id") + } + return c.delete(ctx, "/gists/"+gistID) +} + +// gistFileDTO mirrors the GitHub API's per-file body shape. +type gistFileDTO struct { + Content string `json:"content"` +} + // post makes an authenticated HTTP POST to the GitHub API. // Errors on non-2xx are returned as plain `fmt.Errorf` (not `*GitHubAPIError`), // so callers cannot recover the HTTP status via `errors.As`. Use `put` (or @@ -516,6 +552,60 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro return nil } +// postJSON sends a POST and decodes the response body into result. +// 2xx with no body returns nil; 4xx/5xx returns a *GitHubAPIError. +func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error { + u := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return err + } + c.setGitHubHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request POST %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} + } + if result == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(result) +} + +// delete sends a DELETE request. 2xx and 404 both return nil-or-typed-error per caller intent. +// Here we return a typed error on any non-2xx so callers can inspect for 404. +func (c *PATClient) delete(ctx context.Context, endpoint string) error { + u := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) + if err != nil { + return err + } + c.setGitHubHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request DELETE %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} + } + return nil +} + func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body)) diff --git a/apps/backend/internal/task/share/backend.go b/apps/backend/internal/task/share/backend.go new file mode 100644 index 0000000000..5a5ed6d99e --- /dev/null +++ b/apps/backend/internal/task/share/backend.go @@ -0,0 +1,44 @@ +package share + +import ( + "context" + "errors" + "net/http" + + "github.com/kandev/kandev/internal/github" +) + +// ErrSnapshotTooLarge is returned when a snapshot exceeds the backend's +// per-file size cap. The HTTP handler maps this to 413. +var ErrSnapshotTooLarge = errors.New("snapshot exceeds maximum size") + +// Backend is the contract every storage backend must implement. The snapshot +// has already been redacted by the caller; the backend is responsible only +// for transport and persistence. +type Backend interface { + // Name returns the backend identifier persisted in task_shares.backend. + Name() string + + // Upload publishes the snapshot and returns the backend-specific + // identifier (e.g. gist ID) and the public URL where it can be viewed. + Upload(ctx context.Context, snap *Snapshot) (externalID, externalURL string, err error) + + // Delete removes a previously-uploaded snapshot. Backends MAY return a + // "not found" error which the service detects via IsAlreadyGone. + Delete(ctx context.Context, externalID string) error +} + +// IsAlreadyGone reports whether err signals the backend resource no longer +// exists. The service uses this to treat repeated revoke calls as success. +// Currently scoped to GitHub-API 404s but the helper lives here so future +// backends can extend it without leaking package boundaries. +func IsAlreadyGone(err error) bool { + if err == nil { + return false + } + var apiErr *github.GitHubAPIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusNotFound + } + return false +} diff --git a/apps/backend/internal/task/share/builder.go b/apps/backend/internal/task/share/builder.go new file mode 100644 index 0000000000..b90c783d43 --- /dev/null +++ b/apps/backend/internal/task/share/builder.go @@ -0,0 +1,279 @@ +package share + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/kandev/kandev/internal/sysprompt" + "github.com/kandev/kandev/internal/task/models" +) + +// ErrSessionNotShareable is returned when BuildSnapshot is asked to snapshot +// a session that has not produced any conversation yet (CREATED / STARTING). +// The HTTP handler maps this to 409. +var ErrSessionNotShareable = errors.New("session has no shareable content yet") + +// TaskReader is the narrow slice of the task repository BuildSnapshot needs. +// Keeping it small lets us stub it in unit tests without pulling in the full +// task repo interface. +type TaskReader interface { + GetTask(ctx context.Context, id string) (*models.Task, error) + GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) + ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) +} + +// BuildSnapshot loads the task, session, and messages for the given session +// and returns a redacted snapshot. The kandevVersion is recorded in the +// snapshot for forward debugging but is otherwise opaque. +// +// Sessions in CREATED or STARTING state are rejected because they predate +// any agent output. Every other state — RUNNING, IDLE, WAITING_FOR_INPUT, +// COMPLETED, FAILED, CANCELLED — produces a valid snapshot of whatever +// conversation has happened so far. +func BuildSnapshot(ctx context.Context, repo TaskReader, taskSessionID, kandevVersion string) (*Snapshot, error) { + session, err := repo.GetTaskSession(ctx, taskSessionID) + if err != nil { + return nil, fmt.Errorf("load session: %w", err) + } + if session == nil { + return nil, fmt.Errorf("load session: %w", ErrSessionNotShareable) + } + if !isShareableState(session.State) { + return nil, ErrSessionNotShareable + } + + task, err := repo.GetTask(ctx, session.TaskID) + if err != nil { + return nil, fmt.Errorf("load task: %w", err) + } + + messages, err := repo.ListMessages(ctx, taskSessionID) + if err != nil { + return nil, fmt.Errorf("load messages: %w", err) + } + + red := NewRedactor(workspaceRootsFor(session)...) + snap := &Snapshot{ + Version: SnapshotVersion, + KandevVersion: kandevVersion, + ExportedAt: time.Now().UTC(), + Task: taskMeta(task), + Session: sessionMeta(session), + Messages: mapMessages(messages, red), + } + snap.Redaction.AppliedRules = red.Applied() + return snap, nil +} + +// isShareableState mirrors the frontend gate: any session that has +// progressed past the pre-history states (CREATED, STARTING) is fair game. +func isShareableState(s models.TaskSessionState) bool { + switch s { + case models.TaskSessionStateCreated, models.TaskSessionStateStarting: + return false + } + return true +} + +func taskMeta(t *models.Task) TaskMeta { + if t == nil { + return TaskMeta{} + } + return TaskMeta{ + Title: t.Title, + WorkflowStep: t.WorkflowStepID, + } +} + +func sessionMeta(s *models.TaskSession) SessionMeta { + return SessionMeta{ + AgentType: snapshotString(s.AgentProfileSnapshot, "agent_type"), + Model: snapshotString(s.AgentProfileSnapshot, "model"), + ExecutorType: snapshotString(s.ExecutorSnapshot, "type"), + StartedAt: s.StartedAt, + CompletedAt: s.CompletedAt, + } +} + +// workspaceRootsFor returns every filesystem root the redactor should rewrite +// to repo-relative form. Multi-repo sessions carry multiple worktrees, so we +// emit each WorktreePath plus the session's WorkspacePath fallback — the +// redactor sorts them longest-first internally to handle nested roots. +func workspaceRootsFor(s *models.TaskSession) []string { + roots := make([]string, 0, len(s.Worktrees)+1) + for _, w := range s.Worktrees { + if w != nil && w.WorktreePath != "" { + roots = append(roots, w.WorktreePath) + } + } + if s.WorkspacePath != "" { + roots = append(roots, s.WorkspacePath) + } + return roots +} + +// snapshotString extracts a string field from one of the *Snapshot map +// metadata fields stored on TaskSession. Returns "" if the field is missing +// or is not a string. +func snapshotString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + v, ok := m[key] + if !ok { + return "" + } + s, _ := v.(string) + return s +} + +// mapMessages converts the live message rows into snapshot Messages. +// Hidden system content (wrapped in ) is stripped per the +// existing Message.ToAPI() convention before redaction. +func mapMessages(in []*models.Message, red *Redactor) []Message { + out := make([]Message, 0, len(in)) + for _, m := range in { + role := roleForAuthor(m.AuthorType, m.Type) + blocks := blocksForMessage(m, red) + if len(blocks) == 0 { + continue + } + out = append(out, Message{ + Role: role, + Ts: m.CreatedAt, + Blocks: blocks, + }) + } + return out +} + +func roleForAuthor(author models.MessageAuthorType, mt models.MessageType) string { + switch author { + case models.MessageAuthorUser: + return roleUser + case models.MessageAuthorAgent: + return roleAssistant + } + if mt == models.MessageTypeStatus || mt == models.MessageTypeLog || mt == models.MessageTypeError { + return roleSystem + } + return roleSystem +} + +// blocksForMessage decides whether a Message is shareable conversation +// content and converts it into the snapshot Blocks for that role. We +// whitelist what gets through rather than blacklist noise — that way new +// internal message types (tool_search, agent_plan, todo, mock-agent stderr +// captures, …) don't quietly leak into shared snapshots. +// +// What gets through: +// - User messages of any type → text block (the user's intent is always +// part of the conversation, even if the runtime tagged it oddly). +// - Agent messages with no explicit type, "message", or "content" +// → text block (regular agent prose). +// - Agent messages whose type starts with "tool_" (tool_call, tool_edit, +// tool_read, tool_execute, tool_search, …) → collapsed tool block. +// +// Everything else — status, progress, log, error, thinking, agent_plan, +// todo, permission_request, clarification_request, script_execution, plus +// any future type we don't recognise — is dropped. +func blocksForMessage(m *models.Message, red *Redactor) []Block { + if m.AuthorType == models.MessageAuthorUser { + return textBlockOrNil(red, m.Content) + } + t := string(m.Type) + if t == "" || t == string(models.MessageTypeMessage) || t == string(models.MessageTypeContent) { + return textBlockOrNil(red, m.Content) + } + if strings.HasPrefix(t, "tool_") { + return []Block{toolCallBlock(m, red)} + } + return nil +} + +// textBlockOrNil returns a single text block, or nil if the content is empty +// after trimming. System-injected blocks wrapped in tags +// (the system prompt the runtime prepends to user turns) are stripped first +// — those carry implementation details that should never appear in a public +// share. After stripping, if nothing is left we drop the message entirely. +func textBlockOrNil(red *Redactor, content string) []Block { + stripped := sysprompt.StripSystemContent(content) + text := strings.TrimSpace(stripped) + if text == "" { + return nil + } + return []Block{{Kind: blockKindText, Text: red.String(text)}} +} + +// toolCallBlock builds a "tool_call" Block from a tool-call message. The +// message Content carries a human-readable summary; the Args, when present +// in Metadata, are redacted as a top-level JSON object so RuleEnvVars fires +// for shell payloads. +func toolCallBlock(m *models.Message, red *Redactor) Block { + b := Block{ + Kind: blockKindToolCall, + Text: red.String(strings.TrimSpace(m.Content)), + ToolName: toolNameFor(m), + } + if raw, ok := toolArgsFor(m); ok { + b.Args = red.JSON(raw) + } + return b +} + +// toolNameFor picks the tool label. Production messages don't carry a +// "tool_name" metadata key — service_messages.go writes the normalized +// *streams.NormalizedPayload under metadata["normalized"] and the tool kind +// ends up on Message.Type ("tool_read", "tool_execute", …). Strip the +// "tool_" prefix from Type to get a useful label ("read", "execute", "edit"). +// The debug-fixture replay path *does* set metadata["tool_name"], so prefer +// that when present for backward compatibility. +func toolNameFor(m *models.Message) string { + if name := metaString(m.Metadata, "tool_name"); name != "" { + return name + } + return strings.TrimPrefix(string(m.Type), "tool_") +} + +// toolArgsFor picks the args JSON payload. Production stores the typed tool +// payload under metadata["normalized"]; the debug-fixture replay path stores +// it under metadata["args"]. Prefer "args" so existing fixtures keep their +// shape, fall back to "normalized" so real shares have something to redact. +func toolArgsFor(m *models.Message) (json.RawMessage, bool) { + if raw, ok := metaJSON(m.Metadata, "args"); ok { + return raw, true + } + return metaJSON(m.Metadata, "normalized") +} + +func metaString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + s, _ := v.(string) + return s + } + return "" +} + +// metaJSON marshals an arbitrary metadata value back to a JSON RawMessage so +// it can be redacted via Redactor.JSON. Returns ok=false if the key is missing. +func metaJSON(m map[string]interface{}, key string) (json.RawMessage, bool) { + if m == nil { + return nil, false + } + v, ok := m[key] + if !ok { + return nil, false + } + raw, err := json.Marshal(v) + if err != nil { + return nil, false + } + return raw, true +} diff --git a/apps/backend/internal/task/share/builder_test.go b/apps/backend/internal/task/share/builder_test.go new file mode 100644 index 0000000000..24832face3 --- /dev/null +++ b/apps/backend/internal/task/share/builder_test.go @@ -0,0 +1,389 @@ +package share + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// stubReader implements TaskReader for unit tests. +type stubReader struct { + task *models.Task + session *models.TaskSession + messages []*models.Message +} + +func (s *stubReader) GetTask(_ context.Context, _ string) (*models.Task, error) { + return s.task, nil +} +func (s *stubReader) GetTaskSession(_ context.Context, _ string) (*models.TaskSession, error) { + return s.session, nil +} +func (s *stubReader) ListMessages(_ context.Context, _ string) ([]*models.Message, error) { + return s.messages, nil +} + +func TestBuildSnapshot_AllowsRunningAndOtherPostStartStates(t *testing.T) { + t.Parallel() + for _, state := range []models.TaskSessionState{ + models.TaskSessionStateRunning, + models.TaskSessionStateIdle, + models.TaskSessionStateWaitingForInput, + models.TaskSessionStateCompleted, + models.TaskSessionStateFailed, + models.TaskSessionStateCancelled, + } { + state := state + t.Run(string(state), func(t *testing.T) { + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "Hi"}, + session: &models.TaskSession{ID: "s-1", TaskID: "t-1", State: state}, + messages: []*models.Message{ + {ID: "m-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "hi"}, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("state %q: unexpected error: %v", state, err) + } + if snap == nil || len(snap.Messages) == 0 { + t.Fatalf("state %q: expected non-empty snapshot", state) + } + }) + } +} + +func TestBuildSnapshot_RejectsPreHistoryStates(t *testing.T) { + t.Parallel() + for _, state := range []models.TaskSessionState{ + models.TaskSessionStateCreated, + models.TaskSessionStateStarting, + } { + state := state + t.Run(string(state), func(t *testing.T) { + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "Hi"}, + session: &models.TaskSession{ID: "s-1", TaskID: "t-1", State: state}, + } + _, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if !errors.Is(err, ErrSessionNotShareable) { + t.Fatalf("state %q: expected ErrSessionNotShareable, got %v", state, err) + } + }) + } +} + +func TestBuildSnapshot_BuildsBasicConversation(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "Investigate flaky test", WorkflowStepID: "step-debug"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed.Add(-time.Minute), + CompletedAt: &completed, + AgentProfileSnapshot: map[string]interface{}{ + "agent_type": "claude-acp", + "model": "claude-opus-4-7", + }, + ExecutorSnapshot: map[string]interface{}{"type": "local_docker"}, + WorkspacePath: "/Users/foo/proj", + }, + messages: []*models.Message{ + { + ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorUser, + Type: models.MessageTypeMessage, Content: "Hello", + CreatedAt: completed.Add(-30 * time.Second), + }, + { + ID: "m-2", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeContent, Content: "Hi there", + CreatedAt: completed.Add(-25 * time.Second), + }, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v-test") + if err != nil { + t.Fatalf("build: %v", err) + } + if snap.Version != SnapshotVersion { + t.Fatalf("expected version %d, got %d", SnapshotVersion, snap.Version) + } + if snap.Task.Title != "Investigate flaky test" { + t.Fatalf("task.title = %q", snap.Task.Title) + } + if snap.Session.AgentType != "claude-acp" || snap.Session.Model != "claude-opus-4-7" || snap.Session.ExecutorType != "local_docker" { + t.Fatalf("session metadata not populated: %+v", snap.Session) + } + if len(snap.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(snap.Messages)) + } + if snap.Messages[0].Role != roleUser || snap.Messages[1].Role != roleAssistant { + t.Fatalf("roles wrong: %v", snap.Messages) + } + if snap.Messages[0].Blocks[0].Kind != blockKindText { + t.Fatalf("expected text block, got %q", snap.Messages[0].Blocks[0].Kind) + } +} + +func TestBuildSnapshot_RedactsToolCallEnvAndPaths(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "Shell run"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + WorkspacePath: "/Users/foo/proj", + }, + messages: []*models.Message{ + { + ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeToolExecute, + Content: "ran ls in /Users/foo/proj/src", + Metadata: map[string]interface{}{ + "tool_name": "shell", + "args": map[string]interface{}{ + "cmd": "ls", + "env": map[string]interface{}{"SECRET": "abc"}, + }, + }, + CreatedAt: completed, + }, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + if len(snap.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(snap.Messages)) + } + b := snap.Messages[0].Blocks[0] + if b.Kind != blockKindToolCall { + t.Fatalf("expected tool_call block, got %q", b.Kind) + } + if strings.Contains(b.Text, "/Users/foo/proj") { + t.Fatalf("expected workspace root rewritten, got %q", b.Text) + } + if strings.Contains(string(b.Args), "SECRET") { + t.Fatalf("env should be redacted from args, got %s", string(b.Args)) + } + if !strings.Contains(string(b.Args), `"cmd":"ls"`) { + t.Fatalf("cmd preserved expected, got %s", string(b.Args)) + } + got := snap.Redaction.AppliedRules + wantHas := map[string]bool{RuleEnvVars: false, RuleAbsPath: false} + for _, r := range got { + if _, ok := wantHas[r]; ok { + wantHas[r] = true + } + } + for k, present := range wantHas { + if !present { + t.Fatalf("expected applied rule %q, got %v", k, got) + } + } +} + +// TestBuildSnapshot_ToolCall_ProductionMetadataShape exercises the metadata +// shape that real sessions produce: no "tool_name" key, args under +// metadata["normalized"] as the NormalizedPayload JSON, message Type set to +// "tool_". The earlier shape ("tool_name"/"args" keys) only exists in +// the debug fixture loader. +func TestBuildSnapshot_ToolCall_ProductionMetadataShape(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "Real session"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + WorkspacePath: "/workspace/proj", + }, + messages: []*models.Message{ + { + ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeToolRead, + Content: "read /workspace/proj/src/main.go", + Metadata: map[string]interface{}{ + "normalized": map[string]interface{}{ + "kind": "read_file", + "read_file": map[string]interface{}{ + "path": "/workspace/proj/src/main.go", + }, + }, + }, + CreatedAt: completed, + }, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + b := snap.Messages[0].Blocks[0] + if b.Kind != blockKindToolCall { + t.Fatalf("expected tool_call block, got %q", b.Kind) + } + // "tool_" prefix stripped from m.Type to produce a useful label. + if b.ToolName != "read" { + t.Fatalf("expected ToolName=read, got %q", b.ToolName) + } + // Args populated from metadata["normalized"] and redacted. + if len(b.Args) == 0 { + t.Fatalf("expected Args populated from normalized payload, got empty") + } + if strings.Contains(string(b.Args), "/workspace/proj") { + t.Fatalf("expected abs-path redacted in args, got %s", string(b.Args)) + } + if strings.Contains(b.Text, "/workspace/proj") { + t.Fatalf("expected abs-path redacted in text, got %q", b.Text) + } +} + +func TestBuildSnapshot_FiltersNoiseTypes(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "T"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + }, + messages: []*models.Message{ + // Agent noise types — must all be dropped. + {ID: "n-1", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeLog, Content: "mock-agent: registered ACP MCP server kandev at http://localhost:41027/sse", CreatedAt: completed}, + {ID: "n-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeStatus, Content: "started", CreatedAt: completed}, + {ID: "n-3", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeError, Content: "boom", CreatedAt: completed}, + {ID: "n-4", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeThinking, Content: "hmm", CreatedAt: completed}, + {ID: "n-5", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeAgentPlan, Content: "plan", CreatedAt: completed}, + {ID: "n-6", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeTodo, Content: "todo list", CreatedAt: completed}, + {ID: "n-7", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeScriptExecution, Content: "script", CreatedAt: completed}, + {ID: "n-8", AuthorType: models.MessageAuthorAgent, Type: "completely_unknown_type", Content: "future leak", CreatedAt: completed}, + // Legit content — must survive. + {ID: "ok-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "real question", CreatedAt: completed}, + {ID: "ok-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeMessage, Content: "real answer", CreatedAt: completed}, + {ID: "ok-3", AuthorType: models.MessageAuthorAgent, Type: "tool_search", Content: "Search for \"import \"", Metadata: map[string]interface{}{"tool_name": "grep"}, CreatedAt: completed}, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + if len(snap.Messages) != 3 { + t.Fatalf("expected only 3 surviving messages (user prose, agent prose, tool_search pill), got %d:\n%+v", len(snap.Messages), snap.Messages) + } + // Make sure none of the noise content leaked in. + for _, m := range snap.Messages { + for _, b := range m.Blocks { + if strings.Contains(b.Text, "mock-agent:") { + t.Fatal("mock-agent log line leaked into snapshot") + } + if strings.Contains(b.Text, "hmm") || strings.Contains(b.Text, "todo list") { + t.Fatalf("internal type leaked: %q", b.Text) + } + } + } + // tool_search must produce a tool_call block, not text. + last := snap.Messages[len(snap.Messages)-1] + if last.Blocks[0].Kind != blockKindToolCall { + t.Fatalf("tool_search should render as tool_call block, got kind=%q", last.Blocks[0].Kind) + } +} + +func TestBuildSnapshot_StripsKandevSystemBlocks(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "T"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + }, + messages: []*models.Message{ + // First-turn prompt with system context wrapper — only the + // visible bit "real question" should survive. + { + ID: "u-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, + Content: "\nMCP context goes here\nMore secret stuff\n\nreal question", + CreatedAt: completed, + }, + // Pure system payload — should be dropped entirely after stripping. + { + ID: "u-2", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, + Content: "only system stuff", + CreatedAt: completed, + }, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + if len(snap.Messages) != 1 { + t.Fatalf("expected 1 surviving message after system strip, got %d", len(snap.Messages)) + } + body := snap.Messages[0].Blocks[0].Text + if strings.Contains(body, "kandev-system") || strings.Contains(body, "MCP context") || strings.Contains(body, "secret stuff") { + t.Fatalf("system content leaked into snapshot: %q", body) + } + if body != "real question" { + t.Fatalf("expected only the visible bit to remain, got %q", body) + } +} + +func TestBuildSnapshot_UserMessageWithUnusualTypeStillIncluded(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "T"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + }, + messages: []*models.Message{ + // A user message tagged with a weird type still represents the + // user's intent — keep it. + {ID: "u-1", AuthorType: models.MessageAuthorUser, Type: "some_weird_type", Content: "important question", CreatedAt: completed}, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + if len(snap.Messages) != 1 { + t.Fatalf("expected 1 user message, got %d", len(snap.Messages)) + } + if snap.Messages[0].Role != roleUser { + t.Fatalf("expected user role, got %q", snap.Messages[0].Role) + } +} + +func TestBuildSnapshot_SkipsStatusAndEmptyMessages(t *testing.T) { + t.Parallel() + completed := time.Now().UTC() + r := &stubReader{ + task: &models.Task{ID: "t-1", Title: "T"}, + session: &models.TaskSession{ + ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted, + StartedAt: completed, CompletedAt: &completed, + }, + messages: []*models.Message{ + {ID: "m-1", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeStatus, Content: "started", CreatedAt: completed}, + {ID: "m-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeMessage, Content: " ", CreatedAt: completed}, + {ID: "m-3", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "hi", CreatedAt: completed}, + }, + } + snap, err := BuildSnapshot(context.Background(), r, "s-1", "v") + if err != nil { + t.Fatalf("build: %v", err) + } + if len(snap.Messages) != 1 || snap.Messages[0].Role != roleUser { + t.Fatalf("expected 1 user message after pruning, got %+v", snap.Messages) + } +} diff --git a/apps/backend/internal/task/share/gist_backend.go b/apps/backend/internal/task/share/gist_backend.go new file mode 100644 index 0000000000..e873522475 --- /dev/null +++ b/apps/backend/internal/task/share/gist_backend.go @@ -0,0 +1,161 @@ +package share + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/kandev/kandev/internal/github" +) + +// GistMaxBytes is a conservative cap on a single snapshot.json file. GitHub +// caps individual gist files at 100 MiB but 10 MiB is plenty for any +// reasonable conversation and avoids accidentally pushing huge payloads. +const GistMaxBytes = 10 * 1024 * 1024 + +// GistBackend uploads snapshots to GitHub Gists on the authenticated user's +// account. Created gists are always secret (unlisted); anyone with the URL +// can view them but they are not crawled. +type GistBackend struct { + client github.Client +} + +// NewGistBackend returns a Backend that uses the given github.Client. +func NewGistBackend(client github.Client) *GistBackend { + return &GistBackend{client: client} +} + +// Name implements Backend. +func (b *GistBackend) Name() string { return BackendGitHubGist } + +// Upload marshals the snapshot to JSON, builds the rendered share.html and +// a README, then creates a secret gist with all three. The returned URL is +// the rendered view served via gistpreview.github.io — that's the link +// users share. The gist itself is preserved in the database via the +// externalID so we can still delete it on revoke. +func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, string, error) { + body, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return "", "", fmt.Errorf("marshal snapshot: %w", err) + } + if len(body) > GistMaxBytes { + return "", "", fmt.Errorf("%w: %d bytes > %d", ErrSnapshotTooLarge, len(body), GistMaxBytes) + } + resp, err := b.client.CreateGist(ctx, github.CreateGistInput{ + Description: gistDescription(snap), + Public: false, + Files: map[string]github.GistFile{ + // share.html sorts first alphabetically in the gist file list, + // which puts the rendered view at the top of the gist page too. + "share.html": {Content: BuildShareHTML(snap)}, + "snapshot.json": {Content: string(body)}, + // README is built with empty renderedURL — the user's primary + // link goes to the rendered view; the README is a fallback for + // folks who land on the gist directly. + "README.md": {Content: BuildGistREADME(snap, "")}, + }, + }) + if err != nil { + return "", "", fmt.Errorf("create gist: %w", err) + } + rendered := renderedURLForGist(resp.HTMLURL) + if rendered == "" { + // Fall back to the raw gist URL if we couldn't parse a gist ID — + // not pretty, but at least the user has a working link. + rendered = resp.HTMLURL + } + return resp.ID, rendered, nil +} + +// renderedURLForGist returns the public rendering URL we hand to the user +// for a given gist. We route through gistpreview.github.io, a static +// GitHub-Pages site that fetches the gist via the public API and renders +// an HTML file client-side. We use it instead of raw.githack.com so +// visitors don't hit the "One more step" anti-phishing interstitial. +// +// Critically: we append "/share.html" to the URL because gistpreview's +// file-picker logic picks the first iterated file unless one is named +// "index.html". Our gists contain README.md, share.html, snapshot.json — +// without the explicit suffix, gistpreview picks README.md (alphabetical +// first), renders it raw, and the page appears unstyled. The explicit +// filename pins it to share.html. +// +// https://gist.github.com// → https://gistpreview.github.io/?/share.html +// https://gist.github.com/ → https://gistpreview.github.io/?/share.html +// +// Returns "" if the URL doesn't look like a gist URL — callers fall back +// to whatever was stored. +func renderedURLForGist(gistHTMLURL string) string { + id := gistIDFromGistHTMLURL(gistHTMLURL) + if id == "" { + return "" + } + return gistpreviewURL(id) +} + +// gistpreviewURL builds the gistpreview.github.io URL for a gist id, +// explicitly pinning the file to share.html. +func gistpreviewURL(gistID string) string { + return "https://gistpreview.github.io/?" + gistID + "/share.html" +} + +// gistIDFromGistHTMLURL extracts the gist ID from a github.com gist URL. +// Returns "" for anything that doesn't match the expected shape. +func gistIDFromGistHTMLURL(gistHTMLURL string) string { + const prefix = "https://gist.github.com/" + if !strings.HasPrefix(gistHTMLURL, prefix) { + return "" + } + rest := strings.Trim(strings.TrimPrefix(gistHTMLURL, prefix), "/") + if rest == "" { + return "" + } + // "/" or "" — the ID is always the last segment. + parts := strings.Split(rest, "/") + return parts[len(parts)-1] +} + +// gistIDFromGithackURL pulls the gist ID out of a stored raw.githack URL +// (legacy rows that were written before the gistpreview switchover). +// Returns "" for anything that doesn't match. +func gistIDFromGithackURL(url string) string { + const prefix = "https://gist.githack.com/" + if !strings.HasPrefix(url, prefix) { + return "" + } + parts := strings.Split(strings.TrimPrefix(url, prefix), "/") + if len(parts) < 2 || parts[1] == "" { + return "" + } + return parts[1] +} + +// Delete removes the gist. A 404 from GitHub is returned to the caller as-is +// so the service can treat it as "already gone". +func (b *GistBackend) Delete(ctx context.Context, externalID string) error { + if err := b.client.DeleteGist(ctx, externalID); err != nil { + var apiErr *github.GitHubAPIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return err + } + return fmt.Errorf("delete gist %s: %w", externalID, err) + } + return nil +} + +func gistDescription(snap *Snapshot) string { + if snap == nil || snap.Task.Title == "" { + return "kandev task share" + } + return "kandev share: " + snap.Task.Title +} + +func nonEmpty(s, fallback string) string { + if s == "" { + return fallback + } + return s +} diff --git a/apps/backend/internal/task/share/gist_backend_test.go b/apps/backend/internal/task/share/gist_backend_test.go new file mode 100644 index 0000000000..3462906078 --- /dev/null +++ b/apps/backend/internal/task/share/gist_backend_test.go @@ -0,0 +1,128 @@ +package share + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/github" +) + +func sampleSnapshot() *Snapshot { + completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + return &Snapshot{ + Version: SnapshotVersion, + ExportedAt: completed, + Task: TaskMeta{Title: "Fix flaky test"}, + Session: SessionMeta{ + AgentType: "claude-acp", + Model: "claude-opus-4-7", + ExecutorType: "local_docker", + StartedAt: completed.Add(-time.Minute), + CompletedAt: &completed, + }, + Messages: []Message{ + {Role: "user", Ts: completed, Blocks: []Block{{Kind: "text", Text: "hi"}}}, + }, + Redaction: RedactionLog{AppliedRules: []string{}}, + } +} + +func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) { + t.Parallel() + mock := github.NewMockClient() + b := NewGistBackend(mock) + + id, url, err := b.Upload(context.Background(), sampleSnapshot()) + if err != nil { + t.Fatalf("upload: %v", err) + } + if id == "" || url == "" { + t.Fatalf("expected non-empty id/url, got %q / %q", id, url) + } + + // The returned URL is the rendered gistpreview.github.io viewer URL, + // NOT the raw gist URL — that's what the user shares. + if !strings.HasPrefix(url, "https://gistpreview.github.io/?") { + t.Fatalf("expected gistpreview.github.io URL, got %q", url) + } + + gists := mock.Gists() + g, ok := gists[id] + if !ok { + t.Fatalf("gist %s not stored on mock", id) + } + if g.Public { + t.Fatal("backend should always create secret gists (Public=false)") + } + for _, name := range []string{"snapshot.json", "README.md", "share.html"} { + if _, ok := g.Files[name]; !ok { + t.Fatalf("missing %s in gist files", name) + } + } + if !strings.Contains(g.Files["snapshot.json"].Content, `"title": "Fix flaky test"`) { + t.Fatalf("snapshot.json does not contain task title: %s", g.Files["snapshot.json"].Content) + } + if !strings.Contains(g.Files["README.md"].Content, "# Fix flaky test") { + t.Fatal("README.md missing task title heading") + } + if !strings.Contains(g.Files["share.html"].Content, "") { + t.Fatal("share.html missing doctype") + } + if !strings.Contains(g.Files["share.html"].Content, "Fix flaky test") { + t.Fatal("share.html missing task title") + } +} + +func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) { + t.Parallel() + mock := github.NewMockClient() + b := NewGistBackend(mock) + + snap := sampleSnapshot() + big := strings.Repeat("x", GistMaxBytes+1) + snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}} + + _, _, err := b.Upload(context.Background(), snap) + if err == nil { + t.Fatal("expected ErrSnapshotTooLarge, got nil") + } + if !errors.Is(err, ErrSnapshotTooLarge) { + t.Fatalf("expected ErrSnapshotTooLarge, got %v", err) + } + if len(mock.Gists()) != 0 { + t.Fatalf("no gist should have been created, got %d", len(mock.Gists())) + } +} + +func TestGistBackend_Delete_Success(t *testing.T) { + t.Parallel() + mock := github.NewMockClient() + b := NewGistBackend(mock) + id, _, err := b.Upload(context.Background(), sampleSnapshot()) + if err != nil { + t.Fatalf("upload: %v", err) + } + if err := b.Delete(context.Background(), id); err != nil { + t.Fatalf("delete: %v", err) + } + if got := mock.DeletedGists(); len(got) != 1 || got[0] != id { + t.Fatalf("expected DeletedGists=[%q], got %v", id, got) + } +} + +func TestGistBackend_Delete_PassesThroughNotFoundForCallerInspection(t *testing.T) { + t.Parallel() + mock := github.NewMockClient() + b := NewGistBackend(mock) + + err := b.Delete(context.Background(), "missing-id") + if err == nil { + t.Fatal("expected error for missing gist") + } + if !IsAlreadyGone(err) { + t.Fatalf("expected IsAlreadyGone to be true, got err=%v", err) + } +} diff --git a/apps/backend/internal/task/share/gist_css.go b/apps/backend/internal/task/share/gist_css.go new file mode 100644 index 0000000000..c89e75a8c7 --- /dev/null +++ b/apps/backend/internal/task/share/gist_css.go @@ -0,0 +1,386 @@ +package share + +// shareCSS is the inlined stylesheet for the rendered share.html page. +// Self-contained — no @import, no external fonts — so the page renders +// identically on a fresh CDN with no network outside itself. +// +// Color tokens mirror kandev's dark-mode palette +// (apps/packages/theme/src/globals.css). We use the same oklch() values +// so the share page reads as part of the product rather than a separate +// site. oklch() has full support in current Chrome/Safari/Firefox. +// +// Layout: classic chat. User bubbles right-aligned with the primary +// (purple) fill; assistant content left-aligned in the card surface; +// system messages centered and de-emphasised. Tool calls render inline +// as collapsed pills inside the assistant bubble. Diffs are full-width +// code blocks with per-line tinting for adds/dels/hunks. +const shareCSS = ` +:root { + color-scheme: dark; + + /* — Kandev dark-mode tokens (oklch, ported from globals.css) — */ + --bg: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --border: oklch(1 0 0 / 10%); + --border-strong: oklch(1 0 0 / 18%); + --primary: oklch(0.59 0.2 277); + --primary-foreground: oklch(0.96 0.02 272); + --accent: oklch(0.62 0.18 276); + --accent-strong: oklch(0.79 0.1 275); + --success: oklch(0.68 0.12 150); + --destructive: oklch(0.704 0.191 22.216); + + /* — Derived semantic aliases used in the share layout — */ + --text: var(--foreground); + --text-dim: var(--muted-foreground); + --text-faint: oklch(0.55 0 0); + --surface: var(--card); + --surface-2: oklch(0.235 0 0); + + --diff-add-bg: color-mix(in oklab, var(--success) 14%, transparent); + --diff-add-text: oklch(0.85 0.14 150); + --diff-del-bg: color-mix(in oklab, var(--destructive) 14%, transparent); + --diff-del-text: oklch(0.82 0.16 22); + --diff-hunk: var(--accent-strong); + + --radius: 0.375rem; + --radius-lg: 0.625rem; + --radius-xl: 0.875rem; + + --mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: "Figtree", "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { color: var(--accent-strong); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre { font-family: var(--mono); } +::selection { background: var(--primary); color: var(--primary-foreground); } + +/* ─── Hero ─────────────────────────────────────────────────────── */ + +.hero { + max-width: 880px; + margin: 0 auto; + padding: 56px 24px 28px; + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-dim); + font-size: 13px; + margin-bottom: 16px; +} +.brand a { color: var(--text); font-weight: 600; letter-spacing: -0.01em; } +.brand-sep { color: var(--text-faint); } +.brand-tag { color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; } + +.hero h1 { + margin: 0 0 18px; + font-size: 30px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.2; +} + +.badges { display: flex; flex-wrap: wrap; gap: 6px; } +.badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + font-family: var(--mono); +} + +.redaction { + margin: 16px 0 0; + font-size: 13px; + color: var(--text-dim); +} +.redaction code { + padding: 1px 6px; + background: var(--surface); + border-radius: 4px; + font-size: 12px; +} + +/* ─── Conversation ────────────────────────────────────────────── */ + +.conv { + max-width: 880px; + margin: 0 auto; + padding: 32px 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.group { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.group-user { flex-direction: row-reverse; } +.group-assistant { flex-direction: row; } +.group-system { justify-content: center; } + +.avatar { + flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + margin-bottom: 4px; +} + +.group-user .avatar { + background: var(--primary); + border-color: var(--primary); +} +.group-system .avatar { display: none; } + +.bubble { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-xl); + background: var(--surface); + border: 1px solid var(--border); +} +.group-assistant .bubble { + border-bottom-left-radius: var(--radius); + flex: 1; +} +.group-user .bubble { + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); + border-bottom-right-radius: var(--radius); + max-width: 75%; +} +.group-system .bubble { + background: transparent; + border-style: dashed; + color: var(--text-dim); + font-size: 13px; + max-width: 480px; + text-align: center; +} + +/* Role label sits above the content. Hidden inside user bubbles since + the right-alignment + color already convey "you said this." */ +.role { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + margin: -2px 0 -4px; +} +.group-user .role { display: none; } +.group-assistant .role { color: var(--accent-strong); } + +/* ─── Text inside bubbles ─────────────────────────────────────── */ + +.text > * { margin: 0; } +.text > * + * { margin-top: 8px; } +.text p { line-height: 1.6; } +.group-user .text { font-size: 15px; } + +code.inline { + background: color-mix(in oklab, var(--foreground) 8%, transparent); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.9em; +} +.group-user code.inline { + background: color-mix(in oklab, white 22%, transparent); + color: var(--primary-foreground); +} + +pre.code { + margin: 4px 0; + padding: 12px 14px; + border-radius: var(--radius-lg); + background: var(--bg); + border: 1px solid var(--border); + overflow-x: auto; + font-size: 12.5px; + line-height: 1.5; + color: var(--text); +} +.group-user pre.code { + background: color-mix(in oklab, black 25%, transparent); + border-color: color-mix(in oklab, white 14%, transparent); + color: var(--primary-foreground); +} + +/* ─── Tool call / result pills ────────────────────────────────── */ + +.tool { + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: color-mix(in oklab, var(--foreground) 3%, transparent); + overflow: hidden; +} +.tool > summary { + list-style: none; + cursor: pointer; + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-dim); + user-select: none; +} +.tool > summary::-webkit-details-marker { display: none; } +.tool > summary:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); } + +.tool-icon { font-size: 13px; } +.tool-name { + color: var(--text); + font-family: var(--mono); + font-size: 12px; +} +.tool-summary { + flex: 1; + color: var(--text-faint); + font-family: var(--mono); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.tool-chev { + color: var(--text-faint); + font-size: 10px; + transition: transform 0.15s ease; +} +.tool[open] > summary .tool-chev { transform: rotate(90deg); } + +.args, .output { + margin: 0; + padding: 12px 14px; + background: var(--bg); + border-top: 1px solid var(--border); + font-size: 12.5px; + line-height: 1.5; + overflow-x: auto; + white-space: pre; + color: var(--text); +} + +/* ─── Diff blocks ─────────────────────────────────────────────── */ + +.diff { + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg); + overflow: hidden; +} +.diff-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; + color: var(--text-dim); + background: var(--surface-2); +} +.diff-head code { color: var(--text); font-size: 12px; } +.diff-body { + margin: 0; + padding: 8px 0; + font-size: 12.5px; + line-height: 1.5; + overflow-x: auto; +} +.diff-body > span { + display: block; + padding: 0 14px; + white-space: pre; +} +.diff-add { background: var(--diff-add-bg); color: var(--diff-add-text); } +.diff-del { background: var(--diff-del-bg); color: var(--diff-del-text); } +.diff-hunk { color: var(--diff-hunk); } +.diff-file { color: var(--text-faint); } + +/* ─── Misc ────────────────────────────────────────────────────── */ + +.empty { + text-align: center; + color: var(--text-faint); + padding: 48px 0; +} + +.page-footer { + max-width: 880px; + margin: 0 auto; + padding: 32px 24px 48px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-faint); +} +.cta { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground) !important; + font-weight: 500; +} +.cta:hover { text-decoration: none; background: var(--accent); } +.foot-sep { color: var(--text-faint); } +.foot-link { color: var(--text-dim); } +.foot-version { color: var(--text-faint); font-family: var(--mono); font-size: 12px; } + +/* ─── Mobile ──────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .hero { padding: 32px 16px 20px; } + .hero h1 { font-size: 22px; } + .conv { padding: 20px 12px; gap: 18px; } + .group { gap: 8px; } + .avatar { width: 26px; height: 26px; font-size: 14px; } + .bubble { padding: 10px 12px; border-radius: var(--radius-lg); } + .group-user .bubble { max-width: 85%; } +} +` diff --git a/apps/backend/internal/task/share/gist_html.go b/apps/backend/internal/task/share/gist_html.go new file mode 100644 index 0000000000..69ca33ba14 --- /dev/null +++ b/apps/backend/internal/task/share/gist_html.go @@ -0,0 +1,241 @@ +package share + +import ( + "fmt" + "html" + "strings" +) + +// kandevRepoURL is the public GitHub repo for the project — used in the +// share page's "Try kandev" CTA and brand link. Pointing at the repo +// instead of a marketing site signals "this is open source, here's the +// code" which is the actual value proposition for the audience that +// receives a shared task. +const kandevRepoURL = "https://github.com/kdlbs/kandev" + +// BuildShareHTML produces a self-contained styled HTML page that renders +// the snapshot as a real chat conversation: user bubbles right-aligned, +// assistant content left-aligned, consecutive same-role messages fused +// into one block, and tool calls collapsed inline rather than rendered +// as their own messages. +func BuildShareHTML(snap *Snapshot) string { + if snap == nil { + return "kandev share" + } + var b strings.Builder + b.WriteString("\n\n") + writeHTMLHead(&b, snap) + b.WriteString("\n") + // Inline the stylesheet at the top of rather than in so it + // survives renderers (notably gistpreview.github.io) that copy only the + // body content into their own page — dropping and any \n") + writeHTMLHero(&b, snap) + b.WriteString("
\n") + writeHTMLConversation(&b, snap.Messages) + b.WriteString("
\n") + writeHTMLFooter(&b, snap) + b.WriteString("\n\n") + return b.String() +} + +func writeHTMLHead(b *strings.Builder, snap *Snapshot) { + title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) + fmt.Fprintf(b, "\n\n") + fmt.Fprintf(b, "\n") + fmt.Fprintf(b, "%s — kandev share\n", title) + fmt.Fprintf(b, "\n", title) + b.WriteString("\n\n") +} + +func writeHTMLHero(b *strings.Builder, snap *Snapshot) { + title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) + b.WriteString("
\n") + b.WriteString("
kandev") + b.WriteString("·shared task
\n") + fmt.Fprintf(b, "

%s

\n", title) + b.WriteString("
") + writeHTMLBadge(b, snap.Session.AgentType) + writeHTMLBadge(b, snap.Session.Model) + writeHTMLBadge(b, snap.Session.ExecutorType) + writeHTMLBadge(b, fmt.Sprintf("%d messages", len(snap.Messages))) + if completed := formatPtrTime(snap.Session.CompletedAt); completed != "" { + writeHTMLBadge(b, completed) + } + b.WriteString("
\n") + if len(snap.Redaction.AppliedRules) > 0 { + fmt.Fprintf(b, "

🛡️ Redacted: %s

\n", + html.EscapeString(strings.Join(snap.Redaction.AppliedRules, ", "))) + } + b.WriteString("
\n") +} + +func writeHTMLBadge(b *strings.Builder, text string) { + if text == "" { + return + } + fmt.Fprintf(b, "%s", html.EscapeString(text)) +} + +// messageGroup is a run of consecutive messages sharing the same role. +// Blocks from every member are flattened in order so the whole run renders +// inside a single bubble. +type messageGroup struct { + role string + blocks []Block +} + +// groupMessages collapses consecutive same-role messages so the renderer +// can draw one bubble per group. Empty messages (no blocks) are dropped +// rather than producing empty groups. +func groupMessages(messages []Message) []messageGroup { + var groups []messageGroup + for _, m := range messages { + if len(m.Blocks) == 0 { + continue + } + if n := len(groups); n > 0 && groups[n-1].role == m.Role { + groups[n-1].blocks = append(groups[n-1].blocks, m.Blocks...) + continue + } + groups = append(groups, messageGroup{ + role: m.Role, + blocks: append([]Block(nil), m.Blocks...), + }) + } + return groups +} + +func writeHTMLConversation(b *strings.Builder, messages []Message) { + groups := groupMessages(messages) + if len(groups) == 0 { + b.WriteString("

No messages.

\n") + return + } + for _, g := range groups { + writeHTMLGroup(b, g) + } +} + +func writeHTMLGroup(b *strings.Builder, g messageGroup) { + cls, label, icon := messageRoleAttrs(g.role) + fmt.Fprintf(b, "
\n", cls) + fmt.Fprintf(b, "
%s
\n", icon) + b.WriteString("
\n") + fmt.Fprintf(b, "
%s
\n", html.EscapeString(label)) + for _, block := range g.blocks { + writeHTMLBlock(b, block) + } + b.WriteString("
\n
\n") +} + +func messageRoleAttrs(role string) (cls, label, icon string) { + switch role { + case roleUser: + return "user", "You", "🧑" + case roleAssistant: + return "assistant", "Assistant", "🤖" + case roleSystem: + return "system", "System", "⚙️" + } + return "other", role, "•" +} + +func writeHTMLBlock(b *strings.Builder, block Block) { + switch block.Kind { + case blockKindText: + writeHTMLText(b, block.Text) + case blockKindToolCall: + writeHTMLToolCall(b, block) + case blockKindToolResult: + writeHTMLToolResult(b, block) + case blockKindDiff: + writeHTMLDiff(b, block) + } +} + +func writeHTMLToolCall(b *strings.Builder, block Block) { + name := html.EscapeString(nonEmpty(block.ToolName, "tool")) + summary := html.EscapeString(strings.TrimSpace(block.Text)) + // Closed by default — tool calls are noise unless the reader cares. + b.WriteString("
\n") + b.WriteString("🔧") + fmt.Fprintf(b, "%s", name) + if summary != "" { + fmt.Fprintf(b, "%s", summary) + } + b.WriteString("▸") + b.WriteString("\n") + if len(block.Args) > 0 { + fmt.Fprintf(b, "
%s
\n", + html.EscapeString(prettyJSON(block.Args))) + } + b.WriteString("
\n") +} + +func writeHTMLToolResult(b *strings.Builder, block Block) { + out := strings.TrimRight(block.Output, "\n") + if strings.TrimSpace(out) == "" { + return + } + label := "Tool output" + if block.Truncated { + label += " (truncated)" + } + b.WriteString("
\n") + b.WriteString("📤") + fmt.Fprintf(b, "%s", html.EscapeString(label)) + b.WriteString("▸") + b.WriteString("\n") + fmt.Fprintf(b, "
%s
\n", html.EscapeString(out)) + b.WriteString("
\n") +} + +func writeHTMLDiff(b *strings.Builder, block Block) { + if strings.TrimSpace(block.UnifiedDiff) == "" { + return + } + path := html.EscapeString(nonEmpty(block.Path, "diff")) + fmt.Fprintf(b, "
\n
📝") + fmt.Fprintf(b, "%s
\n
", path)
+	for _, line := range strings.Split(strings.TrimRight(block.UnifiedDiff, "\n"), "\n") {
+		writeHTMLDiffLine(b, line)
+	}
+	b.WriteString("
\n
\n") +} + +func writeHTMLDiffLine(b *strings.Builder, line string) { + cls := "diff-ctx" + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + cls = "diff-file" + case strings.HasPrefix(line, "@@"): + cls = "diff-hunk" + case strings.HasPrefix(line, "+"): + cls = "diff-add" + case strings.HasPrefix(line, "-"): + cls = "diff-del" + } + fmt.Fprintf(b, "%s\n", cls, html.EscapeString(line)) +} + +func writeHTMLFooter(b *strings.Builder, snap *Snapshot) { + b.WriteString("
\n") + b.WriteString("Try kandev on GitHub →\n") + b.WriteString("·\n") + b.WriteString("snapshot.json\n") + if snap.KandevVersion != "" { + fmt.Fprintf(b, "·\nkandev %s\n", + html.EscapeString(snap.KandevVersion)) + } + b.WriteString("
\n") +} diff --git a/apps/backend/internal/task/share/gist_html_test.go b/apps/backend/internal/task/share/gist_html_test.go new file mode 100644 index 0000000000..519195af29 --- /dev/null +++ b/apps/backend/internal/task/share/gist_html_test.go @@ -0,0 +1,220 @@ +package share + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestBuildShareHTML_RendersChatLayout(t *testing.T) { + t.Parallel() + completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + snap := &Snapshot{ + Version: SnapshotVersion, + ExportedAt: completed, + Task: TaskMeta{Title: "Investigate test"}, + Session: SessionMeta{ + AgentType: "claude-acp", + Model: "claude-opus-4-7", + ExecutorType: "local_docker", + StartedAt: completed.Add(-time.Minute), + CompletedAt: &completed, + }, + Messages: []Message{ + {Role: roleUser, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "why is X flaky?"}}}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "Looking into it."}}}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{ + {Kind: blockKindToolCall, ToolName: "shell", Text: "ran tests", + Args: json.RawMessage(`{"cmd":"go test ./..."}`)}, + }}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{ + {Kind: blockKindToolResult, Output: "FAIL pkg/foo TestX"}, + {Kind: blockKindText, Text: "Found it. Use `t.Cleanup`."}, + {Kind: blockKindDiff, Path: "src/x.go", + UnifiedDiff: "--- a/x.go\n+++ b/x.go\n@@ -1,1 +1,1 @@\n-old\n+new\n context\n"}, + }}, + }, + Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}}, + } + + doc := BuildShareHTML(snap) + + assertContains(t, doc, "") + assertContains(t, doc, "Investigate <flaky> test — kandev share") + assertContains(t, doc, "