f8fc3bf506
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
397 lines
13 KiB
Go
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: taskmodels.PublicTaskMetadata(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
|
|
}
|