| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/control" |
| 8 | "reasonix/internal/taskcatalog" |
| 9 | "reasonix/internal/taskmonitor" |
| 10 | ) |
| 11 | |
| 12 | type TaskPageRequest struct { |
| 13 | Scope string `json:"scope"` |
| 14 | TabID string `json:"tabId"` |
| 15 | ProjectKey string `json:"projectKey"` |
| 16 | States []string `json:"states"` |
| 17 | Query string `json:"query"` |
| 18 | Cursor string `json:"cursor"` |
| 19 | Limit int `json:"limit"` |
| 20 | } |
| 21 | |
| 22 | type TaskCatalogItem = taskcatalog.Item |
| 23 | type TaskCatalogStatus = taskcatalog.Status |
| 24 | |
| 25 | type TaskPage struct { |
| 26 | Items []TaskCatalogItem `json:"items"` |
| 27 | NextCursor string `json:"nextCursor"` |
| 28 | Revision uint64 `json:"revision"` |
| 29 | Partial bool `json:"partial"` |
| 30 | StaleCursor bool `json:"staleCursor"` |
| 31 | Status TaskCatalogStatus `json:"status"` |
| 32 | } |
| 33 | |
| 34 | type TaskEventPageRequest struct { |
| 35 | ProjectKey string `json:"projectKey"` |
| 36 | TaskID string `json:"taskId"` |
| 37 | After int `json:"after"` |
| 38 | Limit int `json:"limit"` |
| 39 | } |
| 40 | |
| 41 | type TaskEventPage = taskcatalog.EventPage |
| 42 | |
| 43 | type TaskActionRequest struct { |
| 44 | ProjectKey string `json:"projectKey"` |
| 45 | TaskID string `json:"taskId"` |
| 46 | ExpectedVersion uint64 `json:"expectedVersion"` |
| 47 | Reason string `json:"reason"` |
| 48 | IdempotencyKey string `json:"idempotencyKey"` |
| 49 | } |
| 50 | |
| 51 | type TaskOpenRequest struct { |
| 52 | ProjectKey string `json:"projectKey"` |
| 53 | TaskID string `json:"taskId"` |
| 54 | } |
| 55 | |
| 56 | func (a *App) GetTaskCatalogStatus() TaskCatalogStatus { |
| 57 | if catalog := taskcatalog.Shared(); catalog != nil { |
| 58 | return catalog.Status() |
| 59 | } |
| 60 | return TaskCatalogStatus{State: "opening", Mode: "memory", Pending: 1} |
| 61 | } |
| 62 | |
| 63 | func (a *App) taskProjectKeys(req TaskPageRequest) ([]string, string, error) { |
| 64 | switch strings.TrimSpace(req.Scope) { |
| 65 | case "session": |
| 66 | target, err := a.taskMonitorTargetForTab(req.TabID) |
| 67 | if err != nil { |
| 68 | return nil, "", err |
| 69 | } |
| 70 | if target.sessionID == "" { |
| 71 | return nil, "", fmt.Errorf("session is not ready") |
| 72 | } |
| 73 | key := taskcatalog.RegisterSharedProject(target.projectDir, workspaceName(target.projectDir)) |
| 74 | return []string{key}, target.sessionID, nil |
| 75 | case "project", "": |
| 76 | key := strings.TrimSpace(req.ProjectKey) |
| 77 | if key == "" { |
| 78 | root := a.projectDir() |
| 79 | key = taskcatalog.RegisterSharedProject(root, workspaceName(root)) |
| 80 | } else if !a.allowedTaskProjectKey(key) { |
| 81 | return nil, "", fmt.Errorf("unknown project key") |
| 82 | } |
| 83 | return []string{key}, "", nil |
| 84 | case "all": |
| 85 | projects := loadProjectsFile() |
| 86 | keys := []string{taskcatalog.RegisterSharedProject(globalWorkspaceRoot(), projects.GlobalTitle)} |
| 87 | for _, project := range projects.Projects { |
| 88 | keys = append(keys, taskcatalog.RegisterSharedProject(project.Root, projectDisplayName(project))) |
| 89 | } |
| 90 | return keys, "", nil |
| 91 | default: |
| 92 | return nil, "", fmt.Errorf("unknown task scope %q", req.Scope) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func (a *App) allowedTaskProjectRoots() []string { |
| 97 | roots := []string{globalWorkspaceRoot(), a.projectDir()} |
| 98 | for _, project := range loadProjectsFile().Projects { |
| 99 | roots = append(roots, project.Root) |
| 100 | } |
| 101 | return roots |
| 102 | } |
| 103 | |
| 104 | func (a *App) allowedTaskProjectKey(key string) bool { |
| 105 | _, ok := a.resolveTaskProject(key) |
| 106 | return ok |
| 107 | } |
| 108 | |
| 109 | // resolveTaskProject maps a project key to an allowlisted workspace root without |
| 110 | // consulting the SQLite task catalog. Control actions use FileStore as authority. |
| 111 | func (a *App) resolveTaskProject(key string) (taskcatalog.Project, bool) { |
| 112 | key = strings.TrimSpace(key) |
| 113 | if key == "" { |
| 114 | return taskcatalog.Project{}, false |
| 115 | } |
| 116 | projects := loadProjectsFile() |
| 117 | labels := map[string]string{ |
| 118 | globalWorkspaceRoot(): projects.GlobalTitle, |
| 119 | a.projectDir(): workspaceName(a.projectDir()), |
| 120 | } |
| 121 | for _, project := range projects.Projects { |
| 122 | labels[project.Root] = projectDisplayName(project) |
| 123 | } |
| 124 | for _, root := range a.allowedTaskProjectRoots() { |
| 125 | if taskcatalog.ProjectKey(root) != key { |
| 126 | continue |
| 127 | } |
| 128 | label := labels[root] |
| 129 | if strings.TrimSpace(label) == "" { |
| 130 | label = workspaceName(root) |
| 131 | } |
| 132 | return taskcatalog.Project{Key: key, Root: root, Label: label}, true |
| 133 | } |
| 134 | return taskcatalog.Project{}, false |
| 135 | } |
| 136 | |
| 137 | func (a *App) ListTaskPage(req TaskPageRequest) TaskPage { |
| 138 | status := a.GetTaskCatalogStatus() |
| 139 | out := TaskPage{Items: []TaskCatalogItem{}, Status: status, Partial: true} |
| 140 | catalog := taskcatalog.Shared() |
| 141 | if catalog == nil { |
| 142 | return out |
| 143 | } |
| 144 | keys, sessionID, err := a.taskProjectKeys(req) |
| 145 | if err != nil { |
| 146 | out.Status.LastError = err.Error() |
| 147 | return out |
| 148 | } |
| 149 | for _, key := range keys { |
| 150 | if _, ok, lookupErr := catalog.Project(a.bootContext(), key); lookupErr != nil || !ok { |
| 151 | out.Status.LastError = "unknown project key" |
| 152 | return out |
| 153 | } |
| 154 | } |
| 155 | page, err := catalog.ListPage(a.bootContext(), taskcatalog.PageRequest{ProjectKeys: keys, SessionID: sessionID, |
| 156 | States: req.States, Query: req.Query, Cursor: req.Cursor, Limit: req.Limit}) |
| 157 | if err != nil { |
| 158 | out.Status.LastError = err.Error() |
| 159 | return out |
| 160 | } |
| 161 | a.overlayTaskCatalogRuntime(page.Items) |
| 162 | return TaskPage{Items: page.Items, NextCursor: page.NextCursor, Revision: page.Revision, |
| 163 | Partial: page.Partial, StaleCursor: page.StaleCursor, Status: page.Status} |
| 164 | } |
| 165 | |
| 166 | func (a *App) overlayTaskCatalogRuntime(items []TaskCatalogItem) { |
| 167 | type controllerRef struct { |
| 168 | projectKey string |
| 169 | sessionID string |
| 170 | ctrl control.SessionAPI |
| 171 | } |
| 172 | a.mu.RLock() |
| 173 | tabs := a.runtimeTabsLocked() |
| 174 | controllers := make([]controllerRef, 0, len(tabs)) |
| 175 | for _, tab := range tabs { |
| 176 | if tab == nil || tab.Ctrl == nil { |
| 177 | continue |
| 178 | } |
| 179 | root := strings.TrimSpace(tab.WorkspaceRoot) |
| 180 | if root == "" { |
| 181 | root = globalWorkspaceRoot() |
| 182 | } |
| 183 | controllers = append(controllers, controllerRef{projectKey: taskcatalog.ProjectKey(root), ctrl: tab.Ctrl}) |
| 184 | } |
| 185 | a.mu.RUnlock() |
| 186 | for i := range controllers { |
| 187 | controllers[i].sessionID = controllerTaskSessionID(controllers[i].ctrl) |
| 188 | } |
| 189 | |
| 190 | type runtimeValue struct{ status string } |
| 191 | running := map[string]runtimeValue{} |
| 192 | for _, controller := range controllers { |
| 193 | for _, job := range controller.ctrl.Jobs() { |
| 194 | key := controller.projectKey + "\x00" + controller.sessionID + "\x00" + job.ID |
| 195 | running[key] = runtimeValue{status: job.Status} |
| 196 | } |
| 197 | } |
| 198 | for i := range items { |
| 199 | key := items[i].ProjectKey + "\x00" + items[i].Task.SessionID + "\x00" + items[i].Task.JobID |
| 200 | value, ok := running[key] |
| 201 | if !ok { |
| 202 | continue |
| 203 | } |
| 204 | items[i].Task.RuntimeState = taskmonitor.RuntimeStateAlive |
| 205 | switch value.status { |
| 206 | case "running": |
| 207 | items[i].Task.State = taskmonitor.TaskStateRunning |
| 208 | case "done": |
| 209 | items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited |
| 210 | items[i].Task.State = taskmonitor.TaskStateSucceeded |
| 211 | case "failed": |
| 212 | items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited |
| 213 | items[i].Task.State = taskmonitor.TaskStateFailed |
| 214 | case "killed", "interrupted": |
| 215 | items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited |
| 216 | items[i].Task.State = taskmonitor.TaskStateCancelled |
| 217 | } |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func (a *App) ListTaskEventPage(req TaskEventPageRequest) TaskEventPage { |
| 222 | out := TaskEventPage{Items: []taskmonitor.TaskEvent{}, NextSequence: req.After} |
| 223 | if !a.allowedTaskProjectKey(req.ProjectKey) { |
| 224 | out.Partial = true |
| 225 | return out |
| 226 | } |
| 227 | catalog := taskcatalog.Shared() |
| 228 | if catalog == nil { |
| 229 | out.Partial = true |
| 230 | return out |
| 231 | } |
| 232 | page, err := catalog.ListEventPage(a.bootContext(), req.ProjectKey, req.TaskID, req.After, req.Limit) |
| 233 | if err != nil { |
| 234 | out.Partial = true |
| 235 | return out |
| 236 | } |
| 237 | return page |
| 238 | } |
| 239 | |
| 240 | func (a *App) taskActionProject(key string) (taskcatalog.Project, error) { |
| 241 | project, ok := a.resolveTaskProject(key) |
| 242 | if !ok { |
| 243 | return taskcatalog.Project{}, fmt.Errorf("unknown project key") |
| 244 | } |
| 245 | // Catalog may supply a display label only. Never override the allowlisted |
| 246 | // root — a stale or damaged SQLite projection must not redirect FileStore. |
| 247 | if catalog := taskcatalog.Shared(); catalog != nil { |
| 248 | if row, found, err := catalog.Project(a.bootContext(), project.Key); err == nil && found { |
| 249 | if strings.TrimSpace(row.Label) != "" { |
| 250 | project.Label = row.Label |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | return project, nil |
| 255 | } |
| 256 | |
| 257 | func (a *App) StopTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { |
| 258 | project, err := a.taskActionProject(req.ProjectKey) |
| 259 | if err != nil { |
| 260 | return taskmonitor.ControlResult{}, err |
| 261 | } |
| 262 | return a.taskControl().StopTaskWithKiller(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.Reason, req.IdempotencyKey, |
| 263 | desktopTaskJobKiller{app: a, projectDir: project.Root}) |
| 264 | } |
| 265 | |
| 266 | func (a *App) CancelTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { |
| 267 | project, err := a.taskActionProject(req.ProjectKey) |
| 268 | if err != nil { |
| 269 | return taskmonitor.ControlResult{}, err |
| 270 | } |
| 271 | return a.taskControl().CancelTaskWithKiller(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.Reason, req.IdempotencyKey, |
| 272 | desktopTaskJobKiller{app: a, projectDir: project.Root}) |
| 273 | } |
| 274 | |
| 275 | func (a *App) RequeueTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { |
| 276 | project, err := a.taskActionProject(req.ProjectKey) |
| 277 | if err != nil { |
| 278 | return taskmonitor.ControlResult{}, err |
| 279 | } |
| 280 | return a.taskControl().RequeueTask(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.IdempotencyKey) |
| 281 | } |
| 282 | |
| 283 | func (a *App) OpenTaskSessionByKey(req TaskOpenRequest) (taskmonitor.ControlResult, error) { |
| 284 | project, err := a.taskActionProject(req.ProjectKey) |
| 285 | if err != nil { |
| 286 | return taskmonitor.ControlResult{}, err |
| 287 | } |
| 288 | return a.taskControl().OpenTaskSession(a.bootContext(), project.Root, req.TaskID) |
| 289 | } |
| 290 | |
| 291 | func (a *App) RebuildTaskCatalog() error { |
| 292 | if a == nil || a.shuttingDown.Load() { |
| 293 | return fmt.Errorf("application is shutting down") |
| 294 | } |
| 295 | projects := loadProjectsFile() |
| 296 | items := []taskcatalog.Project{{Root: globalWorkspaceRoot(), Label: projects.GlobalTitle}} |
| 297 | for _, project := range projects.Projects { |
| 298 | items = append(items, taskcatalog.Project{Root: project.Root, Label: projectDisplayName(project)}) |
| 299 | } |
| 300 | return taskcatalog.RebuildSharedCatalog(a.bootContext(), items) |
| 301 | } |
| 302 |