| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | |
| 12 | "reasonix/internal/attachment" |
| 13 | "reasonix/internal/control" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/session" |
| 16 | "reasonix/internal/sessioninbox" |
| 17 | ) |
| 18 | |
| 19 | type ComposerTarget struct { |
| 20 | Kind string `json:"kind"` |
| 21 | DraftID string `json:"draftId,omitempty"` |
| 22 | TabID string `json:"tabId,omitempty"` |
| 23 | Session *session.SessionRef `json:"session,omitempty"` |
| 24 | Generation uint64 `json:"generation,omitempty"` |
| 25 | } |
| 26 | |
| 27 | type attachmentTargetState struct { |
| 28 | // Test hook after I/O and before target revalidation; set before use. |
| 29 | attachmentIOHook func() |
| 30 | attachmentJoinHook func() |
| 31 | attachmentTargetsMu sync.Mutex |
| 32 | attachmentTargets map[string]attachmentTarget |
| 33 | attachmentStageOps map[string]*attachmentStageOperation |
| 34 | } |
| 35 | |
| 36 | type attachmentStageOperation struct { |
| 37 | fingerprint string |
| 38 | done chan struct{} |
| 39 | view DraftImageView |
| 40 | err error |
| 41 | } |
| 42 | |
| 43 | func (a *App) releaseAttachmentStageOperations(ownerPrefix string) { |
| 44 | a.attachmentTargetsMu.Lock() |
| 45 | defer a.attachmentTargetsMu.Unlock() |
| 46 | for key, op := range a.attachmentStageOps { |
| 47 | if strings.HasPrefix(key, ownerPrefix) { |
| 48 | // In-flight operations retain their result until completion so existing |
| 49 | // waiters can observe the owner cancellation. Completed receipts can be |
| 50 | // dropped immediately with their credential family. |
| 51 | select { |
| 52 | case <-op.done: |
| 53 | delete(a.attachmentStageOps, key) |
| 54 | default: |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | type AttachmentTargetView struct { |
| 61 | Token string `json:"token"` |
| 62 | Capabilities []string `json:"capabilities"` |
| 63 | } |
| 64 | |
| 65 | func (a *App) attachmentContext() context.Context { |
| 66 | if a.ctx != nil { |
| 67 | return a.ctx |
| 68 | } |
| 69 | return context.Background() |
| 70 | } |
| 71 | |
| 72 | func (a *App) attachmentOperationContext(target attachmentTarget) context.Context { |
| 73 | if target.ctx != nil { |
| 74 | return target.ctx |
| 75 | } |
| 76 | return a.attachmentContext() |
| 77 | } |
| 78 | |
| 79 | // CaptureAttachmentTarget must run before browser file reads or hashing. |
| 80 | // The opaque token carries no client-selected filesystem authority. |
| 81 | func (a *App) CaptureAttachmentTarget(composer ComposerTarget) (AttachmentTargetView, error) { |
| 82 | empty := AttachmentTargetView{Capabilities: []string{}} |
| 83 | target, err := a.attachmentTargetForComposerTarget(composer) |
| 84 | if err != nil { |
| 85 | return empty, err |
| 86 | } |
| 87 | if target.draftID == "" { |
| 88 | if _, ok := target.ctrl.(*control.Controller); !ok { |
| 89 | return empty, fmt.Errorf("unsupported: attachments-v2") |
| 90 | } |
| 91 | } |
| 92 | var id [24]byte |
| 93 | if _, err := rand.Read(id[:]); err != nil { |
| 94 | return empty, err |
| 95 | } |
| 96 | token := hex.EncodeToString(id[:]) |
| 97 | a.attachmentTargetsMu.Lock() |
| 98 | defer a.attachmentTargetsMu.Unlock() |
| 99 | if a.attachmentTargets == nil { |
| 100 | a.attachmentTargets = make(map[string]attachmentTarget) |
| 101 | } |
| 102 | for key, previous := range a.attachmentTargets { |
| 103 | if !a.attachmentTargetCurrent(previous) { |
| 104 | if previous.cancel != nil { |
| 105 | previous.cancel() |
| 106 | } |
| 107 | delete(a.attachmentTargets, key) |
| 108 | } |
| 109 | } |
| 110 | if len(a.attachmentTargets) >= 256 { |
| 111 | return empty, fmt.Errorf("too many outstanding attachment operations") |
| 112 | } |
| 113 | if controller, ok := target.ctrl.(*control.Controller); ok { |
| 114 | target.ctx, target.cancel = controller.NewAttachmentOperationContext(a.attachmentContext()) |
| 115 | } else { |
| 116 | target.ctx, target.cancel = context.WithCancel(a.attachmentContext()) |
| 117 | } |
| 118 | a.attachmentTargets[token] = target |
| 119 | return AttachmentTargetView{Token: token, Capabilities: []string{"attachments-v2"}}, nil |
| 120 | } |
| 121 | |
| 122 | func (a *App) attachmentTargetToken(token string) (attachmentTarget, error) { |
| 123 | a.attachmentTargetsMu.Lock() |
| 124 | target, ok := a.attachmentTargets[token] |
| 125 | a.attachmentTargetsMu.Unlock() |
| 126 | if !ok || !a.attachmentTargetCurrent(target) { |
| 127 | return attachmentTarget{}, fmt.Errorf("attachment target changed; please retry") |
| 128 | } |
| 129 | return target, nil |
| 130 | } |
| 131 | |
| 132 | func (a *App) ReleaseAttachmentTarget(token string) { |
| 133 | a.attachmentTargetsMu.Lock() |
| 134 | if target, ok := a.attachmentTargets[token]; ok && target.cancel != nil { |
| 135 | target.cancel() |
| 136 | } |
| 137 | delete(a.attachmentTargets, token) |
| 138 | a.attachmentTargetsMu.Unlock() |
| 139 | } |
| 140 | |
| 141 | func (a *App) StageImageForTarget(token, operationID, displayName, mime, dataURL string) (DraftImageView, error) { |
| 142 | target, err := a.attachmentTargetToken(token) |
| 143 | if err != nil { |
| 144 | return DraftImageView{}, err |
| 145 | } |
| 146 | operationID = strings.TrimSpace(operationID) |
| 147 | if operationID == "" { |
| 148 | return DraftImageView{}, fmt.Errorf("attachment operationId is required") |
| 149 | } |
| 150 | fingerprint, err := stageImageFingerprint(displayName, mime, dataURL) |
| 151 | if err != nil { |
| 152 | return DraftImageView{}, err |
| 153 | } |
| 154 | key := target.ownerIdentity + "\x00" + operationID |
| 155 | a.attachmentTargetsMu.Lock() |
| 156 | if a.attachmentStageOps == nil { |
| 157 | a.attachmentStageOps = make(map[string]*attachmentStageOperation) |
| 158 | } |
| 159 | if existing := a.attachmentStageOps[key]; existing != nil { |
| 160 | if existing.fingerprint != fingerprint { |
| 161 | a.attachmentTargetsMu.Unlock() |
| 162 | return DraftImageView{}, fmt.Errorf("attachment operation conflicts with different input") |
| 163 | } |
| 164 | done := existing.done |
| 165 | a.attachmentTargetsMu.Unlock() |
| 166 | if a.attachmentJoinHook != nil { |
| 167 | a.attachmentJoinHook() |
| 168 | } |
| 169 | select { |
| 170 | case <-done: |
| 171 | return existing.view, existing.err |
| 172 | case <-a.attachmentOperationContext(target).Done(): |
| 173 | return DraftImageView{}, a.attachmentOperationContext(target).Err() |
| 174 | } |
| 175 | } |
| 176 | count := 0 |
| 177 | for existingKey := range a.attachmentStageOps { |
| 178 | if strings.HasPrefix(existingKey, target.ownerIdentity+"\x00") { |
| 179 | count++ |
| 180 | } |
| 181 | } |
| 182 | if count >= 256 { |
| 183 | a.attachmentTargetsMu.Unlock() |
| 184 | return DraftImageView{}, fmt.Errorf("too many outstanding attachment operations") |
| 185 | } |
| 186 | op := &attachmentStageOperation{fingerprint: fingerprint, done: make(chan struct{})} |
| 187 | a.attachmentStageOps[key] = op |
| 188 | a.attachmentTargetsMu.Unlock() |
| 189 | |
| 190 | if target.draftID != "" { |
| 191 | rel, stageErr := control.SaveImageDataURLInRoot(target.root, dataURL) |
| 192 | if stageErr == nil { |
| 193 | op.view = DraftImageView{Path: rel, DisplayName: attachment.NormalizeDisplayName(displayName), MIME: strings.ToLower(strings.TrimSpace(mime))} |
| 194 | } |
| 195 | op.err = stageErr |
| 196 | } else { |
| 197 | op.view, op.err = a.stageImageForTarget(target, displayName, mime, dataURL) |
| 198 | } |
| 199 | invalidReason := a.attachmentTargetInvalidReason(target) |
| 200 | ownerCurrent := invalidReason == "" |
| 201 | if op.err == nil && !ownerCurrent { |
| 202 | op.view = DraftImageView{} |
| 203 | op.err = fmt.Errorf("attachment target changed (%s); please retry", invalidReason) |
| 204 | } |
| 205 | a.attachmentTargetsMu.Lock() |
| 206 | close(op.done) |
| 207 | if !ownerCurrent { |
| 208 | delete(a.attachmentStageOps, key) |
| 209 | } |
| 210 | a.attachmentTargetsMu.Unlock() |
| 211 | return op.view, op.err |
| 212 | } |
| 213 | |
| 214 | func stageImageFingerprint(displayName, mime, dataURL string) (string, error) { |
| 215 | const marker = ";base64," |
| 216 | before, encoded, ok := strings.Cut(dataURL, marker) |
| 217 | if !ok || !strings.HasPrefix(before, "data:") { |
| 218 | return "", fmt.Errorf("unsupported pasted image") |
| 219 | } |
| 220 | raw, err := decodeBase64(encoded) |
| 221 | if err != nil { |
| 222 | return "", err |
| 223 | } |
| 224 | digest := sha256.New() |
| 225 | _, _ = digest.Write(raw) |
| 226 | _, _ = digest.Write([]byte{0}) |
| 227 | _, _ = digest.Write([]byte(strings.ToLower(strings.TrimSpace(mime)))) |
| 228 | _, _ = digest.Write([]byte{0}) |
| 229 | _, _ = digest.Write([]byte(attachment.NormalizeDisplayName(displayName))) |
| 230 | return hex.EncodeToString(digest.Sum(nil)), nil |
| 231 | } |
| 232 | |
| 233 | func (a *App) ReadDraftImageForTarget(token, draftID string) (string, error) { |
| 234 | target, err := a.attachmentTargetToken(token) |
| 235 | if err != nil { |
| 236 | return "", err |
| 237 | } |
| 238 | if target.draftID != "" { |
| 239 | return control.ImageDataURLInRoot(target.root, draftID) |
| 240 | } |
| 241 | return a.readDraftImageForTarget(target, draftID) |
| 242 | } |
| 243 | |
| 244 | func (a *App) SavePastedFileForTarget(token, name, dataURL string) (string, error) { |
| 245 | target, err := a.attachmentTargetToken(token) |
| 246 | if err != nil { |
| 247 | return "", err |
| 248 | } |
| 249 | rel, err := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL) |
| 250 | return a.finishAttachmentWrite(target, rel, err) |
| 251 | } |
| 252 | |
| 253 | func (a *App) SaveClipboardImageForTarget(token string) (string, error) { |
| 254 | target, err := a.attachmentTargetToken(token) |
| 255 | if err != nil { |
| 256 | return "", err |
| 257 | } |
| 258 | rel, err := control.SaveClipboardImageInRoot(target.root) |
| 259 | return a.finishAttachmentWrite(target, rel, err) |
| 260 | } |
| 261 | |
| 262 | func (a *App) AttachmentDataURLForTarget(token, path string) (string, error) { |
| 263 | target, err := a.attachmentTargetToken(token) |
| 264 | if err != nil { |
| 265 | return "", err |
| 266 | } |
| 267 | return a.attachmentDataURLForTarget(target, path) |
| 268 | } |
| 269 | |
| 270 | func (a *App) AttachDroppedForTarget(token, path string) (DroppedItem, error) { |
| 271 | target, err := a.attachmentTargetToken(token) |
| 272 | if err != nil { |
| 273 | return DroppedItem{}, err |
| 274 | } |
| 275 | return a.attachDroppedForTarget(target, path) |
| 276 | } |
| 277 | |
| 278 | func (a *App) RebindDraftImageForTarget(token, draftID string) (DraftImageView, error) { |
| 279 | target, err := a.attachmentTargetToken(token) |
| 280 | if err != nil { |
| 281 | return DraftImageView{}, err |
| 282 | } |
| 283 | c, ok := target.ctrl.(*control.Controller) |
| 284 | if !ok { |
| 285 | return DraftImageView{}, fmt.Errorf("unsupported: draft attachment rebind") |
| 286 | } |
| 287 | draft, err := c.RebindDraftImage(a.attachmentOperationContext(target), draftID) |
| 288 | if err != nil { |
| 289 | return DraftImageView{}, err |
| 290 | } |
| 291 | if !a.attachmentTargetCurrent(target) { |
| 292 | return DraftImageView{}, fmt.Errorf("attachment target changed; please retry") |
| 293 | } |
| 294 | return draftImageView(draft), nil |
| 295 | } |
| 296 | |
| 297 | func (a *App) ReleaseDraftImageForTarget(token, draftID string) error { |
| 298 | target, err := a.attachmentTargetToken(token) |
| 299 | if err != nil { |
| 300 | return err |
| 301 | } |
| 302 | if c, ok := target.ctrl.(*control.Controller); ok { |
| 303 | c.ReleaseDraftImage(draftID) |
| 304 | } |
| 305 | a.attachmentTargetsMu.Lock() |
| 306 | for key, op := range a.attachmentStageOps { |
| 307 | if strings.HasPrefix(key, target.ownerIdentity+"\x00") && op.view.DraftID == draftID { |
| 308 | delete(a.attachmentStageOps, key) |
| 309 | } |
| 310 | } |
| 311 | a.attachmentTargetsMu.Unlock() |
| 312 | return nil |
| 313 | } |
| 314 | |
| 315 | // StartTurnForAttachmentTarget shares controller admission for all structured |
| 316 | // sources. beginTabTurn holds the runtime replacement barrier during admission. |
| 317 | func (a *App) StartTurnForAttachmentTarget(token, submissionID string, req control.SubmissionRequest) (TurnStartView, error) { |
| 318 | target, err := a.attachmentTargetToken(token) |
| 319 | if err != nil { |
| 320 | return TurnStartView{}, err |
| 321 | } |
| 322 | if submissionID == "" { |
| 323 | return TurnStartView{}, fmt.Errorf("submissionId is required") |
| 324 | } |
| 325 | req.ID = submissionID |
| 326 | c, ok := target.ctrl.(*control.Controller) |
| 327 | if !ok { |
| 328 | return TurnStartView{}, fmt.Errorf("attachment target is not a session") |
| 329 | } |
| 330 | if receipt, found, err := c.LookupSubmissionContext(a.attachmentOperationContext(target), req); found || err != nil { |
| 331 | return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err |
| 332 | } |
| 333 | prepared, err := c.PrepareSubmission(a.attachmentOperationContext(target), req) |
| 334 | if err != nil { |
| 335 | return TurnStartView{}, err |
| 336 | } |
| 337 | admission, ctrl, err := a.beginTabTurn(target.tabID, true, submissionID) |
| 338 | if err != nil { |
| 339 | return TurnStartView{}, err |
| 340 | } |
| 341 | defer admission.abort() |
| 342 | if ctrl != target.ctrl || !a.attachmentTargetCurrent(target) { |
| 343 | return TurnStartView{}, fmt.Errorf("attachment target changed; please retry") |
| 344 | } |
| 345 | setup := func() error { |
| 346 | if req.Goal != "" { |
| 347 | if err := syncTabGoalToController(ctrl, req.Goal); err != nil { |
| 348 | return err |
| 349 | } |
| 350 | a.mu.Lock() |
| 351 | target.tab.goal = req.Goal |
| 352 | target.tab.toolApprovalMode = normalizeToolApprovalMode(req.ToolApprovalMode) |
| 353 | target.tab.mode = tabModeFromAxes(false, target.tab.toolApprovalMode == control.ToolApprovalYolo) |
| 354 | a.saveTabsLocked() |
| 355 | a.mu.Unlock() |
| 356 | ctrl.SetPlanMode(false) |
| 357 | applyTabToolApprovalModeToController(ctrl, normalizeToolApprovalMode(req.ToolApprovalMode)) |
| 358 | } |
| 359 | return a.ensureTabTopicIndexedForUserTurn(target.tab) |
| 360 | } |
| 361 | receipt, err := c.SubmitPreparedWithSetup(a.attachmentOperationContext(target), prepared, setup) |
| 362 | if err != nil { |
| 363 | return TurnStartView{}, err |
| 364 | } |
| 365 | admission.finish(ctrl) |
| 366 | return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, nil |
| 367 | } |
| 368 | |
| 369 | func (a *App) EnqueueForAttachmentTarget(token, submissionID, input, display string, invocations []control.InvocationRequest, attachments []control.SubmissionAttachment) (InboxReceiptView, error) { |
| 370 | target, err := a.attachmentTargetToken(token) |
| 371 | if err != nil { |
| 372 | return InboxReceiptView{}, err |
| 373 | } |
| 374 | a.runtimeAdmissionMu.RLock() |
| 375 | defer a.runtimeAdmissionMu.RUnlock() |
| 376 | if !a.attachmentTargetCurrent(target) { |
| 377 | return InboxReceiptView{}, fmt.Errorf("attachment target changed; please retry") |
| 378 | } |
| 379 | c, ok := target.ctrl.(*control.Controller) |
| 380 | if !ok { |
| 381 | return InboxReceiptView{}, fmt.Errorf("attachment target is not a session") |
| 382 | } |
| 383 | receipt, err := c.TryEnqueueFollowupContext(a.attachmentOperationContext(target), control.InboxRequest{Intent: sessioninbox.IntentFollowup, Display: display, Raw: input, Submit: input, Source: "desktop", Idempotency: submissionID, Invocations: invocations, Attachments: attachments}) |
| 384 | if err != nil { |
| 385 | return InboxReceiptView{}, err |
| 386 | } |
| 387 | a.emitInboxChanged(target.tabID) |
| 388 | return InboxReceiptView{ItemID: receipt.ItemID, Paused: receipt.Paused}, nil |
| 389 | } |
| 390 |