@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.
- package/CREDITS.md +27 -0
- package/LICENSE +23 -0
- package/README.md +148 -0
- package/extensions/ahp/access_options_test.go +116 -0
- package/extensions/ahp/extension.go +54 -0
- package/extensions/ahp/go.mod +12 -0
- package/extensions/ahp/go.sum +2 -0
- package/extensions/ahp/go.work +8 -0
- package/extensions/ahp/internal/channels/chat.go +110 -0
- package/extensions/ahp/internal/channels/root.go +32 -0
- package/extensions/ahp/internal/channels/session.go +128 -0
- package/extensions/ahp/internal/channels/session_test.go +108 -0
- package/extensions/ahp/internal/compose/compose.go +153 -0
- package/extensions/ahp/internal/compose/compose_test.go +120 -0
- package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
- package/extensions/ahp/internal/host/handshake_test.go +206 -0
- package/extensions/ahp/internal/host/host.go +1305 -0
- package/extensions/ahp/internal/host/reconnect_test.go +197 -0
- package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
- package/extensions/ahp/internal/host/schema_test.go +79 -0
- package/extensions/ahp/internal/host/store.go +291 -0
- package/extensions/ahp/internal/host/store_update_test.go +85 -0
- package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
- package/extensions/ahp/internal/host/surface_test.go +95 -0
- package/extensions/ahp/internal/host/workarounds.go +458 -0
- package/extensions/ahp/internal/host/workarounds_test.go +96 -0
- package/extensions/ahp/internal/live/live.go +328 -0
- package/extensions/ahp/internal/live/live_test.go +91 -0
- package/extensions/ahp/internal/live/store.go +113 -0
- package/extensions/ahp/internal/mapper/activity.go +199 -0
- package/extensions/ahp/internal/mapper/activity_test.go +188 -0
- package/extensions/ahp/internal/mapper/bench_test.go +38 -0
- package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
- package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
- package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
- package/extensions/ahp/internal/mapper/js.go +131 -0
- package/extensions/ahp/internal/mapper/mapper.go +600 -0
- package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
- package/extensions/ahp/internal/mapper/messageinput.go +272 -0
- package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
- package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
- package/extensions/ahp/internal/mapper/title.go +34 -0
- package/extensions/ahp/internal/mapper/usermsg.go +87 -0
- package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
- package/extensions/ahp/internal/pi/backend.go +64 -0
- package/extensions/ahp/internal/pi/catalogue.go +370 -0
- package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
- package/extensions/ahp/internal/pi/chatdriver.go +533 -0
- package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
- package/extensions/ahp/internal/pi/clientactions.go +212 -0
- package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
- package/extensions/ahp/internal/pi/completions.go +230 -0
- package/extensions/ahp/internal/pi/completions_test.go +272 -0
- package/extensions/ahp/internal/pi/config_test.go +269 -0
- package/extensions/ahp/internal/pi/deletesession.go +69 -0
- package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
- package/extensions/ahp/internal/pi/disposal_test.go +362 -0
- package/extensions/ahp/internal/pi/fixture_test.go +308 -0
- package/extensions/ahp/internal/pi/foreign_test.go +122 -0
- package/extensions/ahp/internal/pi/harness_test.go +83 -0
- package/extensions/ahp/internal/pi/history.go +269 -0
- package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
- package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
- package/extensions/ahp/internal/pi/hydration_test.go +209 -0
- package/extensions/ahp/internal/pi/hydrator.go +212 -0
- package/extensions/ahp/internal/pi/imageinput.go +224 -0
- package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
- package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
- package/extensions/ahp/internal/pi/models.go +212 -0
- package/extensions/ahp/internal/pi/models_test.go +128 -0
- package/extensions/ahp/internal/pi/paging.go +95 -0
- package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
- package/extensions/ahp/internal/pi/projecttrust.go +222 -0
- package/extensions/ahp/internal/pi/registry.go +924 -0
- package/extensions/ahp/internal/pi/restart_test.go +185 -0
- package/extensions/ahp/internal/pi/services.go +70 -0
- package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
- package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
- package/extensions/ahp/internal/pi/sessionstore.go +83 -0
- package/extensions/ahp/internal/pi/summary_test.go +426 -0
- package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
- package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
- package/extensions/ahp/internal/pisession/json.go +31 -0
- package/extensions/ahp/internal/pisession/pisession.go +580 -0
- package/extensions/ahp/internal/settings/access.go +63 -0
- package/extensions/ahp/internal/settings/access_test.go +52 -0
- package/extensions/ahp/internal/settings/settings.go +117 -0
- package/extensions/ahp/internal/settings/settings_test.go +104 -0
- package/extensions/ahp/internal/svc/etag_other.go +15 -0
- package/extensions/ahp/internal/svc/etag_unix.go +34 -0
- package/extensions/ahp/internal/svc/glob.go +197 -0
- package/extensions/ahp/internal/svc/mime.go +37 -0
- package/extensions/ahp/internal/svc/paths.go +108 -0
- package/extensions/ahp/internal/svc/pty.go +64 -0
- package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
- package/extensions/ahp/internal/svc/pty_linux.go +36 -0
- package/extensions/ahp/internal/svc/pty_other.go +9 -0
- package/extensions/ahp/internal/svc/pty_test.go +114 -0
- package/extensions/ahp/internal/svc/pty_unix.go +147 -0
- package/extensions/ahp/internal/svc/resource.go +495 -0
- package/extensions/ahp/internal/svc/resource_test.go +501 -0
- package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
- package/extensions/ahp/internal/svc/stat_linux.go +9 -0
- package/extensions/ahp/internal/svc/terminal.go +454 -0
- package/extensions/ahp/internal/svc/terminal_test.go +512 -0
- package/extensions/ahp/internal/svc/watch.go +590 -0
- package/extensions/ahp/internal/svc/watch_test.go +627 -0
- package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
- package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
- package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
- package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
- package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
- package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
- package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
- package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
- package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
- package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
- package/extensions/ahp/internal/testkit/schema.go +360 -0
- package/extensions/ahp/internal/testkit/testkit.go +367 -0
- package/extensions/ahp/internal/twin/twin.go +27 -0
- package/extensions/ahp/internal/wire/dispatchable.go +106 -0
- package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
- package/extensions/ahp/internal/wire/helpers_test.go +5 -0
- package/extensions/ahp/internal/wire/wire.go +218 -0
- package/extensions/ahp/internal/wire/wire_test.go +123 -0
- package/extensions/ahp/internal/ws/helpers_test.go +7 -0
- package/extensions/ahp/internal/ws/vscode_test.go +198 -0
- package/extensions/ahp/internal/ws/ws.go +674 -0
- package/extensions/ahp/internal/ws/ws_test.go +324 -0
- package/extensions/ahp/realpig_test.go +589 -0
- package/extensions/ahp/runtime.go +311 -0
- package/extensions/ahp/runtime_test.go +110 -0
- package/extensions/ahp/testdata/upstream-tests.json +3833 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
- package/extensions/ahp/twins_test.go +91 -0
- package/package.json +40 -0
- package/proof/PORT.md +121 -0
- package/proof/mutations.json +152 -0
- package/proof/oracle/LICENSE +21 -0
- package/proof/oracle/README.md +120 -0
- package/proof/oracle/UPSTREAM.md +7 -0
- package/proof/oracle/package.json +68 -0
- package/proof/oracle/src/bin/cli.ts +66 -0
- package/proof/oracle/src/bin/tunnel.ts +128 -0
- package/proof/oracle/src/channels/chat.ts +134 -0
- package/proof/oracle/src/channels/root.ts +39 -0
- package/proof/oracle/src/channels/session.ts +118 -0
- package/proof/oracle/src/channels/terminal.ts +11 -0
- package/proof/oracle/src/core/channels.ts +108 -0
- package/proof/oracle/src/core/client-workarounds.ts +358 -0
- package/proof/oracle/src/core/connection.ts +41 -0
- package/proof/oracle/src/core/host.ts +882 -0
- package/proof/oracle/src/core/sequencer.ts +75 -0
- package/proof/oracle/src/core/state-store.ts +162 -0
- package/proof/oracle/src/core/uri.ts +25 -0
- package/proof/oracle/src/host/direct-settings.ts +110 -0
- package/proof/oracle/src/host/pi-host.ts +220 -0
- package/proof/oracle/src/host/serve.ts +71 -0
- package/proof/oracle/src/host/terminal-service.ts +346 -0
- package/proof/oracle/src/pi/activity.ts +171 -0
- package/proof/oracle/src/pi/changeset-service.ts +646 -0
- package/proof/oracle/src/pi/changeset-uri.ts +64 -0
- package/proof/oracle/src/pi/chat-driver.ts +529 -0
- package/proof/oracle/src/pi/completions.ts +160 -0
- package/proof/oracle/src/pi/delete-session.ts +53 -0
- package/proof/oracle/src/pi/event-mapper.ts +648 -0
- package/proof/oracle/src/pi/git-changes.ts +605 -0
- package/proof/oracle/src/pi/history.ts +305 -0
- package/proof/oracle/src/pi/image-input.ts +56 -0
- package/proof/oracle/src/pi/image-mime.ts +8 -0
- package/proof/oracle/src/pi/in-process-backend.ts +155 -0
- package/proof/oracle/src/pi/message-input.ts +212 -0
- package/proof/oracle/src/pi/models.ts +124 -0
- package/proof/oracle/src/pi/project-trust.ts +66 -0
- package/proof/oracle/src/pi/provider.ts +2 -0
- package/proof/oracle/src/pi/resource-paths.ts +80 -0
- package/proof/oracle/src/pi/resource-service.ts +356 -0
- package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
- package/proof/oracle/src/pi/resource-watch.ts +357 -0
- package/proof/oracle/src/pi/session-catalogue.ts +318 -0
- package/proof/oracle/src/pi/session-config.ts +84 -0
- package/proof/oracle/src/pi/session-history.ts +75 -0
- package/proof/oracle/src/pi/session-hydrator.ts +219 -0
- package/proof/oracle/src/pi/session-registry.ts +815 -0
- package/proof/oracle/src/pi/session-storage.ts +29 -0
- package/proof/oracle/src/pi/session-title.ts +18 -0
- package/proof/oracle/src/pi/turn-paging.ts +94 -0
- package/proof/oracle/src/pi/user-message.ts +66 -0
- package/proof/oracle/src/protocol/errors.ts +44 -0
- package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
- package/proof/oracle/src/protocol/version.ts +39 -0
- package/proof/oracle/src/transport/websocket.ts +136 -0
- package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
- package/proof/oracle/src/tunnel/discovery.ts +37 -0
- package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
- package/proof/oracle/test/activity.test.ts +171 -0
- package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
- package/proof/oracle/test/changeset-uri.test.ts +37 -0
- package/proof/oracle/test/changeset.test.serial.ts +487 -0
- package/proof/oracle/test/chat-driver.test.ts +691 -0
- package/proof/oracle/test/client-actions.test.ts +409 -0
- package/proof/oracle/test/client-workarounds.test.ts +320 -0
- package/proof/oracle/test/completions.test.ts +323 -0
- package/proof/oracle/test/delete-session.test.ts +142 -0
- package/proof/oracle/test/direct-settings.test.ts +81 -0
- package/proof/oracle/test/event-mapper.test.ts +619 -0
- package/proof/oracle/test/fetch-turns.test.ts +207 -0
- package/proof/oracle/test/fixtures/abort.json +711 -0
- package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
- package/proof/oracle/test/fixtures/compaction.json +6285 -0
- package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
- package/proof/oracle/test/fixtures/plain-text.json +1856 -0
- package/proof/oracle/test/fixtures/single-tool.json +1128 -0
- package/proof/oracle/test/fixtures/steering.json +3926 -0
- package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
- package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
- package/proof/oracle/test/fixtures/tool-error.json +2850 -0
- package/proof/oracle/test/fixtures/tool-find.json +1162 -0
- package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
- package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
- package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
- package/proof/oracle/test/fixtures/tool-write.json +1271 -0
- package/proof/oracle/test/handshake.test.ts +275 -0
- package/proof/oracle/test/harness.ts +153 -0
- package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
- package/proof/oracle/test/image-input.test.ts +28 -0
- package/proof/oracle/test/image-session.test.ts +69 -0
- package/proof/oracle/test/live-turn.test.ts +304 -0
- package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
- package/proof/oracle/test/message-input.test.ts +181 -0
- package/proof/oracle/test/model-discovery.test.ts +170 -0
- package/proof/oracle/test/models.test.ts +109 -0
- package/proof/oracle/test/pi-host.test.ts +91 -0
- package/proof/oracle/test/pi-replay.test.ts +141 -0
- package/proof/oracle/test/project-trust.test.ts +125 -0
- package/proof/oracle/test/protocol-surface.test.ts +121 -0
- package/proof/oracle/test/pty.test.ts +100 -0
- package/proof/oracle/test/reconnect.test.ts +389 -0
- package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
- package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
- package/proof/oracle/test/resource.test.ts +499 -0
- package/proof/oracle/test/schema.test.ts +88 -0
- package/proof/oracle/test/session-catalogue.test.ts +314 -0
- package/proof/oracle/test/session-config.test.ts +224 -0
- package/proof/oracle/test/session-disposal.test.ts +364 -0
- package/proof/oracle/test/session-hydration.test.ts +194 -0
- package/proof/oracle/test/session-lifecycle.test.ts +416 -0
- package/proof/oracle/test/session-storage.test.serial.ts +32 -0
- package/proof/oracle/test/session-summary.test.ts +418 -0
- package/proof/oracle/test/subscriptions.test.ts +198 -0
- package/proof/oracle/test/support/assertions.ts +32 -0
- package/proof/oracle/test/support/async.ts +26 -0
- package/proof/oracle/test/support/hydrated-session.ts +205 -0
- package/proof/oracle/test/support/images.ts +8 -0
- package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
- package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
- package/proof/oracle/test/support/replay.ts +194 -0
- package/proof/oracle/test/support/schema.ts +221 -0
- package/proof/oracle/test/support/session-files.ts +12 -0
- package/proof/oracle/test/support/session-storage.ts +22 -0
- package/proof/oracle/test/support/upstream.ts +25 -0
- package/proof/oracle/test/support/watch-events.ts +108 -0
- package/proof/oracle/test/terminal-service.test.ts +495 -0
- package/proof/oracle/test/truncate.test.ts +230 -0
- package/proof/oracle/test/tunnel.test.ts +258 -0
- package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
- package/proof/oracle/test/uri.test.ts +24 -0
- package/proof/oracle/test/watch-events.test.ts +118 -0
- package/proof/oracle/tsconfig.json +32 -0
- package/proof/proof-piglet/piglet.yaml +18 -0
- package/proof/tools/fakellm/go.mod +3 -0
- package/proof/tools/fakellm/main.go +109 -0
- package/proof/tools/gen-dispatchable.py +30 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
// Package host is the AHP host: message routing, subscriptions, sequencing, broadcast and
|
|
2
|
+
// the authoritative channel state. It is a port of pi-ahp's src/core (host.ts, sequencer.ts,
|
|
3
|
+
// state-store.ts, connection.ts, client-workarounds.ts) and knows nothing about PiG.
|
|
4
|
+
//
|
|
5
|
+
// Concurrency: the host serializes its own state with one mutex. Handlers registered
|
|
6
|
+
// through Capabilities and listeners run outside it, so they may call back into the host.
|
|
7
|
+
package host
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"bytes"
|
|
11
|
+
"context"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"fmt"
|
|
15
|
+
"sync"
|
|
16
|
+
|
|
17
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
18
|
+
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Transport is one reliable, ordered, message-framed stream. Send must not block on a slow
|
|
23
|
+
// peer (the WebSocket transport queues and closes a peer that falls behind): the host
|
|
24
|
+
// broadcasts from inside its critical section.
|
|
25
|
+
type Transport interface {
|
|
26
|
+
Send(frame []byte) error
|
|
27
|
+
Close() error
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Options configure a Host.
|
|
31
|
+
type Options struct {
|
|
32
|
+
// ServerInfo is advertised on InitializeResult.serverInfo. Informational only.
|
|
33
|
+
ServerInfo *ahptypes.Implementation
|
|
34
|
+
// DefaultDirectory is the starting location for remote filesystem browsing, as a file: URI.
|
|
35
|
+
DefaultDirectory string
|
|
36
|
+
// CompletionTriggerCharacters make a client issue a completions request.
|
|
37
|
+
CompletionTriggerCharacters []string
|
|
38
|
+
// ReplayBufferCapacity bounds the replay buffer; 0 selects the default (1000).
|
|
39
|
+
ReplayBufferCapacity int
|
|
40
|
+
Log func(string)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Capabilities are the optional protocol surfaces. A request for an absent surface receives an
|
|
44
|
+
// empty result where meaningful and MethodNotFound otherwise.
|
|
45
|
+
type Capabilities struct {
|
|
46
|
+
Catalogue SessionCatalogue
|
|
47
|
+
Sessions SessionLifecycle
|
|
48
|
+
Terminals TerminalHandler
|
|
49
|
+
Resources ResourceHandler
|
|
50
|
+
ResourceWatches ResourceWatchHandler
|
|
51
|
+
Completions CompletionHandler
|
|
52
|
+
SessionConfig SessionConfigHandler
|
|
53
|
+
TurnPaging TurnPagingHandler
|
|
54
|
+
Hydrator ChannelHydrator
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SessionCatalogue supplies the session list behind listSessions.
|
|
58
|
+
type SessionCatalogue interface {
|
|
59
|
+
List(ctx context.Context, limit *int64, cursor *string) (ahptypes.ListSessionsResult, error)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SessionLifecycle handles createSession and disposeSession.
|
|
63
|
+
type SessionLifecycle interface {
|
|
64
|
+
Create(ctx context.Context, params ahptypes.CreateSessionParams) error
|
|
65
|
+
Dispose(ctx context.Context, channel string) error
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// TerminalHandler handles createTerminal and disposeTerminal.
|
|
69
|
+
type TerminalHandler interface {
|
|
70
|
+
Create(ctx context.Context, params ahptypes.CreateTerminalParams, clientID string) error
|
|
71
|
+
Dispose(ctx context.Context, channel string) error
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ResourceHandler serves the host side of the connection-level resource* family.
|
|
75
|
+
type ResourceHandler interface {
|
|
76
|
+
Read(ctx context.Context, p ahptypes.ResourceReadParams) (ahptypes.ResourceReadResult, error)
|
|
77
|
+
Write(ctx context.Context, p ahptypes.ResourceWriteParams) error
|
|
78
|
+
List(ctx context.Context, uri string) (ahptypes.ResourceListResult, error)
|
|
79
|
+
Resolve(ctx context.Context, p ahptypes.ResourceResolveParams) (ahptypes.ResourceResolveResult, error)
|
|
80
|
+
Mkdir(ctx context.Context, p ahptypes.ResourceMkdirParams) error
|
|
81
|
+
Delete(ctx context.Context, p ahptypes.ResourceDeleteParams) error
|
|
82
|
+
Move(ctx context.Context, p ahptypes.ResourceMoveParams) error
|
|
83
|
+
Copy(ctx context.Context, p ahptypes.ResourceCopyParams) error
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ResourceWatchHandler opens filesystem watchers.
|
|
87
|
+
type ResourceWatchHandler interface {
|
|
88
|
+
Create(ctx context.Context, p ahptypes.CreateResourceWatchParams) (ahptypes.CreateResourceWatchResult, error)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// CompletionHandler serves inline completions for a chat's message input.
|
|
92
|
+
type CompletionHandler interface {
|
|
93
|
+
Complete(ctx context.Context, p ahptypes.CompletionsParams) (ahptypes.CompletionsResult, error)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// SessionConfigHandler serves the pre-creation session configuration exchange.
|
|
97
|
+
type SessionConfigHandler interface {
|
|
98
|
+
Resolve(ctx context.Context, p ahptypes.ResolveSessionConfigParams) (ahptypes.ResolveSessionConfigResult, error)
|
|
99
|
+
Completions(ctx context.Context, p ahptypes.SessionConfigCompletionsParams) (ahptypes.SessionConfigCompletionsResult, error)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// TurnPagingHandler loads older turns into a chat; the page is dispatched before it returns.
|
|
103
|
+
type TurnPagingHandler interface {
|
|
104
|
+
FetchTurns(ctx context.Context, p ahptypes.FetchTurnsParams) error
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ChannelHydrator materializes a channel that exists durably but is not in memory.
|
|
108
|
+
type ChannelHydrator interface {
|
|
109
|
+
Hydrate(ctx context.Context, channel string) (bool, error)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ClientActionListener is a post-commit hook for actions a client dispatched.
|
|
113
|
+
type ClientActionListener func(channel string, action ahptypes.StateAction)
|
|
114
|
+
|
|
115
|
+
// CommittedActionListener observes every accepted action after it was reduced and broadcast.
|
|
116
|
+
type CommittedActionListener func(channel string, action ahptypes.StateAction)
|
|
117
|
+
|
|
118
|
+
// ClientActionValidator returns a reason to refuse a client action, or "" to accept it.
|
|
119
|
+
type ClientActionValidator func(channel string, action ClientAction, clientID string) string
|
|
120
|
+
|
|
121
|
+
// SubscriberCountListener is told when the number of clients subscribed to a channel changes.
|
|
122
|
+
type SubscriberCountListener func(channel string, count int)
|
|
123
|
+
|
|
124
|
+
// defaultReplayBufferCapacity is the local memory bound for replay. A client whose gap predates
|
|
125
|
+
// the buffer gets fresh snapshots instead, preserving correctness at the cost of more data.
|
|
126
|
+
const defaultReplayBufferCapacity = 1000
|
|
127
|
+
|
|
128
|
+
// ProviderScheme is the provider id of the agent this host advertises; VS Code derives its
|
|
129
|
+
// session URIs from it.
|
|
130
|
+
const ProviderScheme = "pi"
|
|
131
|
+
|
|
132
|
+
// Host is the AHP host.
|
|
133
|
+
type Host struct {
|
|
134
|
+
opts Options
|
|
135
|
+
store *Store
|
|
136
|
+
|
|
137
|
+
mu sync.Mutex // guards everything below except the event queue
|
|
138
|
+
seq int64
|
|
139
|
+
buf []ahptypes.ActionEnvelope
|
|
140
|
+
cap int
|
|
141
|
+
conns []*Conn // insertion order
|
|
142
|
+
// clientInfoByID: reconnect omits clientInfo; a present key with "" still records an id seen by this host process.
|
|
143
|
+
clientInfoByID map[string]string
|
|
144
|
+
caps Capabilities
|
|
145
|
+
|
|
146
|
+
listenMu sync.Mutex
|
|
147
|
+
actionListeners []*listener[ClientActionListener]
|
|
148
|
+
commitListeners []*listener[CommittedActionListener]
|
|
149
|
+
validators []*listener[ClientActionValidator]
|
|
150
|
+
countListeners []*listener[SubscriberCountListener]
|
|
151
|
+
|
|
152
|
+
evMu sync.Mutex
|
|
153
|
+
events []func()
|
|
154
|
+
draining bool
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type listener[T any] struct{ fn T }
|
|
158
|
+
|
|
159
|
+
// New returns a host with no channels; install the root channel before clients connect.
|
|
160
|
+
func New(opts Options) *Host {
|
|
161
|
+
c := opts.ReplayBufferCapacity
|
|
162
|
+
if c <= 0 {
|
|
163
|
+
c = defaultReplayBufferCapacity
|
|
164
|
+
}
|
|
165
|
+
return &Host{opts: opts, store: newStore(), cap: c, clientInfoByID: map[string]string{}}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Store returns the authoritative channel state.
|
|
169
|
+
func (h *Host) Store() *Store { return h.store }
|
|
170
|
+
|
|
171
|
+
// ServerSeq is the seq of the most recently emitted action envelope (0 before anything is emitted).
|
|
172
|
+
func (h *Host) ServerSeq() int64 {
|
|
173
|
+
h.mu.Lock()
|
|
174
|
+
defer h.mu.Unlock()
|
|
175
|
+
return h.seq
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Serve declares what this host serves; it merges into earlier declarations.
|
|
179
|
+
func (h *Host) Serve(c Capabilities) {
|
|
180
|
+
h.mu.Lock()
|
|
181
|
+
defer h.mu.Unlock()
|
|
182
|
+
if c.Catalogue != nil {
|
|
183
|
+
h.caps.Catalogue = c.Catalogue
|
|
184
|
+
}
|
|
185
|
+
if c.Sessions != nil {
|
|
186
|
+
h.caps.Sessions = c.Sessions
|
|
187
|
+
}
|
|
188
|
+
if c.Terminals != nil {
|
|
189
|
+
h.caps.Terminals = c.Terminals
|
|
190
|
+
}
|
|
191
|
+
if c.Resources != nil {
|
|
192
|
+
h.caps.Resources = c.Resources
|
|
193
|
+
}
|
|
194
|
+
if c.ResourceWatches != nil {
|
|
195
|
+
h.caps.ResourceWatches = c.ResourceWatches
|
|
196
|
+
}
|
|
197
|
+
if c.Completions != nil {
|
|
198
|
+
h.caps.Completions = c.Completions
|
|
199
|
+
}
|
|
200
|
+
if c.SessionConfig != nil {
|
|
201
|
+
h.caps.SessionConfig = c.SessionConfig
|
|
202
|
+
}
|
|
203
|
+
if c.TurnPaging != nil {
|
|
204
|
+
h.caps.TurnPaging = c.TurnPaging
|
|
205
|
+
}
|
|
206
|
+
if c.Hydrator != nil {
|
|
207
|
+
h.caps.Hydrator = c.Hydrator
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func (h *Host) capabilities() Capabilities {
|
|
212
|
+
h.mu.Lock()
|
|
213
|
+
defer h.mu.Unlock()
|
|
214
|
+
return h.caps
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func (h *Host) log(format string, args ...any) {
|
|
218
|
+
if h.opts.Log != nil {
|
|
219
|
+
h.opts.Log("[ahp-host] " + fmt.Sprintf(format, args...))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// InstallRootChannel registers ahp-root:// with the given agents.
|
|
224
|
+
func (h *Host) InstallRootChannel(agents []ahptypes.AgentInfo) {
|
|
225
|
+
if agents == nil {
|
|
226
|
+
agents = []ahptypes.AgentInfo{}
|
|
227
|
+
}
|
|
228
|
+
zero := int64(0)
|
|
229
|
+
_ = h.store.Create(wire.RootChannel, &ahptypes.RootState{Agents: agents, ActiveSessions: &zero})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── events (listeners run outside every lock, in commit order) ──────────
|
|
233
|
+
|
|
234
|
+
func (h *Host) emit(fn func()) {
|
|
235
|
+
h.evMu.Lock()
|
|
236
|
+
h.events = append(h.events, fn)
|
|
237
|
+
h.evMu.Unlock()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// drain runs queued listener events. A re-entrant call (a listener dispatching another action)
|
|
241
|
+
// only enqueues; the outer drain runs the derived event after the current one returns, so a
|
|
242
|
+
// derived action is always sequenced and observed after the action that caused it.
|
|
243
|
+
func (h *Host) drain() {
|
|
244
|
+
h.evMu.Lock()
|
|
245
|
+
if h.draining {
|
|
246
|
+
h.evMu.Unlock()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
h.draining = true
|
|
250
|
+
for len(h.events) > 0 {
|
|
251
|
+
fn := h.events[0]
|
|
252
|
+
h.events[0] = nil
|
|
253
|
+
h.events = h.events[1:]
|
|
254
|
+
h.evMu.Unlock()
|
|
255
|
+
func() {
|
|
256
|
+
defer func() {
|
|
257
|
+
if r := recover(); r != nil {
|
|
258
|
+
h.log("listener panicked: %v", r)
|
|
259
|
+
}
|
|
260
|
+
}()
|
|
261
|
+
fn()
|
|
262
|
+
}()
|
|
263
|
+
h.evMu.Lock()
|
|
264
|
+
}
|
|
265
|
+
h.draining = false
|
|
266
|
+
h.evMu.Unlock()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func add[T any](h *Host, list *[]*listener[T], fn T) func() {
|
|
270
|
+
l := &listener[T]{fn: fn}
|
|
271
|
+
h.listenMu.Lock()
|
|
272
|
+
*list = append(*list, l)
|
|
273
|
+
h.listenMu.Unlock()
|
|
274
|
+
return func() {
|
|
275
|
+
h.listenMu.Lock()
|
|
276
|
+
defer h.listenMu.Unlock()
|
|
277
|
+
for i, x := range *list {
|
|
278
|
+
if x == l {
|
|
279
|
+
*list = append((*list)[:i:i], (*list)[i+1:]...)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func snapshotListeners[T any](h *Host, list []*listener[T]) []T {
|
|
287
|
+
h.listenMu.Lock()
|
|
288
|
+
defer h.listenMu.Unlock()
|
|
289
|
+
out := make([]T, len(list))
|
|
290
|
+
for i, l := range list {
|
|
291
|
+
out[i] = l.fn
|
|
292
|
+
}
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// OnClientAction registers a post-commit hook for client-dispatched actions. Actions are applied and
|
|
297
|
+
// broadcast before the hook runs. Rejected actions never reach it.
|
|
298
|
+
func (h *Host) OnClientAction(l ClientActionListener) func() { return add(h, &h.actionListeners, l) }
|
|
299
|
+
|
|
300
|
+
// OnActionCommitted observes every accepted action after its envelope has been broadcast.
|
|
301
|
+
func (h *Host) OnActionCommitted(l CommittedActionListener) func() {
|
|
302
|
+
return add(h, &h.commitListeners, l)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// AddClientActionValidator registers a pre-commit check, needed whenever accepting an action would
|
|
306
|
+
// leave the host unable to carry it out.
|
|
307
|
+
func (h *Host) AddClientActionValidator(v ClientActionValidator) func() {
|
|
308
|
+
return add(h, &h.validators, v)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// OnSubscriberCountChanged observes changes to a channel's subscriber count.
|
|
312
|
+
func (h *Host) OnSubscriberCountChanged(l SubscriberCountListener) func() {
|
|
313
|
+
return add(h, &h.countListeners, l)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// SubscriberCount is how many connected clients are subscribed to a channel.
|
|
317
|
+
func (h *Host) SubscriberCount(channel string) int {
|
|
318
|
+
h.mu.Lock()
|
|
319
|
+
defer h.mu.Unlock()
|
|
320
|
+
return h.subscriberCountLocked(channel)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func (h *Host) subscriberCountLocked(channel string) int {
|
|
324
|
+
n := 0
|
|
325
|
+
for _, c := range h.conns {
|
|
326
|
+
if c.subscribed(channel) {
|
|
327
|
+
n++
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return n
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// notifyCountLocked queues a subscriber-count notification (run by drain, outside the lock).
|
|
334
|
+
func (h *Host) notifyCountLocked(channel string) {
|
|
335
|
+
count := h.subscriberCountLocked(channel)
|
|
336
|
+
for _, fn := range snapshotListeners(h, h.countListeners) {
|
|
337
|
+
fn := fn
|
|
338
|
+
h.emit(func() { fn(channel, count) })
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// DeleteChannel removes a channel and releases every connection subscribed to that identity.
|
|
343
|
+
func (h *Host) DeleteChannel(channel string) bool {
|
|
344
|
+
h.mu.Lock()
|
|
345
|
+
deleted := h.store.Delete(channel)
|
|
346
|
+
released := false
|
|
347
|
+
for _, c := range h.conns {
|
|
348
|
+
if c.unsubscribeLocked(channel) {
|
|
349
|
+
released = true
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if released {
|
|
353
|
+
h.notifyCountLocked(channel)
|
|
354
|
+
}
|
|
355
|
+
h.mu.Unlock()
|
|
356
|
+
h.drain()
|
|
357
|
+
return deleted
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── connections ─────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
// Conn is one accepted transport connection and its per-socket subscriptions.
|
|
363
|
+
type Conn struct {
|
|
364
|
+
h *Host
|
|
365
|
+
transport Transport
|
|
366
|
+
ctx context.Context
|
|
367
|
+
cancel context.CancelFunc
|
|
368
|
+
|
|
369
|
+
// guarded by h.mu
|
|
370
|
+
clientID string
|
|
371
|
+
subs []string
|
|
372
|
+
closed bool
|
|
373
|
+
|
|
374
|
+
workMu sync.Mutex // guards workarounds (used outside h.mu for incoming rewrites)
|
|
375
|
+
work *ClientWorkarounds
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Accept attaches a transport. The clientId is not known until initialize.
|
|
379
|
+
func (h *Host) Accept(t Transport) *Conn {
|
|
380
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
381
|
+
c := &Conn{h: h, transport: t, ctx: ctx, cancel: cancel, work: NewClientWorkarounds(ProviderScheme)}
|
|
382
|
+
h.mu.Lock()
|
|
383
|
+
h.conns = append(h.conns, c)
|
|
384
|
+
h.mu.Unlock()
|
|
385
|
+
return c
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func (c *Conn) subscribed(channel string) bool {
|
|
389
|
+
for _, s := range c.subs {
|
|
390
|
+
if s == channel {
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func (c *Conn) subscribeLocked(channel string) bool {
|
|
398
|
+
if c.subscribed(channel) {
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
c.subs = append(c.subs, channel)
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func (c *Conn) unsubscribeLocked(channel string) bool {
|
|
406
|
+
for i, s := range c.subs {
|
|
407
|
+
if s == channel {
|
|
408
|
+
c.subs = append(c.subs[:i:i], c.subs[i+1:]...)
|
|
409
|
+
return true
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Closed tells the host the transport went away; the connection's subscriptions are released just
|
|
416
|
+
// like explicit unsubscribes, since resources tied to them must not outlive the client.
|
|
417
|
+
func (c *Conn) Closed() {
|
|
418
|
+
h := c.h
|
|
419
|
+
h.mu.Lock()
|
|
420
|
+
if c.closed {
|
|
421
|
+
h.mu.Unlock()
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
c.closed = true
|
|
425
|
+
c.cancel()
|
|
426
|
+
h.removeConnLocked(c)
|
|
427
|
+
for _, channel := range c.subs {
|
|
428
|
+
h.notifyCountLocked(channel)
|
|
429
|
+
}
|
|
430
|
+
h.mu.Unlock()
|
|
431
|
+
h.drain()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func (h *Host) removeConnLocked(c *Conn) {
|
|
435
|
+
for i, x := range h.conns {
|
|
436
|
+
if x == c {
|
|
437
|
+
h.conns = append(h.conns[:i:i], h.conns[i+1:]...)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// sendLocked serializes and sends one message to this connection, applying its client workarounds.
|
|
444
|
+
func (c *Conn) sendLocked(msg any) {
|
|
445
|
+
if c.closed {
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
frame, err := json.Marshal(msg)
|
|
449
|
+
if err != nil {
|
|
450
|
+
c.h.log("cannot marshal outgoing message: %v", err)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
c.workMu.Lock()
|
|
454
|
+
active := c.work.Active()
|
|
455
|
+
c.workMu.Unlock()
|
|
456
|
+
if active {
|
|
457
|
+
var generic map[string]any
|
|
458
|
+
dec := json.NewDecoder(bytes.NewReader(frame))
|
|
459
|
+
dec.UseNumber()
|
|
460
|
+
if err := dec.Decode(&generic); err == nil {
|
|
461
|
+
c.workMu.Lock()
|
|
462
|
+
generic = c.work.ApplyToOutgoing(generic)
|
|
463
|
+
c.workMu.Unlock()
|
|
464
|
+
if rewritten, err := json.Marshal(generic); err == nil {
|
|
465
|
+
frame = rewritten
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if err := c.transport.Send(frame); err != nil {
|
|
470
|
+
c.h.log("send failed: %v", err)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── message routing ─────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
type request struct {
|
|
477
|
+
id json.RawMessage
|
|
478
|
+
method string
|
|
479
|
+
params map[string]any
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Receive handles one inbound text frame. Requests that may block run on their own goroutine so the
|
|
483
|
+
// connection's read loop stays live (a ping must always be answered); everything else runs inline,
|
|
484
|
+
// which keeps the order of a client's messages.
|
|
485
|
+
func (c *Conn) Receive(frame []byte) {
|
|
486
|
+
var msg map[string]any
|
|
487
|
+
dec := json.NewDecoder(bytes.NewReader(frame))
|
|
488
|
+
dec.UseNumber()
|
|
489
|
+
if err := dec.Decode(&msg); err != nil {
|
|
490
|
+
// A frame we cannot parse has no id, so there is nobody to answer.
|
|
491
|
+
c.h.log("dropping unparseable frame: %v", err)
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
method, hasMethod := msg["method"].(string)
|
|
495
|
+
_, hasID := msg["id"]
|
|
496
|
+
idNumber, idIsNumber := msg["id"].(json.Number)
|
|
497
|
+
switch {
|
|
498
|
+
case hasMethod && hasID && idIsNumber:
|
|
499
|
+
id := json.RawMessage(idNumber.String())
|
|
500
|
+
c.handleRequest(id, method, msg)
|
|
501
|
+
case hasMethod && !hasID:
|
|
502
|
+
c.handleNotification(method, msg)
|
|
503
|
+
default:
|
|
504
|
+
text := string(frame)
|
|
505
|
+
if len(text) > 200 {
|
|
506
|
+
text = text[:200]
|
|
507
|
+
}
|
|
508
|
+
c.h.log("ignoring unroutable message: %s", text)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
func (c *Conn) respond(id json.RawMessage, result any, err error) {
|
|
513
|
+
h := c.h
|
|
514
|
+
h.mu.Lock()
|
|
515
|
+
c.respondLocked(id, result, err)
|
|
516
|
+
h.mu.Unlock()
|
|
517
|
+
// Listener events queued while handling the request (subscriber counts) run once the response is sent.
|
|
518
|
+
h.drain()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
func (c *Conn) respondLocked(id json.RawMessage, result any, err error) {
|
|
522
|
+
if err != nil {
|
|
523
|
+
var pe *wire.Error
|
|
524
|
+
if !errors.As(err, &pe) {
|
|
525
|
+
pe = &wire.Error{Code: wire.CodeInternalError, Message: err.Error()}
|
|
526
|
+
}
|
|
527
|
+
c.h.log("request failed: %s", pe.Message)
|
|
528
|
+
e := map[string]any{"code": pe.Code, "message": pe.Message}
|
|
529
|
+
if pe.Data != nil {
|
|
530
|
+
e["data"] = pe.Data
|
|
531
|
+
}
|
|
532
|
+
c.sendLocked(map[string]any{"jsonrpc": "2.0", "id": id, "error": e})
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
if result == nil {
|
|
536
|
+
result = json.RawMessage("null")
|
|
537
|
+
}
|
|
538
|
+
c.sendLocked(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
func isRootCommand(method string) bool {
|
|
542
|
+
switch method {
|
|
543
|
+
case "initialize", "ping", "reconnect", "listSessions", "resourceRead", "resourceWrite", "resourceList",
|
|
544
|
+
"resourceCopy", "resourceDelete", "resourceMove", "resourceResolve", "resourceMkdir", "resourceRequest",
|
|
545
|
+
"createResourceWatch", "authenticate", "resolveSessionConfig", "sessionConfigCompletions", "listAutomationTriggerDefinitions":
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func (c *Conn) handleRequest(id json.RawMessage, method string, msg map[string]any) {
|
|
552
|
+
h := c.h
|
|
553
|
+
p, _ := msg["params"].(map[string]any)
|
|
554
|
+
|
|
555
|
+
// Handshake and pre-handshake policy.
|
|
556
|
+
h.mu.Lock()
|
|
557
|
+
initialized := c.clientID != ""
|
|
558
|
+
h.mu.Unlock()
|
|
559
|
+
handshake := method == "initialize" || method == "reconnect"
|
|
560
|
+
if handshake && initialized {
|
|
561
|
+
c.respond(id, nil, &wire.Error{Code: wire.CodeInvalidRequest, Message: "Connection is already initialized"})
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
if !handshake && method != "ping" && !initialized {
|
|
565
|
+
c.respond(id, nil, &wire.Error{Code: wire.CodeInvalidRequest, Message: "initialize or reconnect must be the first request"})
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
// Validate handshake shapes before any workaround observes them, so an invalid request cannot
|
|
569
|
+
// establish a URI dialect.
|
|
570
|
+
var initParams ahptypes.InitializeParams
|
|
571
|
+
var reconnectParams ahptypes.ReconnectParams
|
|
572
|
+
switch method {
|
|
573
|
+
case "initialize":
|
|
574
|
+
if err := decodeParams(p, &initParams); err != nil || initParams.ClientId == "" {
|
|
575
|
+
c.respond(id, nil, wire.InvalidParams(invalidMessage("initialize requires a clientId", err)))
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
case "reconnect":
|
|
579
|
+
// VS Code omits channel in reconnect (AHP 0.9 discriminant): repaired by the workaround
|
|
580
|
+
// below once VS Code is identified, so decode leniently first and validate after.
|
|
581
|
+
}
|
|
582
|
+
if err := c.applyWorkarounds(method, msg, p, initParams); err != nil {
|
|
583
|
+
c.respond(id, nil, err)
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
p, _ = msg["params"].(map[string]any)
|
|
587
|
+
if isRootCommand(method) {
|
|
588
|
+
if channel, _ := p["channel"].(string); channel != wire.RootChannel {
|
|
589
|
+
c.respond(id, nil, wire.InvalidParams(method+" requires channel "+wire.RootChannel))
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
switch method {
|
|
595
|
+
case "ping":
|
|
596
|
+
// Answered whether or not the client has completed initialize or holds any subscription,
|
|
597
|
+
// but like every connection-level command it must be routed on the root channel.
|
|
598
|
+
c.respond(id, nil, nil)
|
|
599
|
+
case "initialize":
|
|
600
|
+
// Re-decode: the workarounds may have rewritten initialSubscriptions.
|
|
601
|
+
initParams = ahptypes.InitializeParams{}
|
|
602
|
+
if err := decodeParams(p, &initParams); err != nil {
|
|
603
|
+
c.respond(id, nil, wire.InvalidParams(invalidMessage("invalid initialize params", err)))
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
if _, err := wire.NegotiateProtocolVersion(initParams.ProtocolVersions); err != nil {
|
|
607
|
+
c.respond(id, nil, err)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
c.runMaybeBlocking(id, c.needsHydration(initParams.InitialSubscriptions), func() (any, error) { return c.initialize(initParams) })
|
|
611
|
+
case "reconnect":
|
|
612
|
+
if err := decodeParams(p, &reconnectParams); err != nil || reconnectParams.ClientId == "" || reconnectParams.LastSeenServerSeq < 0 {
|
|
613
|
+
c.respond(id, nil, wire.InvalidParams(invalidMessage("reconnect requires a clientId, a non-negative integer lastSeenServerSeq and a subscriptions array of URIs", err)))
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
c.runMaybeBlocking(id, c.needsHydration(reconnectParams.Subscriptions), func() (any, error) { return c.reconnect(reconnectParams) })
|
|
617
|
+
case "subscribe":
|
|
618
|
+
var params ahptypes.SubscribeParams
|
|
619
|
+
if err := decodeParams(p, ¶ms); err != nil || params.Channel == "" {
|
|
620
|
+
c.respond(id, nil, wire.InvalidParams("subscribe requires a channel"))
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
c.runMaybeBlocking(id, c.needsHydration([]string{params.Channel}), func() (any, error) { return c.subscribe(params) })
|
|
624
|
+
default:
|
|
625
|
+
c.runBlocking(id, func() (any, error) { return c.dispatchOther(method, p) })
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
func invalidMessage(base string, err error) string {
|
|
630
|
+
if err == nil {
|
|
631
|
+
return base
|
|
632
|
+
}
|
|
633
|
+
return base + " (" + err.Error() + ")"
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
func decodeParams(p map[string]any, into any) error {
|
|
637
|
+
if p == nil {
|
|
638
|
+
return errors.New("params are required")
|
|
639
|
+
}
|
|
640
|
+
b, err := json.Marshal(p)
|
|
641
|
+
if err != nil {
|
|
642
|
+
return err
|
|
643
|
+
}
|
|
644
|
+
return json.Unmarshal(b, into)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// applyWorkarounds identifies the client and rewrites the request in place.
|
|
648
|
+
func (c *Conn) applyWorkarounds(method string, msg, p map[string]any, init ahptypes.InitializeParams) error {
|
|
649
|
+
h := c.h
|
|
650
|
+
c.workMu.Lock()
|
|
651
|
+
defer c.workMu.Unlock()
|
|
652
|
+
switch method {
|
|
653
|
+
case "initialize":
|
|
654
|
+
name := ""
|
|
655
|
+
if init.ClientInfo != nil {
|
|
656
|
+
name = init.ClientInfo.Name
|
|
657
|
+
}
|
|
658
|
+
if name == "" {
|
|
659
|
+
h.mu.Lock()
|
|
660
|
+
name = h.clientInfoByID[init.ClientId]
|
|
661
|
+
h.mu.Unlock()
|
|
662
|
+
}
|
|
663
|
+
c.work.Identify(name)
|
|
664
|
+
case "reconnect":
|
|
665
|
+
clientID, _ := p["clientId"].(string)
|
|
666
|
+
h.mu.Lock()
|
|
667
|
+
name := h.clientInfoByID[clientID]
|
|
668
|
+
h.mu.Unlock()
|
|
669
|
+
c.work.Identify(name)
|
|
670
|
+
}
|
|
671
|
+
if err := c.work.ApplyToIncoming(msg); err != nil {
|
|
672
|
+
return err
|
|
673
|
+
}
|
|
674
|
+
return nil
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// needsHydration reports whether a handshake or subscribe names a channel only the hydrator can supply.
|
|
678
|
+
func (c *Conn) needsHydration(channels []string) bool {
|
|
679
|
+
caps := c.h.capabilities()
|
|
680
|
+
if caps.Hydrator == nil {
|
|
681
|
+
return false
|
|
682
|
+
}
|
|
683
|
+
for _, ch := range channels {
|
|
684
|
+
if ch != wire.RootChannel && !c.h.store.Has(ch) {
|
|
685
|
+
return true
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return false
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
func (c *Conn) runMaybeBlocking(id json.RawMessage, blocking bool, fn func() (any, error)) {
|
|
692
|
+
if blocking {
|
|
693
|
+
c.runBlocking(id, fn)
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
result, err := fn()
|
|
697
|
+
c.respond(id, result, err)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
func (c *Conn) runBlocking(id json.RawMessage, fn func() (any, error)) {
|
|
701
|
+
go func() {
|
|
702
|
+
result, err := func() (result any, err error) {
|
|
703
|
+
defer func() {
|
|
704
|
+
if r := recover(); r != nil {
|
|
705
|
+
err = fmt.Errorf("internal error: %v", r)
|
|
706
|
+
}
|
|
707
|
+
}()
|
|
708
|
+
return fn()
|
|
709
|
+
}()
|
|
710
|
+
c.respond(id, result, err)
|
|
711
|
+
}()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
func (c *Conn) handleNotification(method string, msg map[string]any) {
|
|
715
|
+
h := c.h
|
|
716
|
+
h.mu.Lock()
|
|
717
|
+
initialized := c.clientID != ""
|
|
718
|
+
h.mu.Unlock()
|
|
719
|
+
if !initialized {
|
|
720
|
+
h.log("ignoring %s before initialize or reconnect", method)
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
c.workMu.Lock()
|
|
724
|
+
_ = c.work.ApplyToIncoming(msg)
|
|
725
|
+
c.workMu.Unlock()
|
|
726
|
+
p, _ := msg["params"].(map[string]any)
|
|
727
|
+
switch method {
|
|
728
|
+
case "unsubscribe":
|
|
729
|
+
channel, ok := p["channel"].(string)
|
|
730
|
+
if !ok {
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
h.mu.Lock()
|
|
734
|
+
if c.unsubscribeLocked(channel) {
|
|
735
|
+
h.notifyCountLocked(channel)
|
|
736
|
+
}
|
|
737
|
+
h.mu.Unlock()
|
|
738
|
+
h.drain()
|
|
739
|
+
case "dispatchAction":
|
|
740
|
+
c.dispatchClientAction(p)
|
|
741
|
+
default:
|
|
742
|
+
h.log("ignoring unknown notification: %s", method)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── handshake ───────────────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
func (c *Conn) hydrate(channels []string) {
|
|
749
|
+
caps := c.h.capabilities()
|
|
750
|
+
if caps.Hydrator == nil {
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
for _, ch := range channels {
|
|
754
|
+
if ch != wire.RootChannel && !c.h.store.Has(ch) {
|
|
755
|
+
if _, err := caps.Hydrator.Hydrate(c.ctx, ch); err != nil {
|
|
756
|
+
c.h.log("hydrate %s failed: %v", ch, err)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
func (c *Conn) initialize(p ahptypes.InitializeParams) (any, error) {
|
|
763
|
+
version, err := wire.NegotiateProtocolVersion(p.ProtocolVersions)
|
|
764
|
+
if err != nil {
|
|
765
|
+
return nil, err
|
|
766
|
+
}
|
|
767
|
+
h := c.h
|
|
768
|
+
// Same lazy load as subscribe: a client reconnecting with its previously-open sessions must get them back.
|
|
769
|
+
c.hydrate(p.InitialSubscriptions)
|
|
770
|
+
|
|
771
|
+
h.mu.Lock()
|
|
772
|
+
defer h.mu.Unlock()
|
|
773
|
+
if c.clientID != "" {
|
|
774
|
+
return nil, &wire.Error{Code: wire.CodeInvalidRequest, Message: "Connection is already initialized"}
|
|
775
|
+
}
|
|
776
|
+
h.bindClientLocked(c, p.ClientId)
|
|
777
|
+
name := ""
|
|
778
|
+
if p.ClientInfo != nil {
|
|
779
|
+
name = p.ClientInfo.Name
|
|
780
|
+
}
|
|
781
|
+
if name != "" || !h.knownClientLocked(p.ClientId) {
|
|
782
|
+
h.clientInfoByID[p.ClientId] = name
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
snapshots := []ahptypes.Snapshot{}
|
|
786
|
+
for _, uri := range p.InitialSubscriptions {
|
|
787
|
+
if snap, ok := h.trySubscribeLocked(c, uri); ok {
|
|
788
|
+
snapshots = append(snapshots, snap)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
result := ahptypes.InitializeResult{
|
|
792
|
+
ProtocolVersion: version,
|
|
793
|
+
ServerSeq: h.seq,
|
|
794
|
+
ServerInfo: h.opts.ServerInfo,
|
|
795
|
+
Snapshots: snapshots,
|
|
796
|
+
}
|
|
797
|
+
if h.opts.DefaultDirectory != "" {
|
|
798
|
+
d := h.opts.DefaultDirectory
|
|
799
|
+
result.DefaultDirectory = &d
|
|
800
|
+
}
|
|
801
|
+
if len(h.opts.CompletionTriggerCharacters) > 0 {
|
|
802
|
+
result.CompletionTriggerCharacters = append([]string(nil), h.opts.CompletionTriggerCharacters...)
|
|
803
|
+
}
|
|
804
|
+
return result, nil
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
func (h *Host) knownClientLocked(clientID string) bool {
|
|
808
|
+
_, ok := h.clientInfoByID[clientID]
|
|
809
|
+
return ok
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// bindClientLocked replaces a half-open socket when the same clientId reconnects.
|
|
813
|
+
func (h *Host) bindClientLocked(c *Conn, clientID string) {
|
|
814
|
+
for _, previous := range append([]*Conn(nil), h.conns...) {
|
|
815
|
+
if previous != c && previous.clientID == clientID {
|
|
816
|
+
h.removeConnLocked(previous)
|
|
817
|
+
for _, channel := range previous.subs {
|
|
818
|
+
h.notifyCountLocked(channel)
|
|
819
|
+
}
|
|
820
|
+
previous.subs = nil
|
|
821
|
+
previous.closed = true
|
|
822
|
+
previous.cancel()
|
|
823
|
+
go previous.transport.Close()
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
c.clientID = clientID
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
func (c *Conn) reconnect(p ahptypes.ReconnectParams) (any, error) {
|
|
830
|
+
h := c.h
|
|
831
|
+
c.hydrate(p.Subscriptions)
|
|
832
|
+
|
|
833
|
+
h.mu.Lock()
|
|
834
|
+
defer h.mu.Unlock()
|
|
835
|
+
if c.clientID != "" {
|
|
836
|
+
return nil, &wire.Error{Code: wire.CodeInvalidRequest, Message: "Connection is already initialized"}
|
|
837
|
+
}
|
|
838
|
+
known := h.knownClientLocked(p.ClientId)
|
|
839
|
+
if !known {
|
|
840
|
+
h.clientInfoByID[p.ClientId] = ""
|
|
841
|
+
}
|
|
842
|
+
h.bindClientLocked(c, p.ClientId)
|
|
843
|
+
|
|
844
|
+
missing := []string{}
|
|
845
|
+
previous := c.subs
|
|
846
|
+
c.subs = nil
|
|
847
|
+
for _, uri := range p.Subscriptions {
|
|
848
|
+
if uri == wire.RootChannel || h.store.Has(uri) {
|
|
849
|
+
c.subscribeLocked(uri)
|
|
850
|
+
} else {
|
|
851
|
+
missing = append(missing, uri)
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
for _, channel := range previous {
|
|
855
|
+
if !c.subscribed(channel) {
|
|
856
|
+
h.notifyCountLocked(channel)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for _, channel := range c.subs {
|
|
860
|
+
if !contains(previous, channel) {
|
|
861
|
+
h.notifyCountLocked(channel)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if known && h.canReplayFromLocked(p.LastSeenServerSeq) {
|
|
866
|
+
return reconnectReplay{Type: "replay", Actions: h.replayFromLocked(p.LastSeenServerSeq, c.subs), Missing: missing}, nil
|
|
867
|
+
}
|
|
868
|
+
// Replay is unavailable after buffer eviction or in a fresh host process. Durable subscriptions
|
|
869
|
+
// were hydrated above; return current snapshots.
|
|
870
|
+
snapshots := []ahptypes.Snapshot{}
|
|
871
|
+
for _, uri := range c.subs {
|
|
872
|
+
if snap, ok := h.store.Snapshot(uri, h.seq); ok {
|
|
873
|
+
snapshots = append(snapshots, snap)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return reconnectSnapshot{Type: "snapshot", Snapshots: snapshots}, nil
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// The vendored client's ReconnectReplayResult / ReconnectSnapshotResult carry no "type" discriminator
|
|
880
|
+
// on marshal (the union wrapper only marshals its value), which the wire format requires.
|
|
881
|
+
type reconnectReplay struct {
|
|
882
|
+
Type string `json:"type"`
|
|
883
|
+
Actions []ahptypes.ActionEnvelope `json:"actions"`
|
|
884
|
+
Missing []string `json:"missing"`
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
type reconnectSnapshot struct {
|
|
888
|
+
Type string `json:"type"`
|
|
889
|
+
Snapshots []ahptypes.Snapshot `json:"snapshots"`
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
func contains(list []string, s string) bool {
|
|
893
|
+
for _, x := range list {
|
|
894
|
+
if x == s {
|
|
895
|
+
return true
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return false
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// canReplayFromLocked reports whether the gap after lastSeen is fully covered by the buffer. A client
|
|
902
|
+
// that is already current trivially qualifies; otherwise the buffer must still hold lastSeen+1.
|
|
903
|
+
func (h *Host) canReplayFromLocked(lastSeen int64) bool {
|
|
904
|
+
if lastSeen >= h.seq {
|
|
905
|
+
return true
|
|
906
|
+
}
|
|
907
|
+
return len(h.buf) > 0 && h.buf[0].ServerSeq <= lastSeen+1
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
func (h *Host) replayFromLocked(lastSeen int64, channels []string) []ahptypes.ActionEnvelope {
|
|
911
|
+
out := []ahptypes.ActionEnvelope{}
|
|
912
|
+
for _, env := range h.buf {
|
|
913
|
+
if env.ServerSeq > lastSeen && contains(channels, env.Channel) {
|
|
914
|
+
out = append(out, env)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return out
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── subscriptions ───────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
func (c *Conn) subscribe(p ahptypes.SubscribeParams) (any, error) {
|
|
923
|
+
h := c.h
|
|
924
|
+
channel := p.Channel
|
|
925
|
+
if !h.store.Has(channel) {
|
|
926
|
+
// Not in memory does not mean it does not exist: a session from the catalogue lives on disk
|
|
927
|
+
// until someone opens it. The hydrator gets the first chance to resolve known aliases before
|
|
928
|
+
// the strict scheme check.
|
|
929
|
+
hydrated := false
|
|
930
|
+
if caps := h.capabilities(); caps.Hydrator != nil {
|
|
931
|
+
ok, err := caps.Hydrator.Hydrate(c.ctx, channel)
|
|
932
|
+
hydrated = err == nil && ok
|
|
933
|
+
}
|
|
934
|
+
if !hydrated && !h.store.Has(channel) {
|
|
935
|
+
if _, known := wire.KindOf(channel); !known {
|
|
936
|
+
return nil, wire.InvalidParams("Unsupported channel scheme: " + channel)
|
|
937
|
+
}
|
|
938
|
+
return nil, wire.NotFound(channel)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
h.mu.Lock()
|
|
942
|
+
defer h.mu.Unlock()
|
|
943
|
+
if snap, ok := h.trySubscribeLocked(c, channel); ok {
|
|
944
|
+
return ahptypes.SubscribeResult{Snapshot: &snap}, nil
|
|
945
|
+
}
|
|
946
|
+
return ahptypes.SubscribeResult{}, nil
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
func (h *Host) trySubscribeLocked(c *Conn, channel string) (ahptypes.Snapshot, bool) {
|
|
950
|
+
// Membership in the store is the real test: a session opened at a non-standard URI has no
|
|
951
|
+
// recognised scheme but is a perfectly valid channel.
|
|
952
|
+
snap, ok := h.store.Snapshot(channel, h.seq)
|
|
953
|
+
if !ok {
|
|
954
|
+
h.log("ignoring subscription to unknown channel: %s", channel)
|
|
955
|
+
return ahptypes.Snapshot{}, false
|
|
956
|
+
}
|
|
957
|
+
if c.subscribeLocked(channel) {
|
|
958
|
+
h.notifyCountLocked(channel)
|
|
959
|
+
}
|
|
960
|
+
return snap, true
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ── actions ─────────────────────────────────────────────────────────────
|
|
964
|
+
|
|
965
|
+
func (c *Conn) dispatchClientAction(p map[string]any) {
|
|
966
|
+
h := c.h
|
|
967
|
+
channel, okChannel := p["channel"].(string)
|
|
968
|
+
clientSeqNum, okSeq := p["clientSeq"].(json.Number)
|
|
969
|
+
rawAction, okAction := p["action"].(map[string]any)
|
|
970
|
+
actionType, okType := "", false
|
|
971
|
+
if okAction {
|
|
972
|
+
actionType, okType = rawAction["type"].(string)
|
|
973
|
+
}
|
|
974
|
+
if !okChannel || !okSeq || !okType {
|
|
975
|
+
h.log("ignoring malformed dispatchAction")
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
clientSeq, err := clientSeqNum.Int64()
|
|
979
|
+
if err != nil {
|
|
980
|
+
h.log("ignoring malformed dispatchAction")
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
// Spec: an action naming a channel that does not exist is silently ignored — no echo, no rejection.
|
|
984
|
+
if !h.store.Has(channel) {
|
|
985
|
+
h.log("ignoring action for unknown channel: %s", channel)
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
rawJSON, _ := json.Marshal(rawAction)
|
|
989
|
+
var action ahptypes.StateAction
|
|
990
|
+
decodeErr := json.Unmarshal(rawJSON, &action)
|
|
991
|
+
if action.Value == nil {
|
|
992
|
+
action = ahptypes.StateAction{Value: &ahptypes.StateActionUnknown{Raw: rawJSON}}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
c.h.mu.Lock()
|
|
996
|
+
clientID := c.clientID
|
|
997
|
+
c.h.mu.Unlock()
|
|
998
|
+
origin := &ahptypes.ActionOrigin{ClientId: clientID, ClientSeq: clientSeq}
|
|
999
|
+
|
|
1000
|
+
if !wire.IsClientDispatchable(actionType) {
|
|
1001
|
+
h.reject(channel, action, origin, "Action is not client-dispatchable: "+actionType)
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
if kind, ok := h.store.KindOf(channel); ok && !wire.ActionBelongsToChannel(actionType, kind) {
|
|
1005
|
+
h.reject(channel, action, origin, actionType+" does not belong on a "+string(kind)+" channel")
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
for _, validate := range snapshotListeners(h, h.validators) {
|
|
1009
|
+
if reason := validate(channel, ClientAction{Type: actionType, Raw: rawJSON, Action: action, DecodeErr: decodeErr}, clientID); reason != "" {
|
|
1010
|
+
h.reject(channel, action, origin, reason)
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if decodeErr != nil {
|
|
1015
|
+
h.reject(channel, action, origin, "Malformed action: "+decodeErr.Error())
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
h.commit(channel, action, origin)
|
|
1019
|
+
for _, fn := range snapshotListeners(h, h.actionListeners) {
|
|
1020
|
+
fn := fn
|
|
1021
|
+
h.emit(func() { fn(channel, action) })
|
|
1022
|
+
}
|
|
1023
|
+
h.drain()
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// DispatchServerAction applies a host-originated action and broadcasts it: the single write path
|
|
1027
|
+
// for everything the agent backend produces.
|
|
1028
|
+
func (h *Host) DispatchServerAction(channel string, action ahptypes.StateAction) {
|
|
1029
|
+
if !h.store.Has(channel) {
|
|
1030
|
+
h.log("dropping server action for unknown channel: %s", channel)
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
h.commit(channel, action, nil)
|
|
1034
|
+
h.drain()
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
func (h *Host) commit(channel string, action ahptypes.StateAction, origin *ahptypes.ActionOrigin) {
|
|
1038
|
+
h.mu.Lock()
|
|
1039
|
+
h.store.Apply(channel, action)
|
|
1040
|
+
h.seq++
|
|
1041
|
+
env := ahptypes.ActionEnvelope{Channel: channel, Action: action, ServerSeq: h.seq, Origin: origin}
|
|
1042
|
+
h.retainLocked(env)
|
|
1043
|
+
h.broadcastLocked(channel, "action", env)
|
|
1044
|
+
for _, fn := range snapshotListeners(h, h.commitListeners) {
|
|
1045
|
+
fn := fn
|
|
1046
|
+
h.emit(func() { fn(channel, action) })
|
|
1047
|
+
}
|
|
1048
|
+
h.mu.Unlock()
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// reject echoes a rejected action so the write-ahead client can roll it back.
|
|
1052
|
+
func (h *Host) reject(channel string, action ahptypes.StateAction, origin *ahptypes.ActionOrigin, reason string) {
|
|
1053
|
+
h.mu.Lock()
|
|
1054
|
+
h.seq++
|
|
1055
|
+
env := ahptypes.ActionEnvelope{Channel: channel, Action: action, ServerSeq: h.seq, Origin: origin, RejectionReason: &reason}
|
|
1056
|
+
h.retainLocked(env)
|
|
1057
|
+
h.broadcastLocked(channel, "action", env)
|
|
1058
|
+
h.mu.Unlock()
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
func (h *Host) retainLocked(env ahptypes.ActionEnvelope) {
|
|
1062
|
+
h.buf = append(h.buf, env)
|
|
1063
|
+
if len(h.buf) > h.cap {
|
|
1064
|
+
h.buf[0] = ahptypes.ActionEnvelope{}
|
|
1065
|
+
h.buf = h.buf[1:]
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
func (h *Host) broadcastLocked(channel, method string, params any) {
|
|
1070
|
+
for _, c := range append([]*Conn(nil), h.conns...) {
|
|
1071
|
+
if c.subscribed(channel) {
|
|
1072
|
+
c.sendLocked(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Notify emits a protocol notification (root/sessionAdded, auth/required, ...) to the channel's
|
|
1078
|
+
// subscribers. Notifications are ephemeral: not stored and not replayed on reconnect.
|
|
1079
|
+
func (h *Host) Notify(channel, method string, params map[string]any) {
|
|
1080
|
+
full := map[string]any{"channel": channel}
|
|
1081
|
+
for k, v := range params {
|
|
1082
|
+
full[k] = v
|
|
1083
|
+
}
|
|
1084
|
+
h.mu.Lock()
|
|
1085
|
+
h.broadcastLocked(channel, method, full)
|
|
1086
|
+
h.mu.Unlock()
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ClientAction is a client-dispatched action as a validator sees it: the raw JSON (so a validator
|
|
1090
|
+
// can report the precise malformed field), the typed action when it decoded, and the decode error.
|
|
1091
|
+
type ClientAction struct {
|
|
1092
|
+
Type string
|
|
1093
|
+
Raw json.RawMessage
|
|
1094
|
+
Action ahptypes.StateAction
|
|
1095
|
+
DecodeErr error
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ── other commands ──────────────────────────────────────────────────────
|
|
1099
|
+
|
|
1100
|
+
func (c *Conn) dispatchOther(method string, p map[string]any) (any, error) {
|
|
1101
|
+
h := c.h
|
|
1102
|
+
caps := h.capabilities()
|
|
1103
|
+
ctx := c.ctx
|
|
1104
|
+
switch method {
|
|
1105
|
+
case "listSessions":
|
|
1106
|
+
var params ahptypes.ListSessionsParams
|
|
1107
|
+
_ = decodeParams(p, ¶ms)
|
|
1108
|
+
if caps.Catalogue == nil {
|
|
1109
|
+
return ahptypes.ListSessionsResult{Items: []ahptypes.SessionSummary{}}, nil
|
|
1110
|
+
}
|
|
1111
|
+
res, err := caps.Catalogue.List(ctx, params.Limit, params.Cursor)
|
|
1112
|
+
if res.Items == nil {
|
|
1113
|
+
res.Items = []ahptypes.SessionSummary{}
|
|
1114
|
+
}
|
|
1115
|
+
return res, err
|
|
1116
|
+
case "createSession":
|
|
1117
|
+
if caps.Sessions == nil {
|
|
1118
|
+
return nil, wire.MethodNotFound("createSession")
|
|
1119
|
+
}
|
|
1120
|
+
var params ahptypes.CreateSessionParams
|
|
1121
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1122
|
+
return nil, wire.InvalidParams(invalidMessage("invalid createSession params", err))
|
|
1123
|
+
}
|
|
1124
|
+
return nil, caps.Sessions.Create(ctx, params)
|
|
1125
|
+
case "disposeSession":
|
|
1126
|
+
if caps.Sessions == nil {
|
|
1127
|
+
return nil, wire.MethodNotFound("disposeSession")
|
|
1128
|
+
}
|
|
1129
|
+
channel, ok := p["channel"].(string)
|
|
1130
|
+
if !ok || channel == "" {
|
|
1131
|
+
return nil, wire.InvalidParams("disposeSession requires a channel")
|
|
1132
|
+
}
|
|
1133
|
+
if err := h.assertCompatibleChannel(channel, wire.KindSession, "disposeSession"); err != nil {
|
|
1134
|
+
return nil, err
|
|
1135
|
+
}
|
|
1136
|
+
return nil, caps.Sessions.Dispose(ctx, channel)
|
|
1137
|
+
case "createTerminal":
|
|
1138
|
+
if caps.Terminals == nil {
|
|
1139
|
+
return nil, wire.MethodNotFound("createTerminal")
|
|
1140
|
+
}
|
|
1141
|
+
var params ahptypes.CreateTerminalParams
|
|
1142
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1143
|
+
return nil, wire.InvalidParams(invalidMessage("invalid createTerminal params", err))
|
|
1144
|
+
}
|
|
1145
|
+
h.mu.Lock()
|
|
1146
|
+
clientID := c.clientID
|
|
1147
|
+
h.mu.Unlock()
|
|
1148
|
+
return nil, caps.Terminals.Create(ctx, params, clientID)
|
|
1149
|
+
case "disposeTerminal":
|
|
1150
|
+
if caps.Terminals == nil {
|
|
1151
|
+
return nil, wire.MethodNotFound("disposeTerminal")
|
|
1152
|
+
}
|
|
1153
|
+
channel, ok := p["channel"].(string)
|
|
1154
|
+
if !ok || channel == "" {
|
|
1155
|
+
return nil, wire.InvalidParams("disposeTerminal requires a channel")
|
|
1156
|
+
}
|
|
1157
|
+
if err := h.assertCompatibleChannel(channel, wire.KindTerminal, "disposeTerminal"); err != nil {
|
|
1158
|
+
return nil, err
|
|
1159
|
+
}
|
|
1160
|
+
return nil, caps.Terminals.Dispose(ctx, channel)
|
|
1161
|
+
case "resourceRead", "resourceWrite", "resourceList", "resourceResolve", "resourceMkdir", "resourceDelete", "resourceMove", "resourceCopy":
|
|
1162
|
+
return c.resource(ctx, caps, method, p)
|
|
1163
|
+
case "resolveSessionConfig":
|
|
1164
|
+
// A client calls this before createSession; answering MethodNotFound stops it from getting as far as creating one.
|
|
1165
|
+
if caps.SessionConfig == nil {
|
|
1166
|
+
return ahptypes.ResolveSessionConfigResult{Schema: ahptypes.SessionConfigSchema{Type: "object", Properties: map[string]ahptypes.SessionConfigPropertySchema{}}, Values: map[string]json.RawMessage{}}, nil
|
|
1167
|
+
}
|
|
1168
|
+
var params ahptypes.ResolveSessionConfigParams
|
|
1169
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1170
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resolveSessionConfig params", err))
|
|
1171
|
+
}
|
|
1172
|
+
return caps.SessionConfig.Resolve(ctx, params)
|
|
1173
|
+
case "sessionConfigCompletions":
|
|
1174
|
+
if caps.SessionConfig == nil {
|
|
1175
|
+
return ahptypes.SessionConfigCompletionsResult{Items: []ahptypes.SessionConfigValueItem{}}, nil
|
|
1176
|
+
}
|
|
1177
|
+
var params ahptypes.SessionConfigCompletionsParams
|
|
1178
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1179
|
+
return nil, wire.InvalidParams(invalidMessage("invalid sessionConfigCompletions params", err))
|
|
1180
|
+
}
|
|
1181
|
+
return caps.SessionConfig.Completions(ctx, params)
|
|
1182
|
+
case "fetchTurns":
|
|
1183
|
+
// The result carries no turns: the host must dispatch chat/turnsLoaded before responding, so the
|
|
1184
|
+
// client's state already holds the page by the time this returns.
|
|
1185
|
+
if caps.TurnPaging == nil {
|
|
1186
|
+
return map[string]any{}, nil
|
|
1187
|
+
}
|
|
1188
|
+
if err := h.assertChatChannel(p, "fetchTurns"); err != nil {
|
|
1189
|
+
return nil, err
|
|
1190
|
+
}
|
|
1191
|
+
var params ahptypes.FetchTurnsParams
|
|
1192
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1193
|
+
return nil, wire.InvalidParams(invalidMessage("invalid fetchTurns params", err))
|
|
1194
|
+
}
|
|
1195
|
+
if err := caps.TurnPaging.FetchTurns(ctx, params); err != nil {
|
|
1196
|
+
return nil, err
|
|
1197
|
+
}
|
|
1198
|
+
return map[string]any{}, nil
|
|
1199
|
+
case "completions":
|
|
1200
|
+
// Best-effort by contract: a client debounces keystrokes into this, so an unconfigured host answers with nothing rather than an error.
|
|
1201
|
+
if caps.Completions == nil {
|
|
1202
|
+
return ahptypes.CompletionsResult{Items: []ahptypes.CompletionItem{}}, nil
|
|
1203
|
+
}
|
|
1204
|
+
if err := h.assertChatChannel(p, "completions"); err != nil {
|
|
1205
|
+
return nil, err
|
|
1206
|
+
}
|
|
1207
|
+
var params ahptypes.CompletionsParams
|
|
1208
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1209
|
+
return nil, wire.InvalidParams(invalidMessage("invalid completions params", err))
|
|
1210
|
+
}
|
|
1211
|
+
return caps.Completions.Complete(ctx, params)
|
|
1212
|
+
case "createResourceWatch":
|
|
1213
|
+
if caps.ResourceWatches == nil {
|
|
1214
|
+
return nil, wire.MethodNotFound("createResourceWatch")
|
|
1215
|
+
}
|
|
1216
|
+
var params ahptypes.CreateResourceWatchParams
|
|
1217
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1218
|
+
return nil, wire.InvalidParams(invalidMessage("invalid createResourceWatch params", err))
|
|
1219
|
+
}
|
|
1220
|
+
return caps.ResourceWatches.Create(ctx, params)
|
|
1221
|
+
case "resourceRequest":
|
|
1222
|
+
// No per-resource grants are tracked: a client that reaches this endpoint already holds the
|
|
1223
|
+
// token and can start a session, so a grant ledger here would imply a boundary that does not
|
|
1224
|
+
// exist. The receiver may still refuse individual operations.
|
|
1225
|
+
return map[string]any{}, nil
|
|
1226
|
+
}
|
|
1227
|
+
return nil, wire.MethodNotFound(method)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
func (h *Host) assertCompatibleChannel(channel string, expected wire.ChannelKind, method string) error {
|
|
1231
|
+
if actual, ok := h.store.KindOf(channel); ok && actual != expected {
|
|
1232
|
+
return wire.InvalidParams(method + " cannot target a " + string(actual) + " channel")
|
|
1233
|
+
}
|
|
1234
|
+
return nil
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
func (h *Host) assertChatChannel(p map[string]any, method string) error {
|
|
1238
|
+
channel, ok := p["channel"].(string)
|
|
1239
|
+
if !ok || channel == "" {
|
|
1240
|
+
return wire.InvalidParams(method + " requires a channel")
|
|
1241
|
+
}
|
|
1242
|
+
kind, known := h.store.KindOf(channel)
|
|
1243
|
+
if !known {
|
|
1244
|
+
kind, known = wire.KindOf(channel)
|
|
1245
|
+
}
|
|
1246
|
+
if !known || kind != wire.KindChat {
|
|
1247
|
+
return wire.InvalidParams(method + " requires a chat channel")
|
|
1248
|
+
}
|
|
1249
|
+
return nil
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
func (c *Conn) resource(ctx context.Context, caps Capabilities, method string, p map[string]any) (any, error) {
|
|
1253
|
+
if caps.Resources == nil {
|
|
1254
|
+
return nil, wire.MethodNotFound("resource*")
|
|
1255
|
+
}
|
|
1256
|
+
r := caps.Resources
|
|
1257
|
+
switch method {
|
|
1258
|
+
case "resourceRead":
|
|
1259
|
+
var params ahptypes.ResourceReadParams
|
|
1260
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1261
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceRead params", err))
|
|
1262
|
+
}
|
|
1263
|
+
return r.Read(ctx, params)
|
|
1264
|
+
case "resourceWrite":
|
|
1265
|
+
var params ahptypes.ResourceWriteParams
|
|
1266
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1267
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceWrite params", err))
|
|
1268
|
+
}
|
|
1269
|
+
return map[string]any{}, r.Write(ctx, params)
|
|
1270
|
+
case "resourceList":
|
|
1271
|
+
uri, _ := p["uri"].(string)
|
|
1272
|
+
return r.List(ctx, uri)
|
|
1273
|
+
case "resourceResolve":
|
|
1274
|
+
var params ahptypes.ResourceResolveParams
|
|
1275
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1276
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceResolve params", err))
|
|
1277
|
+
}
|
|
1278
|
+
return r.Resolve(ctx, params)
|
|
1279
|
+
case "resourceMkdir":
|
|
1280
|
+
var params ahptypes.ResourceMkdirParams
|
|
1281
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1282
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceMkdir params", err))
|
|
1283
|
+
}
|
|
1284
|
+
return map[string]any{}, r.Mkdir(ctx, params)
|
|
1285
|
+
case "resourceDelete":
|
|
1286
|
+
var params ahptypes.ResourceDeleteParams
|
|
1287
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1288
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceDelete params", err))
|
|
1289
|
+
}
|
|
1290
|
+
return map[string]any{}, r.Delete(ctx, params)
|
|
1291
|
+
case "resourceMove":
|
|
1292
|
+
var params ahptypes.ResourceMoveParams
|
|
1293
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1294
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceMove params", err))
|
|
1295
|
+
}
|
|
1296
|
+
return map[string]any{}, r.Move(ctx, params)
|
|
1297
|
+
case "resourceCopy":
|
|
1298
|
+
var params ahptypes.ResourceCopyParams
|
|
1299
|
+
if err := decodeParams(p, ¶ms); err != nil {
|
|
1300
|
+
return nil, wire.InvalidParams(invalidMessage("invalid resourceCopy params", err))
|
|
1301
|
+
}
|
|
1302
|
+
return map[string]any{}, r.Copy(ctx, params)
|
|
1303
|
+
}
|
|
1304
|
+
return nil, wire.MethodNotFound(method)
|
|
1305
|
+
}
|