返回 DeepSeek-Reasonix
catalog_watch_service.go
根目录 / internal / skill / catalog_watch_service.go
1 package skill
2
3 import (
4 "context"
5 "crypto/sha256"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10
11 "reasonix/internal/skill/skillwatch"
12 )
13
14 // WatchService is the host-lifetime shared physical watcher.
15 type WatchService = skillwatch.Service
16
17 type hostWatchState struct {
18 service *skillwatch.Service
19 subs []*skillwatch.Subscription
20 active bool
21 }
22
23 // The store derives discovery scopes and owns logical subscriptions. Direct
24 // mutations still invalidate synchronously instead of relying on events.
25
26 // watchScopeDirectories lists the directories discovery can visit under root
27 // for maxDepth levels: dot directories and discovery-skipped bodies
28 // (assets/node_modules/references/scripts) are excluded so content churn
29 // there cannot trigger catalog rebuilds, while nested skill entries stay
30 // covered. For a missing root it subscribes only the nearest existing
31 // ancestor — probing one missing path segment — so creating the root later
32 // still invalidates the snapshot. Symlink targets are traversed once.
33 func watchScopeDirectories(ctx context.Context, root string, maxDepth int) ([]string, bool) {
34 root = filepath.Clean(root)
35 probe := root
36 for {
37 if ctx.Err() != nil {
38 return nil, false
39 }
40 info, err := os.Stat(probe)
41 if err == nil && info.IsDir() {
42 break
43 }
44 parent := filepath.Dir(probe)
45 if parent == probe {
46 return nil, true
47 }
48 probe = parent
49 }
50 if probe != root {
51 // Missing root: watch only the next existing path segment upward.
52 return []string{probe}, true
53 }
54 type pendingDir struct {
55 path string
56 depth int
57 }
58 pending := []pendingDir{{path: root, depth: 0}}
59 seen := map[string]bool{}
60 var out []string
61 for len(pending) > 0 {
62 if ctx.Err() != nil {
63 return nil, false
64 }
65 current := pending[0]
66 pending = pending[1:]
67 resolved := current.path
68 if target, err := filepath.EvalSymlinks(current.path); err == nil {
69 resolved = filepath.Clean(target)
70 }
71 if seen[resolved] {
72 continue
73 }
74 seen[resolved] = true
75 info, err := os.Stat(current.path)
76 if err != nil || !info.IsDir() {
77 continue
78 }
79 out = append(out, current.path)
80 if resolved != current.path {
81 out = append(out, resolved)
82 }
83 if current.depth >= maxDepth {
84 continue
85 }
86 entries, err := os.ReadDir(current.path)
87 if err != nil {
88 continue
89 }
90 for _, entry := range entries {
91 if ctx.Err() != nil {
92 return nil, false
93 }
94 name := entry.Name()
95 if shouldSkipScanDir(name) {
96 continue
97 }
98 child := filepath.Join(current.path, name)
99 if entry.IsDir() {
100 pending = append(pending, pendingDir{path: child, depth: current.depth + 1})
101 continue
102 }
103 if entry.Type()&os.ModeSymlink != 0 {
104 if target, err := os.Stat(child); err == nil && target.IsDir() {
105 pending = append(pending, pendingDir{path: child, depth: current.depth + 1})
106 }
107 }
108 }
109 }
110 sort.Strings(out)
111 return out, true
112 }
113
114 // rootWatchHash summarizes one root's discovery-visible tree for the
115 // service's degraded-mode scan fallback. It hashes the same directory set the
116 // scope watches — entries contribute name, size, mtime and mode, matching the
117 // signature the retired Windows polling generation computed.
118 func rootWatchHash(ctx context.Context, root string, maxDepth int) ([sha256.Size]byte, int, bool) {
119 if ctx.Err() != nil {
120 return [sha256.Size]byte{}, 0, false
121 }
122 directories, ok := watchScopeDirectories(ctx, root, maxDepth)
123 if !ok {
124 return [sha256.Size]byte{}, 0, false
125 }
126 hash := sha256.New()
127 entries := 0
128 for _, dir := range directories {
129 if ctx.Err() != nil {
130 return [sha256.Size]byte{}, 0, false
131 }
132 list, err := os.ReadDir(dir)
133 _, _ = fmt.Fprintf(hash, "%s\x00%v\x00", dir, err)
134 for _, entry := range list {
135 if ctx.Err() != nil {
136 return [sha256.Size]byte{}, 0, false
137 }
138 info, statErr := entry.Info()
139 if statErr != nil {
140 _, _ = fmt.Fprintf(hash, "%s\x00%v\x00", entry.Name(), statErr)
141 continue
142 }
143 entries++
144 _, _ = fmt.Fprintf(hash, "%s\x00%d\x00%d\x00%d\x00", entry.Name(), info.Size(), info.ModTime().UnixNano(), info.Mode())
145 }
146 }
147 var sum [sha256.Size]byte
148 copy(sum[:], hash.Sum(nil))
149 return sum, entries, true
150 }
151
152 // subscribeHostWatch subscribes every discovery root to the shared service.
153 // Subscribe registers physical watches before returning, so the caller's first
154 // catalog scan cannot lose changes to a registration race.
155 func (s *Store) subscribeHostWatch() {
156 s.watcherMu.Lock()
157 if s.closed || s.hostWatch.active {
158 s.watcherMu.Unlock()
159 return
160 }
161 s.hostWatch.active = true
162 s.watcherMu.Unlock()
163 onChange := func(string) { s.Invalidate("filesystem changed") }
164 for _, root := range s.roots() {
165 sub := s.hostWatch.service.Subscribe(
166 root.Dir, s.maxDepth, watchScopeDirectories, rootWatchHash, onChange,
167 )
168 s.watcherMu.Lock()
169 s.hostWatch.subs = append(s.hostWatch.subs, sub)
170 s.watcherMu.Unlock()
171 }
172 }
173
174 // WatchDiagnostics exposes the shared service counters when this store watches
175 // through it. The boolean reports whether the service path is active.
176 func (s *Store) WatchDiagnostics() (skillwatch.Diagnostics, bool) {
177 if s == nil || s.hostWatch.service == nil {
178 return skillwatch.Diagnostics{}, false
179 }
180 return s.hostWatch.service.Diagnostics(), true
181 }
182
182 lines GO