返回 DeepSeek-Reasonix
inbox_app.go
根目录 / desktop / inbox_app.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/control"
9 "reasonix/internal/sessioninbox"
10 )
11
12 const inboxBridgeErrorPrefix = "reasonix_error:"
13
14 type inboxCodedError struct {
15 code string
16 cause error
17 // transient marks a fence that clears by itself (session switching,
18 // reconnecting, route adoption). Callers keep the message and retry
19 // instead of failing it with an unrecoverable-looking error.
20 transient bool
21 }
22
23 func (e *inboxCodedError) Error() string { return inboxBridgeErrorPrefix + e.code }
24 func (e *inboxCodedError) Unwrap() error { return e.cause }
25
26 // RPCErrorData lets the renderer branch on a transient fence without parsing
27 // the message text.
28 func (e *inboxCodedError) RPCErrorData() map[string]any {
29 if e.transient {
30 return map[string]any{"transient": true}
31 }
32 return nil
33 }
34
35 // inboxTargetTransient reports a fence that settles by itself: the message was
36 // not submitted and the same request will land once the tab settles.
37 func inboxTargetTransient(cause error) error {
38 return &inboxCodedError{code: inboxTransientCode, cause: cause, transient: true}
39 }
40
41 // inboxBridgeError keeps backend errors machine-stable across the desktop bridge.
42 // The frontend translates known product states at display time; unknown errors
43 // stay untouched so useful diagnostic details are not discarded.
44 func inboxBridgeError(err error) error {
45 if err == nil {
46 return nil
47 }
48 var imageFailures control.ImageReferenceFailures
49 if errors.As(err, &imageFailures) {
50 return &inboxCodedError{code: "image_attachment_unreadable", cause: err}
51 }
52 known := []struct {
53 target error
54 code string
55 }{
56 {control.ErrInboxSessionChanged, "inbox_not_submitted"},
57 {sessioninbox.ErrCapacityItems, "inbox_capacity_items"},
58 {sessioninbox.ErrCapacityBytes, "inbox_capacity_bytes"},
59 {sessioninbox.ErrItemTooLarge, "inbox_item_too_large"},
60 {sessioninbox.ErrNotFound, "inbox_item_not_found"},
61 {sessioninbox.ErrInvalidState, "inbox_invalid_state"},
62 {sessioninbox.ErrSchemaReadonly, "inbox_schema_readonly"},
63 {sessioninbox.ErrClosed, "inbox_closed"},
64 {sessioninbox.ErrEmpty, "inbox_empty"},
65 {sessioninbox.ErrPaused, "inbox_paused"},
66 {sessioninbox.ErrIdempotencyConflict, "inbox_idempotency_conflict"},
67 }
68 for _, item := range known {
69 if errors.Is(err, item.target) {
70 return &inboxCodedError{code: item.code, cause: err}
71 }
72 }
73 switch {
74 case err.Error() == "channel session is read-only":
75 return &inboxCodedError{code: "channel_read_only", cause: err}
76 case err.Error() == "workspace is still starting":
77 return &inboxCodedError{code: "workspace_starting", cause: err}
78 case strings.HasPrefix(err.Error(), "workspace failed to start:"):
79 return &inboxCodedError{code: "workspace_start_failed", cause: err}
80 default:
81 return err
82 }
83 }
84
85 // InboxItemView is the bridge-facing metadata row (never full body).
86 type InboxItemView struct {
87 ID string `json:"id"`
88 Intent string `json:"intent"`
89 State string `json:"state"`
90 Preview string `json:"preview"`
91 ByteSize int64 `json:"byteSize"`
92 Source string `json:"source,omitempty"`
93 BlockReason string `json:"blockReason,omitempty"`
94 CreatedAt string `json:"createdAt,omitempty"`
95 Position int `json:"position"`
96 }
97
98 // InboxSnapshotView is the bridge-facing queue snapshot.
99 type InboxSnapshotView struct {
100 Readonly bool `json:"readonly,omitempty"`
101 MutationsSupported bool `json:"mutationsSupported"`
102 Revision int64 `json:"revision"`
103 Paused bool `json:"paused"`
104 Recovered bool `json:"recovered"`
105 RecoveredN int `json:"recoveredCount,omitempty"`
106 SessionPath string `json:"sessionPath,omitempty"`
107 Items []InboxItemView `json:"items"`
108 ItemsCount int `json:"itemsCount"`
109 Bytes int64 `json:"bytes"`
110 MaxItems int `json:"maxItems"`
111 MaxBytes int64 `json:"maxBytes"`
112 }
113
114 // InboxReceiptView is returned after durable enqueue/steer.
115 type InboxReceiptView struct {
116 ItemID string `json:"itemId"`
117 Disposition string `json:"disposition"`
118 Position int `json:"position"`
119 Paused bool `json:"paused"`
120 Idempotent bool `json:"idempotent,omitempty"`
121 Error string `json:"error,omitempty"`
122 }
123
124 // InboxCancelResultView is the backend-confirmed withdrawal receipt. The
125 // frontend must restore only these durable item IDs into the draft.
126 type InboxCancelResultView struct {
127 DiscardedItemIDs []string `json:"discardedItemIds"`
128 Warning string `json:"warning,omitempty"`
129 }
130
131 type inboxChangedView struct {
132 TabID string `json:"tabId"`
133 SessionPath string `json:"sessionPath,omitempty"`
134 Revision int64 `json:"revision,omitempty"`
135 }
136
137 // InboxEnvelopeView is the full body for the editor (fetched by id only).
138 type InboxEnvelopeView struct {
139 ID string `json:"id"`
140 DisplayText string `json:"displayText"`
141 RawText string `json:"rawText"`
142 SubmitText string `json:"submitText"`
143 }
144
145 func inboxSnapshotView(snap sessioninbox.InboxSnapshot) InboxSnapshotView {
146 items := make([]InboxItemView, 0, len(snap.Items))
147 for i, it := range snap.Items {
148 items = append(items, InboxItemView{
149 ID: it.ID,
150 Intent: string(it.Intent),
151 State: string(it.State),
152 Preview: it.Preview,
153 ByteSize: it.ByteSize,
154 Source: it.Source,
155 BlockReason: it.BlockReason,
156 CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
157 Position: i + 1,
158 })
159 }
160 return InboxSnapshotView{
161 Readonly: snap.Readonly,
162 MutationsSupported: true,
163 Revision: snap.Revision,
164 Paused: snap.Paused,
165 Recovered: snap.Recovered,
166 RecoveredN: snap.RecoveredN,
167 SessionPath: snap.SessionPath,
168 Items: items,
169 ItemsCount: len(items),
170 Bytes: snap.Capacity.Bytes,
171 MaxItems: snap.Capacity.MaxItems,
172 MaxBytes: snap.Capacity.MaxBytes,
173 }
174 }
175
176 func (a *App) inboxCtrl(tabID string) (control.SessionAPI, error) {
177 tab, ctrl := a.tabAndCtrlByID(tabID)
178 if a.tabIsReadOnly(tab) {
179 return nil, inboxBridgeError(readOnlyChannelErr())
180 }
181 if ctrl == nil {
182 return nil, inboxBridgeError(a.workspaceNotReadyErr(tab))
183 }
184 return ctrl, nil
185 }
186
187 // InboxSnapshot returns durable inbox metadata for a tab (no bodies).
188 func (a *App) InboxSnapshot(tabID string) (InboxSnapshotView, error) {
189 if a.isRemoteTab(tabID) {
190 return a.remoteInboxSnapshot(tabID)
191 }
192 ctrl, err := a.inboxCtrl(tabID)
193 if err != nil {
194 return InboxSnapshotView{}, err
195 }
196 return inboxSnapshotView(ctrl.InboxSnapshot()), nil
197 }
198
199 // EnqueueInboxFollowup durably queues a follow-up for the tab.
200 func (a *App) EnqueueInboxFollowup(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
201 return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, nil, idempotency, false)
202 }
203
204 // EnqueueInboxFollowupWithInvocations preserves rich-composer Skill/Subagent
205 // entities in the durable envelope instead of degrading them to slash text.
206 func (a *App) EnqueueInboxFollowupWithInvocations(tabID, display, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error) {
207 return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, invocations, idempotency, false)
208 }
209
210 // EnqueueInboxSteer durably queues and attempts mid-turn steer.
211 func (a *App) EnqueueInboxSteer(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
212 return a.enqueueInbox(tabID, sessioninbox.IntentSteer, display, submit, nil, idempotency, true)
213 }
214
215 // EnqueueInboxSteerForTurn durably records guidance while ensuring its
216 // mid-turn injection is fenced to the exact turn observed by the frontend.
217 // This legacy tab-only API cannot establish ownership after the observed turn
218 // ends, so it must reject that case. New callers use InboxQueueForTarget for
219 // session-fenced durable follow-up fallback even after turn completion.
220 func (a *App) EnqueueInboxSteerForTurn(tabID, turnID, display, submit, idempotency string) (InboxReceiptView, error) {
221 turnID = strings.TrimSpace(turnID)
222 if turnID == "" {
223 return InboxReceiptView{}, fmt.Errorf("turnId is required")
224 }
225 ctrl, err := a.inboxCtrl(tabID)
226 if err != nil {
227 return InboxReceiptView{}, err
228 }
229 status := ctrl.RuntimeStatus()
230 if status.TurnID != turnID || !status.Running {
231 return InboxReceiptView{}, fmt.Errorf("turn %q is not the active turn for tab %q", turnID, tabID)
232 }
233 return a.enqueueInboxWithController(tabID, ctrl, sessioninbox.IntentSteer, display, submit, nil, idempotency, true, turnID, "")
234 }
235
236 // SteerInboxItem attempts to apply an existing durable queue item to the
237 // current turn. It never creates a second entry for the same instruction.
238 func (a *App) SteerInboxItem(tabID, itemID string) (InboxReceiptView, error) {
239 ctrl, err := a.inboxCtrl(tabID)
240 if err != nil {
241 return InboxReceiptView{}, err
242 }
243 rec, err := ctrl.TrySteerInboxItem(strings.TrimSpace(itemID))
244 if err != nil {
245 err = inboxBridgeError(err)
246 return InboxReceiptView{Error: err.Error()}, err
247 }
248 a.emitInboxChanged(tabID)
249 return InboxReceiptView{
250 ItemID: rec.ItemID,
251 Disposition: string(rec.Disposition),
252 Position: rec.Position,
253 Paused: rec.Paused,
254 Idempotent: rec.Idempotent,
255 }, nil
256 }
257
258 // SteerInboxItemForTurn is the exact-turn counterpart for an existing durable
259 // guidance item.
260 func (a *App) SteerInboxItemForTurn(tabID, turnID, itemID string) (InboxReceiptView, error) {
261 turnID = strings.TrimSpace(turnID)
262 if turnID == "" {
263 return InboxReceiptView{}, fmt.Errorf("turnId is required")
264 }
265 ctrl, err := a.inboxCtrl(tabID)
266 if err != nil {
267 return InboxReceiptView{}, err
268 }
269 status := ctrl.RuntimeStatus()
270 if status.TurnID != turnID || !status.Running {
271 return InboxReceiptView{}, fmt.Errorf("turn %q is not the active turn for tab %q", turnID, tabID)
272 }
273 exact, ok := ctrl.(interface {
274 TrySteerInboxItemForTurn(string, string) (sessioninbox.InboxReceipt, error)
275 })
276 if !ok {
277 return InboxReceiptView{}, fmt.Errorf("exact-turn steer is unavailable")
278 }
279 rec, err := exact.TrySteerInboxItemForTurn(turnID, strings.TrimSpace(itemID))
280 if err != nil {
281 err = inboxBridgeError(err)
282 return InboxReceiptView{Error: err.Error()}, err
283 }
284 a.emitInboxChanged(tabID)
285 return InboxReceiptView{
286 ItemID: rec.ItemID, Disposition: string(rec.Disposition), Position: rec.Position,
287 Paused: rec.Paused, Idempotent: rec.Idempotent,
288 }, nil
289 }
290
291 // CancelTabWithInboxItems cancels the turn and atomically discards only the
292 // durable pending items currently shown by that tab's Composer.
293 func (a *App) CancelTabWithInboxItems(tabID string, itemIDs []string) error {
294 ctrl, err := a.inboxCtrl(tabID)
295 if err != nil {
296 return err
297 }
298 if err := ctrl.CancelWithInboxItems(itemIDs, "desktop"); err != nil {
299 return inboxBridgeError(err)
300 }
301 a.emitInboxChanged(tabID)
302 return nil
303 }
304
305 // CancelTabWithInboxItemsResult is the receipt-capable cancellation API. It is
306 // additive so older desktop frontends can continue using the legacy method.
307 func (a *App) CancelTabWithInboxItemsResult(tabID string, itemIDs []string) (InboxCancelResultView, error) {
308 view := InboxCancelResultView{DiscardedItemIDs: []string{}}
309 ctrl, err := a.inboxCtrl(tabID)
310 if err != nil {
311 return view, err
312 }
313 result, err := ctrl.CancelWithInboxItemsResult(itemIDs, "desktop")
314 if err != nil {
315 return view, inboxBridgeError(err)
316 }
317 view.DiscardedItemIDs = append(view.DiscardedItemIDs, result.DiscardedItemIDs...)
318 view.Warning = result.Warning
319 a.emitInboxChanged(tabID)
320 return view, nil
321 }
322
323 func (a *App) enqueueInbox(tabID string, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool) (InboxReceiptView, error) {
324 a.remoteTabMu.Lock()
325 remote := a.remoteTabs[tabID] != nil
326 a.remoteTabMu.Unlock()
327 if remote && !trySteer {
328 return a.enqueueRemoteFollowup(tabID, display, submit, invocations, idempotency)
329 }
330 ctrl, err := a.inboxCtrl(tabID)
331 if err != nil {
332 return InboxReceiptView{}, err
333 }
334 return a.enqueueInboxWithController(tabID, ctrl, intent, display, submit, invocations, idempotency, trySteer, "", "")
335 }
336
337 func (a *App) enqueueInboxWithController(tabID string, ctrl control.SessionAPI, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool, turnID, expectedPath string) (InboxReceiptView, error) {
338 if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
339 ensurer.EnsureSessionPath()
340 }
341 submit = strings.TrimSpace(submit)
342 display = strings.TrimSpace(display)
343 if submit == "" && len(invocations) == 0 {
344 submit = display
345 }
346 if display == "" {
347 display = submit
348 }
349 req := control.InboxRequest{
350 ExpectedSessionPath: expectedPath,
351 Intent: intent,
352 Display: display,
353 Raw: submit,
354 Submit: submit,
355 Source: "desktop",
356 Idempotency: strings.TrimSpace(idempotency),
357 Invocations: controlInvocationRequests(invocations),
358 }
359 var (
360 rec sessioninbox.InboxReceipt
361 err error
362 )
363 if trySteer {
364 if turnID != "" {
365 exact, ok := ctrl.(interface {
366 TryEnqueueAndSteerForTurn(string, control.InboxRequest) (sessioninbox.InboxReceipt, error)
367 })
368 if !ok {
369 return InboxReceiptView{}, fmt.Errorf("exact-turn steer is unavailable")
370 }
371 rec, err = exact.TryEnqueueAndSteerForTurn(turnID, req)
372 } else {
373 rec, err = ctrl.TryEnqueueAndSteer(req)
374 }
375 } else {
376 rec, err = ctrl.TryEnqueueFollowup(req)
377 }
378 if err != nil {
379 err = inboxBridgeError(err)
380 return InboxReceiptView{Error: err.Error()}, err
381 }
382 a.emitInboxChanged(tabID)
383 return InboxReceiptView{
384 ItemID: rec.ItemID,
385 Disposition: string(rec.Disposition),
386 Position: rec.Position,
387 Paused: rec.Paused,
388 Idempotent: rec.Idempotent,
389 }, nil
390 }
391
392 // ReadInboxItem returns the full envelope for editing.
393 func (a *App) ReadInboxItem(tabID, id string) (InboxEnvelopeView, error) {
394 ctrl, err := a.inboxCtrl(tabID)
395 if err != nil {
396 return InboxEnvelopeView{}, err
397 }
398 meta, env, err := ctrl.ReadInboxItem(id)
399 if err != nil {
400 return InboxEnvelopeView{}, inboxBridgeError(err)
401 }
402 return InboxEnvelopeView{
403 ID: meta.ID,
404 DisplayText: env.DisplayText,
405 RawText: env.RawText,
406 SubmitText: env.SubmitText,
407 }, nil
408 }
409
410 // UpdateInboxItem rewrites a durable entry and re-freezes refs.
411 func (a *App) UpdateInboxItem(tabID, id, display, submit string) error {
412 ctrl, err := a.inboxCtrl(tabID)
413 if err != nil {
414 return err
415 }
416 if _, err := ctrl.UpdateInboxItem(id, display, submit, submit); err != nil {
417 return inboxBridgeError(err)
418 }
419 a.emitInboxChanged(tabID)
420 return nil
421 }
422
423 // DeleteInboxItem removes a durable entry.
424 func (a *App) DeleteInboxItem(tabID, id string) error {
425 ctrl, err := a.inboxCtrl(tabID)
426 if err != nil {
427 return err
428 }
429 if err := ctrl.DeleteInboxItem(id); err != nil {
430 return inboxBridgeError(err)
431 }
432 a.emitInboxChanged(tabID)
433 return nil
434 }
435
436 // MoveInboxItem reorders (toIndex is 0-based).
437 func (a *App) MoveInboxItem(tabID, id string, toIndex int) error {
438 ctrl, err := a.inboxCtrl(tabID)
439 if err != nil {
440 return err
441 }
442 if err := ctrl.MoveInboxItem(id, toIndex); err != nil {
443 return inboxBridgeError(err)
444 }
445 a.emitInboxChanged(tabID)
446 return nil
447 }
448
449 // SetInboxPaused pauses or resumes dispatch.
450 func (a *App) SetInboxPaused(tabID string, paused bool) error {
451 ctrl, err := a.inboxCtrl(tabID)
452 if err != nil {
453 return err
454 }
455 if err := ctrl.SetInboxPaused(paused); err != nil {
456 return inboxBridgeError(err)
457 }
458 a.emitInboxChanged(tabID)
459 return nil
460 }
461
462 // RetryInboxItem resets uncertain/blocked items to queued.
463 func (a *App) RetryInboxItem(tabID, id string) error {
464 ctrl, err := a.inboxCtrl(tabID)
465 if err != nil {
466 return err
467 }
468 if err := ctrl.RetryInboxItem(id); err != nil {
469 return inboxBridgeError(err)
470 }
471 a.emitInboxChanged(tabID)
472 return nil
473 }
474
475 // RefreshInboxReferences re-freezes @-refs for an item.
476 func (a *App) RefreshInboxItem(tabID, id string) error {
477 ctrl, err := a.inboxCtrl(tabID)
478 if err != nil {
479 return err
480 }
481 if err := ctrl.RefreshInboxReferences(id); err != nil {
482 return inboxBridgeError(err)
483 }
484 a.emitInboxChanged(tabID)
485 return nil
486 }
487
488 // SteerForTab still works for compatibility; prefer EnqueueInboxSteer so the
489 // guidance is durable before admission.
490 func (a *App) emitInboxChanged(tabID string) {
491 if a == nil || a.ctx == nil {
492 return
493 }
494 runtimeEventsEmitFallback(a.ctx, "InboxChanged", map[string]string{"tabId": tabID})
495 }
496
497 // ClearSessionConfirm checks for a non-empty inbox before clear.
498 func (a *App) InboxHasItems(tabID string) (bool, error) {
499 ctrl, err := a.inboxCtrl(tabID)
500 if err != nil {
501 return false, err
502 }
503 return len(ctrl.InboxSnapshot().Items) > 0, nil
504 }
505
505 lines GO