@pi-in-go/pigpen-ahp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/CREDITS.md +27 -0
  2. package/LICENSE +23 -0
  3. package/README.md +148 -0
  4. package/extensions/ahp/access_options_test.go +116 -0
  5. package/extensions/ahp/extension.go +54 -0
  6. package/extensions/ahp/go.mod +12 -0
  7. package/extensions/ahp/go.sum +2 -0
  8. package/extensions/ahp/go.work +8 -0
  9. package/extensions/ahp/internal/channels/chat.go +110 -0
  10. package/extensions/ahp/internal/channels/root.go +32 -0
  11. package/extensions/ahp/internal/channels/session.go +128 -0
  12. package/extensions/ahp/internal/channels/session_test.go +108 -0
  13. package/extensions/ahp/internal/compose/compose.go +153 -0
  14. package/extensions/ahp/internal/compose/compose_test.go +120 -0
  15. package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
  16. package/extensions/ahp/internal/host/handshake_test.go +206 -0
  17. package/extensions/ahp/internal/host/host.go +1305 -0
  18. package/extensions/ahp/internal/host/reconnect_test.go +197 -0
  19. package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
  20. package/extensions/ahp/internal/host/schema_test.go +79 -0
  21. package/extensions/ahp/internal/host/store.go +291 -0
  22. package/extensions/ahp/internal/host/store_update_test.go +85 -0
  23. package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
  24. package/extensions/ahp/internal/host/surface_test.go +95 -0
  25. package/extensions/ahp/internal/host/workarounds.go +458 -0
  26. package/extensions/ahp/internal/host/workarounds_test.go +96 -0
  27. package/extensions/ahp/internal/live/live.go +328 -0
  28. package/extensions/ahp/internal/live/live_test.go +91 -0
  29. package/extensions/ahp/internal/live/store.go +113 -0
  30. package/extensions/ahp/internal/mapper/activity.go +199 -0
  31. package/extensions/ahp/internal/mapper/activity_test.go +188 -0
  32. package/extensions/ahp/internal/mapper/bench_test.go +38 -0
  33. package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
  34. package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
  35. package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
  36. package/extensions/ahp/internal/mapper/js.go +131 -0
  37. package/extensions/ahp/internal/mapper/mapper.go +600 -0
  38. package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
  39. package/extensions/ahp/internal/mapper/messageinput.go +272 -0
  40. package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
  41. package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
  42. package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
  43. package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
  44. package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
  45. package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
  46. package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
  47. package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
  48. package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
  49. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
  50. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
  51. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
  52. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
  53. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
  54. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
  55. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
  56. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
  57. package/extensions/ahp/internal/mapper/title.go +34 -0
  58. package/extensions/ahp/internal/mapper/usermsg.go +87 -0
  59. package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
  60. package/extensions/ahp/internal/pi/backend.go +64 -0
  61. package/extensions/ahp/internal/pi/catalogue.go +370 -0
  62. package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
  63. package/extensions/ahp/internal/pi/chatdriver.go +533 -0
  64. package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
  65. package/extensions/ahp/internal/pi/clientactions.go +212 -0
  66. package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
  67. package/extensions/ahp/internal/pi/completions.go +230 -0
  68. package/extensions/ahp/internal/pi/completions_test.go +272 -0
  69. package/extensions/ahp/internal/pi/config_test.go +269 -0
  70. package/extensions/ahp/internal/pi/deletesession.go +69 -0
  71. package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
  72. package/extensions/ahp/internal/pi/disposal_test.go +362 -0
  73. package/extensions/ahp/internal/pi/fixture_test.go +308 -0
  74. package/extensions/ahp/internal/pi/foreign_test.go +122 -0
  75. package/extensions/ahp/internal/pi/harness_test.go +83 -0
  76. package/extensions/ahp/internal/pi/history.go +269 -0
  77. package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
  78. package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
  79. package/extensions/ahp/internal/pi/hydration_test.go +209 -0
  80. package/extensions/ahp/internal/pi/hydrator.go +212 -0
  81. package/extensions/ahp/internal/pi/imageinput.go +224 -0
  82. package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
  83. package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
  84. package/extensions/ahp/internal/pi/models.go +212 -0
  85. package/extensions/ahp/internal/pi/models_test.go +128 -0
  86. package/extensions/ahp/internal/pi/paging.go +95 -0
  87. package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
  88. package/extensions/ahp/internal/pi/projecttrust.go +222 -0
  89. package/extensions/ahp/internal/pi/registry.go +924 -0
  90. package/extensions/ahp/internal/pi/restart_test.go +185 -0
  91. package/extensions/ahp/internal/pi/services.go +70 -0
  92. package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
  93. package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
  94. package/extensions/ahp/internal/pi/sessionstore.go +83 -0
  95. package/extensions/ahp/internal/pi/summary_test.go +426 -0
  96. package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
  97. package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
  98. package/extensions/ahp/internal/pisession/json.go +31 -0
  99. package/extensions/ahp/internal/pisession/pisession.go +580 -0
  100. package/extensions/ahp/internal/settings/access.go +63 -0
  101. package/extensions/ahp/internal/settings/access_test.go +52 -0
  102. package/extensions/ahp/internal/settings/settings.go +117 -0
  103. package/extensions/ahp/internal/settings/settings_test.go +104 -0
  104. package/extensions/ahp/internal/svc/etag_other.go +15 -0
  105. package/extensions/ahp/internal/svc/etag_unix.go +34 -0
  106. package/extensions/ahp/internal/svc/glob.go +197 -0
  107. package/extensions/ahp/internal/svc/mime.go +37 -0
  108. package/extensions/ahp/internal/svc/paths.go +108 -0
  109. package/extensions/ahp/internal/svc/pty.go +64 -0
  110. package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
  111. package/extensions/ahp/internal/svc/pty_linux.go +36 -0
  112. package/extensions/ahp/internal/svc/pty_other.go +9 -0
  113. package/extensions/ahp/internal/svc/pty_test.go +114 -0
  114. package/extensions/ahp/internal/svc/pty_unix.go +147 -0
  115. package/extensions/ahp/internal/svc/resource.go +495 -0
  116. package/extensions/ahp/internal/svc/resource_test.go +501 -0
  117. package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
  118. package/extensions/ahp/internal/svc/stat_linux.go +9 -0
  119. package/extensions/ahp/internal/svc/terminal.go +454 -0
  120. package/extensions/ahp/internal/svc/terminal_test.go +512 -0
  121. package/extensions/ahp/internal/svc/watch.go +590 -0
  122. package/extensions/ahp/internal/svc/watch_test.go +627 -0
  123. package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
  124. package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
  125. package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
  126. package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
  127. package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
  128. package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
  129. package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
  130. package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
  131. package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
  132. package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
  133. package/extensions/ahp/internal/testkit/schema.go +360 -0
  134. package/extensions/ahp/internal/testkit/testkit.go +367 -0
  135. package/extensions/ahp/internal/twin/twin.go +27 -0
  136. package/extensions/ahp/internal/wire/dispatchable.go +106 -0
  137. package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
  138. package/extensions/ahp/internal/wire/helpers_test.go +5 -0
  139. package/extensions/ahp/internal/wire/wire.go +218 -0
  140. package/extensions/ahp/internal/wire/wire_test.go +123 -0
  141. package/extensions/ahp/internal/ws/helpers_test.go +7 -0
  142. package/extensions/ahp/internal/ws/vscode_test.go +198 -0
  143. package/extensions/ahp/internal/ws/ws.go +674 -0
  144. package/extensions/ahp/internal/ws/ws_test.go +324 -0
  145. package/extensions/ahp/realpig_test.go +589 -0
  146. package/extensions/ahp/runtime.go +311 -0
  147. package/extensions/ahp/runtime_test.go +110 -0
  148. package/extensions/ahp/testdata/upstream-tests.json +3833 -0
  149. package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
  150. package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
  151. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
  152. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
  153. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
  154. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
  155. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
  156. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
  157. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
  158. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
  159. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
  160. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
  161. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
  162. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
  163. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
  164. package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
  165. package/extensions/ahp/twins_test.go +91 -0
  166. package/package.json +40 -0
  167. package/proof/PORT.md +121 -0
  168. package/proof/mutations.json +152 -0
  169. package/proof/oracle/LICENSE +21 -0
  170. package/proof/oracle/README.md +120 -0
  171. package/proof/oracle/UPSTREAM.md +7 -0
  172. package/proof/oracle/package.json +68 -0
  173. package/proof/oracle/src/bin/cli.ts +66 -0
  174. package/proof/oracle/src/bin/tunnel.ts +128 -0
  175. package/proof/oracle/src/channels/chat.ts +134 -0
  176. package/proof/oracle/src/channels/root.ts +39 -0
  177. package/proof/oracle/src/channels/session.ts +118 -0
  178. package/proof/oracle/src/channels/terminal.ts +11 -0
  179. package/proof/oracle/src/core/channels.ts +108 -0
  180. package/proof/oracle/src/core/client-workarounds.ts +358 -0
  181. package/proof/oracle/src/core/connection.ts +41 -0
  182. package/proof/oracle/src/core/host.ts +882 -0
  183. package/proof/oracle/src/core/sequencer.ts +75 -0
  184. package/proof/oracle/src/core/state-store.ts +162 -0
  185. package/proof/oracle/src/core/uri.ts +25 -0
  186. package/proof/oracle/src/host/direct-settings.ts +110 -0
  187. package/proof/oracle/src/host/pi-host.ts +220 -0
  188. package/proof/oracle/src/host/serve.ts +71 -0
  189. package/proof/oracle/src/host/terminal-service.ts +346 -0
  190. package/proof/oracle/src/pi/activity.ts +171 -0
  191. package/proof/oracle/src/pi/changeset-service.ts +646 -0
  192. package/proof/oracle/src/pi/changeset-uri.ts +64 -0
  193. package/proof/oracle/src/pi/chat-driver.ts +529 -0
  194. package/proof/oracle/src/pi/completions.ts +160 -0
  195. package/proof/oracle/src/pi/delete-session.ts +53 -0
  196. package/proof/oracle/src/pi/event-mapper.ts +648 -0
  197. package/proof/oracle/src/pi/git-changes.ts +605 -0
  198. package/proof/oracle/src/pi/history.ts +305 -0
  199. package/proof/oracle/src/pi/image-input.ts +56 -0
  200. package/proof/oracle/src/pi/image-mime.ts +8 -0
  201. package/proof/oracle/src/pi/in-process-backend.ts +155 -0
  202. package/proof/oracle/src/pi/message-input.ts +212 -0
  203. package/proof/oracle/src/pi/models.ts +124 -0
  204. package/proof/oracle/src/pi/project-trust.ts +66 -0
  205. package/proof/oracle/src/pi/provider.ts +2 -0
  206. package/proof/oracle/src/pi/resource-paths.ts +80 -0
  207. package/proof/oracle/src/pi/resource-service.ts +356 -0
  208. package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
  209. package/proof/oracle/src/pi/resource-watch.ts +357 -0
  210. package/proof/oracle/src/pi/session-catalogue.ts +318 -0
  211. package/proof/oracle/src/pi/session-config.ts +84 -0
  212. package/proof/oracle/src/pi/session-history.ts +75 -0
  213. package/proof/oracle/src/pi/session-hydrator.ts +219 -0
  214. package/proof/oracle/src/pi/session-registry.ts +815 -0
  215. package/proof/oracle/src/pi/session-storage.ts +29 -0
  216. package/proof/oracle/src/pi/session-title.ts +18 -0
  217. package/proof/oracle/src/pi/turn-paging.ts +94 -0
  218. package/proof/oracle/src/pi/user-message.ts +66 -0
  219. package/proof/oracle/src/protocol/errors.ts +44 -0
  220. package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
  221. package/proof/oracle/src/protocol/version.ts +39 -0
  222. package/proof/oracle/src/transport/websocket.ts +136 -0
  223. package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
  224. package/proof/oracle/src/tunnel/discovery.ts +37 -0
  225. package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
  226. package/proof/oracle/test/activity.test.ts +171 -0
  227. package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
  228. package/proof/oracle/test/changeset-uri.test.ts +37 -0
  229. package/proof/oracle/test/changeset.test.serial.ts +487 -0
  230. package/proof/oracle/test/chat-driver.test.ts +691 -0
  231. package/proof/oracle/test/client-actions.test.ts +409 -0
  232. package/proof/oracle/test/client-workarounds.test.ts +320 -0
  233. package/proof/oracle/test/completions.test.ts +323 -0
  234. package/proof/oracle/test/delete-session.test.ts +142 -0
  235. package/proof/oracle/test/direct-settings.test.ts +81 -0
  236. package/proof/oracle/test/event-mapper.test.ts +619 -0
  237. package/proof/oracle/test/fetch-turns.test.ts +207 -0
  238. package/proof/oracle/test/fixtures/abort.json +711 -0
  239. package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
  240. package/proof/oracle/test/fixtures/compaction.json +6285 -0
  241. package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
  242. package/proof/oracle/test/fixtures/plain-text.json +1856 -0
  243. package/proof/oracle/test/fixtures/single-tool.json +1128 -0
  244. package/proof/oracle/test/fixtures/steering.json +3926 -0
  245. package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
  246. package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
  247. package/proof/oracle/test/fixtures/tool-error.json +2850 -0
  248. package/proof/oracle/test/fixtures/tool-find.json +1162 -0
  249. package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
  250. package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
  251. package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
  252. package/proof/oracle/test/fixtures/tool-write.json +1271 -0
  253. package/proof/oracle/test/handshake.test.ts +275 -0
  254. package/proof/oracle/test/harness.ts +153 -0
  255. package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
  256. package/proof/oracle/test/image-input.test.ts +28 -0
  257. package/proof/oracle/test/image-session.test.ts +69 -0
  258. package/proof/oracle/test/live-turn.test.ts +304 -0
  259. package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
  260. package/proof/oracle/test/message-input.test.ts +181 -0
  261. package/proof/oracle/test/model-discovery.test.ts +170 -0
  262. package/proof/oracle/test/models.test.ts +109 -0
  263. package/proof/oracle/test/pi-host.test.ts +91 -0
  264. package/proof/oracle/test/pi-replay.test.ts +141 -0
  265. package/proof/oracle/test/project-trust.test.ts +125 -0
  266. package/proof/oracle/test/protocol-surface.test.ts +121 -0
  267. package/proof/oracle/test/pty.test.ts +100 -0
  268. package/proof/oracle/test/reconnect.test.ts +389 -0
  269. package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
  270. package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
  271. package/proof/oracle/test/resource.test.ts +499 -0
  272. package/proof/oracle/test/schema.test.ts +88 -0
  273. package/proof/oracle/test/session-catalogue.test.ts +314 -0
  274. package/proof/oracle/test/session-config.test.ts +224 -0
  275. package/proof/oracle/test/session-disposal.test.ts +364 -0
  276. package/proof/oracle/test/session-hydration.test.ts +194 -0
  277. package/proof/oracle/test/session-lifecycle.test.ts +416 -0
  278. package/proof/oracle/test/session-storage.test.serial.ts +32 -0
  279. package/proof/oracle/test/session-summary.test.ts +418 -0
  280. package/proof/oracle/test/subscriptions.test.ts +198 -0
  281. package/proof/oracle/test/support/assertions.ts +32 -0
  282. package/proof/oracle/test/support/async.ts +26 -0
  283. package/proof/oracle/test/support/hydrated-session.ts +205 -0
  284. package/proof/oracle/test/support/images.ts +8 -0
  285. package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
  286. package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
  287. package/proof/oracle/test/support/replay.ts +194 -0
  288. package/proof/oracle/test/support/schema.ts +221 -0
  289. package/proof/oracle/test/support/session-files.ts +12 -0
  290. package/proof/oracle/test/support/session-storage.ts +22 -0
  291. package/proof/oracle/test/support/upstream.ts +25 -0
  292. package/proof/oracle/test/support/watch-events.ts +108 -0
  293. package/proof/oracle/test/terminal-service.test.ts +495 -0
  294. package/proof/oracle/test/truncate.test.ts +230 -0
  295. package/proof/oracle/test/tunnel.test.ts +258 -0
  296. package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
  297. package/proof/oracle/test/uri.test.ts +24 -0
  298. package/proof/oracle/test/watch-events.test.ts +118 -0
  299. package/proof/oracle/tsconfig.json +32 -0
  300. package/proof/proof-piglet/piglet.yaml +18 -0
  301. package/proof/tools/fakellm/go.mod +3 -0
  302. package/proof/tools/fakellm/main.go +109 -0
  303. package/proof/tools/gen-dispatchable.py +30 -0
  304. package/provenance.json +28 -0
@@ -0,0 +1,590 @@
1
+ package svc
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "io/fs"
13
+ "os"
14
+ "path/filepath"
15
+ "sort"
16
+ "sync"
17
+ "syscall"
18
+ "time"
19
+
20
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
21
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
22
+ ahptypes "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
23
+ )
24
+
25
+ // Clock schedules the debounce and grace timers; tests drive it by hand.
26
+ type Clock interface {
27
+ AfterFunc(d time.Duration, f func()) Stopper
28
+ }
29
+
30
+ // Stopper cancels a scheduled function.
31
+ type Stopper interface{ Stop() bool }
32
+
33
+ type realClock struct{}
34
+
35
+ func (realClock) AfterFunc(d time.Duration, f func()) Stopper { return time.AfterFunc(d, f) }
36
+
37
+ // Defaults of the upstream service; the poll interval replaces chokidar's native events.
38
+ const (
39
+ DefaultGrace = 30 * time.Second
40
+ DefaultDebounce = 50 * time.Millisecond
41
+ DefaultPollInterval = time.Second
42
+ )
43
+
44
+ // WatchOptions configures a WatchService.
45
+ type WatchOptions struct {
46
+ Paths *PathPolicy
47
+ Grace time.Duration // 0 = DefaultGrace
48
+ Debounce time.Duration // 0 = DefaultDebounce
49
+ PollInterval time.Duration // 0 = DefaultPollInterval
50
+ Log func(string)
51
+ Clock Clock // nil = wall clock
52
+
53
+ // test hooks
54
+ afterPath func() // after the path policy resolved a create's URI
55
+ afterReady func() // after the first scan of a create
56
+ onClosed func() // each time a poller is stopped
57
+ }
58
+
59
+ // WatchService is the resource watch channel factory (port of resource-watch.ts). Upstream leans
60
+ // on chokidar's native events; the standard library has no portable file notification, so this
61
+ // port keeps a snapshot of the watched tree and diffs it every PollInterval. Everything above the
62
+ // watcher (policy, batching, grace, disposal) is the upstream design. It implements
63
+ // host.ResourceWatchHandler.
64
+ type WatchService struct {
65
+ host *host.Host
66
+ opts WatchOptions
67
+ paths *PathPolicy
68
+ clock Clock
69
+ unhook func()
70
+
71
+ mu sync.Mutex
72
+ watches map[string]*activeWatch
73
+ pending sync.WaitGroup
74
+ disposed bool
75
+ disposal chan struct{}
76
+ }
77
+
78
+ type activeWatch struct {
79
+ channel string
80
+ resourceRoot string
81
+ watchedRoot string
82
+ isDir bool
83
+ recursive bool
84
+ excludes []string
85
+ includes []string
86
+
87
+ stopPoll chan struct{}
88
+ pollDone chan struct{}
89
+
90
+ // guarded by WatchService.mu
91
+ changes map[string]ahptypes.ResourceChangeType
92
+ flushTimer Stopper
93
+ graceTimer Stopper
94
+ released bool
95
+ snapshot map[string]watchEntry
96
+ }
97
+
98
+ // NewWatchService creates the service and hooks it to the host's subscriber counts.
99
+ func NewWatchService(h *host.Host, opts WatchOptions) *WatchService {
100
+ s := &WatchService{host: h, opts: opts, paths: opts.Paths, clock: opts.Clock, watches: map[string]*activeWatch{}, disposal: make(chan struct{})}
101
+ if s.paths == nil {
102
+ s.paths = &PathPolicy{}
103
+ }
104
+ if s.clock == nil {
105
+ s.clock = realClock{}
106
+ }
107
+ s.unhook = h.OnSubscriberCountChanged(s.onSubscriberCount)
108
+ return s
109
+ }
110
+
111
+ func (s *WatchService) grace() time.Duration {
112
+ if s.opts.Grace > 0 {
113
+ return s.opts.Grace
114
+ }
115
+ return DefaultGrace
116
+ }
117
+
118
+ func (s *WatchService) debounce() time.Duration {
119
+ if s.opts.Debounce > 0 {
120
+ return s.opts.Debounce
121
+ }
122
+ return DefaultDebounce
123
+ }
124
+
125
+ func (s *WatchService) pollInterval() time.Duration {
126
+ if s.opts.PollInterval > 0 {
127
+ return s.opts.PollInterval
128
+ }
129
+ return DefaultPollInterval
130
+ }
131
+
132
+ func (s *WatchService) log(format string, args ...any) {
133
+ if s.opts.Log != nil {
134
+ s.opts.Log(fmt.Sprintf(format, args...))
135
+ }
136
+ }
137
+
138
+ // ActiveCount is how many watches are live.
139
+ func (s *WatchService) ActiveCount() int {
140
+ s.mu.Lock()
141
+ defer s.mu.Unlock()
142
+ return len(s.watches)
143
+ }
144
+
145
+ var errDisposed = errors.New("ResourceWatchService is disposed")
146
+
147
+ // Dispose stops accepting creations, drains the ones in flight and releases every watch. Every
148
+ // call returns the same channel, closed when shutdown completed.
149
+ func (s *WatchService) Dispose() <-chan struct{} {
150
+ s.mu.Lock()
151
+ if s.disposed {
152
+ s.mu.Unlock()
153
+ return s.disposal
154
+ }
155
+ s.disposed = true
156
+ channels := make([]string, 0, len(s.watches))
157
+ for channel := range s.watches {
158
+ channels = append(channels, channel)
159
+ }
160
+ s.mu.Unlock()
161
+ s.unhook()
162
+ go func() {
163
+ var wg sync.WaitGroup
164
+ for _, channel := range channels {
165
+ wg.Add(1)
166
+ go func() { defer wg.Done(); s.release(channel) }()
167
+ }
168
+ wg.Wait()
169
+ s.pending.Wait()
170
+ close(s.disposal)
171
+ }()
172
+ return s.disposal
173
+ }
174
+
175
+ func readPatterns(raw *json.RawMessage, name string) ([]string, error) {
176
+ if raw == nil {
177
+ return nil, nil
178
+ }
179
+ var wrapper map[string]json.RawMessage
180
+ if err := json.Unmarshal(*raw, &wrapper); err != nil || wrapper == nil {
181
+ return nil, wire.InvalidParams(name + " must contain an items array")
182
+ }
183
+ items, ok := wrapper["items"]
184
+ if !ok {
185
+ return nil, wire.InvalidParams(name + " must contain an items array")
186
+ }
187
+ var list []json.RawMessage
188
+ if err := json.Unmarshal(items, &list); err != nil || list == nil {
189
+ return nil, wire.InvalidParams(name + ".items must be an array of strings")
190
+ }
191
+ out := make([]string, 0, len(list))
192
+ for _, item := range list {
193
+ var str string
194
+ if err := json.Unmarshal(item, &str); err != nil {
195
+ return nil, wire.InvalidParams(name + ".items must be an array of strings")
196
+ }
197
+ out = append(out, str)
198
+ }
199
+ return out, nil
200
+ }
201
+
202
+ func watchError(err error, uri string) error {
203
+ var we *wire.Error
204
+ switch {
205
+ case errors.As(err, &we):
206
+ return err
207
+ case errors.Is(err, fs.ErrNotExist):
208
+ return wire.NotFound(uri)
209
+ case errors.Is(err, fs.ErrPermission):
210
+ return wire.Coded(wire.CodePermissionDenied, "Permission denied: "+uri)
211
+ case errors.Is(err, syscall.EINVAL), errors.Is(err, syscall.ENOTDIR):
212
+ return wire.InvalidParams("Cannot watch " + uri)
213
+ }
214
+ return wire.Coded(wire.CodeInternalError, "Cannot watch "+uri+": "+err.Error())
215
+ }
216
+
217
+ func newUUID() string {
218
+ var b [16]byte
219
+ _, _ = rand.Read(b[:])
220
+ b[6] = b[6]&0x0f | 0x40
221
+ b[8] = b[8]&0x3f | 0x80
222
+ h := hex.EncodeToString(b[:])
223
+ return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
224
+ }
225
+
226
+ // Create implements host.ResourceWatchHandler.
227
+ func (s *WatchService) Create(_ context.Context, params ahptypes.CreateResourceWatchParams) (ahptypes.CreateResourceWatchResult, error) {
228
+ s.mu.Lock()
229
+ if s.disposed {
230
+ s.mu.Unlock()
231
+ return ahptypes.CreateResourceWatchResult{}, errDisposed
232
+ }
233
+ s.pending.Add(1)
234
+ s.mu.Unlock()
235
+ defer s.pending.Done()
236
+ return s.create(params)
237
+ }
238
+
239
+ func (s *WatchService) isDisposed() bool {
240
+ s.mu.Lock()
241
+ defer s.mu.Unlock()
242
+ return s.disposed
243
+ }
244
+
245
+ func (s *WatchService) create(params ahptypes.CreateResourceWatchParams) (ahptypes.CreateResourceWatchResult, error) {
246
+ var zero ahptypes.CreateResourceWatchResult
247
+ recursive := params.Recursive != nil && *params.Recursive
248
+ excludes, err := readPatterns(params.Excludes, "excludes")
249
+ if err != nil {
250
+ return zero, err
251
+ }
252
+ includes, err := readPatterns(params.Includes, "includes")
253
+ if err != nil {
254
+ return zero, err
255
+ }
256
+ resourceRoot, err := s.paths.PathFor(params.Uri)
257
+ if err != nil {
258
+ return zero, err
259
+ }
260
+ if s.opts.afterPath != nil {
261
+ s.opts.afterPath()
262
+ }
263
+
264
+ info, err := os.Stat(resourceRoot)
265
+ if err != nil {
266
+ return zero, watchError(err, params.Uri)
267
+ }
268
+ isDir := info.IsDir()
269
+ if !isDir && recursive {
270
+ return zero, wire.InvalidParams("Cannot watch a file recursively: " + params.Uri)
271
+ }
272
+ watchedRoot, err := filepath.EvalSymlinks(resourceRoot)
273
+ if err != nil {
274
+ return zero, watchError(err, params.Uri)
275
+ }
276
+ if s.isDisposed() {
277
+ return zero, errDisposed
278
+ }
279
+
280
+ active := &activeWatch{
281
+ channel: wire.ResourceWatchScheme + "/" + newUUID(), resourceRoot: resourceRoot, watchedRoot: watchedRoot,
282
+ isDir: isDir, recursive: recursive, excludes: excludes, includes: includes,
283
+ changes: map[string]ahptypes.ResourceChangeType{}, stopPoll: make(chan struct{}), pollDone: make(chan struct{}),
284
+ }
285
+ active.snapshot = scanTree(active, nil)
286
+ if s.opts.afterReady != nil {
287
+ s.opts.afterReady()
288
+ }
289
+ if s.isDisposed() {
290
+ s.stopPoller(active)
291
+ return zero, errDisposed
292
+ }
293
+
294
+ watchState := ahptypes.ResourceWatchState{Root: params.Uri, Recursive: recursive}
295
+ if len(excludes) > 0 {
296
+ raw, _ := json.Marshal(map[string][]string{"items": excludes})
297
+ message := json.RawMessage(raw)
298
+ watchState.Excludes = &message
299
+ }
300
+ if len(includes) > 0 {
301
+ raw, _ := json.Marshal(map[string][]string{"items": includes})
302
+ message := json.RawMessage(raw)
303
+ watchState.Includes = &message
304
+ }
305
+ if err := s.host.Store().Create(active.channel, &watchState); err != nil {
306
+ s.stopPoller(active)
307
+ return zero, err
308
+ }
309
+ s.mu.Lock()
310
+ if s.disposed {
311
+ s.mu.Unlock()
312
+ s.stopPoller(active)
313
+ s.host.DeleteChannel(active.channel)
314
+ return zero, errDisposed
315
+ }
316
+ s.watches[active.channel] = active
317
+ s.startGraceLocked(active)
318
+ s.mu.Unlock()
319
+ go s.poll(active)
320
+ return ahptypes.CreateResourceWatchResult{Channel: active.channel}, nil
321
+ }
322
+
323
+ func (s *WatchService) stopPoller(active *activeWatch) {
324
+ select {
325
+ case <-active.stopPoll:
326
+ default:
327
+ close(active.stopPoll)
328
+ }
329
+ if s.opts.onClosed != nil {
330
+ s.opts.onClosed()
331
+ }
332
+ }
333
+
334
+ // ── polling ─────────────────────────────────────────────────────────────
335
+
336
+ type watchEntry struct {
337
+ dir bool
338
+ sig string
339
+ hash string // content digest kept for the next scan while the file is "racy", see racyWindow
340
+ cmp string // digest taken by this scan, for comparison with the previous scan's hash only
341
+ }
342
+
343
+ // racyWindow is git's "racy timestamp" problem: the kernel stamps a file with a coarse clock (a
344
+ // few milliseconds, whole seconds on some file systems), so a rewrite of equal size right after a
345
+ // scan leaves the stat signature unchanged. A file modified within this window of the scan is
346
+ // therefore also compared by content on the next scan. Larger files are compared by stat only.
347
+ const (
348
+ racyWindow = 2 * time.Second
349
+ racyMaxSize = 1 << 20
350
+ )
351
+
352
+ func contentHash(path string) string {
353
+ f, err := os.Open(path)
354
+ if err != nil {
355
+ return ""
356
+ }
357
+ defer f.Close()
358
+ h := sha256.New()
359
+ if _, err := io.Copy(h, io.LimitReader(f, racyMaxSize+1)); err != nil {
360
+ return ""
361
+ }
362
+ return hex.EncodeToString(h.Sum(nil))
363
+ }
364
+
365
+ // scanTree lists the watched scope: the root itself, and for a directory its children (all
366
+ // descendants when recursive). Excluded paths are pruned and symlinks are entries, never followed.
367
+ func scanTree(a *activeWatch, previous map[string]watchEntry) map[string]watchEntry {
368
+ scanned := time.Now()
369
+ out := map[string]watchEntry{}
370
+ info, err := os.Lstat(a.watchedRoot)
371
+ if err != nil {
372
+ return out
373
+ }
374
+ out[""] = entryOf(info, a.watchedRoot, "", previous, scanned)
375
+ if !info.IsDir() {
376
+ return out
377
+ }
378
+ var walk func(dir, rel string, depth int)
379
+ walk = func(dir, rel string, depth int) {
380
+ children, err := os.ReadDir(dir)
381
+ if err != nil {
382
+ return
383
+ }
384
+ for _, child := range children {
385
+ childRel := child.Name()
386
+ if rel != "" {
387
+ childRel = rel + "/" + child.Name()
388
+ }
389
+ if IsExcluded(childRel, a.excludes) {
390
+ continue
391
+ }
392
+ childInfo, err := child.Info()
393
+ if err != nil {
394
+ continue
395
+ }
396
+ out[childRel] = entryOf(childInfo, filepath.Join(dir, child.Name()), childRel, previous, scanned)
397
+ if childInfo.IsDir() && a.recursive {
398
+ walk(filepath.Join(dir, child.Name()), childRel, depth+1)
399
+ }
400
+ }
401
+ }
402
+ walk(a.watchedRoot, "", 1)
403
+ return out
404
+ }
405
+
406
+ func entryOf(info os.FileInfo, path, rel string, previous map[string]watchEntry, scanned time.Time) watchEntry {
407
+ if info.IsDir() {
408
+ return watchEntry{dir: true}
409
+ }
410
+ entry := watchEntry{sig: etagOf(info)}
411
+ if !info.Mode().IsRegular() || info.Size() > racyMaxSize {
412
+ return entry
413
+ }
414
+ old, seen := previous[rel]
415
+ recent := scanned.Sub(info.ModTime()) < racyWindow
416
+ // A previously hashed file with an unchanged stat is hashed once more: that scan is the one
417
+ // that shows whether the equal-looking stat hid a rewrite.
418
+ if recent || (seen && old.hash != "" && old.sig == entry.sig) {
419
+ entry.cmp = contentHash(path)
420
+ if recent {
421
+ entry.hash = entry.cmp
422
+ }
423
+ }
424
+ return entry
425
+ }
426
+
427
+ // changed reports whether a file entry differs from its earlier scan.
428
+ func (e watchEntry) changed(old watchEntry) bool {
429
+ if e.sig != old.sig {
430
+ return true
431
+ }
432
+ return e.cmp != "" && old.hash != "" && e.cmp != old.hash
433
+ }
434
+
435
+ func (s *WatchService) poll(a *activeWatch) {
436
+ defer close(a.pollDone)
437
+ ticker := time.NewTicker(s.pollInterval())
438
+ defer ticker.Stop()
439
+ for {
440
+ select {
441
+ case <-a.stopPoll:
442
+ return
443
+ case <-ticker.C:
444
+ }
445
+ previous := a.snapshot
446
+ next := scanTree(a, previous)
447
+ a.snapshot = next
448
+ for rel, entry := range next {
449
+ old, existed := previous[rel]
450
+ switch {
451
+ case !existed:
452
+ s.record(a, rel, ahptypes.ResourceChangeTypeAdded)
453
+ case old.dir != entry.dir:
454
+ s.record(a, rel, ahptypes.ResourceChangeTypeDeleted)
455
+ s.record(a, rel, ahptypes.ResourceChangeTypeAdded)
456
+ case !entry.dir && entry.changed(old):
457
+ s.record(a, rel, ahptypes.ResourceChangeTypeUpdated)
458
+ }
459
+ }
460
+ for rel := range previous {
461
+ if _, still := next[rel]; !still {
462
+ s.record(a, rel, ahptypes.ResourceChangeTypeDeleted)
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ func (s *WatchService) record(a *activeWatch, rel string, change ahptypes.ResourceChangeType) {
469
+ if !MatchesPatterns(rel, a.includes, a.excludes) {
470
+ return
471
+ }
472
+ path := a.resourceRoot
473
+ if rel != "" {
474
+ path = filepath.Join(a.resourceRoot, filepath.FromSlash(rel))
475
+ }
476
+ s.mu.Lock()
477
+ defer s.mu.Unlock()
478
+ if a.released {
479
+ return
480
+ }
481
+ merged := MergeChange(a.changes[path], change)
482
+ if merged == "" {
483
+ delete(a.changes, path)
484
+ } else {
485
+ a.changes[path] = merged
486
+ }
487
+ if len(a.changes) == 0 {
488
+ if a.flushTimer != nil {
489
+ a.flushTimer.Stop()
490
+ a.flushTimer = nil
491
+ }
492
+ return
493
+ }
494
+ if a.flushTimer != nil {
495
+ return
496
+ }
497
+ a.flushTimer = s.clock.AfterFunc(s.debounce(), func() {
498
+ s.mu.Lock()
499
+ a.flushTimer = nil
500
+ s.mu.Unlock()
501
+ s.flush(a)
502
+ })
503
+ }
504
+
505
+ func (s *WatchService) flush(a *activeWatch) {
506
+ s.mu.Lock()
507
+ if a.released || s.watches[a.channel] != a {
508
+ a.changes = map[string]ahptypes.ResourceChangeType{}
509
+ s.mu.Unlock()
510
+ return
511
+ }
512
+ paths := make([]string, 0, len(a.changes))
513
+ for path := range a.changes {
514
+ paths = append(paths, path)
515
+ }
516
+ sort.Slice(paths, func(i, j int) bool { return localeLess(paths[i], paths[j]) })
517
+ items := make([]ahptypes.ResourceChange, 0, len(paths))
518
+ for _, path := range paths {
519
+ items = append(items, ahptypes.ResourceChange{Uri: wire.PathToFileURI(path), Type: a.changes[path]})
520
+ }
521
+ a.changes = map[string]ahptypes.ResourceChangeType{}
522
+ s.mu.Unlock()
523
+ if len(items) == 0 {
524
+ return
525
+ }
526
+ raw, _ := json.Marshal(map[string]any{"items": items})
527
+ s.host.DispatchServerAction(a.channel, ahptypes.StateAction{Value: &ahptypes.ResourceWatchChangedAction{
528
+ Type: ahptypes.ActionTypeResourceWatchChanged, Changes: raw,
529
+ }})
530
+ }
531
+
532
+ // ── lifetime ────────────────────────────────────────────────────────────
533
+
534
+ func (s *WatchService) onSubscriberCount(channel string, count int) {
535
+ s.mu.Lock()
536
+ defer s.mu.Unlock()
537
+ a := s.watches[channel]
538
+ if a == nil {
539
+ return
540
+ }
541
+ if count > 0 {
542
+ if a.graceTimer != nil {
543
+ a.graceTimer.Stop()
544
+ a.graceTimer = nil
545
+ }
546
+ return
547
+ }
548
+ s.startGraceLocked(a)
549
+ }
550
+
551
+ func (s *WatchService) startGraceLocked(a *activeWatch) {
552
+ if a.graceTimer != nil {
553
+ return
554
+ }
555
+ var timer Stopper
556
+ timer = s.clock.AfterFunc(s.grace(), func() {
557
+ s.mu.Lock()
558
+ if a.graceTimer == timer {
559
+ a.graceTimer = nil
560
+ }
561
+ s.mu.Unlock()
562
+ if s.host.SubscriberCount(a.channel) == 0 {
563
+ s.log("releasing unwatched %s", a.channel)
564
+ s.release(a.channel)
565
+ }
566
+ })
567
+ a.graceTimer = timer
568
+ }
569
+
570
+ func (s *WatchService) release(channel string) {
571
+ s.mu.Lock()
572
+ a := s.watches[channel]
573
+ if a == nil {
574
+ s.mu.Unlock()
575
+ return
576
+ }
577
+ delete(s.watches, channel)
578
+ a.released = true
579
+ if a.flushTimer != nil {
580
+ a.flushTimer.Stop()
581
+ }
582
+ if a.graceTimer != nil {
583
+ a.graceTimer.Stop()
584
+ }
585
+ a.changes = map[string]ahptypes.ResourceChangeType{}
586
+ s.mu.Unlock()
587
+ s.host.DeleteChannel(channel)
588
+ s.stopPoller(a)
589
+ <-a.pollDone
590
+ }