@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,924 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"sync"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
12
|
+
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/channels"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Provider is the provider id this host advertises and accepts as a session URI alias
|
|
20
|
+
// (`pi:/<id>`).
|
|
21
|
+
const Provider = "pi"
|
|
22
|
+
|
|
23
|
+
// Live sessions: creation, disposal, adoption from disk, and routing client actions to whatever
|
|
24
|
+
// can carry them out (port of src/pi/session-registry.ts).
|
|
25
|
+
//
|
|
26
|
+
// The session URI's uuid is Pi's session id, so the two identity spaces are the same one and no
|
|
27
|
+
// mapping table is needed.
|
|
28
|
+
|
|
29
|
+
// LiveSession is everything the host tracks for a live session.
|
|
30
|
+
type LiveSession struct {
|
|
31
|
+
URI string
|
|
32
|
+
// SessionID is Pi's session id, identical to the uuid in URI.
|
|
33
|
+
SessionID string
|
|
34
|
+
WorkingDirectory string
|
|
35
|
+
SessionManager SessionStore
|
|
36
|
+
CreatedAt string
|
|
37
|
+
// ChatChannel is the session's single chat channel.
|
|
38
|
+
ChatChannel string
|
|
39
|
+
|
|
40
|
+
mu sync.Mutex
|
|
41
|
+
driver *ChatDriver
|
|
42
|
+
attaching chan struct{} // closed when the in-flight backend attach settles
|
|
43
|
+
turnAnchors map[string]string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Driver is the chat driver, nil until a backend is attached (a session read from disk has none).
|
|
47
|
+
func (s *LiveSession) Driver() *ChatDriver { s.mu.Lock(); defer s.mu.Unlock(); return s.driver }
|
|
48
|
+
|
|
49
|
+
// Attaching returns a channel closed when the in-flight backend attach settles, nil if none.
|
|
50
|
+
func (s *LiveSession) Attaching() <-chan struct{} {
|
|
51
|
+
s.mu.Lock()
|
|
52
|
+
defer s.mu.Unlock()
|
|
53
|
+
if s.attaching == nil {
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
return s.attaching
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Store implements [HistorySource].
|
|
60
|
+
func (s *LiveSession) Store() SessionStore { return s.SessionManager }
|
|
61
|
+
|
|
62
|
+
// ID implements [HistorySource].
|
|
63
|
+
func (s *LiveSession) ID() string { return s.SessionID }
|
|
64
|
+
|
|
65
|
+
// Anchor implements [HistorySource].
|
|
66
|
+
func (s *LiveSession) Anchor(turnID string) (string, bool) {
|
|
67
|
+
s.mu.Lock()
|
|
68
|
+
defer s.mu.Unlock()
|
|
69
|
+
a, ok := s.turnAnchors[turnID]
|
|
70
|
+
return a, ok
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (s *LiveSession) setAnchor(turnID, entryID string) {
|
|
74
|
+
s.mu.Lock()
|
|
75
|
+
s.turnAnchors[turnID] = entryID
|
|
76
|
+
s.mu.Unlock()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SetAnchors merges turn anchors (from a rebuild of the session file).
|
|
80
|
+
func (s *LiveSession) SetAnchors(anchors map[string]string) {
|
|
81
|
+
s.mu.Lock()
|
|
82
|
+
for k, v := range anchors {
|
|
83
|
+
s.turnAnchors[k] = v
|
|
84
|
+
}
|
|
85
|
+
s.mu.Unlock()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// BackendFactory creates the agent backend for a session. Absent in storage-only mode.
|
|
89
|
+
type BackendFactory func(session *LiveSession) (Backend, error)
|
|
90
|
+
|
|
91
|
+
// SessionFileDeletionResult is the outcome of deleting a session file.
|
|
92
|
+
type SessionFileDeletionResult struct {
|
|
93
|
+
OK bool
|
|
94
|
+
Error string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// LiveCatalogueEntry is a live summary that overrides or supplements Pi's on-disk catalogue.
|
|
98
|
+
type LiveCatalogueEntry struct {
|
|
99
|
+
Summary ahptypes.SessionSummary
|
|
100
|
+
File string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// RegistryOptions configure a [Registry].
|
|
104
|
+
type RegistryOptions struct {
|
|
105
|
+
Host *host.Host
|
|
106
|
+
// DefaultWorkingDirectory is used for sessions created without one of their own.
|
|
107
|
+
DefaultWorkingDirectory string
|
|
108
|
+
CreateBackend BackendFactory
|
|
109
|
+
// CreateSessionManager is the explicit storage boundary; composition chooses durable or
|
|
110
|
+
// in-memory sessions.
|
|
111
|
+
CreateSessionManager SessionManagerFactory
|
|
112
|
+
// DefaultSelection seeds a new chat's draft so a client has a model selected from the start.
|
|
113
|
+
DefaultSelection func() *ahptypes.ModelSelection
|
|
114
|
+
// DeleteFile: a failed result or error prevents protocol removal.
|
|
115
|
+
DeleteFile func(path string) (SessionFileDeletionResult, error)
|
|
116
|
+
// FindSessionFile locates the file behind a session this host never ran.
|
|
117
|
+
FindSessionFile func(sessionID string) (string, error)
|
|
118
|
+
// AdoptForeignTurns mirrors turns started outside AHP into the chat (see ChatDriverOptions).
|
|
119
|
+
AdoptForeignTurns bool
|
|
120
|
+
Log func(format string, args ...any)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Registry owns the live sessions.
|
|
124
|
+
type Registry struct {
|
|
125
|
+
opts RegistryOptions
|
|
126
|
+
host *host.Host
|
|
127
|
+
|
|
128
|
+
mu sync.Mutex
|
|
129
|
+
sessions map[string]*LiveSession
|
|
130
|
+
byChat map[string]*LiveSession
|
|
131
|
+
baselines map[string]ahptypes.SessionSummary // last root-catalogue value; a diff baseline, never authoritative
|
|
132
|
+
disposals map[string]*disposal
|
|
133
|
+
available []*func(*LiveSession)
|
|
134
|
+
deleted []*func(string) error
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type disposal struct {
|
|
138
|
+
done chan struct{}
|
|
139
|
+
err error
|
|
140
|
+
waiters int // requests coalesced onto this disposal
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// NewRegistry creates the registry and hooks it into the host.
|
|
144
|
+
func NewRegistry(opts RegistryOptions) *Registry {
|
|
145
|
+
r := &Registry{
|
|
146
|
+
opts: opts, host: opts.Host, sessions: map[string]*LiveSession{}, byChat: map[string]*LiveSession{},
|
|
147
|
+
baselines: map[string]ahptypes.SessionSummary{}, disposals: map[string]*disposal{},
|
|
148
|
+
}
|
|
149
|
+
// Client actions are routed to whichever channel owns them; the host core stays agnostic of
|
|
150
|
+
// chats and backends.
|
|
151
|
+
r.host.OnActionCommitted(r.onCommitted)
|
|
152
|
+
r.host.OnClientAction(r.routeClientAction)
|
|
153
|
+
r.host.AddClientActionValidator(func(channel string, action host.ClientAction, _ string) string {
|
|
154
|
+
return r.validateClientAction(channel, action)
|
|
155
|
+
})
|
|
156
|
+
return r
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (r *Registry) logf(format string, args ...any) {
|
|
160
|
+
if r.opts.Log != nil {
|
|
161
|
+
r.opts.Log(format, args...)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Lookup ──────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
// Get returns the live session at a session URI.
|
|
168
|
+
func (r *Registry) Get(uri string) (*LiveSession, bool) {
|
|
169
|
+
r.mu.Lock()
|
|
170
|
+
defer r.mu.Unlock()
|
|
171
|
+
s, ok := r.sessions[uri]
|
|
172
|
+
return s, ok
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// GetByChat returns the live session owning a chat channel.
|
|
176
|
+
func (r *Registry) GetByChat(chat string) (*LiveSession, bool) {
|
|
177
|
+
r.mu.Lock()
|
|
178
|
+
defer r.mu.Unlock()
|
|
179
|
+
s, ok := r.byChat[chat]
|
|
180
|
+
return s, ok
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Has reports whether a session is live.
|
|
184
|
+
func (r *Registry) Has(uri string) bool { _, ok := r.Get(uri); return ok }
|
|
185
|
+
|
|
186
|
+
// Count is the number of live sessions.
|
|
187
|
+
func (r *Registry) Count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.sessions) }
|
|
188
|
+
|
|
189
|
+
// IsDisposing reports whether a disposal is in flight.
|
|
190
|
+
func (r *Registry) IsDisposing(uri string) bool {
|
|
191
|
+
r.mu.Lock()
|
|
192
|
+
defer r.mu.Unlock()
|
|
193
|
+
_, ok := r.disposals[uri]
|
|
194
|
+
return ok
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// OnSessionAvailable observes sessions after their registry and protocol channels exist,
|
|
198
|
+
// including those that already do.
|
|
199
|
+
func (r *Registry) OnSessionAvailable(listener func(*LiveSession)) (remove func()) {
|
|
200
|
+
l := &listener
|
|
201
|
+
r.mu.Lock()
|
|
202
|
+
r.available = append(r.available, l)
|
|
203
|
+
existing := make([]*LiveSession, 0, len(r.sessions))
|
|
204
|
+
for _, s := range r.sessions {
|
|
205
|
+
existing = append(existing, s)
|
|
206
|
+
}
|
|
207
|
+
r.mu.Unlock()
|
|
208
|
+
for _, s := range existing {
|
|
209
|
+
r.notifyAvailable(*l, s)
|
|
210
|
+
}
|
|
211
|
+
return func() {
|
|
212
|
+
r.mu.Lock()
|
|
213
|
+
defer r.mu.Unlock()
|
|
214
|
+
for i, x := range r.available {
|
|
215
|
+
if x == l {
|
|
216
|
+
r.available = append(r.available[:i:i], r.available[i+1:]...)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// OnSessionDeletionCommitted runs after durable deletion commits, before protocol channels
|
|
224
|
+
// disappear.
|
|
225
|
+
func (r *Registry) OnSessionDeletionCommitted(listener func(sessionID string) error) (remove func()) {
|
|
226
|
+
l := &listener
|
|
227
|
+
r.mu.Lock()
|
|
228
|
+
r.deleted = append(r.deleted, l)
|
|
229
|
+
r.mu.Unlock()
|
|
230
|
+
return func() {
|
|
231
|
+
r.mu.Lock()
|
|
232
|
+
defer r.mu.Unlock()
|
|
233
|
+
for i, x := range r.deleted {
|
|
234
|
+
if x == l {
|
|
235
|
+
r.deleted = append(r.deleted[:i:i], r.deleted[i+1:]...)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// CatalogueOverrides are the live summaries that override or supplement Pi's on-disk catalogue.
|
|
243
|
+
func (r *Registry) CatalogueOverrides() []LiveCatalogueEntry {
|
|
244
|
+
r.mu.Lock()
|
|
245
|
+
live := make([]*LiveSession, 0, len(r.sessions))
|
|
246
|
+
for _, s := range r.sessions {
|
|
247
|
+
live = append(live, s)
|
|
248
|
+
}
|
|
249
|
+
r.mu.Unlock()
|
|
250
|
+
out := make([]LiveCatalogueEntry, 0, len(live))
|
|
251
|
+
for _, s := range live {
|
|
252
|
+
summary, err := r.summaryOf(s)
|
|
253
|
+
if err != nil {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
out = append(out, LiveCatalogueEntry{Summary: summary, File: s.SessionManager.File()})
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
|
|
264
|
+
|
|
265
|
+
// Create handles createSession. It returns as soon as the channel exists; readiness arrives later
|
|
266
|
+
// as a session/ready action on that channel.
|
|
267
|
+
func (r *Registry) Create(_ context.Context, params ahptypes.CreateSessionParams) error {
|
|
268
|
+
uri := params.Channel
|
|
269
|
+
// Only canonical and provider-alias URIs can be recovered after a host restart.
|
|
270
|
+
sessionID, ok := wire.SessionIDFromURI(uri, Provider)
|
|
271
|
+
if !ok {
|
|
272
|
+
return wire.InvalidParams("Not a session URI: " + uri)
|
|
273
|
+
}
|
|
274
|
+
r.mu.Lock()
|
|
275
|
+
_, disposing := r.disposals[uri]
|
|
276
|
+
_, live := r.sessions[uri]
|
|
277
|
+
r.mu.Unlock()
|
|
278
|
+
if disposing || live || r.host.Store().Has(uri) {
|
|
279
|
+
return wire.SessionAlreadyExists(uri)
|
|
280
|
+
}
|
|
281
|
+
if params.Provider != nil && *params.Provider != Provider {
|
|
282
|
+
return wire.ProviderNotFound(*params.Provider)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
workingDirectory := r.opts.DefaultWorkingDirectory
|
|
286
|
+
if len(params.WorkingDirectories) > 0 {
|
|
287
|
+
// A set in the protocol, but this host does not declare multipleWorkingDirectories, which
|
|
288
|
+
// is what forbids a client from sending more than one. Anything past the first is ignored
|
|
289
|
+
// rather than refused: the session still runs, in the directory asked for first.
|
|
290
|
+
path, err := wire.FileURIToPath(params.WorkingDirectories[0])
|
|
291
|
+
if err != nil {
|
|
292
|
+
return wire.InvalidParams(err.Error())
|
|
293
|
+
}
|
|
294
|
+
workingDirectory = path
|
|
295
|
+
}
|
|
296
|
+
if workingDirectory == "" {
|
|
297
|
+
workingDirectory = "."
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
title := mapper.NewSessionTitle
|
|
301
|
+
createdAt := nowISO()
|
|
302
|
+
// A provider-alias URI still needs the session reducer.
|
|
303
|
+
if err := r.host.Store().Create(uri, channels.InitialSessionState(Provider, title, workingDirectory), wire.KindSession); err != nil {
|
|
304
|
+
return err
|
|
305
|
+
}
|
|
306
|
+
store, err := r.opts.CreateSessionManager(workingDirectory, sessionID)
|
|
307
|
+
if err != nil {
|
|
308
|
+
r.host.DeleteChannel(uri)
|
|
309
|
+
return fmt.Errorf("could not create the session store: %w", err)
|
|
310
|
+
}
|
|
311
|
+
var selection *ahptypes.ModelSelection
|
|
312
|
+
if r.opts.DefaultSelection != nil {
|
|
313
|
+
selection = r.opts.DefaultSelection()
|
|
314
|
+
}
|
|
315
|
+
chat := channels.InstallDefaultChat(r.host, uri, sessionID, title, selection)
|
|
316
|
+
|
|
317
|
+
session := &LiveSession{
|
|
318
|
+
URI: uri, SessionID: sessionID, WorkingDirectory: workingDirectory, SessionManager: store,
|
|
319
|
+
ChatChannel: chat, CreatedAt: createdAt, turnAnchors: map[string]string{},
|
|
320
|
+
}
|
|
321
|
+
r.track(session)
|
|
322
|
+
r.notifyAvailableToAll(session)
|
|
323
|
+
|
|
324
|
+
summary, err := r.summaryOf(session)
|
|
325
|
+
if err == nil {
|
|
326
|
+
channels.NotifySessionAdded(r.host, summary)
|
|
327
|
+
r.setBaseline(uri, summary)
|
|
328
|
+
}
|
|
329
|
+
r.bumpActiveSessions()
|
|
330
|
+
|
|
331
|
+
// Readiness is genuinely asynchronous once a backend is involved: the client already holds
|
|
332
|
+
// the channel and sees lifecycle "creating" until the agent is up.
|
|
333
|
+
go r.attachBackend(session)
|
|
334
|
+
return nil
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// AdoptedSession describes a session loaded from disk.
|
|
338
|
+
type AdoptedSession struct {
|
|
339
|
+
URI, SessionID, WorkingDirectory, CreatedAt, ChatChannel string
|
|
340
|
+
SessionManager SessionStore
|
|
341
|
+
TurnAnchors map[string]string
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Adopt registers a session loaded from disk. Deliberately without a backend: a client browsing
|
|
345
|
+
// its history would otherwise start an agent for every session it looks at. The agent is attached
|
|
346
|
+
// on the first action that needs one.
|
|
347
|
+
func (r *Registry) Adopt(a AdoptedSession) *LiveSession {
|
|
348
|
+
r.mu.Lock()
|
|
349
|
+
if existing, ok := r.sessions[a.URI]; ok {
|
|
350
|
+
r.mu.Unlock()
|
|
351
|
+
return existing
|
|
352
|
+
}
|
|
353
|
+
r.mu.Unlock()
|
|
354
|
+
anchors := a.TurnAnchors
|
|
355
|
+
if anchors == nil {
|
|
356
|
+
anchors = map[string]string{}
|
|
357
|
+
}
|
|
358
|
+
s := &LiveSession{
|
|
359
|
+
URI: a.URI, SessionID: a.SessionID, WorkingDirectory: a.WorkingDirectory, SessionManager: a.SessionManager,
|
|
360
|
+
CreatedAt: a.CreatedAt, ChatChannel: a.ChatChannel, turnAnchors: anchors,
|
|
361
|
+
}
|
|
362
|
+
r.track(s)
|
|
363
|
+
r.notifyAvailableToAll(s)
|
|
364
|
+
if summary, err := r.summaryOf(s); err == nil {
|
|
365
|
+
r.setBaseline(a.URI, summary)
|
|
366
|
+
}
|
|
367
|
+
r.bumpActiveSessions()
|
|
368
|
+
return s
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Dispose handles disposeSession.
|
|
372
|
+
//
|
|
373
|
+
// It works for any session in the catalogue, not just ones this host has running: most sessions a
|
|
374
|
+
// client can see were written by Pi and have never been live here, so refusing to dispose them
|
|
375
|
+
// would make the delete affordance fail on almost everything the list shows. AHP requires backend
|
|
376
|
+
// teardown and catalogue removal but does not prescribe how a host stores sessions; this
|
|
377
|
+
// catalogue is the set of Pi session files, so stable removal also requires deleting the backing
|
|
378
|
+
// file.
|
|
379
|
+
func (r *Registry) Dispose(ctx context.Context, uri string) error {
|
|
380
|
+
r.mu.Lock()
|
|
381
|
+
if pending, ok := r.disposals[uri]; ok {
|
|
382
|
+
pending.waiters++
|
|
383
|
+
r.mu.Unlock()
|
|
384
|
+
<-pending.done
|
|
385
|
+
return pending.err
|
|
386
|
+
}
|
|
387
|
+
d := &disposal{done: make(chan struct{})}
|
|
388
|
+
r.disposals[uri] = d
|
|
389
|
+
r.mu.Unlock()
|
|
390
|
+
|
|
391
|
+
d.err = r.disposeOnce(ctx, uri)
|
|
392
|
+
r.mu.Lock()
|
|
393
|
+
if r.disposals[uri] == d {
|
|
394
|
+
delete(r.disposals, uri)
|
|
395
|
+
}
|
|
396
|
+
r.mu.Unlock()
|
|
397
|
+
close(d.done)
|
|
398
|
+
return d.err
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// DisposalWaiters reports how many requests are coalesced onto the disposal in flight for uri
|
|
402
|
+
// (an observation point for tests, which cannot otherwise tell when a second request has arrived).
|
|
403
|
+
func (r *Registry) DisposalWaiters(uri string) int {
|
|
404
|
+
r.mu.Lock()
|
|
405
|
+
defer r.mu.Unlock()
|
|
406
|
+
if d, ok := r.disposals[uri]; ok {
|
|
407
|
+
return d.waiters
|
|
408
|
+
}
|
|
409
|
+
return 0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
func (r *Registry) disposeOnce(ctx context.Context, uri string) error {
|
|
413
|
+
r.mu.Lock()
|
|
414
|
+
session := r.sessions[uri]
|
|
415
|
+
r.mu.Unlock()
|
|
416
|
+
var sessionID, file string
|
|
417
|
+
var quiesced *ChatDriver
|
|
418
|
+
|
|
419
|
+
fail := func(err error) error {
|
|
420
|
+
if quiesced != nil {
|
|
421
|
+
quiesced.Resume()
|
|
422
|
+
}
|
|
423
|
+
var pe *wire.Error
|
|
424
|
+
if errors.As(err, &pe) {
|
|
425
|
+
return err
|
|
426
|
+
}
|
|
427
|
+
return fmt.Errorf("Could not dispose session %s: %w", uri, err)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if session != nil {
|
|
431
|
+
sessionID = session.SessionID
|
|
432
|
+
quiesced = session.Driver()
|
|
433
|
+
if quiesced != nil {
|
|
434
|
+
if err := quiesced.Quiesce(ctx); err != nil {
|
|
435
|
+
return fail(err)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
file = session.SessionManager.File()
|
|
439
|
+
// Storage-only or failed-start backends have no driver to close a turn.
|
|
440
|
+
if chat := r.host.Store().Chat(session.ChatChannel); chat != nil && chat.ActiveTurn != nil {
|
|
441
|
+
duration := int64(0)
|
|
442
|
+
if started, err := time.Parse(time.RFC3339Nano, chat.ActiveTurn.StartedAt); err == nil {
|
|
443
|
+
if d := time.Since(started).Milliseconds(); d > 0 {
|
|
444
|
+
duration = d
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
r.host.DispatchServerAction(session.ChatChannel, ahptypes.StateAction{Value: &ahptypes.ChatTurnCancelledAction{
|
|
448
|
+
Type: ahptypes.ActionTypeChatTurnCancelled, TurnId: chat.ActiveTurn.Id, Duration: duration,
|
|
449
|
+
}})
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
if kind, ok := r.host.Store().KindOf(uri); ok && kind != wire.KindSession {
|
|
453
|
+
return fail(wire.SessionNotFound(uri))
|
|
454
|
+
}
|
|
455
|
+
parsed, ok := wire.SessionIDFromURI(uri, Provider)
|
|
456
|
+
if !ok {
|
|
457
|
+
return fail(wire.SessionNotFound(uri))
|
|
458
|
+
}
|
|
459
|
+
sessionID = parsed
|
|
460
|
+
if r.opts.FindSessionFile != nil {
|
|
461
|
+
f, err := r.opts.FindSessionFile(sessionID)
|
|
462
|
+
if err != nil {
|
|
463
|
+
return fail(err)
|
|
464
|
+
}
|
|
465
|
+
file = f
|
|
466
|
+
}
|
|
467
|
+
if file == "" && !r.host.Store().Has(uri) {
|
|
468
|
+
return fail(wire.SessionNotFound(uri))
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if file != "" {
|
|
473
|
+
if r.opts.DeleteFile == nil {
|
|
474
|
+
return fail(errors.New("durable session deletion is not configured"))
|
|
475
|
+
}
|
|
476
|
+
result, err := r.opts.DeleteFile(file)
|
|
477
|
+
if err != nil {
|
|
478
|
+
return fail(err)
|
|
479
|
+
}
|
|
480
|
+
if !result.OK {
|
|
481
|
+
msg := result.Error
|
|
482
|
+
if msg == "" {
|
|
483
|
+
msg = "the durable session file remains"
|
|
484
|
+
}
|
|
485
|
+
return fail(errors.New(msg))
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Durable deletion is now committed. Protocol children disappear before the parent so their
|
|
490
|
+
// subscribers can observe final cleanup actions.
|
|
491
|
+
r.mu.Lock()
|
|
492
|
+
listeners := make([]func(string) error, 0, len(r.deleted))
|
|
493
|
+
for _, l := range r.deleted {
|
|
494
|
+
listeners = append(listeners, *l)
|
|
495
|
+
}
|
|
496
|
+
r.mu.Unlock()
|
|
497
|
+
for _, l := range listeners {
|
|
498
|
+
if err := l(sessionID); err != nil {
|
|
499
|
+
r.logf("protocol child cleanup failed for %s: %v", uri, err)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Re-read the live entry defensively so cleanup cannot leave registry and protocol state
|
|
504
|
+
// disagreeing.
|
|
505
|
+
r.mu.Lock()
|
|
506
|
+
current := r.sessions[uri]
|
|
507
|
+
if current != nil {
|
|
508
|
+
delete(r.sessions, uri)
|
|
509
|
+
delete(r.byChat, current.ChatChannel)
|
|
510
|
+
}
|
|
511
|
+
delete(r.baselines, uri)
|
|
512
|
+
r.mu.Unlock()
|
|
513
|
+
if current != nil {
|
|
514
|
+
if d := current.Driver(); d != nil {
|
|
515
|
+
d.Dispose()
|
|
516
|
+
}
|
|
517
|
+
// Disposing a session cascades to every chat in its catalogue.
|
|
518
|
+
r.host.DeleteChannel(current.ChatChannel)
|
|
519
|
+
} else {
|
|
520
|
+
r.host.DeleteChannel(wire.ChatURI(sessionID))
|
|
521
|
+
}
|
|
522
|
+
r.host.DeleteChannel(uri)
|
|
523
|
+
channels.NotifySessionRemoved(r.host, uri)
|
|
524
|
+
r.bumpActiveSessions()
|
|
525
|
+
return nil
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Backend attachment ──────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
// attachBackend starts the agent for a session. Concurrent actions during startup wait on one
|
|
531
|
+
// attempt rather than racing to build a second agent over the same session file.
|
|
532
|
+
func (r *Registry) attachBackend(s *LiveSession) {
|
|
533
|
+
s.mu.Lock()
|
|
534
|
+
if s.attaching != nil {
|
|
535
|
+
wait := s.attaching
|
|
536
|
+
s.mu.Unlock()
|
|
537
|
+
<-wait
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
done := make(chan struct{})
|
|
541
|
+
s.attaching = done
|
|
542
|
+
s.mu.Unlock()
|
|
543
|
+
defer func() {
|
|
544
|
+
s.mu.Lock()
|
|
545
|
+
s.attaching = nil
|
|
546
|
+
s.mu.Unlock()
|
|
547
|
+
close(done)
|
|
548
|
+
}()
|
|
549
|
+
r.startBackend(s)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// EnsureBackend attaches the agent to a live session now, instead of on the first client action
|
|
553
|
+
// that needs one, and waits for the attempt to settle. It is for a session whose agent is already
|
|
554
|
+
// running elsewhere (the PiG session itself) and whose events must be mirrored from the start.
|
|
555
|
+
func (r *Registry) EnsureBackend(uri string) error {
|
|
556
|
+
s, ok := r.Get(uri)
|
|
557
|
+
if !ok {
|
|
558
|
+
return fmt.Errorf("session %s is not registered", uri)
|
|
559
|
+
}
|
|
560
|
+
if s.Driver() != nil {
|
|
561
|
+
return nil
|
|
562
|
+
}
|
|
563
|
+
r.attachBackend(s)
|
|
564
|
+
if s.Driver() == nil {
|
|
565
|
+
return fmt.Errorf("the agent for %s could not be attached", uri)
|
|
566
|
+
}
|
|
567
|
+
return nil
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
func (r *Registry) isCurrent(s *LiveSession) bool {
|
|
571
|
+
r.mu.Lock()
|
|
572
|
+
defer r.mu.Unlock()
|
|
573
|
+
return r.sessions[s.URI] == s
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
func (r *Registry) startBackend(s *LiveSession) {
|
|
577
|
+
if r.opts.CreateBackend == nil {
|
|
578
|
+
// Storage-only mode: the session owns identity and history but has no agent, so it is
|
|
579
|
+
// ready as soon as it exists.
|
|
580
|
+
r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionReadyAction{Type: ahptypes.ActionTypeSessionReady}})
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
backend, err := r.opts.CreateBackend(s)
|
|
584
|
+
if err == nil {
|
|
585
|
+
// Disposal may win while an asynchronous backend is starting. Never attach a late backend to
|
|
586
|
+
// a session that no longer exists.
|
|
587
|
+
if !r.isCurrent(s) {
|
|
588
|
+
if d, ok := backend.(Disposer); ok {
|
|
589
|
+
if err := d.Dispose(); err != nil {
|
|
590
|
+
r.logf("late backend disposal failed for %s: %v", s.URI, err)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
driver := NewChatDriver(ChatDriverOptions{
|
|
596
|
+
Host: r.host, ChatChannel: s.ChatChannel, Backend: backend, WorkingDirectory: s.WorkingDirectory,
|
|
597
|
+
// A completed turn's last entry is wherever the leaf now points.
|
|
598
|
+
RecordTurnAnchor: func(turnID string) {
|
|
599
|
+
leaf := s.SessionManager.LeafEntry()
|
|
600
|
+
// Navigating to a user entry branches before it, so it cannot represent "keep this
|
|
601
|
+
// cancelled user-only turn" safely.
|
|
602
|
+
if leaf == nil || leaf.Role() == "user" {
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
s.setAnchor(turnID, leaf.ID())
|
|
606
|
+
},
|
|
607
|
+
AdoptForeignTurns: r.opts.AdoptForeignTurns,
|
|
608
|
+
Log: r.opts.Log,
|
|
609
|
+
})
|
|
610
|
+
s.mu.Lock()
|
|
611
|
+
s.driver = driver
|
|
612
|
+
s.mu.Unlock()
|
|
613
|
+
r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionReadyAction{Type: ahptypes.ActionTypeSessionReady}})
|
|
614
|
+
// Tell the client which model and reasoning effort are actually in effect. There is no
|
|
615
|
+
// protocol field for a "default model", but a client initialises its input from the chat's
|
|
616
|
+
// draft, so seeding the draft's selection is how a host answers that question.
|
|
617
|
+
driver.PublishDefaultSelection()
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
if !r.isCurrent(s) {
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
message := err.Error()
|
|
624
|
+
r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionCreationFailedAction{
|
|
625
|
+
Type: ahptypes.ActionTypeSessionCreationFailed, Error: ahptypes.ErrorInfo{ErrorType: "backendStartFailed", Message: message},
|
|
626
|
+
}})
|
|
627
|
+
// A turn may already be active: the client's chat/turnStarted was reduced before the agent was
|
|
628
|
+
// asked for. Nothing will ever emit agent_settled now, so close it here or the chat sits in
|
|
629
|
+
// progress forever with no reply and no error.
|
|
630
|
+
if chat := r.host.Store().Chat(s.ChatChannel); chat != nil && chat.ActiveTurn != nil {
|
|
631
|
+
r.host.DispatchServerAction(s.ChatChannel, ahptypes.StateAction{Value: &ahptypes.ChatErrorAction{
|
|
632
|
+
Type: ahptypes.ActionTypeChatError, TurnId: chat.ActiveTurn.Id, Duration: 0,
|
|
633
|
+
Part: ahptypes.ErrorResponsePart{Kind: ahptypes.ResponsePartKindError, Error: ahptypes.ErrorInfo{ErrorType: "backendStartFailed", Message: message}},
|
|
634
|
+
}})
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── Routing client actions ──────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
// routeClientAction routes an accepted client action to whatever owns it. Chat actions go to the
|
|
641
|
+
// chat's driver, starting the agent first if this session has only ever been read from disk.
|
|
642
|
+
// Session actions are handled here: they carry no turn and need no agent.
|
|
643
|
+
func (r *Registry) routeClientAction(channel string, action ahptypes.StateAction) {
|
|
644
|
+
r.mu.Lock()
|
|
645
|
+
owning := r.sessions[channel]
|
|
646
|
+
session := r.byChat[channel]
|
|
647
|
+
_, disposing := func() (*disposal, bool) {
|
|
648
|
+
if session == nil {
|
|
649
|
+
return nil, false
|
|
650
|
+
}
|
|
651
|
+
d, ok := r.disposals[session.URI]
|
|
652
|
+
return d, ok
|
|
653
|
+
}()
|
|
654
|
+
r.mu.Unlock()
|
|
655
|
+
if owning != nil {
|
|
656
|
+
r.handleSessionAction(owning, action)
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
if session == nil {
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
// Truncation waits on the agent and lazy startup waits on the backend: those must not block
|
|
663
|
+
// the caller (a connection's read loop).
|
|
664
|
+
_, truncate := action.Value.(*ahptypes.ChatTruncatedAction)
|
|
665
|
+
if session.Driver() == nil || disposing || truncate {
|
|
666
|
+
go r.routeChat(channel, session, action)
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
r.routeChat(channel, session, action)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
func (r *Registry) routeChat(channel string, session *LiveSession, action ahptypes.StateAction) {
|
|
673
|
+
defer func() {
|
|
674
|
+
if p := recover(); p != nil {
|
|
675
|
+
r.logf("side effect failed for %s: %v", channel, p)
|
|
676
|
+
}
|
|
677
|
+
}()
|
|
678
|
+
if session.Driver() == nil {
|
|
679
|
+
r.attachBackend(session)
|
|
680
|
+
}
|
|
681
|
+
// An action accepted before disposal may have been waiting for lazy backend startup. On
|
|
682
|
+
// failure it still belongs to the surviving session; on success there is no session left to
|
|
683
|
+
// mutate.
|
|
684
|
+
r.mu.Lock()
|
|
685
|
+
pending := r.disposals[session.URI]
|
|
686
|
+
r.mu.Unlock()
|
|
687
|
+
if pending != nil {
|
|
688
|
+
<-pending.done
|
|
689
|
+
if pending.err == nil {
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
// Disposal rolled back; continue with the action already in state.
|
|
693
|
+
}
|
|
694
|
+
if !r.isCurrent(session) {
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
switch a := action.Value.(type) {
|
|
698
|
+
case *ahptypes.ChatTruncatedAction:
|
|
699
|
+
r.truncate(session, a.TurnId)
|
|
700
|
+
return
|
|
701
|
+
case *ahptypes.ChatTurnStartedAction:
|
|
702
|
+
if chat := r.host.Store().Chat(channel); chat == nil || chat.ActiveTurn == nil || chat.ActiveTurn.Id != a.TurnId {
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if d := session.Driver(); d != nil {
|
|
707
|
+
d.HandleClientAction(channel, action)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
func (r *Registry) handleSessionAction(session *LiveSession, action ahptypes.StateAction) {
|
|
712
|
+
a, ok := action.Value.(*ahptypes.SessionTitleChangedAction)
|
|
713
|
+
if !ok {
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
// The reducer has already updated the in-memory title. Persisting it is what makes the rename
|
|
717
|
+
// survive a restart: Pi stores session names as session_info entries, and this is the same
|
|
718
|
+
// call its own /resume rename makes, so a session renamed here reads the same in Pi's CLI.
|
|
719
|
+
if _, err := session.SessionManager.AppendSessionInfo(a.Title); err != nil {
|
|
720
|
+
r.logf("could not persist title for %s: %v", session.URI, err)
|
|
721
|
+
}
|
|
722
|
+
// The chat mirrors the session's title. Updating its modification time lets the ordinary
|
|
723
|
+
// chat-to-session projection update the root catalogue.
|
|
724
|
+
r.renameChat(session, a.Title, true)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
func (r *Registry) applyFallbackTitle(s *LiveSession, firstUserMessage string) {
|
|
728
|
+
state := r.host.Store().Session(s.URI)
|
|
729
|
+
if state == nil || state.Title != mapper.NewSessionTitle || s.SessionManager.SessionName() != "" {
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
title, ok := mapper.FallbackSessionTitle(firstUserMessage)
|
|
733
|
+
if !ok {
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
// This is Pi's unnamed-session fallback, not an explicit session name: publish it to AHP
|
|
737
|
+
// without adding a session_info entry.
|
|
738
|
+
r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionTitleChangedAction{Type: ahptypes.ActionTypeSessionTitleChanged, Title: title}})
|
|
739
|
+
// The host delivers listener callbacks after the current one returns; publish now so the title
|
|
740
|
+
// reaches the root catalogue before the chat projection's status change does.
|
|
741
|
+
r.publishSummaryIfLive(s)
|
|
742
|
+
r.renameChat(s, title, false)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
func (r *Registry) renameChat(s *LiveSession, title string, touch bool) {
|
|
746
|
+
// There is no chat title action in AHP 0.9, so the authoritative chat state is edited in place
|
|
747
|
+
// (atomically with respect to reduced actions: a read-modify-write here once dropped the
|
|
748
|
+
// actions of a turn already running), then the same projection runs as after ordinary actions.
|
|
749
|
+
changed := r.host.Store().UpdateChat(s.ChatChannel, func(chat *ahptypes.ChatState) bool {
|
|
750
|
+
if chat.Title == title {
|
|
751
|
+
return false
|
|
752
|
+
}
|
|
753
|
+
chat.Title = title
|
|
754
|
+
if touch {
|
|
755
|
+
chat.ModifiedAt = nowISO()
|
|
756
|
+
}
|
|
757
|
+
return true
|
|
758
|
+
})
|
|
759
|
+
if changed {
|
|
760
|
+
r.syncChatProjection(s)
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// FetchTurns serves fetchTurns; the page is dispatched before this returns.
|
|
765
|
+
func (r *Registry) FetchTurns(_ context.Context, params ahptypes.FetchTurnsParams) error {
|
|
766
|
+
session, ok := r.GetByChat(params.Channel)
|
|
767
|
+
if !ok {
|
|
768
|
+
return wire.NotFound(params.Channel)
|
|
769
|
+
}
|
|
770
|
+
return DispatchOlderTurns(r.host, params.Channel, session, params.Cursor)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
func (r *Registry) truncate(s *LiveSession, turnID *string) {
|
|
774
|
+
anchor, ok := TruncationAnchor(s, turnID)
|
|
775
|
+
label := "(all)"
|
|
776
|
+
if turnID != nil {
|
|
777
|
+
label = *turnID
|
|
778
|
+
}
|
|
779
|
+
if !ok {
|
|
780
|
+
// Validated before the action was applied, so this only happens if the file changed
|
|
781
|
+
// underneath us.
|
|
782
|
+
r.logf("no truncation anchor for %s in %s", label, s.URI)
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
if d := s.Driver(); d != nil && !d.Truncate(context.Background(), anchor) {
|
|
786
|
+
// The agent refused (or cannot). Protocol state is already truncated, so the two have
|
|
787
|
+
// diverged; say so loudly rather than pretend.
|
|
788
|
+
r.logf("agent refused truncation of %s; state and session now differ", s.URI)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ── Internals ───────────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
func (r *Registry) summaryOf(s *LiveSession) (ahptypes.SessionSummary, error) {
|
|
795
|
+
state := r.host.Store().Session(s.URI)
|
|
796
|
+
if state == nil {
|
|
797
|
+
return ahptypes.SessionSummary{}, fmt.Errorf("Session state is missing: %s", s.URI)
|
|
798
|
+
}
|
|
799
|
+
meta := map[string]json.RawMessage{}
|
|
800
|
+
for k, v := range state.Meta {
|
|
801
|
+
meta[k] = v
|
|
802
|
+
}
|
|
803
|
+
id, _ := json.Marshal(s.SessionID)
|
|
804
|
+
meta["piSessionId"] = id
|
|
805
|
+
return channels.SessionSummaryOf(s.URI, s.CreatedAt, state, meta), nil
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
func (r *Registry) setBaseline(uri string, summary ahptypes.SessionSummary) {
|
|
809
|
+
r.mu.Lock()
|
|
810
|
+
r.baselines[uri] = summary
|
|
811
|
+
r.mu.Unlock()
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
func (r *Registry) publishSummaryIfLive(s *LiveSession) {
|
|
815
|
+
if r.isCurrent(s) {
|
|
816
|
+
r.publishSummary(s)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// publishSummary publishes only changed root-catalogue fields.
|
|
821
|
+
func (r *Registry) publishSummary(s *LiveSession) {
|
|
822
|
+
current, err := r.summaryOf(s)
|
|
823
|
+
if err != nil {
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
r.mu.Lock()
|
|
827
|
+
previous, had := r.baselines[s.URI]
|
|
828
|
+
r.baselines[s.URI] = current
|
|
829
|
+
r.mu.Unlock()
|
|
830
|
+
var changes ahptypes.PartialSessionSummary
|
|
831
|
+
changed := false
|
|
832
|
+
if !had || current.Title != previous.Title {
|
|
833
|
+
changes.Title, changed = ¤t.Title, true
|
|
834
|
+
}
|
|
835
|
+
if !had || current.Status != previous.Status {
|
|
836
|
+
changes.Status, changed = ¤t.Status, true
|
|
837
|
+
}
|
|
838
|
+
if !had || current.ModifiedAt != previous.ModifiedAt {
|
|
839
|
+
changes.ModifiedAt, changed = ¤t.ModifiedAt, true
|
|
840
|
+
}
|
|
841
|
+
if changed {
|
|
842
|
+
channels.NotifySessionSummaryChanged(r.host, s.URI, changes)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// onCommitted reacts to every committed action: it keeps the chat's summary mirrored into its
|
|
847
|
+
// session and the session's into the root catalogue.
|
|
848
|
+
func (r *Registry) onCommitted(channel string, action ahptypes.StateAction) {
|
|
849
|
+
r.mu.Lock()
|
|
850
|
+
owner := r.byChat[channel]
|
|
851
|
+
session := r.sessions[channel]
|
|
852
|
+
r.mu.Unlock()
|
|
853
|
+
if owner != nil {
|
|
854
|
+
if a, ok := action.Value.(*ahptypes.ChatTurnStartedAction); ok {
|
|
855
|
+
r.applyFallbackTitle(owner, a.Message.Text)
|
|
856
|
+
}
|
|
857
|
+
r.syncChatProjection(owner)
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
if session != nil {
|
|
861
|
+
r.publishSummary(session)
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// syncChatProjection projects a reduced chat summary onto its parent session and root catalogue.
|
|
866
|
+
//
|
|
867
|
+
// The vendored Microsoft 0.9 reducer cannot update top-level SessionState.status, so this deliberately
|
|
868
|
+
// leaves that field alone. Clients still receive the live status in SessionState.chats[],
|
|
869
|
+
// ChatState, and the root summary.
|
|
870
|
+
func (r *Registry) syncChatProjection(s *LiveSession) {
|
|
871
|
+
if !channels.SyncChatSummary(r.host, s.URI, s.ChatChannel) {
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
if state := r.host.Store().Session(s.URI); state != nil {
|
|
875
|
+
aggregate := channels.AggregateSessionChats(state)
|
|
876
|
+
if !equalStrPtr(state.Activity, aggregate.Activity) {
|
|
877
|
+
r.host.DispatchServerAction(s.URI, ahptypes.StateAction{Value: &ahptypes.SessionActivityChangedAction{
|
|
878
|
+
Type: ahptypes.ActionTypeSessionActivityChanged, Activity: aggregate.Activity,
|
|
879
|
+
}})
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
r.publishSummaryIfLive(s)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
func equalStrPtr(a, b *string) bool {
|
|
886
|
+
if a == nil || b == nil {
|
|
887
|
+
return a == b
|
|
888
|
+
}
|
|
889
|
+
return *a == *b
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
func (r *Registry) track(s *LiveSession) {
|
|
893
|
+
r.mu.Lock()
|
|
894
|
+
r.sessions[s.URI] = s
|
|
895
|
+
r.byChat[s.ChatChannel] = s
|
|
896
|
+
r.mu.Unlock()
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
func (r *Registry) notifyAvailableToAll(s *LiveSession) {
|
|
900
|
+
r.mu.Lock()
|
|
901
|
+
ls := make([]func(*LiveSession), 0, len(r.available))
|
|
902
|
+
for _, l := range r.available {
|
|
903
|
+
ls = append(ls, *l)
|
|
904
|
+
}
|
|
905
|
+
r.mu.Unlock()
|
|
906
|
+
for _, l := range ls {
|
|
907
|
+
r.notifyAvailable(l, s)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
func (r *Registry) notifyAvailable(l func(*LiveSession), s *LiveSession) {
|
|
912
|
+
defer func() {
|
|
913
|
+
if p := recover(); p != nil {
|
|
914
|
+
r.logf("session availability listener failed for %s: %v", s.URI, p)
|
|
915
|
+
}
|
|
916
|
+
}()
|
|
917
|
+
l(s)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
func (r *Registry) bumpActiveSessions() {
|
|
921
|
+
r.host.DispatchServerAction(wire.RootChannel, ahptypes.StateAction{Value: &ahptypes.RootActiveSessionsChangedAction{
|
|
922
|
+
Type: ahptypes.ActionTypeRootActiveSessionsChanged, ActiveSessions: int64(r.Count()),
|
|
923
|
+
}})
|
|
924
|
+
}
|