返回 DeepSeek-Reasonix
session_draft.go
根目录 / desktop / session_draft.go
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
1312 lines GO