Files
kandev/apps/backend/internal/plugins/host_data_mappers.go
T

397 lines
13 KiB
Go

package plugins
import (
"context"
"encoding/json"
"net/url"
"sort"
"strings"
"time"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
githubsvc "github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/sysprompt"
taskmodels "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// sessionToDTO maps a TaskSession to the Go-native Session DTO, resolving
// ACPSessionID via resolveACPSessionID's metadata-then-executors_running
// fallback.
func (h *pluginHost) sessionToDTO(ctx context.Context, s *taskmodels.TaskSession) pluginsdk.Session {
return pluginsdk.Session{
ID: s.ID,
TaskID: s.TaskID,
AgentProfileID: s.AgentProfileID,
AgentDisplayName: sessionSnapshotString(s.AgentProfileSnapshot, "agent_display_name"),
AgentProfileName: sessionSnapshotString(s.AgentProfileSnapshot, "name"),
Model: sessionSnapshotModel(s.AgentProfileSnapshot),
ACPSessionID: h.resolveACPSessionID(ctx, s),
State: string(s.State),
StartedAt: s.StartedAt.UTC().Format(time.RFC3339),
EndedAt: timePtrToRFC3339(s.CompletedAt),
}
}
// resolveACPSessionID replicates the source agent-stats plugin's join key
// (docs/decisions/0043-plugin-host-data-api.md, "A real plugin exposed the
// gap"): the agent CLI's own session UUID at
// TaskSession.Metadata["acp"]["session_id"], populated once the agent emits
// a session_info frame. executors_running.resume_token carries the same id
// and survives on sessions that never got that far, so it is a best-effort
// fallback — a lookup failure (including "no such row") is silently treated
// as "no id available" rather than failing the whole read.
func (h *pluginHost) resolveACPSessionID(ctx context.Context, s *taskmodels.TaskSession) string {
if id := acpSessionIDFromMetadata(s.Metadata); id != "" {
return id
}
running, err := h.taskData.GetExecutorRunningBySessionID(ctx, s.ID)
if err != nil || running == nil {
return ""
}
return running.ResumeToken
}
func acpSessionIDFromMetadata(metadata map[string]any) string {
acp, ok := metadata["acp"].(map[string]any)
if !ok {
return ""
}
id, _ := acp["session_id"].(string)
return id
}
func sessionSnapshotString(snapshot map[string]any, key string) string {
if snapshot == nil {
return ""
}
v, _ := snapshot[key].(string)
return v
}
// sessionSnapshotModel mirrors the source plugin's fallback chain for the
// agent-profile snapshot's model field, which has varied key names across
// agent types over time.
func sessionSnapshotModel(snapshot map[string]any) string {
for _, key := range []string{"model", "model_name", "llm"} {
if v := sessionSnapshotString(snapshot, key); v != "" {
return v
}
}
return ""
}
func stringPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// ── Internal model → pluginsdk DTO mapping ──────────────────────────────
func taskModelToDTO(t *taskmodels.Task) pluginsdk.Task {
repos := make([]pluginsdk.TaskRepository, len(t.Repositories))
for i, r := range t.Repositories {
repos[i] = pluginsdk.TaskRepository{
ID: r.ID,
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
Position: int32(r.Position),
CheckoutBranch: r.CheckoutBranch,
}
}
return pluginsdk.Task{
ID: t.ID,
WorkspaceID: t.WorkspaceID,
WorkflowID: t.WorkflowID,
Title: t.Title,
Description: t.Description,
State: string(t.State),
Priority: t.Priority,
// CreatedBy: kandev's Task model has no creating-user column — Origin
// ("manual"/"agent_created"/"routine"/"automation_run") is the
// closest analogue and is what this surfaces.
CreatedBy: t.Origin,
CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
// StartedAt/CompletedAt: the Task model has no started_at/completed_at
// columns (ArchivedAt is a different concept); left nil in v1.
ParentID: stringPtrOrNil(t.ParentID),
Identifier: t.Identifier,
IsEphemeral: t.IsEphemeral,
Repositories: repos,
Metadata: t.Metadata,
ArchivedAt: timePtrToRFC3339(t.ArchivedAt),
WorkflowStepID: t.WorkflowStepID,
Position: int32(t.Position),
AssigneeAgentProfileID: t.AssigneeAgentProfileID,
Labels: decodeTaskLabels(t.Labels),
Autopilot: t.Autopilot,
WIPAdmitted: t.WIPAdmitted,
QueuedForStepID: t.QueuedForStepID,
QueuedAt: timePtrToRFC3339(t.QueuedAt),
ProjectID: t.ProjectID,
ExternalID: t.ExternalID,
}
}
func timePtrToRFC3339(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}
func decodeTaskLabels(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return nil
}
var labels []string
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
return nil
}
return labels
}
// taskPRsToDTOs maps kandev's own PR rows onto the plugin contract. Review and
// check state come along because kandev's PR watcher already syncs them: a
// plugin asking "what can merge" would otherwise re-query the forge per pull
// request, which is slower and rate-limited.
func taskPRsToDTOs(prs []*githubsvc.TaskPR) []pluginsdk.TaskPullRequest {
ordered := make([]*githubsvc.TaskPR, 0, len(prs))
for _, pr := range prs {
if pr != nil {
ordered = append(ordered, pr)
}
}
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].CreatedAt.After(ordered[j].CreatedAt)
})
out := make([]pluginsdk.TaskPullRequest, 0, len(ordered))
for _, pr := range ordered {
out = append(out, pluginsdk.TaskPullRequest{
Number: int64(pr.PRNumber),
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
HeadBranch: pr.HeadBranch,
BaseBranch: pr.BaseBranch,
// IsDraft is nullable on the row (unknown before the first sync);
// nil reads as not-draft, matching how kandev's own UI treats it.
IsDraft: pr.IsDraft != nil && *pr.IsDraft,
// Only github writes these rows today; the field exists so a future
// gitlab-backed row is distinguishable rather than mislabelled.
Provider: "github",
MergedAt: timePtrToRFC3339(pr.MergedAt),
ClosedAt: timePtrToRFC3339(pr.ClosedAt),
ReviewState: pr.ReviewState,
ChecksState: pr.ChecksState,
MergeableState: pr.MergeableState,
UnresolvedReviewThreads: int32(pr.UnresolvedReviewThreads),
ChecksTotal: int32(pr.ChecksTotal),
ChecksPassing: int32(pr.ChecksPassing),
Additions: int32(pr.Additions),
Deletions: int32(pr.Deletions),
AuthorLogin: pr.AuthorLogin,
})
}
return out
}
func workspaceModelToDTO(w *taskmodels.Workspace) pluginsdk.Workspace {
return pluginsdk.Workspace{
ID: w.ID,
Name: w.Name,
Description: stringPtrOrNil(w.Description),
OwnerID: w.OwnerID,
DefaultExecutorID: w.DefaultExecutorID,
DefaultAgentProfileID: w.DefaultAgentProfileID,
CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func workflowModelToDTO(w *taskmodels.Workflow) pluginsdk.Workflow {
return pluginsdk.Workflow{
ID: w.ID,
WorkspaceID: w.WorkspaceID,
Name: w.Name,
Description: stringPtrOrNil(w.Description),
SortOrder: int32(w.SortOrder),
CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func workflowStepModelToDTO(s *wfmodels.WorkflowStep) pluginsdk.WorkflowStep {
return pluginsdk.WorkflowStep{
ID: s.ID,
WorkflowID: s.WorkflowID,
Name: s.Name,
Position: int32(s.Position),
StageType: string(s.StageType),
Color: s.Color,
IsStartStep: s.IsStartStep,
WIPLimit: int32(s.WIPLimit),
AgentProfileID: s.AgentProfileID,
OnEnterActionTypes: onEnterActionTypesToDTO(s.Events.OnEnter),
}
}
// onEnterActionTypesToDTO exposes only the on_enter action TYPES, order
// preserved as authored. Action Config (target task ids, payloads, participant
// roles, agent profile ids) is deliberately not exposed to plugins.
func onEnterActionTypesToDTO(actions []wfmodels.OnEnterAction) []string {
if len(actions) == 0 {
return nil
}
out := make([]string, len(actions))
for i, action := range actions {
out[i] = string(action.Type)
}
return out
}
func repositoryModelToDTO(r *taskmodels.Repository) pluginsdk.Repository {
return pluginsdk.Repository{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
Name: r.Name,
DefaultBranch: stringPtrOrNil(r.DefaultBranch),
SourceType: r.SourceType,
ProviderID: r.Provider,
ProviderRepositoryID: r.ProviderRepoID,
ProviderHost: r.ProviderHost,
ProviderScope: r.ProviderScope,
OwnerOrProject: r.ProviderOwner,
ProviderName: r.ProviderName,
RemoteURL: credentialFreeRepositoryURL(r.RemoteURL),
}
}
func credentialFreeRepositoryURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
parsed.User = nil
return parsed.String()
}
func agentProfileDTOToSDK(p agentsettingsdto.AgentProfileDTO) pluginsdk.AgentProfile {
return pluginsdk.AgentProfile{
ID: p.ID,
AgentID: p.AgentID,
DisplayName: p.AgentDisplayName,
Name: p.Name,
Model: p.Model,
Mode: p.Mode,
}
}
// messageModelToDTO maps a stored message to the Go-native Message DTO. It
// strips kandev-injected <kandev-system> blocks from content via
// sysprompt.StripSystemContent — the same sanitization the message.added bus
// event applies — so a plugin never sees raw system prompts. Type defaults to
// "message" (matching Message.ToAPI and the repository) when empty.
func messageModelToDTO(m *taskmodels.Message) pluginsdk.Message {
msgType := string(m.Type)
if msgType == "" {
msgType = string(taskmodels.MessageTypeMessage)
}
return pluginsdk.Message{
ID: m.ID,
SessionID: m.TaskSessionID,
TaskID: m.TaskID,
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
Content: sysprompt.StripSystemContent(m.Content),
Type: msgType,
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
}
}
// sessionCodeStatsModelToDTO converts the internal analytics model - which
// represents "capture wasn't active yet for this session" as a nil
// LinesAddedCommitted/LinesDeletedCommitted pair - into the public plugin
// SDK's DTO, which represents the same fact as CommittedLinesAvailable ==
// false with both fields reading 0 (see pluginsdk.SessionCodeStats' doc for
// why the SDK doesn't use pointers here).
func sessionCodeStatsModelToDTO(s *analyticsmodels.SessionCodeStats) pluginsdk.SessionCodeStats {
dto := pluginsdk.SessionCodeStats{
SessionID: s.SessionID,
LinesAddedPeakPending: s.LinesAddedPeakPending,
LinesDeletedPeakPending: s.LinesDeletedPeakPending,
}
if s.LinesAddedCommitted != nil && s.LinesDeletedCommitted != nil {
dto.LinesAddedCommitted = *s.LinesAddedCommitted
dto.LinesDeletedCommitted = *s.LinesDeletedCommitted
dto.CommittedLinesAvailable = true
}
return dto
}
// interactionModelToDTO converts the assembled durable interaction into the
// public plugin DTO. Timestamps become RFC3339 UTC strings like every other
// Host DTO. The permission title is the agent's prompt text, which the
// message row stores as its content — it is agent-authored, not a
// kandev-injected system block, so it needs no sanitization pass.
func interactionModelToDTO(i *taskmodels.Interaction) pluginsdk.Interaction {
return pluginsdk.Interaction{
ID: i.ID,
Kind: string(i.Kind),
TaskID: i.TaskID,
SessionID: i.SessionID,
TurnID: i.TurnID,
Status: string(i.Status),
Title: i.Title,
Context: i.Context,
ToolCallID: i.ToolCallID,
ActionType: i.ActionType,
Options: interactionOptionsToDTOs(i.Options),
Questions: interactionQuestionsToDTOs(i.Questions),
AgentDisconnected: i.AgentDisconnected,
CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: i.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func interactionOptionsToDTOs(options []taskmodels.InteractionOption) []pluginsdk.InteractionOption {
if len(options) == 0 {
return nil
}
out := make([]pluginsdk.InteractionOption, len(options))
for i, option := range options {
out[i] = pluginsdk.InteractionOption{
OptionID: option.ID,
Label: option.Label,
Kind: option.Kind,
Description: option.Description,
}
}
return out
}
func interactionQuestionsToDTOs(questions []taskmodels.InteractionQuestion) []pluginsdk.InteractionQuestion {
if len(questions) == 0 {
return nil
}
out := make([]pluginsdk.InteractionQuestion, len(questions))
for i, question := range questions {
out[i] = pluginsdk.InteractionQuestion{
ID: question.ID,
Title: question.Title,
Prompt: question.Prompt,
Options: interactionOptionsToDTOs(question.Options),
}
}
return out
}