返回 DeepSeek-Reasonix
historical_import.go
根目录 / desktop / historical_import.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/agent"
15 "reasonix/internal/identitylock"
16 "reasonix/internal/session"
17 "reasonix/internal/store"
18 )
19
20 var errHistoricalSourceBusy = errors.New("historical session is in use; close the other instance and retry")
21
22 type HistoricalSessionView struct {
23 ID string `json:"id"`
24 Title string `json:"title"`
25 Format string `json:"format"`
26 Status string `json:"status"`
27 ErrorCode string `json:"errorCode,omitempty"`
28 Session *session.SessionRef `json:"session,omitempty"`
29 Source *SessionSourceRef `json:"source,omitempty"`
30 }
31
32 type HistoricalImportStatus struct {
33 Items []HistoricalSessionView `json:"items"`
34 Running bool `json:"running"`
35 Paused bool `json:"paused"`
36 Remaining int `json:"remaining"`
37 Completed int `json:"completed"`
38 Blocked int `json:"blocked"`
39 Failed int `json:"failed"`
40 }
41
42 type historicalSource struct {
43 path, format, scope, root, head, version string
44 }
45 type historicalImportCall struct {
46 operationID string
47 sourceKey string
48 ctx context.Context
49 cancel context.CancelFunc
50 done chan struct{}
51 result SessionRestoreResult
52 err error
53 status string
54 errorCode string
55 revision uint64
56 interactive, batch bool
57 }
58 type historicalImportCoordinator struct {
59 mu sync.Mutex
60 discoveryMu sync.Mutex
61 discoveryPending bool
62 catalogEnabled bool
63 catalogAt time.Time
64 catalog []historicalCatalogEntry
65 sources map[string]historicalSource
66 views map[string]HistoricalSessionView
67 calls map[string]*historicalImportCall
68 operations map[string]*historicalImportCall
69 updates map[string]*historicalSourceUpdateCall
70 updateWorker chan struct{}
71 revision uint64
72 ctx context.Context
73 cancel context.CancelFunc
74 queue []string
75 current string
76 queueLoaded bool
77 queueRevision uint64
78 queueRelease func()
79 presentations map[string]historicalSourcePresentation
80 running, paused, stopped bool
81 wake chan struct{}
82 workers sync.WaitGroup
83 }
84
85 func (a *App) GetHistoricalImportStatus() HistoricalImportStatus {
86 c := &a.historicalImports
87 c.mu.Lock()
88 defer c.mu.Unlock()
89 return c.status()
90 }
91
92 func (a *App) historicalPreparationStatus(sourceKey string) string {
93 c := &a.historicalImports
94 c.mu.Lock()
95 defer c.mu.Unlock()
96 if view, ok := c.views[sourceKey]; ok {
97 return view.Status
98 }
99 return "available"
100 }
101
102 // Listing reads directory entries and registry metadata only.
103 func (a *App) ListHistoricalSessions() (HistoricalImportStatus, error) {
104 return a.listHistoricalSessions(a.bootContext())
105 }
106
107 func (a *App) listHistoricalSessions(ctx context.Context) (HistoricalImportStatus, error) {
108 c := &a.historicalImports
109 c.discoveryMu.Lock()
110 defer c.discoveryMu.Unlock()
111 state, err := a.workspaceRegistry().Load(ctx)
112 if err != nil {
113 return HistoricalImportStatus{Items: []HistoricalSessionView{}}, err
114 }
115 sources := map[string]historicalSource{}
116 add := func(path, format, scope, root, head string) {
117 sources[desktopSourceKey(path, head)] = historicalSource{path: path, format: format, scope: scope, root: root, head: head}
118 }
119 canonical, legacy := a.desktopHistoricalRoots()
120 var joined error
121 for _, source := range canonical {
122 joined = errors.Join(joined, scanHistoricalRoot(ctx, *source, "canonical", add))
123 }
124 for _, source := range legacy {
125 joined = errors.Join(joined, scanHistoricalRoot(ctx, source, "legacy", add))
126 }
127 addHistoricalRegistrySources(state, add)
128 catalog := readHistoricalCanonicalCatalog(ctx, sources)
129 saved, presentationErr := readHistoricalSidecar()
130 c.mu.Lock()
131 defer c.mu.Unlock()
132 c.initialize(ctx)
133 if !c.queueLoaded {
134 c.loadQueueLocked()
135 }
136 c.catalog, c.catalogAt = catalog, time.Now()
137 if presentationErr == nil {
138 c.presentations = saved.Presentations
139 }
140 for id, source := range sources {
141 if source.path == "" {
142 continue
143 }
144 c.sources[id] = source
145 view := historicalImportView(state, id, source, c.views[id])
146 if presentation := c.presentations[id]; presentation.Title != "" {
147 view.Title = presentation.Title
148 }
149 c.views[id] = view
150 }
151 return c.status(), joined
152 }
153
154 func scanHistoricalRoot(ctx context.Context, source desktopMigrationSource, format string, add func(string, string, string, string, string)) error {
155 entries, err := os.ReadDir(source.root)
156 if os.IsNotExist(err) {
157 return nil
158 }
159 if err != nil {
160 return err
161 }
162 for _, entry := range entries {
163 if err := ctx.Err(); err != nil {
164 return err
165 }
166 if strings.HasPrefix(entry.Name(), ".") || entry.Type()&os.ModeSymlink != 0 {
167 continue
168 }
169 if format == "canonical" && !entry.IsDir() {
170 continue
171 }
172 if format == "legacy" && (entry.IsDir() || !store.IsSessionTranscriptName(entry.Name())) {
173 continue
174 }
175 path := filepath.Join(source.root, entry.Name())
176 if format == "canonical" && !hasHistoricalSessionArtifacts(path) {
177 continue
178 }
179 if format == "legacy" && addIndexedHistoricalHeads(path, source, add) {
180 continue
181 }
182 add(path, format, source.scope, source.workspaceRoot, "")
183 }
184 return nil
185 }
186
187 func addIndexedHistoricalHeads(path string, source desktopMigrationSource, add func(string, string, string, string, string)) bool {
188 index, err := agent.ReadSessionHeadIndex(path)
189 if err != nil || index == nil || !index.Current(path) {
190 return false
191 }
192 selected := ""
193 for _, head := range index.Heads {
194 if !head.Retired && head.Selected {
195 selected = head.ID
196 }
197 }
198 add(path, "legacy", source.scope, source.workspaceRoot, "")
199 for _, head := range index.Heads {
200 if !head.Retired && head.ID != "" && head.ID != selected {
201 add(path, "legacy", source.scope, source.workspaceRoot, head.ID)
202 }
203 }
204 return true
205 }
206
207 func addHistoricalRegistrySources(state workspacestate.State, add func(string, string, string, string, string)) {
208 workspaceSource := func(path, format, workspaceID, head string) {
209 w := state.Workspaces[workspaceID]
210 scope := "project"
211 if workspaceID == "global" {
212 scope = "global"
213 }
214 add(path, format, scope, w.Root, head)
215 }
216 for _, mapping := range state.SourceMappings {
217 workspaceSource(mapping.Path, mapping.Format, mapping.WorkspaceID, mapping.HeadID)
218 }
219 for _, op := range state.PendingOperations {
220 if op.Mapping != nil && (op.Kind == "import" || op.Kind == "restore") {
221 workspaceSource(op.Mapping.Path, op.Mapping.Format, op.WorkspaceID, op.Mapping.HeadID)
222 }
223 }
224 }
225
226 func historicalImportView(state workspacestate.State, id string, source historicalSource, view HistoricalSessionView) HistoricalSessionView {
227 if view.ID == "" {
228 view = HistoricalSessionView{ID: id, Title: filepath.Base(source.path), Format: source.format, Status: "available"}
229 }
230 view.Source = &SessionSourceRef{HostID: localDesktopHostID, SourceKey: desktopSourceKey(source.path, source.head), Path: source.path, HeadID: source.head}
231 mapping, ok := historicalMappingForSource(state, id)
232 if !ok {
233 return view
234 }
235 view.Status = "imported"
236 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}
237 view.Session = &ref
238 if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle == workspacestate.Deleted || lifecycle == workspacestate.Archived {
239 view.Status = strings.ToLower(lifecycle)
240 view.Session = nil
241 }
242 return view
243 }
244
245 func historicalSourceKeyMatches(mappingKey, sourceID string) bool {
246 return mappingKey == sourceID || strings.HasPrefix(mappingKey, sourceID+":review:")
247 }
248
249 func historicalMappingForSource(state workspacestate.State, sourceID string) (workspacestate.SourceMapping, bool) {
250 if mapping, ok := state.SourceMappings[sourceID]; ok {
251 return mapping, true
252 }
253 keys := make([]string, 0, len(state.SourceMappings))
254 for key := range state.SourceMappings {
255 if historicalSourceKeyMatches(key, sourceID) {
256 keys = append(keys, key)
257 }
258 }
259 sort.Strings(keys)
260 if len(keys) == 0 {
261 return workspacestate.SourceMapping{}, false
262 }
263 return state.SourceMappings[keys[0]], true
264 }
265
266 func (c *historicalImportCoordinator) initialize(ctx context.Context) {
267 if c.sources != nil {
268 if !c.stopped && !c.running && len(c.calls) == 0 && c.ctx.Err() != nil {
269 c.ctx, c.cancel = context.WithCancel(ctx)
270 }
271 return
272 }
273 c.sources = map[string]historicalSource{}
274 c.views = map[string]HistoricalSessionView{}
275 c.calls = map[string]*historicalImportCall{}
276 c.operations = map[string]*historicalImportCall{}
277 c.updates = map[string]*historicalSourceUpdateCall{}
278 c.updateWorker = make(chan struct{}, 1)
279 c.presentations = map[string]historicalSourcePresentation{}
280 c.ctx, c.cancel = context.WithCancel(ctx)
281 c.wake = make(chan struct{}, 1)
282 }
283 func (c *historicalImportCoordinator) status() HistoricalImportStatus {
284 out := HistoricalImportStatus{Items: []HistoricalSessionView{}, Running: c.running, Paused: c.paused, Remaining: len(c.queue)}
285 if c.current != "" {
286 out.Remaining++
287 }
288 for _, view := range c.views {
289 out.Items = append(out.Items, view)
290 switch view.Status {
291 case "imported":
292 out.Completed++
293 case "blocked":
294 out.Blocked++
295 case "failed":
296 out.Failed++
297 }
298 }
299 sort.Slice(out.Items, func(i, j int) bool { return out.Items[i].ID < out.Items[j].ID })
300 return out
301 }
302
303 func (a *App) ImportHistoricalSession(id string) (SessionRestoreResult, error) {
304 _, listErr := a.ListHistoricalSessions()
305 if listErr != nil {
306 // A damaged or inaccessible historical root must not make healthy
307 // sources unusable. The requested source is checked below; callers can
308 // still inspect the list's per-source status for the affected root.
309 c := &a.historicalImports
310 c.mu.Lock()
311 _, known := c.sources[id]
312 c.mu.Unlock()
313 if !known {
314 return SessionRestoreResult{}, listErr
315 }
316 }
317 call, err := a.prepareHistoricalSession(id, true, false)
318 if err != nil {
319 return SessionRestoreResult{}, err
320 }
321 return waitHistoricalImport(call)
322 }
323
324 // Duplicate requests join one import. Interactive navigation and the bulk queue
325 // hold independent demands so cancelling one cannot abort the other.
326 func (a *App) prepareHistoricalSession(id string, interactive, batch bool) (*historicalImportCall, error) {
327 c := &a.historicalImports
328 c.mu.Lock()
329 if c.stopped || a.shuttingDown.Load() {
330 c.mu.Unlock()
331 return nil, context.Canceled
332 }
333 source, ok := c.sources[id]
334 if !ok {
335 c.mu.Unlock()
336 return nil, errors.New("historical session is unavailable; refresh the list")
337 }
338 if call := c.calls[id]; call != nil {
339 call.interactive = call.interactive || interactive
340 call.batch = call.batch || batch
341 c.mu.Unlock()
342 return call, nil
343 }
344 for operationID, previous := range c.operations {
345 if previous.sourceKey == id {
346 if previous.status == "ready" {
347 c.mu.Unlock()
348 return previous, nil
349 }
350 delete(c.operations, operationID)
351 }
352 }
353 ctx, cancel := context.WithCancel(c.ctx)
354 c.revision++
355 operationID := "prepare-" + strings.TrimPrefix(newTabID(), "tab_")
356 call := &historicalImportCall{operationID: operationID, sourceKey: id, ctx: ctx, cancel: cancel,
357 done: make(chan struct{}), status: "queued", revision: c.revision, interactive: interactive, batch: batch}
358 c.calls[id] = call
359 c.operations[call.operationID] = call
360 view := c.views[id]
361 view.Status, view.ErrorCode = "queued", ""
362 c.views[id] = view
363 c.workers.Add(1)
364 c.mu.Unlock()
365 go a.runHistoricalPreparation(call, id, source)
366 return call, nil
367 }
368
369 func waitHistoricalImport(call *historicalImportCall) (SessionRestoreResult, error) {
370 select {
371 case <-call.done:
372 return call.result, call.err
373 case <-call.ctx.Done():
374 <-call.done
375 return call.result, call.err
376 }
377 }
378
379 func (a *App) runHistoricalPreparation(call *historicalImportCall, id string, source historicalSource) {
380 c := &a.historicalImports
381 defer c.workers.Done()
382 c.mu.Lock()
383 c.revision++
384 call.status, call.revision = "preparing", c.revision
385 view := c.views[id]
386 view.Status, view.ErrorCode = "importing", ""
387 c.views[id] = view
388 c.mu.Unlock()
389 result, err := a.importHistoricalSource(call.ctx, id, source)
390 var presentationErr error
391 if err == nil {
392 presentationErr = a.applyHistoricalSourcePresentation(desktopSourceKey(source.path, source.head), result.Session)
393 }
394 c.mu.Lock()
395 if c.calls[id] != call {
396 call.result, call.err = result, err
397 close(call.done)
398 c.mu.Unlock()
399 return
400 }
401 call.result, call.err = result, err
402 view = c.views[id]
403 if err == nil {
404 view.Status, call.status = "imported", "ready"
405 ref := result.Session
406 view.Session = &ref
407 if presentationErr != nil {
408 view.ErrorCode = "presentation_pending"
409 }
410 } else {
411 view.Status, view.ErrorCode, call.status, call.errorCode = "failed", "import_failed", "failed", "import_failed"
412 if historicalSourceBusyError(err) {
413 view.Status, view.ErrorCode, call.status, call.errorCode = "blocked", "source_busy", "blocked", "source_busy"
414 err = errHistoricalSourceBusy
415 }
416 if errors.Is(err, context.Canceled) {
417 view.Status, view.ErrorCode, call.status, call.errorCode = "available", "cancelled", "cancelled", "cancelled"
418 }
419 call.err = err
420 }
421 c.revision++
422 call.revision = c.revision
423 c.views[id] = view
424 delete(c.calls, id)
425 close(call.done)
426 c.mu.Unlock()
427 a.emitProjectTreeChanged()
428 }
429
430 func (a *App) saveHistoricalSourcePresentation(sourceKey string, update func(*historicalSourcePresentation)) error {
431 c := &a.historicalImports
432 c.mu.Lock()
433 defer c.mu.Unlock()
434 c.initialize(a.bootContext())
435 if !c.queueLoaded {
436 c.loadQueueLocked()
437 }
438 return updateHistoricalSidecar(func(saved *historicalImportQueueSidecar) error {
439 presentation := saved.Presentations[sourceKey]
440 update(&presentation)
441 saved.Presentations[sourceKey] = presentation
442 c.presentations = saved.Presentations
443 return nil
444 })
445 }
446
447 func (a *App) applyHistoricalSourcePresentation(sourceKey string, ref session.SessionRef) error {
448 saved, err := readHistoricalSidecar()
449 if err != nil {
450 return err
451 }
452 presentation, ok := saved.Presentations[sourceKey]
453 if !ok {
454 return nil
455 }
456 var joined error
457 if presentation.Title != "" {
458 joined = errors.Join(joined, a.desktopSessionService("").SetTitle(a.bootContext(), ref, presentation.Title))
459 }
460 if presentation.Pinned != nil {
461 joined = errors.Join(joined, a.workspaceRegistry().UpdatePresentation(a.bootContext(), []string{ref.SessionID}, nil, presentation.Pinned))
462 }
463 return joined
464 }
465
466 func historicalSourceBusyError(err error) bool {
467 return errors.Is(err, identitylock.ErrHeld) ||
468 errors.Is(err, errHistoricalSourceBusy) ||
469 errors.Is(err, agent.ErrSessionLeaseHeld) ||
470 errors.Is(err, session.ErrWriterOwned)
471 }
472
473 func (a *App) importHistoricalSource(ctx context.Context, id string, source historicalSource) (SessionRestoreResult, error) {
474 state, err := a.workspaceRegistry().Load(ctx)
475 if err != nil {
476 return SessionRestoreResult{}, err
477 }
478 if result, handled, err := a.resumeReadyHistoricalImport(ctx, state, id, source); handled {
479 return result, err
480 }
481 release, err := acquireHistoricalSource(ctx, id, source)
482 if err != nil {
483 return SessionRestoreResult{}, err
484 }
485 defer release()
486 // Another process may have committed between the initial read and our claim.
487 state, err = a.workspaceRegistry().Load(ctx)
488 if err != nil {
489 return SessionRestoreResult{}, err
490 }
491 if result, handled, err := a.resumeReadyHistoricalImport(ctx, state, id, source); handled {
492 return result, err
493 }
494 if source.version != "" {
495 current, fingerprintErr := desktopSourceFingerprint(source.path)
496 if fingerprintErr != nil {
497 return SessionRestoreResult{}, fingerprintErr
498 }
499 if current != source.version {
500 return SessionRestoreResult{}, newSessionOperationError("target_changed", "The historical source changed. Check for updates again.")
501 }
502 }
503 workspace, err := a.ensureDesktopWorkspace(ctx, source.scope, source.root)
504 if err != nil {
505 return SessionRestoreResult{}, err
506 }
507 if result, handled, err := a.resumeConflictingHistoricalVersion(ctx, state, source, workspace); handled {
508 return result, err
509 }
510 migration := desktopMigrationSource{scope: source.scope, workspaceRoot: source.root, headID: source.head, versionFingerprint: source.version}
511 if resume := pendingHistoricalOperation(state, id); resume != nil {
512 migration.operationID = resume.ID
513 }
514 err = a.convertHistoricalSource(ctx, source, migration, workspace)
515 if err != nil {
516 return SessionRestoreResult{}, err
517 }
518 state, err = a.workspaceRegistry().Load(ctx)
519 if err != nil {
520 return SessionRestoreResult{}, err
521 }
522 mapping, ok := state.SourceMappings[id]
523 if !ok {
524 return SessionRestoreResult{}, errors.New("historical import has not committed")
525 }
526 return SessionRestoreResult{Session: session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}, WorkspaceID: mapping.WorkspaceID, Generation: state.Generation}, nil
527 }
528
529 func historicalOperationRank(op workspacestate.Operation) int {
530 switch op.Phase {
531 case "content_ready":
532 return 0
533 case "prepared":
534 return 1
535 default:
536 return 2
537 }
538 }
539
540 func (a *App) convertHistoricalSource(ctx context.Context, source historicalSource, migration desktopMigrationSource, workspace string) (err error) {
541 if source.format == "canonical" {
542 migration.root = filepath.Dir(source.path)
543 old, openErr := session.NewService("migration-source", session.NewFilesystemPersistence(migration.root))
544 if openErr != nil {
545 return openErr
546 }
547 defer func() { err = errors.Join(err, old.Shutdown(context.Background())) }()
548 return a.migrateCanonicalSession(ctx, old, migration, workspace, filepath.Base(source.path))
549 }
550 if source.format == "legacy" || source.format == "legacy-trash" {
551 return a.migrateLegacySession(ctx, source.path, migration, workspace)
552 }
553 return errors.New("historical format is unsupported")
554 }
555
556 // StartHistoricalImport snapshots the requested set; later discoveries are not
557 // silently added. Empty means all currently available/failed/busy sources.
558 func (a *App) StartHistoricalImport(ids []string) (HistoricalImportStatus, error) {
559 _, listErr := a.ListHistoricalSessions()
560 c := &a.historicalImports
561 c.mu.Lock()
562 defer c.mu.Unlock()
563 if listErr != nil && len(ids) > 0 {
564 for _, id := range ids {
565 if _, ok := c.sources[id]; !ok {
566 return c.status(), listErr
567 }
568 }
569 }
570 if c.running || c.stopped || a.shuttingDown.Load() {
571 return c.status(), errors.New("historical import is already running or stopping")
572 }
573 if err := c.claimQueueLocked(); err != nil {
574 return c.status(), err
575 }
576 started := false
577 defer func() {
578 if !started {
579 c.releaseQueueLocked()
580 }
581 }()
582 if len(ids) == 0 {
583 for id, v := range c.views {
584 if v.Status != "imported" && v.Status != "deleted" && v.Status != "archived" {
585 ids = append(ids, id)
586 }
587 }
588 sort.Strings(ids)
589 }
590 seen := map[string]bool{}
591 queue := []string{}
592 for _, id := range ids {
593 if _, ok := c.sources[id]; !ok {
594 return c.status(), errors.New("historical source is unavailable")
595 }
596 if !seen[id] {
597 queue = append(queue, id)
598 seen[id] = true
599 }
600 }
601 c.queue, c.running, c.paused = queue, true, false
602 c.current = ""
603 if err := c.saveQueueLocked(); err != nil {
604 c.queue, c.running = nil, false
605 return c.status(), err
606 }
607 c.workers.Add(1)
608 started = true
609 go a.runHistoricalImportQueue()
610 return c.status(), nil
611 }
612
613 func (a *App) runHistoricalImportQueue() {
614 c := &a.historicalImports
615 defer c.workers.Done()
616 defer func() {
617 c.mu.Lock()
618 c.running = false
619 c.releaseQueueLocked()
620 c.mu.Unlock()
621 }()
622 for {
623 c.mu.Lock()
624 if len(c.queue) == 0 || c.stopped || c.ctx.Err() != nil {
625 c.mu.Unlock()
626 return
627 }
628 if c.paused {
629 wake, ctx := c.wake, c.ctx
630 c.mu.Unlock()
631 select {
632 case <-wake:
633 case <-ctx.Done():
634 }
635 continue
636 }
637 id := c.queue[0]
638 c.queue = c.queue[1:]
639 c.current = id
640 if err := c.saveQueueLocked(); err != nil {
641 c.queue = append([]string{id}, c.queue...)
642 c.current, c.paused = "", true
643 c.mu.Unlock()
644 return
645 }
646 c.mu.Unlock()
647 call, err := a.prepareHistoricalSession(id, false, true)
648 if err == nil {
649 _, _ = waitHistoricalImport(call)
650 }
651 c.mu.Lock()
652 // Shutdown preserves the last durable selection, including the current
653 // item. A committed item is idempotently resolved on manual continuation.
654 if c.stopped || c.ctx.Err() != nil {
655 c.paused = true
656 c.mu.Unlock()
657 return
658 }
659 if c.current == id {
660 c.current = ""
661 }
662 if err := c.saveQueueLocked(); err != nil {
663 c.current, c.paused = id, true
664 c.mu.Unlock()
665 return
666 }
667 c.mu.Unlock()
668 }
669 }
670
671 // Pause finishes the current item. Cancel also interrupts its source work;
672 // durable prepared/content_ready records remain available to the next request.
673 func (a *App) ControlHistoricalImport(action string) (HistoricalImportStatus, error) {
674 c := &a.historicalImports
675 c.mu.Lock()
676 c.initialize(a.bootContext())
677 if c.stopped || a.shuttingDown.Load() {
678 c.mu.Unlock()
679 return HistoricalImportStatus{Items: []HistoricalSessionView{}}, context.Canceled
680 }
681 if err := c.claimQueueLocked(); err != nil {
682 status := c.status()
683 c.mu.Unlock()
684 return status, err
685 }
686 startWorker := false
687 defer func() {
688 c.mu.Lock()
689 if !c.running {
690 c.releaseQueueLocked()
691 }
692 c.mu.Unlock()
693 }()
694 switch action {
695 case "pause":
696 c.paused = true
697 case "resume":
698 c.paused = false
699 if !c.running && (len(c.queue) > 0 || c.current != "") {
700 if c.current != "" {
701 c.queue = append([]string{c.current}, c.queue...)
702 c.current = ""
703 }
704 c.running, startWorker = true, true
705 }
706 select {
707 case c.wake <- struct{}{}:
708 default:
709 }
710 case "cancel":
711 c.queue = nil
712 c.current = ""
713 c.paused = false
714 for _, call := range c.calls {
715 call.batch = false
716 if !call.interactive {
717 call.cancel()
718 }
719 }
720 default:
721 status := c.status()
722 c.mu.Unlock()
723 return status, errors.New("unknown historical import action")
724 }
725 if err := c.saveQueueLocked(); err != nil {
726 if startWorker {
727 c.running = false
728 }
729 status := c.status()
730 c.mu.Unlock()
731 return status, err
732 }
733 status := c.status()
734 if startWorker {
735 c.workers.Add(1)
736 }
737 c.mu.Unlock()
738 if startWorker {
739 go a.runHistoricalImportQueue()
740 }
741 return status, nil
742 }
743
744 func (a *App) stopHistoricalImports() {
745 c := &a.historicalImports
746 c.mu.Lock()
747 c.initialize(a.bootContext())
748 c.stopped = true
749 c.cancel()
750 for _, call := range c.calls {
751 call.cancel()
752 }
753 c.mu.Unlock()
754 // Cancellation and draining happen before the runtime shutdown barrier.
755 c.workers.Wait()
756 }
757
758 func acquireHistoricalSource(ctx context.Context, id string, source historicalSource) (func(), error) {
759 if err := ctx.Err(); err != nil {
760 return nil, err
761 }
762 lockDir := filepath.Join(desktopConfigDir(), "desktop", "historical-import-locks")
763 if err := os.MkdirAll(lockDir, 0700); err != nil {
764 return nil, err
765 }
766 lockKey := desktopSourceKey(source.path, source.head)
767 release, err := identitylock.TryAcquire(filepath.Join(lockDir, lockKey+".lock"))
768 if err != nil {
769 return nil, err
770 }
771 if source.format != "canonical" {
772 return release, nil
773 }
774 ownership, err := identitylock.TryAcquireMode(filepath.Join(filepath.Dir(source.path), "."+filepath.Base(source.path)+".ownership.lock"), identitylock.ModeShared)
775 if err != nil {
776 release()
777 return nil, err
778 }
779 return func() { ownership(); release() }, nil
780 }
781
782 // Legacy recovery RPCs share cancellation/draining with the on-demand queue.
783 func (a *App) beginHistoricalRecovery() (context.Context, func(), error) {
784 c := &a.historicalImports
785 c.mu.Lock()
786 defer c.mu.Unlock()
787 c.initialize(a.bootContext())
788 if c.stopped || a.shuttingDown.Load() {
789 return nil, nil, context.Canceled
790 }
791 c.workers.Add(1)
792 return c.ctx, c.workers.Done, nil
793 }
794
794 lines GO