e145d48f22
* feat: add plugin system with grpc runtime and native ui plugins - go plugin backends ship as release tarballs (per-platform binaries, checksums) that kandev installs from url/upload or filesystem sideload+sync, then spawns and supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls) - strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook plus a capability-gated host service (state, secrets, emit); author sdk in pkg/pluginsdk; packaging cli in cmd/plugin-pack - native frontend bundles: window.registerKandevPlugin + registry/host loader with dynamic routes, nav items, slots and ws handlers; plugins settings page with install dialog, sync button and enable/disable/uninstall - gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and integration tests plus playwright e2e against a real packaged fixture plugin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): hydrate base app context on plugin routes Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped only feature flags — no workspaces, workflows, or kanban context. Native plugin UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same base context as the Home route for unknown SPA routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): expose TaskCreateDialog on host.ui Lets a native plugin open kandev's real create-task modal (prefilled via initialValues) instead of POSTing directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): harden filesystem/URL sinks against CodeQL findings Add sink-level guards on the plugin trust boundary: securejoin containment on archive extraction (zip-slip), plugin-id validation before every fs_store path, http/https scheme validation on install-from-URL, and non-interpolated ws-handler error logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address review findings (races, resource caps, lifecycle) - serialize per-plugin startup to close a TOCTOU double-spawn race, and let a plugin restart after exhausting attempts - bound plugin startup with an explicit 30s timeout - reject backslash paths and cap decompressed bytes by actual size in pkgtar; only mark a package Signed when a verifier actually ran and passed - delete plugin_state on uninstall; validate webhook keys against the manifest and cap webhook body size; restart an active plugin when a new version installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): sanitize plugin-id path sinks for CodeQL Pin the plugin id to a single path segment with filepath.Base at recordPath/ configPath and pkgtar.Remove — a barrier the taint analysis recognizes. Behaviour-identical for valid ids (already validated by safePluginID); clears the go/path-injection alerts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): inline plugin-id validation at path sinks for CodeQL filepath.Base does not strip '..', so it is not a path-injection barrier. Call the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/ SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (backend) - add capability-gating tests for the Host RPC service (was untested) - close a stop-during-start orphan race so Disable/Uninstall can't leave a supervised subprocess behind - on failed upgrade, remove only the new version dir (not all versions + data); restart the prior version - validate manifest version and reject ids ending in .config (record-file alias) - make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop - clamp out-of-range webhook status, skip-and-warn corrupt records in List, write records atomically, semver-aware sideload version pick Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (frontend) - wrap plugin routes + settings routes in a shared PluginErrorBoundary so a throwing plugin page shows a fallback instead of white-screening the SPA - fix disable/re-enable in one session: keep the cached registration and re-run initialize instead of relying on a cached ES-module re-import - prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri) - surface partial-install warnings via toast.warning instead of a bare success - dedupe/remove injected style links on unload; gate PluginNavItems on the flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): serialize per-plugin lifecycle + guard config writes - add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/ Uninstall/UpdateConfig for one id are serialized (no status-machine race) - take s.mu in SetConfig/GetConfig and write config atomically - bound each plugin initialize() with a timeout so one hung bundle can't stall the rest of the boot load Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): split service_test.go to satisfy file-length lint Move the install/URL test group into service_install_test.go; no logic change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: build and ship the e2e fixture plugin package to E2E shards The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz. Build it in the E2E build job, upload it as an artifact, and download it in both the regular and container shard jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1002 lines
28 KiB
Go
1002 lines
28 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
|
|
taskdto "github.com/kandev/kandev/internal/task/dto"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
userdto "github.com/kandev/kandev/internal/user/dto"
|
|
"github.com/kandev/kandev/internal/webapp"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
const (
|
|
activeWorkspaceCookie = "kandev-active-workspace"
|
|
legacyOfficeWorkspaceCookie = "office-active-workspace"
|
|
bootStateKeySessionID = "sessionId"
|
|
bootStateKeyWorkspaceID = "workspaceId"
|
|
)
|
|
|
|
func bootInitialState(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
p routeParams,
|
|
route webapp.RouteClassification,
|
|
) map[string]any {
|
|
builder := bootStateBuilder{p: p}
|
|
state := map[string]any{
|
|
"features": p.features,
|
|
}
|
|
|
|
if route.Route == webapp.RouteSettings {
|
|
builder.addWorkspaceState(ctx, state, nil)
|
|
builder.addUserSettingsState(ctx, state, "")
|
|
builder.addSettingsRouteState(ctx, state, route.Path)
|
|
}
|
|
// Home and unknown SPA routes both render the full app shell (nav,
|
|
// workspace picker) without a route-specific data payload. Unknown
|
|
// covers plugin-owned routes (e.g. /github-plugin) registered at
|
|
// runtime, which the backend classifier can't enumerate — they still
|
|
// need the base workspace/workflow/kanban context so native plugin UI
|
|
// (like host.ui.TaskCreateDialog) has workspaces and workflows to work
|
|
// with, not an empty store.
|
|
if route.Route == webapp.RouteHome || route.Route == webapp.RouteUnknown {
|
|
builder.addHomeKanbanRouteState(ctx, req, state)
|
|
}
|
|
if route.Route == webapp.RouteTasks {
|
|
tasksState, _ := builder.tasksPageBootData(ctx, req)
|
|
mergeBootState(state, tasksState)
|
|
}
|
|
if isLocalContextRoute(route.Route) {
|
|
contextState, _ := builder.routeContextBootData(ctx, req)
|
|
mergeBootState(state, contextState)
|
|
}
|
|
if route.Route == webapp.RouteOffice {
|
|
builder.addOfficeRouteState(ctx, req, state)
|
|
}
|
|
builder.addQuickChatState(ctx, req, state, route)
|
|
return state
|
|
}
|
|
|
|
func bootRouteData(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
p routeParams,
|
|
route webapp.RouteClassification,
|
|
) map[string]any {
|
|
builder := bootStateBuilder{p: p}
|
|
switch route.Route {
|
|
case webapp.RouteTaskDetail:
|
|
return builder.taskDetailRouteData(ctx, route.Params["taskId"])
|
|
case webapp.RouteTasks:
|
|
_, routeData := builder.tasksPageBootData(ctx, req)
|
|
if routeData == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{"tasksPage": routeData}
|
|
case webapp.RouteGitHub, webapp.RouteGitLab, webapp.RouteJira, webapp.RouteLinear, webapp.RouteStats:
|
|
_, routeData := builder.routeContextBootData(ctx, req)
|
|
if routeData == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{"routeContext": routeData}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func isLocalContextRoute(route webapp.RouteName) bool {
|
|
switch route {
|
|
case webapp.RouteGitHub, webapp.RouteGitLab, webapp.RouteJira, webapp.RouteLinear, webapp.RouteStats:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type bootStateBuilder struct {
|
|
p routeParams
|
|
}
|
|
|
|
func (b bootStateBuilder) addWorkspaceState(ctx context.Context, state map[string]any, activeID *string) {
|
|
if b.p.taskSvc == nil {
|
|
return
|
|
}
|
|
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
|
|
if err != nil {
|
|
b.logBootError("list workspaces", err)
|
|
return
|
|
}
|
|
items := make([]taskdto.WorkspaceDTO, 0, len(workspaces))
|
|
for _, workspace := range workspaces {
|
|
if workspace == nil {
|
|
continue
|
|
}
|
|
items = append(items, taskdto.FromWorkspace(workspace))
|
|
}
|
|
var active any
|
|
if activeID != nil {
|
|
active = *activeID
|
|
}
|
|
state["workspaces"] = map[string]any{
|
|
"items": items,
|
|
"activeId": active,
|
|
}
|
|
}
|
|
|
|
func (b bootStateBuilder) addUserSettingsState(ctx context.Context, state map[string]any, workspaceID string) {
|
|
if b.p.userCtrl == nil {
|
|
return
|
|
}
|
|
response, err := b.p.userCtrl.GetUserSettings(ctx)
|
|
if err != nil {
|
|
b.logBootError("get user settings", err)
|
|
return
|
|
}
|
|
state["userSettings"] = mapUserSettingsState(response, workspaceID)
|
|
}
|
|
|
|
func (b bootStateBuilder) addSettingsRouteState(ctx context.Context, state map[string]any, path string) {
|
|
switch path {
|
|
case "/settings/prompts":
|
|
b.addPromptsState(ctx, state)
|
|
case "/settings/general/editors":
|
|
b.addEditorsState(ctx, state)
|
|
}
|
|
}
|
|
|
|
func (b bootStateBuilder) addHomeKanbanRouteState(ctx context.Context, req *http.Request, state map[string]any) {
|
|
if b.p.taskSvc == nil {
|
|
return
|
|
}
|
|
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
|
|
if err != nil {
|
|
b.logBootError("list home workspaces", err)
|
|
return
|
|
}
|
|
workspaceItems := make([]map[string]any, 0, len(workspaces))
|
|
workspaceIDs := make(map[string]bool, len(workspaces))
|
|
for _, workspace := range workspaces {
|
|
if workspace == nil {
|
|
continue
|
|
}
|
|
workspaceIDs[workspace.ID] = true
|
|
workspaceItems = append(workspaceItems, mapWorkspaceItemState(taskdto.FromWorkspace(workspace)))
|
|
}
|
|
|
|
settings, hasSettings := b.userSettings(ctx)
|
|
settingsWorkspaceID := ""
|
|
settingsWorkflowID := ""
|
|
if hasSettings {
|
|
settingsWorkspaceID = settings.Settings.WorkspaceID
|
|
settingsWorkflowID = settings.Settings.WorkflowFilterID
|
|
}
|
|
activeWorkspaceID := firstValidID(
|
|
workspaceIDs,
|
|
queryValue(req, "workspaceId"),
|
|
readActiveWorkspaceCookie(req),
|
|
settingsWorkspaceID,
|
|
firstWorkspaceID(workspaces),
|
|
)
|
|
state["workspaces"] = map[string]any{
|
|
"items": workspaceItems,
|
|
"activeId": nullString(activeWorkspaceID),
|
|
}
|
|
if hasSettings {
|
|
state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID)
|
|
}
|
|
if activeWorkspaceID == "" {
|
|
return
|
|
}
|
|
|
|
workflows, err := b.homeWorkflows(ctx, activeWorkspaceID)
|
|
if err != nil {
|
|
b.logBootError("list home workflows", err)
|
|
return
|
|
}
|
|
workflowItems := make([]map[string]any, 0, len(workflows))
|
|
for _, workflow := range workflows {
|
|
if workflow == nil {
|
|
continue
|
|
}
|
|
workflowItems = append(workflowItems, mapWorkflowItemState(taskdto.FromWorkflow(workflow)))
|
|
}
|
|
activeWorkflowID := resolveHomeWorkflowID(workflows, queryValue(req, "workflowId"), settingsWorkflowID, hasSettings)
|
|
state["workflows"] = map[string]any{
|
|
"items": workflowItems,
|
|
"activeId": nullString(activeWorkflowID),
|
|
}
|
|
if hasSettings {
|
|
state["userSettings"] = mapUserSettingsStateWithWorkflow(settings, activeWorkspaceID, activeWorkflowID)
|
|
}
|
|
b.addRepositoriesState(ctx, state, activeWorkspaceID)
|
|
b.addKanbanSnapshotsState(ctx, state, workflows, activeWorkflowID)
|
|
}
|
|
|
|
func (b bootStateBuilder) userSettings(ctx context.Context) (userdto.UserSettingsResponse, bool) {
|
|
if b.p.userCtrl == nil {
|
|
return userdto.UserSettingsResponse{}, false
|
|
}
|
|
response, err := b.p.userCtrl.GetUserSettings(ctx)
|
|
if err != nil {
|
|
b.logBootError("get user settings", err)
|
|
return userdto.UserSettingsResponse{}, false
|
|
}
|
|
return response, true
|
|
}
|
|
|
|
func (b bootStateBuilder) homeWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) {
|
|
workflows, err := b.p.taskSvc.ListWorkflows(ctx, workspaceID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
officeIDs := b.p.taskSvc.GetOfficeWorkflowIDs(ctx)
|
|
filtered := make([]*taskmodels.Workflow, 0, len(workflows))
|
|
for _, workflow := range workflows {
|
|
if workflow == nil {
|
|
continue
|
|
}
|
|
if _, isOffice := officeIDs[workflow.ID]; isOffice {
|
|
continue
|
|
}
|
|
filtered = append(filtered, workflow)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (b bootStateBuilder) addRepositoriesState(ctx context.Context, state map[string]any, workspaceID string) {
|
|
repositories, err := b.p.taskSvc.ListRepositories(ctx, workspaceID)
|
|
if err != nil {
|
|
b.logBootError("list home repositories", err)
|
|
return
|
|
}
|
|
items := make([]taskdto.RepositoryDTO, 0, len(repositories))
|
|
for _, repository := range repositories {
|
|
if repository == nil {
|
|
continue
|
|
}
|
|
items = append(items, taskdto.FromRepository(repository))
|
|
}
|
|
state["repositories"] = map[string]any{
|
|
"itemsByWorkspaceId": map[string]any{workspaceID: items},
|
|
"loadingByWorkspaceId": map[string]any{
|
|
workspaceID: false,
|
|
},
|
|
"loadedByWorkspaceId": map[string]any{
|
|
workspaceID: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (b bootStateBuilder) addQuickChatState(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
state map[string]any,
|
|
route webapp.RouteClassification,
|
|
) {
|
|
workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state, route)
|
|
if workspaceID == "" {
|
|
return
|
|
}
|
|
quickChat, err := b.quickChatSessions(ctx, workspaceID)
|
|
if err != nil {
|
|
b.logBootError("list quick-chat sessions", err)
|
|
return
|
|
}
|
|
state["quickChat"] = map[string]any{
|
|
"isOpen": false,
|
|
"sessions": quickChat.sessions,
|
|
"activeSessionId": nil,
|
|
}
|
|
mergeBootTaskSessionItems(state, quickChat.taskSessions)
|
|
}
|
|
|
|
func (b bootStateBuilder) resolveQuickChatWorkspaceID(
|
|
ctx context.Context,
|
|
req *http.Request,
|
|
state map[string]any,
|
|
route webapp.RouteClassification,
|
|
) string {
|
|
if workspaceID := b.quickChatTaskRouteWorkspaceID(ctx, route); workspaceID != "" {
|
|
return workspaceID
|
|
}
|
|
if active := activeWorkspaceIDFromState(state); active != "" {
|
|
return active
|
|
}
|
|
if b.p.taskSvc == nil {
|
|
return ""
|
|
}
|
|
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
|
|
if err != nil {
|
|
b.logBootError("list quick-chat workspaces", err)
|
|
return ""
|
|
}
|
|
settingsWorkspaceID := ""
|
|
if settings, ok := b.userSettings(ctx); ok {
|
|
settingsWorkspaceID = settings.Settings.WorkspaceID
|
|
}
|
|
return firstValidID(
|
|
workspaceIDSet(workspaces),
|
|
queryValue(req, "workspaceId"),
|
|
queryValue(req, "workspace"),
|
|
readActiveWorkspaceCookie(req),
|
|
settingsWorkspaceID,
|
|
firstWorkspaceID(workspaces),
|
|
)
|
|
}
|
|
|
|
func (b bootStateBuilder) quickChatTaskRouteWorkspaceID(
|
|
ctx context.Context,
|
|
route webapp.RouteClassification,
|
|
) string {
|
|
if b.p.taskSvc == nil || (route.Route != webapp.RouteTaskDetail && route.Route != webapp.RouteOffice) {
|
|
return ""
|
|
}
|
|
taskID := route.Params["taskId"]
|
|
if taskID == "" {
|
|
return ""
|
|
}
|
|
task, err := b.p.taskSvc.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
b.logBootError("get quick-chat task route workspace", err)
|
|
return ""
|
|
}
|
|
if task == nil {
|
|
return ""
|
|
}
|
|
return task.WorkspaceID
|
|
}
|
|
|
|
func activeWorkspaceIDFromState(state map[string]any) string {
|
|
workspaces, ok := state["workspaces"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
active, _ := workspaces["activeId"].(string)
|
|
return active
|
|
}
|
|
|
|
func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID string) (quickChatBootState, error) {
|
|
tasks, err := b.listQuickChatTasks(ctx, workspaceID)
|
|
if err != nil {
|
|
return quickChatBootState{}, err
|
|
}
|
|
if len(tasks) == 0 {
|
|
return quickChatBootState{sessions: []map[string]any{}}, nil
|
|
}
|
|
taskIDs := taskIDs(tasks)
|
|
primaryByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs)
|
|
if err != nil {
|
|
return quickChatBootState{}, err
|
|
}
|
|
|
|
items := make([]quickChatBootSession, 0, len(tasks))
|
|
taskSessions := make(map[string]taskdto.TaskSessionDTO, len(tasks))
|
|
for _, task := range tasks {
|
|
if !isRestorableQuickChatTask(task) {
|
|
continue
|
|
}
|
|
primary := primaryByTask[task.ID]
|
|
if primary == nil || primary.ID == "" {
|
|
continue
|
|
}
|
|
items = append(items, quickChatBootSession{
|
|
state: mapQuickChatSessionState(task, primary),
|
|
createdAt: task.CreatedAt,
|
|
})
|
|
taskSessions[primary.ID] = taskdto.FromTaskSession(primary)
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].createdAt.Before(items[j].createdAt)
|
|
})
|
|
result := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, item.state)
|
|
}
|
|
return quickChatBootState{sessions: result, taskSessions: taskSessions}, nil
|
|
}
|
|
|
|
func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID string) ([]*taskmodels.Task, error) {
|
|
const pageSize = 1000
|
|
var all []*taskmodels.Task
|
|
for page := 1; ; page++ {
|
|
tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, tasks...)
|
|
if len(tasks) == 0 || len(all) >= total {
|
|
return all, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
type quickChatBootSession struct {
|
|
state map[string]any
|
|
createdAt time.Time
|
|
}
|
|
|
|
type quickChatBootState struct {
|
|
sessions []map[string]any
|
|
taskSessions map[string]taskdto.TaskSessionDTO
|
|
}
|
|
|
|
func mergeBootTaskSessionItems(state map[string]any, items map[string]taskdto.TaskSessionDTO) {
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
taskSessions, ok := state["taskSessions"].(map[string]any)
|
|
if !ok {
|
|
state["taskSessions"] = map[string]any{"items": items}
|
|
return
|
|
}
|
|
merged := make(map[string]any, len(items))
|
|
switch existing := taskSessions["items"].(type) {
|
|
case map[string]taskdto.TaskSessionDTO:
|
|
for id, session := range existing {
|
|
merged[id] = session
|
|
}
|
|
case map[string]any:
|
|
for id, session := range existing {
|
|
merged[id] = session
|
|
}
|
|
}
|
|
for id, session := range items {
|
|
merged[id] = session
|
|
}
|
|
taskSessions["items"] = merged
|
|
}
|
|
|
|
func taskIDs(tasks []*taskmodels.Task) []string {
|
|
ids := make([]string, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task != nil {
|
|
ids = append(ids, task.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func isRestorableQuickChatTask(task *taskmodels.Task) bool {
|
|
return task != nil &&
|
|
task.IsEphemeral &&
|
|
task.WorkflowID == "" &&
|
|
task.Origin != taskmodels.TaskOriginAutomationRun
|
|
}
|
|
|
|
func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSession) map[string]any {
|
|
state := map[string]any{
|
|
bootStateKeySessionID: primary.ID,
|
|
bootStateKeyWorkspaceID: task.WorkspaceID,
|
|
}
|
|
if task.Title != "" && task.Title != "Quick Chat" {
|
|
state["name"] = task.Title
|
|
}
|
|
if agentProfileID := quickChatAgentProfileID(task, primary); agentProfileID != "" {
|
|
state["agentProfileId"] = agentProfileID
|
|
}
|
|
return state
|
|
}
|
|
|
|
func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSession) string {
|
|
if task != nil {
|
|
if value, ok := task.Metadata[taskmodels.MetaKeyAgentProfileID].(string); ok {
|
|
return value
|
|
}
|
|
}
|
|
if primary != nil {
|
|
return primary.AgentProfileID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (b bootStateBuilder) addKanbanSnapshotsState(
|
|
ctx context.Context,
|
|
state map[string]any,
|
|
workflows []*taskmodels.Workflow,
|
|
activeWorkflowID string,
|
|
) {
|
|
snapshots := make(map[string]any, len(workflows))
|
|
var active map[string]any
|
|
for _, workflow := range workflows {
|
|
if workflow == nil {
|
|
continue
|
|
}
|
|
snapshot, ok := b.workflowSnapshotState(ctx, workflow)
|
|
if !ok {
|
|
continue
|
|
}
|
|
snapshots[workflow.ID] = snapshot
|
|
if workflow.ID == activeWorkflowID {
|
|
active = snapshot
|
|
}
|
|
}
|
|
state["kanbanMulti"] = map[string]any{
|
|
"snapshots": snapshots,
|
|
"isLoading": false,
|
|
}
|
|
if active != nil {
|
|
state["kanban"] = map[string]any{
|
|
"workflowId": active["workflowId"],
|
|
"steps": active["steps"],
|
|
"tasks": active["tasks"],
|
|
"isLoading": false,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b bootStateBuilder) workflowSnapshotState(ctx context.Context, workflow *taskmodels.Workflow) (map[string]any, bool) {
|
|
steps, err := b.workflowStepStates(ctx, workflow.ID)
|
|
if err != nil {
|
|
b.logBootError("list home workflow steps", err)
|
|
return nil, false
|
|
}
|
|
tasks, err := b.p.taskSvc.ListTasks(ctx, workflow.ID)
|
|
if err != nil {
|
|
b.logBootError("list home workflow tasks", err)
|
|
return nil, false
|
|
}
|
|
visibleTasks := make([]*taskmodels.Task, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task == nil || task.IsEphemeral || task.WorkflowStepID == "" {
|
|
continue
|
|
}
|
|
visibleTasks = append(visibleTasks, task)
|
|
}
|
|
taskStates := make([]map[string]any, 0, len(visibleTasks))
|
|
for _, task := range b.taskDTOsWithSessionInfo(ctx, visibleTasks) {
|
|
taskStates = append(taskStates, mapKanbanTaskState(task))
|
|
}
|
|
return map[string]any{
|
|
"workflowId": workflow.ID,
|
|
"workflowName": workflow.Name,
|
|
"steps": steps,
|
|
"tasks": taskStates,
|
|
}, true
|
|
}
|
|
|
|
func (b bootStateBuilder) workflowStepStates(ctx context.Context, workflowID string) ([]map[string]any, error) {
|
|
if b.p.services == nil || b.p.services.Workflow == nil {
|
|
return []map[string]any{}, nil
|
|
}
|
|
steps, err := b.p.services.Workflow.ListStepsByWorkflow(ctx, workflowID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]map[string]any, 0, len(steps))
|
|
for _, step := range steps {
|
|
if step == nil {
|
|
continue
|
|
}
|
|
result = append(result, mapKanbanStepState(taskdto.FromWorkflowStepWithTimestamps(step)))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (b bootStateBuilder) taskDetailRouteData(ctx context.Context, taskID string) map[string]any {
|
|
if b.p.taskSvc == nil || taskID == "" {
|
|
return nil
|
|
}
|
|
task, err := b.p.taskSvc.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
b.logBootError("get task detail task", err)
|
|
return nil
|
|
}
|
|
sessions, err := b.p.taskSvc.ListTaskSessions(ctx, task.ID)
|
|
if err != nil {
|
|
b.logBootError("list task detail sessions", err)
|
|
sessions = nil
|
|
}
|
|
activeSessionID := resolveTaskDetailSessionID(task, sessions)
|
|
taskDTO := b.taskDTOWithSessionInfo(ctx, task)
|
|
initialState := b.taskDetailInitialState(ctx, task, taskDTO, sessions, activeSessionID)
|
|
return map[string]any{
|
|
"taskDetail": map[string]any{
|
|
"task": taskDTO,
|
|
"sessionId": nullString(activeSessionID),
|
|
"initialState": initialState,
|
|
"initialTerminals": []any{},
|
|
},
|
|
}
|
|
}
|
|
|
|
func resolveTaskDetailSessionID(task *taskmodels.Task, sessions []*taskmodels.TaskSession) string {
|
|
if task != nil {
|
|
for _, session := range sessions {
|
|
if session != nil && session.IsPrimary {
|
|
return session.ID
|
|
}
|
|
}
|
|
}
|
|
for _, session := range sessions {
|
|
if session != nil && session.ID != "" {
|
|
return session.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (b bootStateBuilder) taskDTOWithSessionInfo(ctx context.Context, task *taskmodels.Task) taskdto.TaskDTO {
|
|
if task == nil {
|
|
return taskdto.TaskDTO{}
|
|
}
|
|
dtos := b.taskDTOsWithSessionInfo(ctx, []*taskmodels.Task{task})
|
|
if len(dtos) == 0 {
|
|
return taskdto.FromTask(task)
|
|
}
|
|
return dtos[0]
|
|
}
|
|
|
|
func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*taskmodels.Task) []taskdto.TaskDTO {
|
|
if len(tasks) == 0 {
|
|
return []taskdto.TaskDTO{}
|
|
}
|
|
taskIDs := make([]string, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task != nil {
|
|
taskIDs = append(taskIDs, task.ID)
|
|
}
|
|
}
|
|
sessionsByTask, err := b.p.taskSvc.BatchGetSessionsForTasks(ctx, taskIDs)
|
|
if err != nil {
|
|
b.logBootError("batch task detail sessions", err)
|
|
return taskDTOs(tasks)
|
|
}
|
|
primaryInfoByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs)
|
|
if err != nil {
|
|
b.logBootError("get task detail primary session info", err)
|
|
return taskDTOs(tasks)
|
|
}
|
|
pendingActionsBySession, err := b.bootPendingActionsForWaitingPrimarySessions(ctx, primaryInfoByTask)
|
|
if err != nil {
|
|
b.logBootError("get task detail pending actions", err)
|
|
pendingActionsBySession = map[string]taskmodels.TaskPendingAction{}
|
|
}
|
|
result := make([]taskdto.TaskDTO, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task == nil {
|
|
continue
|
|
}
|
|
sessions := sessionsByTask[task.ID]
|
|
var primarySessionID *string
|
|
for _, session := range sessions {
|
|
if session != nil && session.IsPrimary {
|
|
id := session.ID
|
|
primarySessionID = &id
|
|
break
|
|
}
|
|
}
|
|
var sessionCount *int
|
|
if len(sessions) > 0 {
|
|
count := len(sessions)
|
|
sessionCount = &count
|
|
}
|
|
info := bootSessionInfo(primaryInfoByTask[task.ID])
|
|
result = append(result, taskdto.FromTaskWithSessionInfo(
|
|
task,
|
|
primarySessionID,
|
|
sessionCount,
|
|
info.reviewStatus,
|
|
info.executorID,
|
|
info.executorType,
|
|
info.executorName,
|
|
info.agentName,
|
|
info.workingDirectory,
|
|
info.sessionState,
|
|
bootPendingActionPtr(info.sessionID, pendingActionsBySession),
|
|
))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func taskDTOs(tasks []*taskmodels.Task) []taskdto.TaskDTO {
|
|
result := make([]taskdto.TaskDTO, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task != nil {
|
|
result = append(result, taskdto.FromTask(task))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
type bootSessionInfoFields struct {
|
|
sessionID *string
|
|
reviewStatus taskmodels.ReviewStatus
|
|
sessionState *string
|
|
executorID *string
|
|
executorType *string
|
|
executorName *string
|
|
agentName *string
|
|
workingDirectory *string
|
|
}
|
|
|
|
func bootSessionInfo(session *taskmodels.TaskSession) bootSessionInfoFields {
|
|
var info bootSessionInfoFields
|
|
if session == nil {
|
|
return info
|
|
}
|
|
if session.ID != "" {
|
|
value := session.ID
|
|
info.sessionID = &value
|
|
}
|
|
info.reviewStatus = session.ReviewStatus
|
|
if session.State != "" {
|
|
value := string(session.State)
|
|
info.sessionState = &value
|
|
}
|
|
if session.ExecutorID != "" {
|
|
value := session.ExecutorID
|
|
info.executorID = &value
|
|
}
|
|
if session.ExecutorSnapshot != nil {
|
|
if value, ok := session.ExecutorSnapshot["executor_type"].(string); ok && value != "" {
|
|
info.executorType = &value
|
|
}
|
|
if value, ok := session.ExecutorSnapshot["executor_name"].(string); ok && value != "" {
|
|
info.executorName = &value
|
|
}
|
|
}
|
|
if session.AgentProfileSnapshot != nil {
|
|
if value, ok := session.AgentProfileSnapshot["name"].(string); ok && value != "" {
|
|
info.agentName = &value
|
|
}
|
|
}
|
|
if session.RepositorySnapshot != nil {
|
|
if value, ok := session.RepositorySnapshot["path"].(string); ok && value != "" {
|
|
info.workingDirectory = &value
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func (b bootStateBuilder) bootPendingActionsForWaitingPrimarySessions(
|
|
ctx context.Context,
|
|
primaryInfoByTask map[string]*taskmodels.TaskSession,
|
|
) (map[string]taskmodels.TaskPendingAction, error) {
|
|
sessionIDs := make([]string, 0, len(primaryInfoByTask))
|
|
for _, info := range primaryInfoByTask {
|
|
if info != nil && info.State == taskmodels.TaskSessionStateWaitingForInput {
|
|
sessionIDs = append(sessionIDs, info.ID)
|
|
}
|
|
}
|
|
if len(sessionIDs) == 0 {
|
|
return map[string]taskmodels.TaskPendingAction{}, nil
|
|
}
|
|
return b.p.taskSvc.GetPendingActionsForSessions(ctx, sessionIDs)
|
|
}
|
|
|
|
func bootPendingActionPtr(
|
|
sessionID *string,
|
|
pendingActionsBySession map[string]taskmodels.TaskPendingAction,
|
|
) *string {
|
|
if sessionID == nil {
|
|
return nil
|
|
}
|
|
action, ok := pendingActionsBySession[*sessionID]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
value := string(action)
|
|
return &value
|
|
}
|
|
|
|
func (b bootStateBuilder) taskDetailInitialState(
|
|
ctx context.Context,
|
|
task *taskmodels.Task,
|
|
taskDTO taskdto.TaskDTO,
|
|
sessions []*taskmodels.TaskSession,
|
|
activeSessionID string,
|
|
) map[string]any {
|
|
state := map[string]any{}
|
|
b.addTaskDetailResourceState(ctx, state, task)
|
|
b.addTaskDetailKanbanState(ctx, state, task)
|
|
b.addTaskDetailActiveTaskState(ctx, state, taskDTO, activeSessionID)
|
|
b.addTaskDetailSessionsState(state, task.ID, sessions, activeSessionID)
|
|
b.addTaskDetailAgentsState(ctx, state)
|
|
return state
|
|
}
|
|
|
|
func (b bootStateBuilder) addTaskDetailResourceState(ctx context.Context, state map[string]any, task *taskmodels.Task) {
|
|
b.addWorkspaceState(ctx, state, &task.WorkspaceID)
|
|
b.addUserSettingsState(ctx, state, task.WorkspaceID)
|
|
workflows, err := b.p.taskSvc.ListWorkflows(ctx, task.WorkspaceID, true)
|
|
if err != nil {
|
|
b.logBootError("list task detail workflows", err)
|
|
} else {
|
|
state["workflows"] = map[string]any{
|
|
"items": workflowItemStates(workflows),
|
|
"activeId": nil,
|
|
}
|
|
}
|
|
b.addRepositoriesState(ctx, state, task.WorkspaceID)
|
|
}
|
|
|
|
func workflowItemStates(workflows []*taskmodels.Workflow) []map[string]any {
|
|
items := make([]map[string]any, 0, len(workflows))
|
|
for _, workflow := range workflows {
|
|
if workflow != nil {
|
|
items = append(items, mapWorkflowItemState(taskdto.FromWorkflow(workflow)))
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (b bootStateBuilder) addTaskDetailKanbanState(ctx context.Context, state map[string]any, task *taskmodels.Task) {
|
|
if task.WorkflowID == "" {
|
|
state["kanban"] = map[string]any{"workflowId": "", "steps": []any{}, "tasks": []any{}, "isLoading": false}
|
|
return
|
|
}
|
|
workflows, err := b.p.taskSvc.ListWorkflows(ctx, task.WorkspaceID, true)
|
|
if err != nil {
|
|
b.logBootError("list task detail kanban workflows", err)
|
|
return
|
|
}
|
|
for _, workflow := range workflows {
|
|
if workflow == nil || workflow.ID != task.WorkflowID {
|
|
continue
|
|
}
|
|
snapshot, ok := b.workflowSnapshotState(ctx, workflow)
|
|
if !ok {
|
|
return
|
|
}
|
|
state["kanban"] = map[string]any{
|
|
"workflowId": snapshot["workflowId"],
|
|
"steps": snapshot["steps"],
|
|
"tasks": snapshot["tasks"],
|
|
"isLoading": false,
|
|
}
|
|
state["kanbanMulti"] = map[string]any{
|
|
"snapshots": map[string]any{workflow.ID: snapshot},
|
|
"isLoading": false,
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func (b bootStateBuilder) addTaskDetailActiveTaskState(
|
|
ctx context.Context,
|
|
state map[string]any,
|
|
task taskdto.TaskDTO,
|
|
activeSessionID string,
|
|
) {
|
|
state["tasks"] = map[string]any{
|
|
"activeTaskId": task.ID,
|
|
"activeSessionId": nullString(activeSessionID),
|
|
"pinnedSessionId": nil,
|
|
"lastSessionByTaskId": lastSessionByTaskState(task.ID, activeSessionID),
|
|
}
|
|
if activeSessionID == "" {
|
|
return
|
|
}
|
|
messages, hasMore, err := b.p.taskSvc.ListMessagesPaginated(ctx, taskservice.ListMessagesRequest{
|
|
TaskSessionID: activeSessionID,
|
|
Limit: 50,
|
|
Sort: "desc",
|
|
})
|
|
if err != nil {
|
|
b.logBootError("list task detail messages", err)
|
|
return
|
|
}
|
|
apiMessages := make([]*v1.Message, 0, len(messages))
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i] != nil {
|
|
apiMessages = append(apiMessages, messages[i].ToAPI())
|
|
}
|
|
}
|
|
var oldest any
|
|
if len(apiMessages) > 0 {
|
|
oldest = apiMessages[0].ID
|
|
}
|
|
state["messages"] = map[string]any{
|
|
"bySession": map[string]any{activeSessionID: apiMessages},
|
|
"metaBySession": map[string]any{
|
|
activeSessionID: map[string]any{
|
|
"isLoading": false,
|
|
"hasMore": hasMore,
|
|
"oldestCursor": oldest,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func lastSessionByTaskState(taskID, sessionID string) map[string]string {
|
|
if taskID == "" || sessionID == "" {
|
|
return map[string]string{}
|
|
}
|
|
return map[string]string{taskID: sessionID}
|
|
}
|
|
|
|
func (b bootStateBuilder) addTaskDetailSessionsState(
|
|
state map[string]any,
|
|
taskID string,
|
|
sessions []*taskmodels.TaskSession,
|
|
activeSessionID string,
|
|
) {
|
|
sessionItems := make(map[string]taskdto.TaskSessionDTO, len(sessions))
|
|
sessionList := make([]taskdto.TaskSessionDTO, 0, len(sessions))
|
|
environmentBySession := make(map[string]string, len(sessions))
|
|
worktrees := make(map[string]any)
|
|
worktreesBySession := make(map[string]any)
|
|
for _, session := range sessions {
|
|
if session == nil {
|
|
continue
|
|
}
|
|
dto := taskdto.FromTaskSession(session)
|
|
sessionItems[session.ID] = dto
|
|
sessionList = append(sessionList, dto)
|
|
if session.TaskEnvironmentID != "" {
|
|
environmentBySession[session.ID] = session.TaskEnvironmentID
|
|
}
|
|
if dto.WorktreeID != "" {
|
|
worktrees[dto.WorktreeID] = map[string]any{
|
|
"id": dto.WorktreeID,
|
|
"sessionId": session.ID,
|
|
"repositoryId": nullString(dto.RepositoryID),
|
|
"path": nullString(dto.WorktreePath),
|
|
"branch": nullString(dto.WorktreeBranch),
|
|
}
|
|
worktreesBySession[session.ID] = []string{dto.WorktreeID}
|
|
}
|
|
}
|
|
state["taskSessions"] = map[string]any{"items": sessionItems}
|
|
state["taskSessionsByTask"] = map[string]any{
|
|
"itemsByTaskId": map[string]any{taskID: sessionList},
|
|
"loadingByTaskId": map[string]any{taskID: false},
|
|
"loadedByTaskId": map[string]any{taskID: true},
|
|
}
|
|
state["turns"] = map[string]any{
|
|
"bySession": map[string]any{},
|
|
"activeBySession": activeTurnBySessionState(activeSessionID),
|
|
}
|
|
state["environmentIdBySessionId"] = environmentBySession
|
|
state["worktrees"] = map[string]any{"items": worktrees}
|
|
state["sessionWorktreesBySessionId"] = map[string]any{"itemsBySessionId": worktreesBySession}
|
|
}
|
|
|
|
func activeTurnBySessionState(sessionID string) map[string]any {
|
|
if sessionID == "" {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{sessionID: nil}
|
|
}
|
|
|
|
func (b bootStateBuilder) addTaskDetailAgentsState(ctx context.Context, state map[string]any) {
|
|
if b.p.agentSettingsController == nil {
|
|
return
|
|
}
|
|
response, err := b.p.agentSettingsController.ListAgents(ctx)
|
|
if err != nil {
|
|
b.logBootError("list task detail agents", err)
|
|
return
|
|
}
|
|
state["settingsAgents"] = map[string]any{"items": response.Agents}
|
|
state["settingsData"] = map[string]any{"agentsLoaded": true, "executorsLoaded": false}
|
|
state["agentProfiles"] = map[string]any{
|
|
"items": agentProfileOptionStates(response.Agents),
|
|
"version": 0,
|
|
}
|
|
}
|
|
|
|
func agentProfileOptionStates(agents []agentsettingsdto.AgentDTO) []map[string]any {
|
|
items := []map[string]any{}
|
|
for _, agent := range agents {
|
|
for _, profile := range agent.Profiles {
|
|
items = append(items, map[string]any{
|
|
"id": profile.ID,
|
|
"label": profile.AgentDisplayName + " - " + profile.Name,
|
|
"agent_id": agent.ID,
|
|
"agent_name": agent.Name,
|
|
"cli_passthrough": profile.CLIPassthrough,
|
|
"capability_status": nullString(agent.CapabilityStatus),
|
|
"capability_error": nullString(agent.CapabilityError),
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
}
|