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