| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "log/slog" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/desktop/internal/draftstate" |
| 16 | "reasonix/desktop/internal/workspacestate" |
| 17 | "reasonix/internal/command" |
| 18 | "reasonix/internal/config" |
| 19 | "reasonix/internal/control" |
| 20 | "reasonix/internal/session" |
| 21 | "reasonix/internal/skill" |
| 22 | ) |
| 23 | |
| 24 | type SessionDraftSettings struct { |
| 25 | Model string `json:"model"` |
| 26 | ModelSource string `json:"modelSource,omitempty"` |
| 27 | Effort string `json:"effort,omitempty"` |
| 28 | QualityFloor string `json:"qualityFloor,omitempty"` |
| 29 | Mode string `json:"mode"` |
| 30 | CollaborationMode string `json:"collaborationMode,omitempty"` |
| 31 | ToolApprovalMode string `json:"toolApprovalMode"` |
| 32 | Goal string `json:"goal,omitempty"` |
| 33 | DisabledMCP map[string]ServerView `json:"disabledMcp"` |
| 34 | MCPOrder []string `json:"mcpOrder"` |
| 35 | } |
| 36 | |
| 37 | type SessionDraftView struct { |
| 38 | SnapshotDigest string `json:"snapshotDigest,omitempty"` |
| 39 | ID string `json:"id"` |
| 40 | WorkspaceID string `json:"workspaceId"` |
| 41 | Scope string `json:"scope"` |
| 42 | WorkspaceRoot string `json:"workspaceRoot"` |
| 43 | Revision uint64 `json:"revision"` |
| 44 | ContentJSON string `json:"contentJson"` |
| 45 | Settings SessionDraftSettings `json:"settings"` |
| 46 | Status string `json:"status"` |
| 47 | UpdatedAt int64 `json:"updatedAt"` |
| 48 | } |
| 49 | |
| 50 | type SessionDraftSummary struct { |
| 51 | ID string `json:"id"` |
| 52 | WorkspaceID string `json:"workspaceId"` |
| 53 | Scope string `json:"scope"` |
| 54 | WorkspaceRoot string `json:"workspaceRoot"` |
| 55 | Revision uint64 `json:"revision"` |
| 56 | HasContent bool `json:"hasContent"` |
| 57 | State string `json:"state,omitempty"` |
| 58 | UpdatedAt int64 `json:"updatedAt"` |
| 59 | } |
| 60 | |
| 61 | type SessionDraftSaveRequest struct { |
| 62 | DraftID string `json:"draftId"` |
| 63 | Revision uint64 `json:"revision"` |
| 64 | ContentJSON string `json:"contentJson"` |
| 65 | Settings SessionDraftSettings `json:"settings"` |
| 66 | Force bool `json:"force,omitempty"` |
| 67 | } |
| 68 | |
| 69 | type SessionDraftSaveResult struct { |
| 70 | Draft SessionDraftView `json:"draft"` |
| 71 | Conflict bool `json:"conflict"` |
| 72 | Outcome string `json:"outcome"` |
| 73 | } |
| 74 | |
| 75 | type SessionDraftSubmissionRequest struct { |
| 76 | SourceContentJSON string `json:"sourceContentJson,omitempty"` |
| 77 | RequestID string `json:"requestId,omitempty"` |
| 78 | SourceDigest string `json:"sourceDigest,omitempty"` |
| 79 | SnapshotVersion int `json:"snapshotVersion,omitempty"` |
| 80 | DraftID string `json:"draftId"` |
| 81 | Revision uint64 `json:"revision"` |
| 82 | Kind string `json:"kind,omitempty"` |
| 83 | Display string `json:"display"` |
| 84 | Input string `json:"input"` |
| 85 | Invocations []InvocationRequest `json:"invocations"` |
| 86 | Goal string `json:"goal,omitempty"` |
| 87 | CollaborationMode string `json:"collaborationMode,omitempty"` |
| 88 | ToolApprovalMode string `json:"toolApprovalMode,omitempty"` |
| 89 | WorkspaceRefs []DraftWorkspaceRef `json:"workspaceRefs"` |
| 90 | Settings SessionDraftSettings `json:"settings"` |
| 91 | } |
| 92 | |
| 93 | type DraftWorkspaceRef struct { |
| 94 | Path string `json:"path"` |
| 95 | IsDir bool `json:"isDir,omitempty"` |
| 96 | DisplayPath string `json:"displayPath,omitempty"` |
| 97 | } |
| 98 | |
| 99 | type SessionDraftSubmissionView struct { |
| 100 | RequestID string `json:"requestId,omitempty"` |
| 101 | Revision uint64 `json:"revision"` |
| 102 | CanResume bool `json:"canResume"` |
| 103 | CanEdit bool `json:"canEdit"` |
| 104 | CanCancel bool `json:"canCancel"` |
| 105 | CanDiscard bool `json:"canDiscard"` |
| 106 | OperationID string `json:"operationId"` |
| 107 | DraftID string `json:"draftId"` |
| 108 | Phase string `json:"phase"` |
| 109 | Error string `json:"error,omitempty"` |
| 110 | Session *session.SessionRef `json:"session,omitempty"` |
| 111 | SubmissionID string `json:"submissionId"` |
| 112 | Tab *TabMeta `json:"tab,omitempty"` |
| 113 | UpdatedAt int64 `json:"updatedAt"` |
| 114 | } |
| 115 | |
| 116 | type SessionDraftContextView struct { |
| 117 | Operation *SessionDraftSubmissionView `json:"operation,omitempty"` |
| 118 | Draft SessionDraftView `json:"draft"` |
| 119 | Commands []CommandInfo `json:"commands"` |
| 120 | Servers []ServerView `json:"servers"` |
| 121 | Models []ModelInfo `json:"models,omitempty"` |
| 122 | } |
| 123 | |
| 124 | type SessionDraftState struct { |
| 125 | Draft SessionDraftView `json:"draft"` |
| 126 | Operation *SessionDraftSubmissionView `json:"operation,omitempty"` |
| 127 | } |
| 128 | |
| 129 | func (a *App) GetSessionDraftState(draftID string) (SessionDraftState, error) { |
| 130 | record, op, err := a.draftStore().State(a.bootContext(), strings.TrimSpace(draftID)) |
| 131 | if err != nil { |
| 132 | return SessionDraftState{}, err |
| 133 | } |
| 134 | if op != nil && (op.Phase == "dispatching" || op.Phase == "dispatching_shell" || op.Phase == "dispatch_unknown") { |
| 135 | // Reconcile a receipt without creating a Controller; reread both sides |
| 136 | // together so callers never observe a converted operation with an old slot. |
| 137 | if _, checkErr := a.GetDraftSubmission(op.ID); checkErr == nil { |
| 138 | record, op, err = a.draftStore().State(a.bootContext(), strings.TrimSpace(draftID)) |
| 139 | if err != nil { |
| 140 | return SessionDraftState{}, err |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | view, err := a.draftViewForOperation(record, op) |
| 145 | if err != nil { |
| 146 | return SessionDraftState{}, err |
| 147 | } |
| 148 | state := SessionDraftState{Draft: view} |
| 149 | if op != nil { |
| 150 | projection := draftOperationView(*op, a.metaForDraftSession(op.SessionID)) |
| 151 | state.Operation = &projection |
| 152 | } |
| 153 | return state, nil |
| 154 | } |
| 155 | |
| 156 | func (a *App) ResumeDraftSubmission(operationID string, expectedRevision uint64) (SessionDraftSubmissionView, error) { |
| 157 | op, err := a.draftStore().Operation(a.bootContext(), operationID) |
| 158 | if err != nil { |
| 159 | return SessionDraftSubmissionView{}, err |
| 160 | } |
| 161 | if op.Revision != expectedRevision { |
| 162 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), draftstate.ErrConflict |
| 163 | } |
| 164 | release, err := a.draftStore().WorkerLease(op.SessionID) |
| 165 | if err != nil { |
| 166 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 167 | } |
| 168 | op, err = a.draftStore().ResumeOperation(a.bootContext(), operationID, expectedRevision) |
| 169 | if err != nil { |
| 170 | release() |
| 171 | return SessionDraftSubmissionView{}, err |
| 172 | } |
| 173 | a.goSafe("resumeDraftSubmission", func() { _, _ = a.resumeDraftSubmissionOwned(op, release) }) |
| 174 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), nil |
| 175 | } |
| 176 | |
| 177 | func (a *App) draftStore() *draftstate.Store { |
| 178 | if a.desktopDrafts == nil { |
| 179 | a.desktopDrafts = draftstate.New(config.DesktopDraftStatePath()) |
| 180 | } |
| 181 | return a.desktopDrafts |
| 182 | } |
| 183 | |
| 184 | func (a *App) draftView(record draftstate.Draft) (SessionDraftView, error) { |
| 185 | settings := SessionDraftSettings{DisabledMCP: map[string]ServerView{}, MCPOrder: []string{}} |
| 186 | if strings.TrimSpace(record.SettingsJSON) != "" { |
| 187 | if err := json.Unmarshal([]byte(record.SettingsJSON), &settings); err != nil { |
| 188 | return SessionDraftView{}, err |
| 189 | } |
| 190 | } |
| 191 | if settings.DisabledMCP == nil { |
| 192 | settings.DisabledMCP = map[string]ServerView{} |
| 193 | } |
| 194 | if settings.MCPOrder == nil { |
| 195 | settings.MCPOrder = []string{} |
| 196 | } |
| 197 | if settings.ModelSource == draftModelSourceDefault { |
| 198 | settings.Model, _ = desktopNewSessionDefaults(record.Scope, draftWorkspaceRoot(record)) |
| 199 | } |
| 200 | digest, err := draftstate.SnapshotDigest(record.ContentJSON, record.SettingsJSON) |
| 201 | if err != nil { |
| 202 | return SessionDraftView{}, err |
| 203 | } |
| 204 | return SessionDraftView{SnapshotDigest: digest, ID: record.ID, WorkspaceID: record.WorkspaceID, Scope: record.Scope, |
| 205 | WorkspaceRoot: record.WorkspaceRoot, Revision: record.Revision, ContentJSON: record.ContentJSON, |
| 206 | Settings: settings, Status: record.Status, UpdatedAt: record.UpdatedAt.UnixMilli()}, nil |
| 207 | } |
| 208 | |
| 209 | func (a *App) defaultDraftSettings(scope, workspaceRoot string) SessionDraftSettings { |
| 210 | actualRoot := workspaceRoot |
| 211 | if scope != "project" { |
| 212 | scope, workspaceRoot, actualRoot = "global", "", globalWorkspaceRoot() |
| 213 | } |
| 214 | model, approval := desktopNewSessionDefaults(scope, actualRoot) |
| 215 | settings := SessionDraftSettings{Model: model, ModelSource: draftModelSourceDefault, QualityFloor: tabQualityFloor(workspaceRoot, "standard"), |
| 216 | Mode: tabModeFromAxes(false, approval == control.ToolApprovalDangerFullAccess), ToolApprovalMode: approval, |
| 217 | DisabledMCP: map[string]ServerView{}, MCPOrder: []string{}} |
| 218 | a.mu.RLock() |
| 219 | if active := a.activeTabLocked(); active != nil { |
| 220 | if effort := config.RebindSessionEffort(nil, active.model, settings.Model, active.effort); effort != nil { |
| 221 | settings.Effort = *effort |
| 222 | } |
| 223 | settings.QualityFloor = tabQualityFloor(workspaceRoot, active.qualityFloorSafe()) |
| 224 | settings.DisabledMCP = cloneServerViewMap(active.disabledMCP) |
| 225 | settings.MCPOrder = append([]string(nil), active.mcpOrder...) |
| 226 | } |
| 227 | a.mu.RUnlock() |
| 228 | return settings |
| 229 | } |
| 230 | |
| 231 | // OpenSessionDraft creates no Topic, Session, Controller, runtime, or lease. |
| 232 | func (a *App) OpenSessionDraft(workspaceID string) (SessionDraftView, error) { |
| 233 | started := time.Now() |
| 234 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 235 | if err != nil { |
| 236 | return SessionDraftView{}, err |
| 237 | } |
| 238 | workspaceID = strings.TrimSpace(workspaceID) |
| 239 | workspace, ok := state.Workspaces[workspaceID] |
| 240 | if !ok { |
| 241 | return SessionDraftView{}, workspacestate.ErrWorkspaceNotFound |
| 242 | } |
| 243 | scope, root := "project", workspace.Root |
| 244 | if workspaceID == workspacestate.GlobalWorkspaceID { |
| 245 | scope, root = "global", "" |
| 246 | } |
| 247 | settings, err := json.Marshal(a.defaultDraftSettings(scope, root)) |
| 248 | if err != nil { |
| 249 | return SessionDraftView{}, err |
| 250 | } |
| 251 | record, created, err := a.draftStore().Open(a.bootContext(), workspaceID, scope, root, |
| 252 | "draft-"+strings.TrimPrefix(newTabID(), "tab_"), string(settings)) |
| 253 | if err != nil { |
| 254 | return SessionDraftView{}, err |
| 255 | } |
| 256 | if !created { |
| 257 | record, err = a.migrateLegacyUntouchedDraftModel(record) |
| 258 | if err != nil { |
| 259 | return SessionDraftView{}, err |
| 260 | } |
| 261 | } |
| 262 | slog.Debug("desktop: session draft opened", "draft", record.ID, "workspace", workspaceID, |
| 263 | "created", created, "duration_ms", time.Since(started).Milliseconds()) |
| 264 | return a.draftView(record) |
| 265 | } |
| 266 | |
| 267 | func (a *App) OpenSessionDraftForTarget(scope, workspaceRoot string) (SessionDraftView, error) { |
| 268 | workspaceID, err := a.ensureDesktopWorkspace(a.bootContext(), scope, workspaceRoot) |
| 269 | if err != nil { |
| 270 | return SessionDraftView{}, err |
| 271 | } |
| 272 | return a.OpenSessionDraft(workspaceID) |
| 273 | } |
| 274 | |
| 275 | func (a *App) RestoreSessionDraft() (*SessionDraftView, error) { |
| 276 | select { |
| 277 | case <-a.tabsRestoredSignal(): |
| 278 | case <-a.bootContext().Done(): |
| 279 | return nil, a.bootContext().Err() |
| 280 | } |
| 281 | record, err := a.draftStore().Restore(a.bootContext()) |
| 282 | if errors.Is(err, draftstate.ErrNotFound) { |
| 283 | return nil, nil |
| 284 | } |
| 285 | if err != nil { |
| 286 | return nil, err |
| 287 | } |
| 288 | record, err = a.migrateLegacyUntouchedDraftModel(record) |
| 289 | if err != nil { |
| 290 | return nil, err |
| 291 | } |
| 292 | view, err := a.draftView(record) |
| 293 | if err != nil { |
| 294 | return nil, err |
| 295 | } |
| 296 | return &view, nil |
| 297 | } |
| 298 | |
| 299 | // DismissSessionDraft clears only the page-restore target. The draft remains |
| 300 | // active and continues to appear beside its Workspace. |
| 301 | func (a *App) DismissSessionDraft(draftID string) error { |
| 302 | return a.draftStore().ClearRestore(a.bootContext(), strings.TrimSpace(draftID)) |
| 303 | } |
| 304 | |
| 305 | func (a *App) SetSessionDraftRestoreTarget(draftID string) error { |
| 306 | return a.draftStore().SetRestore(a.bootContext(), strings.TrimSpace(draftID)) |
| 307 | } |
| 308 | |
| 309 | func (a *App) GetSessionDraft(draftID string) (SessionDraftView, error) { |
| 310 | record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(draftID)) |
| 311 | if err != nil { |
| 312 | return SessionDraftView{}, err |
| 313 | } |
| 314 | return a.draftView(record) |
| 315 | } |
| 316 | |
| 317 | func (a *App) SaveSessionDraft(request SessionDraftSaveRequest) (SessionDraftSaveResult, error) { |
| 318 | content := strings.TrimSpace(request.ContentJSON) |
| 319 | if content == "" { |
| 320 | content = "{}" |
| 321 | } |
| 322 | if !json.Valid([]byte(content)) { |
| 323 | return SessionDraftSaveResult{}, errors.New("session draft content is invalid") |
| 324 | } |
| 325 | current, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(request.DraftID)) |
| 326 | if err != nil { |
| 327 | return SessionDraftSaveResult{}, err |
| 328 | } |
| 329 | request.Settings = a.normalizeDraftSettingsForStorage(current, request.Settings) |
| 330 | settings, err := json.Marshal(request.Settings) |
| 331 | if err != nil { |
| 332 | return SessionDraftSaveResult{}, err |
| 333 | } |
| 334 | record, err := a.draftStore().Save(a.bootContext(), strings.TrimSpace(request.DraftID), request.Revision, content, string(settings), request.Force) |
| 335 | if errors.Is(err, draftstate.ErrConflict) { |
| 336 | view, viewErr := a.draftView(record) |
| 337 | return SessionDraftSaveResult{Draft: view, Conflict: true, Outcome: "conflict"}, viewErr |
| 338 | } |
| 339 | if errors.Is(err, draftstate.ErrConverted) { |
| 340 | record, getErr := a.draftStore().Get(a.bootContext(), strings.TrimSpace(request.DraftID)) |
| 341 | if getErr != nil { |
| 342 | return SessionDraftSaveResult{}, err |
| 343 | } |
| 344 | view, viewErr := a.draftView(record) |
| 345 | outcome := "converted" |
| 346 | if record.Status == "discarded" { |
| 347 | outcome = "discarded" |
| 348 | } |
| 349 | return SessionDraftSaveResult{Draft: view, Outcome: outcome}, viewErr |
| 350 | } |
| 351 | if errors.Is(err, draftstate.ErrOperationConflict) { |
| 352 | record, getErr := a.draftStore().Get(a.bootContext(), strings.TrimSpace(request.DraftID)) |
| 353 | if getErr != nil { |
| 354 | return SessionDraftSaveResult{}, err |
| 355 | } |
| 356 | view, viewErr := a.draftView(record) |
| 357 | return SessionDraftSaveResult{Draft: view, Outcome: "operation_locked"}, viewErr |
| 358 | } |
| 359 | if err != nil { |
| 360 | return SessionDraftSaveResult{}, err |
| 361 | } |
| 362 | view, err := a.draftView(record) |
| 363 | return SessionDraftSaveResult{Draft: view, Outcome: "saved"}, err |
| 364 | } |
| 365 | |
| 366 | func (a *App) ListSessionDraftSummaries() ([]SessionDraftSummary, error) { |
| 367 | records, err := a.draftStore().ListActive(a.bootContext()) |
| 368 | if err != nil { |
| 369 | return []SessionDraftSummary{}, err |
| 370 | } |
| 371 | out := make([]SessionDraftSummary, 0, len(records)) |
| 372 | for _, record := range records { |
| 373 | out = append(out, SessionDraftSummary{ID: record.ID, WorkspaceID: record.WorkspaceID, Scope: record.Scope, |
| 374 | WorkspaceRoot: record.WorkspaceRoot, Revision: record.Revision, |
| 375 | HasContent: draftHasContent(record.ContentJSON), State: "saved", UpdatedAt: record.UpdatedAt.UnixMilli()}) |
| 376 | } |
| 377 | return out, nil |
| 378 | } |
| 379 | |
| 380 | // draftHasContent reports unsent work the user may want to return to: text or |
| 381 | // any attached reference. The composer saves a full content object even after |
| 382 | // every field was cleared, so only the fields themselves can decide this. |
| 383 | func draftHasContent(contentJSON string) bool { |
| 384 | trimmed := strings.TrimSpace(contentJSON) |
| 385 | if trimmed == "" || trimmed == "{}" { |
| 386 | return false |
| 387 | } |
| 388 | var content struct { |
| 389 | Text string `json:"text"` |
| 390 | Invocations []json.RawMessage `json:"invocations"` |
| 391 | Attachments []json.RawMessage `json:"attachments"` |
| 392 | WorkspaceRefs []json.RawMessage `json:"workspaceRefs"` |
| 393 | PastedBlocks []json.RawMessage `json:"pastedBlocks"` |
| 394 | SessionRefs []json.RawMessage `json:"sessionRefs"` |
| 395 | SelectedTextRefs []json.RawMessage `json:"selectedTextRefs"` |
| 396 | } |
| 397 | if err := json.Unmarshal([]byte(trimmed), &content); err != nil { |
| 398 | // Unknown shapes stay visible rather than silently hiding saved work. |
| 399 | return true |
| 400 | } |
| 401 | return strings.TrimSpace(content.Text) != "" || len(content.Invocations) > 0 || len(content.Attachments) > 0 || |
| 402 | len(content.WorkspaceRefs) > 0 || len(content.PastedBlocks) > 0 || len(content.SessionRefs) > 0 || |
| 403 | len(content.SelectedTextRefs) > 0 |
| 404 | } |
| 405 | |
| 406 | func (a *App) DiscardSessionDraft(draftID string, revision uint64) error { |
| 407 | draftID = strings.TrimSpace(draftID) |
| 408 | if err := a.draftStore().Discard(a.bootContext(), draftID, revision); err != nil { |
| 409 | return err |
| 410 | } |
| 411 | a.releaseAttachmentStageOperations("draft:" + draftID + ":") |
| 412 | return nil |
| 413 | } |
| 414 | |
| 415 | func (a *App) GetDraftContext(draftID string) (SessionDraftContextView, error) { |
| 416 | state, err := a.GetSessionDraftState(draftID) |
| 417 | if err != nil { |
| 418 | return SessionDraftContextView{Commands: []CommandInfo{}, Servers: []ServerView{}}, err |
| 419 | } |
| 420 | record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(draftID)) |
| 421 | if err != nil { |
| 422 | return SessionDraftContextView{Commands: []CommandInfo{}, Servers: []ServerView{}}, err |
| 423 | } |
| 424 | result := SessionDraftContextView{Draft: state.Draft, Operation: state.Operation, Commands: draftCommandInfos(record), Servers: draftMCPServerViews(record, state.Draft.Settings), Models: a.desktopModelCatalog(state.Draft.Settings.Model, draftWorkspaceRoot(record), nil)} |
| 425 | return result, nil |
| 426 | } |
| 427 | |
| 428 | // draftMCPServerViews projects configured capabilities without constructing a |
| 429 | // Controller or starting an MCP process. Runtime readiness is revalidated when |
| 430 | // the reserved Session is started. |
| 431 | func draftMCPServerViews(record draftstate.Draft, settings SessionDraftSettings) []ServerView { |
| 432 | root := record.WorkspaceRoot |
| 433 | if record.Scope != "project" { |
| 434 | root = globalWorkspaceRoot() |
| 435 | } |
| 436 | cfg, err := config.LoadForRootReadOnly(root) |
| 437 | if err != nil { |
| 438 | return []ServerView{} |
| 439 | } |
| 440 | servers := make([]ServerView, 0, len(cfg.Plugins)) |
| 441 | for _, entry := range cfg.Plugins { |
| 442 | status, intent := "disabled", "off" |
| 443 | if mcpEntryEnabled(entry, root) { |
| 444 | status, intent = "deferred", "automatic" |
| 445 | } |
| 446 | view := withPluginConfigInWorkspace(ServerView{ |
| 447 | Name: entry.Name, Status: status, StartIntent: intent, RuntimeState: "idle", |
| 448 | }, entry, root) |
| 449 | if owner, ok := cfg.PluginPackageOwner(entry.Name); ok { |
| 450 | view.ManagedByPlugin = owner |
| 451 | } |
| 452 | servers = append(servers, finalizeServerView(view)) |
| 453 | } |
| 454 | return orderServerViews(servers, settings.MCPOrder) |
| 455 | } |
| 456 | |
| 457 | func draftCommandInfos(record draftstate.Draft) []CommandInfo { |
| 458 | root := record.WorkspaceRoot |
| 459 | if record.Scope != "project" { |
| 460 | root = globalWorkspaceRoot() |
| 461 | } |
| 462 | out := builtinCommandInfos() |
| 463 | commands, _ := command.LoadRoots(config.CommandRootsForRoot(root)...) |
| 464 | for _, item := range commands { |
| 465 | if item.Hidden { |
| 466 | continue |
| 467 | } |
| 468 | out = append(out, CommandInfo{Name: item.Name, Description: item.Description, Hint: item.ArgHint, Kind: "custom", Group: "actions", Plugin: item.Plugin}) |
| 469 | } |
| 470 | cfg := config.LoadForEdit(config.UserConfigPath()) |
| 471 | if projectCfg, err := config.LoadForRootReadOnly(root); err == nil { |
| 472 | cfg = projectCfg |
| 473 | } |
| 474 | store := skill.New(skill.Options{ |
| 475 | ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), |
| 476 | PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), |
| 477 | DisabledNames: cfg.DisabledSkillNames(), MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard, |
| 478 | }) |
| 479 | defer store.Close() |
| 480 | for _, item := range store.SlashList() { |
| 481 | kind, group := "skill", "skills" |
| 482 | if item.RunAs == skill.RunSubagent { |
| 483 | kind, group = "subagent", "subagents" |
| 484 | } |
| 485 | out = append(out, CommandInfo{Name: item.SlashName(), Description: item.Description, Kind: kind, Group: group, Plugin: item.Plugin, Color: item.Color}) |
| 486 | } |
| 487 | return resolveDocsCommand(out) |
| 488 | } |
| 489 | |
| 490 | func draftOperationView(op draftstate.Operation, tab *TabMeta) SessionDraftSubmissionView { |
| 491 | view := SessionDraftSubmissionView{OperationID: op.ID, DraftID: op.DraftID, Phase: op.Phase, |
| 492 | RequestID: op.RequestID, Revision: op.Revision, |
| 493 | CanResume: op.Phase == "resume_required" || op.Phase == "runtime_failed", |
| 494 | CanEdit: op.Phase == "terminal_failed" || op.Phase == "cancelled", |
| 495 | CanDiscard: op.Phase == "terminal_failed" || op.Phase == "cancelled", |
| 496 | CanCancel: op.Phase != "terminal_failed" && op.Phase != "cancelled" && op.Phase != "cancel_requested", |
| 497 | Error: op.Error, SubmissionID: op.SubmissionID, UpdatedAt: op.UpdatedAt.UnixMilli(), Tab: tab} |
| 498 | if op.SessionID != "" { |
| 499 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionID} |
| 500 | view.Session = &ref |
| 501 | } |
| 502 | return view |
| 503 | } |
| 504 | |
| 505 | func (a *App) BeginDraftSubmission(request SessionDraftSubmissionRequest) (result SessionDraftSubmissionView, resultErr error) { |
| 506 | // A retry of a lost response must wait for the original request's durable |
| 507 | // decision before absence can mean rejection, including across processes. |
| 508 | if request.RequestID != "" { |
| 509 | release, err := a.draftStore().PublicationLease(a.bootContext(), "request-"+request.DraftID+"-"+request.RequestID) |
| 510 | if err != nil { |
| 511 | return result, err |
| 512 | } |
| 513 | defer release() |
| 514 | } |
| 515 | durableAttempt := false |
| 516 | defer func() { |
| 517 | if resultErr != nil && !durableAttempt { |
| 518 | resultErr = draftAdmissionError(resultErr) |
| 519 | } |
| 520 | }() |
| 521 | if request.SnapshotVersion > draftstate.SnapshotVersion { |
| 522 | return SessionDraftSubmissionView{}, errors.New("unsupported draft execution snapshot version") |
| 523 | } |
| 524 | request.DraftID = strings.TrimSpace(request.DraftID) |
| 525 | // Identity belongs to the incoming request, before aliases are resolved or |
| 526 | // inherited defaults are frozen into the execution snapshot. Retries must |
| 527 | // compare the same bytes even when provider configuration has since changed. |
| 528 | fingerprint, _, err := draftSubmissionFingerprint(request) |
| 529 | if err != nil { |
| 530 | return SessionDraftSubmissionView{}, err |
| 531 | } |
| 532 | if request.RequestID != "" { |
| 533 | if prior, err := a.draftStore().RequestOperation(a.bootContext(), request.DraftID, request.RequestID); err == nil { |
| 534 | if fingerprint != prior.Fingerprint { |
| 535 | return SessionDraftSubmissionView{}, draftstate.ErrOperationConflict |
| 536 | } |
| 537 | return draftOperationView(prior, a.metaForDraftSession(prior.SessionID)), nil |
| 538 | } else if !errors.Is(err, draftstate.ErrOperationNotFound) { |
| 539 | return SessionDraftSubmissionView{}, err |
| 540 | } |
| 541 | } |
| 542 | if request.Kind == "shell" { |
| 543 | if strings.TrimSpace(request.Input) == "" { |
| 544 | return SessionDraftSubmissionView{}, errors.New("shell command is required") |
| 545 | } |
| 546 | } else if strings.TrimSpace(request.Input) == "" && len(request.Invocations) == 0 { |
| 547 | return SessionDraftSubmissionView{}, errors.New("session draft input is required") |
| 548 | } |
| 549 | if behavior, name := draftBuiltinBehavior(request.Display); behavior != "" && behavior != "submit" { |
| 550 | return SessionDraftSubmissionView{}, fmt.Errorf("/%s must be handled on the draft surface before session creation", name) |
| 551 | } |
| 552 | record, err := a.draftStore().Get(a.bootContext(), request.DraftID) |
| 553 | if err != nil { |
| 554 | return SessionDraftSubmissionView{}, err |
| 555 | } |
| 556 | if record.Revision != request.Revision { |
| 557 | return SessionDraftSubmissionView{}, draftstate.ErrConflict |
| 558 | } |
| 559 | view, err := a.draftView(record) |
| 560 | if err != nil { |
| 561 | return SessionDraftSubmissionView{}, err |
| 562 | } |
| 563 | if request.SnapshotVersion >= 4 { |
| 564 | profile, _ := json.Marshal(request.Settings) |
| 565 | digest, digestErr := draftstate.SnapshotDigest(record.ContentJSON, string(profile)) |
| 566 | if digestErr != nil || request.SourceDigest == "" || request.SourceDigest != view.SnapshotDigest || digest != view.SnapshotDigest { |
| 567 | return SessionDraftSubmissionView{}, draftstate.ErrConflict |
| 568 | } |
| 569 | } |
| 570 | request, err = a.freezeDraftSubmissionModel(record, view, request) |
| 571 | if err != nil { |
| 572 | return SessionDraftSubmissionView{}, err |
| 573 | } |
| 574 | request.SourceContentJSON = record.ContentJSON |
| 575 | _, payload, err := draftSubmissionFingerprint(request) |
| 576 | if err != nil { |
| 577 | return SessionDraftSubmissionView{}, err |
| 578 | } |
| 579 | if err := validateDraftAttachments(record); err != nil { |
| 580 | return SessionDraftSubmissionView{}, err |
| 581 | } |
| 582 | durableAttempt = true |
| 583 | op, created, err := a.draftStore().BeginOperation(a.bootContext(), draftstate.Operation{ |
| 584 | RequestID: request.RequestID, SourceDigest: request.SourceDigest, |
| 585 | ID: "draft-op-" + strings.TrimPrefix(newTabID(), "tab_"), DraftID: record.ID, WorkspaceID: record.WorkspaceID, |
| 586 | DraftRevision: record.Revision, SessionID: "desktop-" + strings.TrimPrefix(newTabID(), "tab_"), |
| 587 | TopicID: newTopicID(), |
| 588 | SubmissionID: "draft-submit-" + strings.TrimPrefix(newTabID(), "tab_"), Fingerprint: fingerprint, RequestJSON: payload, |
| 589 | }) |
| 590 | if err != nil { |
| 591 | return SessionDraftSubmissionView{}, err |
| 592 | } |
| 593 | slog.Debug("desktop: draft submission reserved", "draft", op.DraftID, "operation", op.ID, |
| 594 | "session", op.SessionID, "phase", op.Phase, "reused", !created) |
| 595 | if op.Phase == "accepted" || op.Phase == "cancelled" || op.Phase == "dispatching_shell" || op.Phase == "dispatch_unknown" || op.Phase == "dispatching" { |
| 596 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), nil |
| 597 | } |
| 598 | if created { |
| 599 | release, leaseErr := a.draftStore().WorkerLease(op.SessionID) |
| 600 | if leaseErr != nil { |
| 601 | // A prior failed worker may still be unwinding. Only this newly |
| 602 | // created operation is paused; never reset another process's worker. |
| 603 | op, _, err = a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, []string{"reserved"}, "resume_required", "Previous session worker is finishing. Continue to retry.") |
| 604 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 605 | } |
| 606 | a.goSafe("resumeDraftSubmission", func() { _, _ = a.resumeDraftSubmissionOwned(op, release) }) |
| 607 | } else if op.Phase == "reserved" { |
| 608 | a.goSafe("resumeDraftSubmission", func() { |
| 609 | if _, resumeErr := a.resumeDraftSubmission(op); resumeErr != nil { |
| 610 | // Runtime preparation errors may retain provider configuration. |
| 611 | // Keep diagnostics value-free and surface the actionable error through |
| 612 | // the durable operation state instead of copying it into logs. |
| 613 | slog.Warn("desktop: draft submission paused", "operation", op.ID, "phase", op.Phase) |
| 614 | } |
| 615 | }) |
| 616 | } |
| 617 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), nil |
| 618 | } |
| 619 | |
| 620 | func draftBuiltinBehavior(input string) (behavior, name string) { |
| 621 | fields := strings.Fields(strings.TrimSpace(input)) |
| 622 | if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") { |
| 623 | return "", "" |
| 624 | } |
| 625 | name = strings.TrimPrefix(fields[0], "/") |
| 626 | for _, command := range builtinCommandInfos() { |
| 627 | if command.Name == name { |
| 628 | if command.DraftBehavior == "" { |
| 629 | return "submit", name |
| 630 | } |
| 631 | return command.DraftBehavior, name |
| 632 | } |
| 633 | } |
| 634 | return "", name |
| 635 | } |
| 636 | |
| 637 | func (a *App) GetDraftSubmission(operationID string) (SessionDraftSubmissionView, error) { |
| 638 | op, err := a.draftStore().Operation(a.bootContext(), strings.TrimSpace(operationID)) |
| 639 | if err != nil { |
| 640 | return SessionDraftSubmissionView{}, err |
| 641 | } |
| 642 | if op.Phase == "dispatch_unknown" || op.Phase == "dispatching" || op.Phase == "dispatching_shell" { |
| 643 | { |
| 644 | var request SessionDraftSubmissionRequest |
| 645 | payload := op.ExecutionJSON |
| 646 | if payload == "" { |
| 647 | payload = op.RequestJSON |
| 648 | } |
| 649 | if json.Unmarshal([]byte(payload), &request) == nil { |
| 650 | found, lookupErr := a.lookupDraftReceipt(op, request) |
| 651 | if lookupErr == nil && found { |
| 652 | op, err = a.draftStore().AcceptAndConvert(a.bootContext(), op.DraftID, op.ID) |
| 653 | if err == nil { |
| 654 | a.completeDraftSessionTabOperation(op) |
| 655 | } |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | } |
| 660 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 661 | } |
| 662 | |
| 663 | func (a *App) lookupDraftReceipt(op draftstate.Operation, request SessionDraftSubmissionRequest) (bool, error) { |
| 664 | req := draftControlSubmissionRequest(op.SubmissionID, request) |
| 665 | if tab := a.metaForDraftSession(op.SessionID); tab != nil { |
| 666 | if found, err := a.knownSubmission(tab.ID, req); found || err != nil { |
| 667 | return found, err |
| 668 | } |
| 669 | } |
| 670 | snapshot, err := a.desktopSessionService("").Query().Snapshot(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: op.SessionID}) |
| 671 | if err != nil { |
| 672 | return false, err |
| 673 | } |
| 674 | if snapshot.DurableSequence < snapshot.EventSequence { |
| 675 | return false, errors.New("submission durability remains unknown") |
| 676 | } |
| 677 | receipt, found := snapshot.Projection.Submissions.Lookup(op.SessionID, op.SubmissionID) |
| 678 | if found && !control.MatchesSubmissionReceipt(req, receipt) { |
| 679 | return false, errors.New("submission receipt does not match frozen request") |
| 680 | } |
| 681 | return found, nil |
| 682 | } |
| 683 | |
| 684 | func (a *App) resumeDraftSubmission(op draftstate.Operation) (SessionDraftSubmissionView, error) { |
| 685 | release, err := a.draftStore().WorkerLease(op.SessionID) |
| 686 | if err != nil { |
| 687 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 688 | } |
| 689 | return a.resumeDraftSubmissionOwned(op, release) |
| 690 | } |
| 691 | |
| 692 | // Ownership is transferred into the goroutine without an unlock/reacquire gap. |
| 693 | func (a *App) resumeDraftSubmissionOwned(op draftstate.Operation, release func()) (SessionDraftSubmissionView, error) { |
| 694 | defer func() { |
| 695 | defer release() |
| 696 | a.finishDraftCancellation(op) |
| 697 | }() |
| 698 | var err error |
| 699 | started := time.Now() |
| 700 | initialPhase := op.Phase |
| 701 | defer func() { |
| 702 | slog.Debug("desktop: draft submission resume finished", "operation", op.ID, "session", op.SessionID, |
| 703 | "initial_phase", initialPhase, "final_phase", op.Phase, "duration_ms", time.Since(started).Milliseconds()) |
| 704 | }() |
| 705 | if op.Phase == "accepted" || op.Phase == "cancelled" { |
| 706 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), nil |
| 707 | } |
| 708 | if op.Phase == "dispatching_shell" { |
| 709 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), errors.New("shell execution result is unknown; it will not be replayed automatically") |
| 710 | } |
| 711 | claimed, ok, err := a.draftStore().ClaimOperationPhase(a.bootContext(), op.ID, []string{"reserved", "runtime_failed", "resume_required"}, "starting") |
| 712 | if err != nil { |
| 713 | return SessionDraftSubmissionView{}, err |
| 714 | } |
| 715 | if !ok { |
| 716 | return draftOperationView(claimed, a.metaForDraftSession(claimed.SessionID)), nil |
| 717 | } |
| 718 | op = claimed |
| 719 | var request SessionDraftSubmissionRequest |
| 720 | if err := json.Unmarshal([]byte(op.RequestJSON), &request); err != nil { |
| 721 | op, _, _ = a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, []string{"starting"}, "terminal_failed", err.Error()) |
| 722 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 723 | } |
| 724 | request.Settings, err = a.draftOperationSettings(op) |
| 725 | if err != nil { |
| 726 | op, _, _ = a.setDraftOperationFailure(op, []string{"starting"}, "terminal_failed", err) |
| 727 | return draftOperationView(op, nil), err |
| 728 | } |
| 729 | tab, err := a.ensureDraftSessionTab(op) |
| 730 | if err != nil { |
| 731 | op, _, _ = a.setDraftOperationFailure(op, []string{"starting"}, "terminal_failed", err) |
| 732 | return draftOperationView(op, nil), err |
| 733 | } |
| 734 | if err := a.resolveDraftExternalRefs(tab.ID, &request); err != nil { |
| 735 | op, _, _ = a.setDraftOperationFailure(op, []string{"starting"}, "terminal_failed", err) |
| 736 | return draftOperationView(op, &tab), err |
| 737 | } |
| 738 | resolvedPayload, err := json.Marshal(request) |
| 739 | if err != nil { |
| 740 | return SessionDraftSubmissionView{}, err |
| 741 | } |
| 742 | op, ok, err = a.draftStore().UpdateOperationRequest(a.bootContext(), op.ID, "starting", string(resolvedPayload)) |
| 743 | if err != nil { |
| 744 | return SessionDraftSubmissionView{}, err |
| 745 | } |
| 746 | if !ok { |
| 747 | return draftOperationView(op, &tab), nil |
| 748 | } |
| 749 | dispatchPhase := map[bool]string{true: "dispatching_shell", false: "dispatching"}[request.Kind == "shell"] |
| 750 | // Serialize cancellation with the admission boundary. Cancellation either |
| 751 | // prevents this transition, or observes its durable receipt afterwards. |
| 752 | releaseDispatch, err := a.draftStore().PublicationLease(a.bootContext(), op.ID) |
| 753 | if err != nil { |
| 754 | return draftOperationView(op, &tab), err |
| 755 | } |
| 756 | defer releaseDispatch() |
| 757 | op, ok, err = a.draftStore().ClaimOperationPhase(a.bootContext(), op.ID, []string{"starting"}, dispatchPhase) |
| 758 | if err != nil { |
| 759 | return SessionDraftSubmissionView{}, err |
| 760 | } |
| 761 | if !ok { |
| 762 | return draftOperationView(op, &tab), nil |
| 763 | } |
| 764 | a.mu.Lock() |
| 765 | if target := a.tabs[tab.ID]; target != nil && target.SessionID == op.SessionID { |
| 766 | target.draftAdmission = &draftAdmissionProfile{submissionID: op.SubmissionID, settings: request.Settings, controller: target.Ctrl} |
| 767 | } |
| 768 | a.mu.Unlock() |
| 769 | if request.Kind == "shell" { |
| 770 | err = a.runShellForTabWithID(tab.ID, request.Input, op.SubmissionID) |
| 771 | } else if request.Goal != "" { |
| 772 | _, err = a.SubmitInitialGoalToTabWithID(tab.ID, request.Goal, request.Display, request.Input, request.Invocations, request.CollaborationMode, request.ToolApprovalMode, op.SubmissionID) |
| 773 | } else if len(request.Invocations) > 0 { |
| 774 | err = a.SubmitInvocationsToTabWithID(tab.ID, request.Display, request.Input, request.Invocations, op.SubmissionID) |
| 775 | } else if request.Display != request.Input { |
| 776 | err = a.SubmitDisplayToTabWithID(tab.ID, request.Display, request.Input, op.SubmissionID) |
| 777 | } else { |
| 778 | _, err = a.StartTurnForTab(tab.ID, request.Input, op.SubmissionID) |
| 779 | } |
| 780 | if err != nil { |
| 781 | phase := "dispatch_unknown" |
| 782 | if errors.Is(err, control.ErrSubmissionNotAccepted) { |
| 783 | phase = "terminal_failed" |
| 784 | } |
| 785 | op, _, _ = a.setDraftOperationFailure(op, []string{dispatchPhase}, phase, err) |
| 786 | return draftOperationView(op, &tab), err |
| 787 | } |
| 788 | op, err = a.draftStore().AcceptAndConvert(a.bootContext(), op.DraftID, op.ID) |
| 789 | if err != nil { |
| 790 | return draftOperationView(op, &tab), err |
| 791 | } |
| 792 | a.completeDraftSessionTabOperation(op) |
| 793 | a.emitProjectTreeChanged() |
| 794 | return draftOperationView(op, &tab), nil |
| 795 | } |
| 796 | |
| 797 | func (a *App) completeDraftSessionTabOperation(op draftstate.Operation) { |
| 798 | a.mu.Lock() |
| 799 | defer a.mu.Unlock() |
| 800 | for _, tab := range a.runtimeTabsLocked() { |
| 801 | if tab != nil && tab.SessionID == op.SessionID && tab.PendingCreateOperationID == op.ID { |
| 802 | tab.PendingCreateOperationID = "" |
| 803 | tab.draftAdmission = nil |
| 804 | a.saveTabsLocked() |
| 805 | return |
| 806 | } |
| 807 | } |
| 808 | } |
| 809 | |
| 810 | func (a *App) resolveDraftExternalRefs(tabID string, request *SessionDraftSubmissionRequest) error { |
| 811 | if request == nil || len(request.WorkspaceRefs) == 0 { |
| 812 | return nil |
| 813 | } |
| 814 | _, ctrl := a.tabAndCtrlByID(tabID) |
| 815 | if ctrl == nil { |
| 816 | return errors.New("session runtime is not ready") |
| 817 | } |
| 818 | for _, ref := range request.WorkspaceRefs { |
| 819 | if !ref.IsDir || !filepath.IsAbs(ref.Path) { |
| 820 | continue |
| 821 | } |
| 822 | token, _, err := ctrl.RegisterExternalFolderRef(ref.Path) |
| 823 | if err != nil { |
| 824 | return err |
| 825 | } |
| 826 | request.Input = rewriteDraftExternalFolderRef(request.Input, ref.Path, token) |
| 827 | } |
| 828 | return nil |
| 829 | } |
| 830 | |
| 831 | func rewriteDraftExternalFolderRef(input, path, token string) string { |
| 832 | old := "@" + strings.TrimSuffix(filepath.Clean(path), string(filepath.Separator)) + "/" |
| 833 | return strings.ReplaceAll(input, old, "@"+strings.Trim(token, "/")+"/") |
| 834 | } |
| 835 | |
| 836 | func draftControlSubmissionRequest(submissionID string, request SessionDraftSubmissionRequest) control.SubmissionRequest { |
| 837 | result := control.SubmissionRequest{ID: submissionID, Input: request.Input, Display: request.Display} |
| 838 | if request.Kind == "shell" { |
| 839 | result.Action = "shell" |
| 840 | result.Display = request.Input |
| 841 | return result |
| 842 | } |
| 843 | if request.Goal != "" { |
| 844 | result.Goal = strings.TrimSpace(request.Goal) |
| 845 | result.ToolApprovalMode = normalizeToolApprovalMode(request.ToolApprovalMode) |
| 846 | result.Invocations = controlInvocationRequests(request.Invocations) |
| 847 | } else if len(request.Invocations) > 0 { |
| 848 | result.Invocations = controlInvocationRequests(request.Invocations) |
| 849 | } |
| 850 | return result |
| 851 | } |
| 852 | |
| 853 | func (a *App) setDraftOperationFailure(op draftstate.Operation, from []string, phase string, cause error) (draftstate.Operation, bool, error) { |
| 854 | message := cause.Error() |
| 855 | if len(message) > 500 { |
| 856 | message = message[:500] |
| 857 | } |
| 858 | result, changed, err := a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, from, phase, message) |
| 859 | if err == nil && changed && (phase == "terminal_failed" || phase == "cancelled") { |
| 860 | if cleanupErr := a.workspaceRegistry().AbortCreateIfOperation(a.bootContext(), op.SessionID, op.ID); cleanupErr != nil { |
| 861 | slog.Warn("desktop: clear failed draft create reservation", "operation", op.ID, "session", op.SessionID, "err", cleanupErr) |
| 862 | } |
| 863 | } |
| 864 | return result, changed, err |
| 865 | } |
| 866 | |
| 867 | func (a *App) beginDraftWorkspaceCreate(op draftstate.Operation, workspaceID string) error { |
| 868 | store := a.workspaceRegistry() |
| 869 | state, err := store.Load(a.bootContext()) |
| 870 | if err != nil { |
| 871 | return err |
| 872 | } |
| 873 | if lifecycle := state.SessionStates[op.SessionID].Lifecycle; lifecycle == workspacestate.Archived || lifecycle == workspacestate.Deleted { |
| 874 | return fmt.Errorf("session %q is %s and cannot accept the draft", op.SessionID, lifecycle) |
| 875 | } |
| 876 | pending := workspacestate.PendingCreate{OperationID: op.ID, WorkspaceID: workspaceID, SessionID: op.SessionID} |
| 877 | err = store.BeginCreate(a.bootContext(), pending) |
| 878 | if !errors.Is(err, workspacestate.ErrMutationConflict) { |
| 879 | return err |
| 880 | } |
| 881 | stale, ok := state.PendingCreates[op.SessionID] |
| 882 | if !ok || stale.OperationID == op.ID || stale.WorkspaceID != workspaceID { |
| 883 | return err |
| 884 | } |
| 885 | prior, lookupErr := a.draftStore().Operation(a.bootContext(), stale.OperationID) |
| 886 | if lookupErr != nil || prior.DraftID != op.DraftID || (prior.Phase != "terminal_failed" && prior.Phase != "cancelled") { |
| 887 | return err |
| 888 | } |
| 889 | if cleanupErr := store.AbortCreateIfOperation(a.bootContext(), op.SessionID, stale.OperationID); cleanupErr != nil { |
| 890 | return cleanupErr |
| 891 | } |
| 892 | return store.BeginCreate(a.bootContext(), pending) |
| 893 | } |
| 894 | |
| 895 | func (a *App) ensureDraftSessionTab(op draftstate.Operation) (TabMeta, error) { |
| 896 | if op.TopicID == "" { |
| 897 | var err error |
| 898 | op, err = a.draftStore().EnsureOperationTopic(a.bootContext(), op.ID, newTopicID()) |
| 899 | if err != nil { |
| 900 | return TabMeta{}, err |
| 901 | } |
| 902 | } |
| 903 | settings, err := a.draftOperationSettings(op) |
| 904 | if err != nil { |
| 905 | return TabMeta{}, err |
| 906 | } |
| 907 | if meta := a.metaForDraftSession(op.SessionID); meta != nil { |
| 908 | state, stateErr := a.workspaceRegistry().Load(a.bootContext()) |
| 909 | if stateErr != nil { |
| 910 | return *meta, stateErr |
| 911 | } |
| 912 | if desktopWorkspaceOwnerID(state, meta.Scope, meta.WorkspaceRoot) != op.WorkspaceID { |
| 913 | return *meta, workspacestate.ErrMutationConflict |
| 914 | } |
| 915 | if lifecycle := state.SessionStates[op.SessionID].Lifecycle; lifecycle == workspacestate.Archived || lifecycle == workspacestate.Deleted { |
| 916 | return *meta, fmt.Errorf("session %q is %s and cannot accept the draft", op.SessionID, lifecycle) |
| 917 | } |
| 918 | if err := a.applyDraftOperationSettings(op, settings); err != nil { |
| 919 | return *meta, err |
| 920 | } |
| 921 | meta = a.metaForDraftSession(op.SessionID) |
| 922 | if meta != nil && meta.Ready { |
| 923 | return *meta, nil |
| 924 | } |
| 925 | if meta != nil && meta.StartupErr != "" { |
| 926 | tab, _ := a.tabAndCtrlByID(meta.ID) |
| 927 | if tab == nil { |
| 928 | return *meta, errors.New(meta.StartupErr) |
| 929 | } |
| 930 | a.mu.Lock() |
| 931 | if a.tabs[tab.ID] == tab { |
| 932 | clearTabStartupError(tab) |
| 933 | tab.PendingCreateOperationID = op.ID |
| 934 | } |
| 935 | a.mu.Unlock() |
| 936 | return a.startCreatedSessionTab(tab, desktopWorkspaceRoot(tab.Scope, tab.WorkspaceRoot)) |
| 937 | } |
| 938 | return *meta, errors.New("session runtime is still starting") |
| 939 | } |
| 940 | record, err := a.draftStore().Get(a.bootContext(), op.DraftID) |
| 941 | if err != nil { |
| 942 | return TabMeta{}, err |
| 943 | } |
| 944 | mode := tabModeFromAxes( |
| 945 | normalizeCollaborationMode(settings.CollaborationMode) == "plan" || (settings.CollaborationMode == "" && tabModeHasPlan(settings.Mode)), |
| 946 | normalizeToolApprovalMode(settings.ToolApprovalMode) == control.ToolApprovalDangerFullAccess, |
| 947 | ) |
| 948 | workspaceID, err := a.ensureDesktopWorkspace(a.bootContext(), record.Scope, record.WorkspaceRoot) |
| 949 | if err != nil { |
| 950 | return TabMeta{}, err |
| 951 | } |
| 952 | if workspaceID != op.WorkspaceID { |
| 953 | return TabMeta{}, workspacestate.ErrMutationConflict |
| 954 | } |
| 955 | if err := a.beginDraftWorkspaceCreate(op, workspaceID); err != nil { |
| 956 | return TabMeta{}, err |
| 957 | } |
| 958 | actualRoot := record.WorkspaceRoot |
| 959 | if record.Scope != "project" { |
| 960 | actualRoot = globalWorkspaceRoot() |
| 961 | if err := os.MkdirAll(actualRoot, 0o755); err != nil { |
| 962 | return TabMeta{}, err |
| 963 | } |
| 964 | } |
| 965 | topicID := op.TopicID |
| 966 | if err := createTopicState(record.WorkspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli()); err != nil { |
| 967 | return TabMeta{}, err |
| 968 | } |
| 969 | _ = prependTopicInProjectsFile(record.WorkspaceRoot, topicID, false) |
| 970 | tab := &WorkspaceTab{Scope: record.Scope, WorkspaceRoot: actualRoot, |
| 971 | TopicID: topicID, TopicTitle: topicTitleForTab(record.Scope, record.WorkspaceRoot, topicID), topicTitleSource: topicTitleSourceAuto, |
| 972 | SessionID: op.SessionID, PendingCreateOperationID: op.ID, model: settings.Model, qualityFloor: settings.QualityFloor, |
| 973 | mode: mode, toolApprovalMode: settings.ToolApprovalMode, disabledMCP: cloneServerViewMap(settings.DisabledMCP), mcpOrder: append([]string(nil), settings.MCPOrder...)} |
| 974 | if settings.Effort != "" { |
| 975 | effort := settings.Effort |
| 976 | tab.effort = &effort |
| 977 | } |
| 978 | a.mu.Lock() |
| 979 | tab.ID = a.newUniqueTabIDLocked() |
| 980 | tab.sink = &tabEventSink{tabID: tab.ID, app: a} |
| 981 | a.tabs[tab.ID] = tab |
| 982 | a.tabOrder = append(a.tabOrder, tab.ID) |
| 983 | a.saveTabsLocked() |
| 984 | a.mu.Unlock() |
| 985 | if _, err := a.startCreatedSessionTab(tab, actualRoot); err != nil { |
| 986 | return TabMeta{}, err |
| 987 | } |
| 988 | a.mu.RLock() |
| 989 | meta := enrichTabMeta(a.tabMeta(tab, a.activeTabID == tab.ID)) |
| 990 | a.mu.RUnlock() |
| 991 | return meta, nil |
| 992 | } |
| 993 | |
| 994 | func (a *App) draftOperationSettings(op draftstate.Operation) (SessionDraftSettings, error) { |
| 995 | var request SessionDraftSubmissionRequest |
| 996 | if err := json.Unmarshal([]byte(op.RequestJSON), &request); err != nil { |
| 997 | return SessionDraftSettings{}, err |
| 998 | } |
| 999 | if request.SnapshotVersion > draftstate.SnapshotVersion { |
| 1000 | return SessionDraftSettings{}, errors.New("unsupported draft execution snapshot version") |
| 1001 | } |
| 1002 | if request.SnapshotVersion >= 3 && request.SnapshotVersion <= draftstate.SnapshotVersion && strings.TrimSpace(request.Settings.Model) != "" { |
| 1003 | return request.Settings, nil |
| 1004 | } |
| 1005 | // A pre-v3 operation may only inherit the current draft settings when the |
| 1006 | // revision still proves that they are the settings it froze. |
| 1007 | record, err := a.draftStore().Get(a.bootContext(), op.DraftID) |
| 1008 | if err != nil { |
| 1009 | return SessionDraftSettings{}, err |
| 1010 | } |
| 1011 | if record.Revision != op.DraftRevision { |
| 1012 | return SessionDraftSettings{}, errors.New("draft operation predates frozen settings and cannot be resumed safely") |
| 1013 | } |
| 1014 | view, err := a.draftView(record) |
| 1015 | if err != nil { |
| 1016 | return SessionDraftSettings{}, err |
| 1017 | } |
| 1018 | return view.Settings, nil |
| 1019 | } |
| 1020 | |
| 1021 | func (a *App) applyDraftOperationSettings(op draftstate.Operation, settings SessionDraftSettings) error { |
| 1022 | return a.prepareDraftRuntime(op, settings) |
| 1023 | } |
| 1024 | |
| 1025 | // Caller holds App.mu; this publishes metadata only after runtime preparation. |
| 1026 | func (a *App) publishDraftSettingsLocked(op draftstate.Operation, settings SessionDraftSettings) { |
| 1027 | for _, tab := range a.runtimeTabsLocked() { |
| 1028 | if tab == nil || tab.SessionID != op.SessionID { |
| 1029 | continue |
| 1030 | } |
| 1031 | tab.PendingCreateOperationID = op.ID |
| 1032 | tab.model = settings.Model |
| 1033 | tab.Label = settings.Model |
| 1034 | tab.qualityFloor = settings.QualityFloor |
| 1035 | tab.mode = tabModeFromAxes( |
| 1036 | normalizeCollaborationMode(settings.CollaborationMode) == "plan" || (settings.CollaborationMode == "" && tabModeHasPlan(settings.Mode)), |
| 1037 | normalizeToolApprovalMode(settings.ToolApprovalMode) == control.ToolApprovalDangerFullAccess, |
| 1038 | ) |
| 1039 | tab.toolApprovalMode = settings.ToolApprovalMode |
| 1040 | // The source editor owns the initial objective until atomic Goal |
| 1041 | // submission accepts it. Runtime preparation must not start a Goal. |
| 1042 | tab.goal = "" |
| 1043 | tab.disabledMCP = cloneServerViewMap(settings.DisabledMCP) |
| 1044 | tab.mcpOrder = append([]string(nil), settings.MCPOrder...) |
| 1045 | if settings.Effort == "" { |
| 1046 | tab.effort = nil |
| 1047 | } else { |
| 1048 | effort := settings.Effort |
| 1049 | tab.effort = &effort |
| 1050 | } |
| 1051 | a.saveTabsLocked() |
| 1052 | return |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | func (a *App) metaForDraftSession(sessionID string) *TabMeta { |
| 1057 | a.mu.RLock() |
| 1058 | defer a.mu.RUnlock() |
| 1059 | for _, tab := range a.runtimeTabsLocked() { |
| 1060 | if tab != nil && tab.SessionID == sessionID { |
| 1061 | meta := enrichTabMeta(a.tabMeta(tab, tab.ID == a.activeTabID)) |
| 1062 | return &meta |
| 1063 | } |
| 1064 | } |
| 1065 | return nil |
| 1066 | } |
| 1067 | |
| 1068 | func (a *App) CancelDraftSubmission(operationID string) (SessionDraftSubmissionView, error) { |
| 1069 | releasePublication, err := a.draftStore().PublicationLease(a.bootContext(), strings.TrimSpace(operationID)) |
| 1070 | if err != nil { |
| 1071 | return SessionDraftSubmissionView{}, err |
| 1072 | } |
| 1073 | defer releasePublication() |
| 1074 | op, err := a.draftStore().Operation(a.bootContext(), strings.TrimSpace(operationID)) |
| 1075 | if err != nil { |
| 1076 | return SessionDraftSubmissionView{}, err |
| 1077 | } |
| 1078 | if op.Phase == "dispatching" || op.Phase == "dispatch_unknown" || op.Phase == "dispatching_shell" { |
| 1079 | if checked, checkErr := a.GetDraftSubmission(op.ID); checkErr == nil { |
| 1080 | if checked.Phase == "accepted" { |
| 1081 | if checked.Tab != nil { |
| 1082 | _, err = a.CancelSessionForTab(checked.Tab.ID) |
| 1083 | } |
| 1084 | return checked, err |
| 1085 | } |
| 1086 | if refreshed, refreshErr := a.draftStore().Operation(a.bootContext(), op.ID); refreshErr == nil { |
| 1087 | op = refreshed |
| 1088 | } |
| 1089 | } |
| 1090 | } |
| 1091 | if op.Phase == "accepted" { |
| 1092 | if meta := a.metaForDraftSession(op.SessionID); meta != nil { |
| 1093 | _, err = a.CancelSessionForTab(meta.ID) |
| 1094 | } |
| 1095 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 1096 | } |
| 1097 | if op.Phase == "dispatching" || op.Phase == "dispatch_unknown" || op.Phase == "dispatching_shell" { |
| 1098 | if meta := a.metaForDraftSession(op.SessionID); meta != nil { |
| 1099 | _, err = a.CancelSessionForTab(meta.ID) |
| 1100 | } |
| 1101 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 1102 | } |
| 1103 | op, cancelled, err := a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, |
| 1104 | []string{"reserved", "starting", "runtime_failed", "resume_required"}, "cancel_requested", "") |
| 1105 | if err != nil { |
| 1106 | return SessionDraftSubmissionView{}, err |
| 1107 | } |
| 1108 | if cancelled { |
| 1109 | if release, leaseErr := a.draftStore().WorkerLease(op.SessionID); leaseErr == nil { |
| 1110 | a.finishDraftCancellation(op) |
| 1111 | release() |
| 1112 | op, err = a.draftStore().Operation(a.bootContext(), op.ID) |
| 1113 | } |
| 1114 | } else if op.Phase == "accepted" || op.Phase == "dispatching" || op.Phase == "dispatch_unknown" || op.Phase == "dispatching_shell" { |
| 1115 | if meta := a.metaForDraftSession(op.SessionID); meta != nil { |
| 1116 | _, err = a.CancelSessionForTab(meta.ID) |
| 1117 | } |
| 1118 | } |
| 1119 | return draftOperationView(op, a.metaForDraftSession(op.SessionID)), err |
| 1120 | } |
| 1121 | |
| 1122 | // Called only while holding the creation-worker lease, after its work ends. |
| 1123 | func (a *App) finishDraftCancellation(op draftstate.Operation) { |
| 1124 | current, err := a.draftStore().Operation(a.bootContext(), op.ID) |
| 1125 | if err != nil || current.Phase != "cancel_requested" { |
| 1126 | return |
| 1127 | } |
| 1128 | if err := a.workspaceRegistry().AbortCreateIfOperation(a.bootContext(), op.SessionID, op.ID); err != nil { |
| 1129 | return |
| 1130 | } |
| 1131 | _, _, _ = a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, []string{"cancel_requested"}, "cancelled", "") |
| 1132 | } |
| 1133 | |
| 1134 | // reconcileDraftSubmissionOperations never replays work. It only completes a |
| 1135 | // conversion backed by a durable receipt or moves interrupted work into an |
| 1136 | // explicit user-resume/unknown state. |
| 1137 | func (a *App) reconcileDraftSubmissionOperations() { |
| 1138 | select { |
| 1139 | case <-a.tabsRestoredSignal(): |
| 1140 | case <-a.bootContext().Done(): |
| 1141 | return |
| 1142 | } |
| 1143 | ops, err := a.draftStore().PendingOperations(a.bootContext()) |
| 1144 | if err != nil { |
| 1145 | slog.Warn("desktop: reconcile draft submissions", "err", err) |
| 1146 | return |
| 1147 | } |
| 1148 | if len(ops) > 0 { |
| 1149 | slog.Info("desktop: reconciling draft submissions", "count", len(ops)) |
| 1150 | } |
| 1151 | for _, op := range ops { |
| 1152 | release, leaseErr := a.draftStore().WorkerLease(op.SessionID) |
| 1153 | if leaseErr != nil { |
| 1154 | continue |
| 1155 | } |
| 1156 | switch op.Phase { |
| 1157 | case "cancel_requested": |
| 1158 | a.finishDraftCancellation(op) |
| 1159 | case "accepted": |
| 1160 | accepted, err := a.draftStore().AcceptAndConvert(a.bootContext(), op.DraftID, op.ID) |
| 1161 | if err != nil { |
| 1162 | slog.Warn("desktop: finish accepted draft conversion", "operation", op.ID, "err", err) |
| 1163 | } else { |
| 1164 | a.completeDraftSessionTabOperation(accepted) |
| 1165 | } |
| 1166 | case "reserved", "starting": |
| 1167 | if _, _, err := a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, []string{op.Phase}, "resume_required", "Continue to resume this session creation."); err != nil { |
| 1168 | slog.Warn("desktop: pause interrupted draft creation", "operation", op.ID, "err", err) |
| 1169 | } |
| 1170 | case "dispatching", "dispatching_shell", "failed": |
| 1171 | checked, checkErr := a.GetDraftSubmission(op.ID) |
| 1172 | if checkErr == nil && checked.Phase == "accepted" { |
| 1173 | release() |
| 1174 | continue |
| 1175 | } |
| 1176 | if _, _, err := a.draftStore().TransitionOperationPhase(a.bootContext(), op.ID, []string{op.Phase}, "dispatch_unknown", "Submission acceptance is unknown; it will not be replayed automatically."); err != nil { |
| 1177 | slog.Warn("desktop: mark interrupted draft dispatch unknown", "operation", op.ID, "err", err) |
| 1178 | } |
| 1179 | } |
| 1180 | release() |
| 1181 | } |
| 1182 | } |
| 1183 | |
| 1184 | func (a *App) composerTargetWorkspace(target ComposerTarget) (string, control.SessionAPI, error) { |
| 1185 | if target.Kind == "draft" { |
| 1186 | record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(target.DraftID)) |
| 1187 | if err != nil { |
| 1188 | return "", nil, err |
| 1189 | } |
| 1190 | root := record.WorkspaceRoot |
| 1191 | if record.Scope != "project" { |
| 1192 | root = globalWorkspaceRoot() |
| 1193 | } |
| 1194 | base, err := workspaceBaseFromRoot(root) |
| 1195 | return base, nil, err |
| 1196 | } |
| 1197 | root, ctrl, ok := a.workspaceTargetForTab(target.TabID) |
| 1198 | if !ok { |
| 1199 | return "", nil, errors.New("composer target is unavailable") |
| 1200 | } |
| 1201 | base, err := workspaceBaseFromRoot(root) |
| 1202 | return base, ctrl, err |
| 1203 | } |
| 1204 | |
| 1205 | func (a *App) SavePastedImageForComposerTarget(target ComposerTarget, dataURL string) (string, error) { |
| 1206 | root, _, err := a.composerTargetWorkspace(target) |
| 1207 | if err != nil { |
| 1208 | return "", err |
| 1209 | } |
| 1210 | const marker = ";base64," |
| 1211 | before, after, ok := strings.Cut(dataURL, marker) |
| 1212 | if !ok || !strings.HasPrefix(before, "data:") { |
| 1213 | return "", errors.New("unsupported pasted image") |
| 1214 | } |
| 1215 | raw, err := decodeBase64(after) |
| 1216 | if err != nil { |
| 1217 | return "", err |
| 1218 | } |
| 1219 | return control.SaveImageBytesInRoot(root, strings.TrimPrefix(before, "data:"), raw) |
| 1220 | } |
| 1221 | |
| 1222 | func (a *App) SavePastedFileForComposerTarget(target ComposerTarget, name, dataURL string) (string, error) { |
| 1223 | root, _, err := a.composerTargetWorkspace(target) |
| 1224 | if err != nil { |
| 1225 | return "", err |
| 1226 | } |
| 1227 | _, after, ok := strings.Cut(dataURL, ";base64,") |
| 1228 | if !ok { |
| 1229 | return "", errors.New("unsupported pasted file") |
| 1230 | } |
| 1231 | raw, err := decodeBase64(after) |
| 1232 | if err != nil { |
| 1233 | return "", err |
| 1234 | } |
| 1235 | return control.SaveAttachmentBytesInRoot(root, name, raw) |
| 1236 | } |
| 1237 | |
| 1238 | func (a *App) SaveClipboardImageForComposerTarget(target ComposerTarget) (string, error) { |
| 1239 | root, _, err := a.composerTargetWorkspace(target) |
| 1240 | if err != nil { |
| 1241 | return "", err |
| 1242 | } |
| 1243 | return control.SaveClipboardImageInRoot(root) |
| 1244 | } |
| 1245 | |
| 1246 | func decodeBase64(value string) ([]byte, error) { |
| 1247 | decoded, err := base64.StdEncoding.DecodeString(value) |
| 1248 | if err != nil { |
| 1249 | return nil, fmt.Errorf("decode attachment: %w", err) |
| 1250 | } |
| 1251 | return decoded, nil |
| 1252 | } |
| 1253 | |
| 1254 | func (a *App) ListDirForTarget(target ComposerTarget, rel string) []DirEntry { |
| 1255 | root, ctrl, err := a.composerTargetWorkspace(target) |
| 1256 | if err != nil { |
| 1257 | return []DirEntry{} |
| 1258 | } |
| 1259 | return listDirForWorkspaceTarget(root, ctrl, rel) |
| 1260 | } |
| 1261 | |
| 1262 | func (a *App) SearchFileRefsForTarget(target ComposerTarget, query string) []DirEntry { |
| 1263 | root, ctrl, err := a.composerTargetWorkspace(target) |
| 1264 | if err != nil { |
| 1265 | return []DirEntry{} |
| 1266 | } |
| 1267 | return searchFileRefsForWorkspaceTarget(root, ctrl, query) |
| 1268 | } |
| 1269 | |
| 1270 | func (a *App) AttachmentDataURLForComposerTarget(target ComposerTarget, rel string) (string, error) { |
| 1271 | root, _, err := a.composerTargetWorkspace(target) |
| 1272 | if err != nil { |
| 1273 | return "", err |
| 1274 | } |
| 1275 | return control.ImageDataURLInRoot(root, rel) |
| 1276 | } |
| 1277 | |
| 1278 | func (a *App) AttachDroppedForComposerTarget(target ComposerTarget, path string) (DroppedItem, error) { |
| 1279 | root, _, err := a.composerTargetWorkspace(target) |
| 1280 | if err != nil { |
| 1281 | return DroppedItem{}, err |
| 1282 | } |
| 1283 | info, err := os.Lstat(path) |
| 1284 | if err != nil { |
| 1285 | return DroppedItem{}, err |
| 1286 | } |
| 1287 | if info.Mode()&os.ModeSymlink != 0 { |
| 1288 | return DroppedItem{}, errors.New("dropped path must not be a symlink") |
| 1289 | } |
| 1290 | if isImageExt(path) { |
| 1291 | rel, saveErr := control.SaveImageFileInRoot(root, path) |
| 1292 | if saveErr == nil { |
| 1293 | preview, _ := control.ImageDataURLInRoot(root, rel) |
| 1294 | return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil |
| 1295 | } |
| 1296 | } |
| 1297 | if rel, ok := workspaceRelativeIn(path, root); ok { |
| 1298 | return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil |
| 1299 | } |
| 1300 | if info.IsDir() { |
| 1301 | // External folders stay as durable draft references. The controller |
| 1302 | // registers them immediately before first execution; no runtime is built |
| 1303 | // merely to create the reference chip. |
| 1304 | return DroppedItem{Kind: "workspace", Path: filepath.Clean(path), IsDir: true, DisplayPath: filepath.Base(path)}, nil |
| 1305 | } |
| 1306 | rel, err := control.SaveAttachmentFileInRoot(root, path) |
| 1307 | if err != nil { |
| 1308 | return DroppedItem{}, err |
| 1309 | } |
| 1310 | return DroppedItem{Kind: "attachment", Path: rel}, nil |
| 1311 | } |
| 1312 |