@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,370 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"io"
|
|
8
|
+
"math"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"sort"
|
|
12
|
+
"strconv"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
18
|
+
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
20
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
|
|
21
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// The session catalogue (port of src/pi/session-catalogue.ts): the set of Pi session files under
|
|
25
|
+
// a root, most recently modified first, merged with live sessions the host is running.
|
|
26
|
+
//
|
|
27
|
+
// The catalogue is read-only. It opens files only to summarise them and never rewrites or
|
|
28
|
+
// migrates one; Pi owns those files.
|
|
29
|
+
|
|
30
|
+
const (
|
|
31
|
+
defaultCataloguePage = 30
|
|
32
|
+
maxCataloguePage = 100
|
|
33
|
+
headerScanBytes = 8192
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
type sessionFile struct {
|
|
37
|
+
path string
|
|
38
|
+
mtimeMs float64
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type catalogueEntry struct {
|
|
42
|
+
sessionFile
|
|
43
|
+
file string
|
|
44
|
+
onDisk bool
|
|
45
|
+
summary *ahptypes.SessionSummary
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func mtimeMs(info os.FileInfo) float64 { return float64(info.ModTime().UnixNano()) / 1e6 }
|
|
49
|
+
|
|
50
|
+
func listSessionFiles(root string) ([]sessionFile, error) {
|
|
51
|
+
dirs, err := os.ReadDir(root)
|
|
52
|
+
if err != nil {
|
|
53
|
+
// No sessions directory yet is an empty catalogue, not an error.
|
|
54
|
+
if os.IsNotExist(err) {
|
|
55
|
+
return nil, nil
|
|
56
|
+
}
|
|
57
|
+
return nil, err
|
|
58
|
+
}
|
|
59
|
+
var files []sessionFile
|
|
60
|
+
for _, d := range dirs {
|
|
61
|
+
dirPath := filepath.Join(root, d.Name())
|
|
62
|
+
entries, err := os.ReadDir(dirPath)
|
|
63
|
+
if err != nil {
|
|
64
|
+
continue // a file at the top level, or a race with deletion
|
|
65
|
+
}
|
|
66
|
+
for _, e := range entries {
|
|
67
|
+
if !strings.HasSuffix(e.Name(), ".jsonl") {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
path := filepath.Join(dirPath, e.Name())
|
|
71
|
+
info, err := os.Stat(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
continue // deleted between the directory read and the stat
|
|
74
|
+
}
|
|
75
|
+
files = append(files, sessionFile{path, mtimeMs(info)})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
sortFiles(files)
|
|
79
|
+
return files, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// sortFiles orders by recency; ties break by path so the keyset cursor is total and stable.
|
|
83
|
+
func sortFiles(files []sessionFile) {
|
|
84
|
+
sort.Slice(files, func(i, j int) bool {
|
|
85
|
+
if files[i].mtimeMs != files[j].mtimeMs {
|
|
86
|
+
return files[i].mtimeMs > files[j].mtimeMs
|
|
87
|
+
}
|
|
88
|
+
return files[i].path < files[j].path
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func encodeCursor(f sessionFile) string {
|
|
93
|
+
raw := strconv.FormatFloat(f.mtimeMs, 'f', -1, 64) + "\x00" + f.path
|
|
94
|
+
return base64.RawURLEncoding.EncodeToString([]byte(raw))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func decodeCursor(cursor string) (sessionFile, error) {
|
|
98
|
+
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
99
|
+
bad := wire.InvalidParams("Unrecognised listSessions cursor")
|
|
100
|
+
if err != nil {
|
|
101
|
+
return sessionFile{}, bad
|
|
102
|
+
}
|
|
103
|
+
s := string(raw)
|
|
104
|
+
sep := strings.IndexByte(s, 0)
|
|
105
|
+
if sep < 0 {
|
|
106
|
+
return sessionFile{}, bad
|
|
107
|
+
}
|
|
108
|
+
ms, err := strconv.ParseFloat(s[:sep], 64)
|
|
109
|
+
path := s[sep+1:]
|
|
110
|
+
if err != nil || math.IsInf(ms, 0) || math.IsNaN(ms) || path == "" {
|
|
111
|
+
return sessionFile{}, bad
|
|
112
|
+
}
|
|
113
|
+
return sessionFile{path, ms}, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// readSessionID reads the id from a session file's header line without loading the file.
|
|
117
|
+
func readSessionID(path string) (string, bool) {
|
|
118
|
+
f, err := os.Open(path)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return "", false
|
|
121
|
+
}
|
|
122
|
+
defer f.Close()
|
|
123
|
+
buf := make([]byte, headerScanBytes)
|
|
124
|
+
n, err := io.ReadFull(f, buf)
|
|
125
|
+
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
|
126
|
+
return "", false
|
|
127
|
+
}
|
|
128
|
+
line := buf[:n]
|
|
129
|
+
if i := strings.IndexByte(string(line), '\n'); i >= 0 {
|
|
130
|
+
line = line[:i]
|
|
131
|
+
}
|
|
132
|
+
var header struct {
|
|
133
|
+
Type string `json:"type"`
|
|
134
|
+
ID string `json:"id"`
|
|
135
|
+
}
|
|
136
|
+
if json.Unmarshal(line, &header) != nil || header.Type != "session" || header.ID == "" {
|
|
137
|
+
return "", false
|
|
138
|
+
}
|
|
139
|
+
return header.ID, true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func isoMs(ms float64) string {
|
|
143
|
+
return time.UnixMilli(int64(ms)).UTC().Format("2006-01-02T15:04:05.000Z")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// SessionName mirrors Pi: the latest session_info name.
|
|
147
|
+
func sessionNameOf(entries []pisession.Entry) string {
|
|
148
|
+
for i := len(entries) - 1; i >= 0; i-- {
|
|
149
|
+
if entries[i].Type() == "session_info" {
|
|
150
|
+
s, _ := entries[i]["name"].(string)
|
|
151
|
+
return strings.TrimSpace(s)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return ""
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// firstUserMessage is the first non-blank user text in a session.
|
|
158
|
+
func firstUserMessage(entries []pisession.Entry) string {
|
|
159
|
+
for _, e := range entries {
|
|
160
|
+
if msg := e.Message(); msg != nil && msg["role"] == "user" {
|
|
161
|
+
if text := strings.TrimSpace(mapper.TextFromPiUserContent(msg["content"])); text != "" {
|
|
162
|
+
return text
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return ""
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func readSessionSummary(f sessionFile) (ahptypes.SessionSummary, bool) {
|
|
170
|
+
all, err := pisession.LoadEntries(f.path)
|
|
171
|
+
if err != nil || len(all) == 0 {
|
|
172
|
+
return ahptypes.SessionSummary{}, false
|
|
173
|
+
}
|
|
174
|
+
header, entries := all[0], all[1:]
|
|
175
|
+
sessionID, _ := header["id"].(string)
|
|
176
|
+
if sessionID == "" {
|
|
177
|
+
return ahptypes.SessionSummary{}, false
|
|
178
|
+
}
|
|
179
|
+
createdAt := ""
|
|
180
|
+
if len(entries) > 0 {
|
|
181
|
+
createdAt = entries[0].Timestamp()
|
|
182
|
+
}
|
|
183
|
+
if createdAt == "" {
|
|
184
|
+
createdAt = isoMs(f.mtimeMs)
|
|
185
|
+
}
|
|
186
|
+
meta, _ := json.Marshal(f.path)
|
|
187
|
+
summary := ahptypes.SessionSummary{
|
|
188
|
+
Resource: wire.SessionURI(sessionID), Provider: Provider,
|
|
189
|
+
Title: mapper.SessionDisplayTitle(sessionNameOf(entries), firstUserMessage(entries)),
|
|
190
|
+
// Idle by definition, and reported as read. Read/unread is not modelled: Pi has no such
|
|
191
|
+
// concept, so tracking it would mean this host inventing durable state of its own, and
|
|
192
|
+
// without archiving to go with it, a catalogue where everything is permanently unread is
|
|
193
|
+
// worse than one that is quiet.
|
|
194
|
+
Status: ahptypes.SessionStatusIdle | ahptypes.SessionStatusIsRead,
|
|
195
|
+
CreatedAt: createdAt,
|
|
196
|
+
ModifiedAt: isoMs(f.mtimeMs),
|
|
197
|
+
Meta: map[string]json.RawMessage{"piSessionFile": meta},
|
|
198
|
+
}
|
|
199
|
+
if cwd, _ := header["cwd"].(string); cwd != "" {
|
|
200
|
+
summary.WorkingDirectories = []ahptypes.URI{wire.PathToFileURI(cwd)}
|
|
201
|
+
}
|
|
202
|
+
return summary, true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Catalogue lists Pi session files under a root.
|
|
206
|
+
type Catalogue struct {
|
|
207
|
+
root string
|
|
208
|
+
|
|
209
|
+
mu sync.Mutex
|
|
210
|
+
index map[string]string // session URI -> file
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// NewCatalogue creates a catalogue over a sessions root (one sub-directory per working directory).
|
|
214
|
+
func NewCatalogue(root string) *Catalogue { return &Catalogue{root: root, index: map[string]string{}} }
|
|
215
|
+
|
|
216
|
+
// FileFor is the file last seen for a session URI.
|
|
217
|
+
func (c *Catalogue) FileFor(uri string) (string, bool) {
|
|
218
|
+
c.mu.Lock()
|
|
219
|
+
defer c.mu.Unlock()
|
|
220
|
+
f, ok := c.index[uri]
|
|
221
|
+
return f, ok
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// FindSessionFile locates the file behind a session id, "" if there is none.
|
|
225
|
+
func (c *Catalogue) FindSessionFile(sessionID string) (string, error) {
|
|
226
|
+
uri := wire.SessionURI(sessionID)
|
|
227
|
+
c.mu.Lock()
|
|
228
|
+
cached, ok := c.index[uri]
|
|
229
|
+
c.mu.Unlock()
|
|
230
|
+
if ok {
|
|
231
|
+
// The cache is only an index hint: deletion by this host, Pi, or the user must invalidate
|
|
232
|
+
// a path before it is returned again.
|
|
233
|
+
if id, ok := readSessionID(cached); ok && id == sessionID {
|
|
234
|
+
return cached, nil
|
|
235
|
+
}
|
|
236
|
+
c.mu.Lock()
|
|
237
|
+
delete(c.index, uri)
|
|
238
|
+
c.mu.Unlock()
|
|
239
|
+
}
|
|
240
|
+
files, err := listSessionFiles(c.root)
|
|
241
|
+
if err != nil {
|
|
242
|
+
return "", err
|
|
243
|
+
}
|
|
244
|
+
for _, f := range files {
|
|
245
|
+
id, ok := readSessionID(f.path)
|
|
246
|
+
if ok {
|
|
247
|
+
c.mu.Lock()
|
|
248
|
+
c.index[wire.SessionURI(id)] = f.path
|
|
249
|
+
c.mu.Unlock()
|
|
250
|
+
}
|
|
251
|
+
if ok && id == sessionID {
|
|
252
|
+
return f.path, nil
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return "", nil
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// List serves listSessions: one page of the catalogue, live sessions overriding what is on disk.
|
|
259
|
+
func (c *Catalogue) List(_ context.Context, limit *int64, cursor *string, live func() []LiveCatalogueEntry) (ahptypes.ListSessionsResult, error) {
|
|
260
|
+
pageSize := int64(defaultCataloguePage)
|
|
261
|
+
if limit != nil {
|
|
262
|
+
pageSize = *limit
|
|
263
|
+
}
|
|
264
|
+
if pageSize < 1 {
|
|
265
|
+
pageSize = 1
|
|
266
|
+
}
|
|
267
|
+
if pageSize > maxCataloguePage {
|
|
268
|
+
pageSize = maxCataloguePage
|
|
269
|
+
}
|
|
270
|
+
files, err := listSessionFiles(c.root)
|
|
271
|
+
if err != nil {
|
|
272
|
+
return ahptypes.ListSessionsResult{}, err
|
|
273
|
+
}
|
|
274
|
+
entries := map[string]*catalogueEntry{}
|
|
275
|
+
for _, f := range files {
|
|
276
|
+
entries[f.path] = &catalogueEntry{sessionFile: f, file: f.path, onDisk: true}
|
|
277
|
+
}
|
|
278
|
+
// Live state is read after disk discovery, and the page is built without further waiting, so a
|
|
279
|
+
// turn transition cannot make the returned page older than a notification sent before it.
|
|
280
|
+
if live != nil {
|
|
281
|
+
for _, item := range live() {
|
|
282
|
+
ms := 0.0
|
|
283
|
+
if t, err := time.Parse(time.RFC3339Nano, item.Summary.ModifiedAt); err == nil {
|
|
284
|
+
ms = float64(t.UnixNano()) / 1e6
|
|
285
|
+
}
|
|
286
|
+
path := item.File
|
|
287
|
+
if path == "" {
|
|
288
|
+
path = "live:" + item.Summary.Resource
|
|
289
|
+
}
|
|
290
|
+
summary := item.Summary
|
|
291
|
+
entry := &catalogueEntry{sessionFile: sessionFile{path, ms}, file: item.File, summary: &summary}
|
|
292
|
+
if existing := entries[path]; existing != nil && existing.onDisk {
|
|
293
|
+
entry.onDisk = true
|
|
294
|
+
}
|
|
295
|
+
entries[path] = entry
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
ordered := make([]*catalogueEntry, 0, len(entries))
|
|
299
|
+
for _, e := range entries {
|
|
300
|
+
ordered = append(ordered, e)
|
|
301
|
+
}
|
|
302
|
+
sort.Slice(ordered, func(i, j int) bool {
|
|
303
|
+
if ordered[i].mtimeMs != ordered[j].mtimeMs {
|
|
304
|
+
return ordered[i].mtimeMs > ordered[j].mtimeMs
|
|
305
|
+
}
|
|
306
|
+
return ordered[i].path < ordered[j].path
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
start := 0
|
|
310
|
+
if cursor != nil {
|
|
311
|
+
after, err := decodeCursor(*cursor)
|
|
312
|
+
if err != nil {
|
|
313
|
+
return ahptypes.ListSessionsResult{}, err
|
|
314
|
+
}
|
|
315
|
+
index := -1
|
|
316
|
+
for i, e := range ordered {
|
|
317
|
+
if e.path == after.path {
|
|
318
|
+
index = i
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if index < 0 {
|
|
323
|
+
// The entry the cursor named is gone (deleted between pages): fall back to the
|
|
324
|
+
// position it would have occupied rather than failing the whole call.
|
|
325
|
+
for i, e := range ordered {
|
|
326
|
+
if e.mtimeMs < after.mtimeMs || (e.mtimeMs == after.mtimeMs && e.path > after.path) {
|
|
327
|
+
index = i
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if index < 0 {
|
|
332
|
+
return ahptypes.ListSessionsResult{Items: []ahptypes.SessionSummary{}}, nil
|
|
333
|
+
}
|
|
334
|
+
start = index
|
|
335
|
+
} else {
|
|
336
|
+
start = index + 1
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
end := start + int(pageSize)
|
|
340
|
+
if end > len(ordered) {
|
|
341
|
+
end = len(ordered)
|
|
342
|
+
}
|
|
343
|
+
page := ordered[start:end]
|
|
344
|
+
items := []ahptypes.SessionSummary{}
|
|
345
|
+
for _, e := range page {
|
|
346
|
+
var summary ahptypes.SessionSummary
|
|
347
|
+
ok := false
|
|
348
|
+
switch {
|
|
349
|
+
case e.summary != nil:
|
|
350
|
+
summary, ok = *e.summary, true
|
|
351
|
+
case e.file != "":
|
|
352
|
+
summary, ok = readSessionSummary(sessionFile{e.file, e.mtimeMs})
|
|
353
|
+
}
|
|
354
|
+
if !ok {
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if e.file != "" && e.onDisk {
|
|
358
|
+
c.mu.Lock()
|
|
359
|
+
c.index[summary.Resource] = e.file
|
|
360
|
+
c.mu.Unlock()
|
|
361
|
+
}
|
|
362
|
+
items = append(items, summary)
|
|
363
|
+
}
|
|
364
|
+
result := ahptypes.ListSessionsResult{Items: items}
|
|
365
|
+
if end < len(ordered) && len(page) > 0 {
|
|
366
|
+
next := encodeCursor(page[len(page)-1].sessionFile)
|
|
367
|
+
result.NextCursor = &next
|
|
368
|
+
}
|
|
369
|
+
return result, nil
|
|
370
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"reflect"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
17
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
18
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Twins of upstream test/session-catalogue.test.ts: ordering, pagination, title derivation, live
|
|
22
|
+
// overlays, over a synthetic sessions directory.
|
|
23
|
+
|
|
24
|
+
func list(t *testing.T, c *pi.Catalogue, limit int64, cursor *string, live func() []pi.LiveCatalogueEntry) ahptypes.ListSessionsResult {
|
|
25
|
+
t.Helper()
|
|
26
|
+
var l *int64
|
|
27
|
+
if limit > 0 {
|
|
28
|
+
l = &limit
|
|
29
|
+
}
|
|
30
|
+
result, err := c.List(context.Background(), l, cursor, live)
|
|
31
|
+
if err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func resources(items []ahptypes.SessionSummary) []string {
|
|
38
|
+
out := []string{}
|
|
39
|
+
for _, i := range items {
|
|
40
|
+
out = append(out, i.Resource)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestSessionCatalogue(t *testing.T) {
|
|
46
|
+
root := t.TempDir()
|
|
47
|
+
var ids []string
|
|
48
|
+
// Interleave two working directories so the walk covers more than one session dir.
|
|
49
|
+
for i := 0; i < 5; i++ {
|
|
50
|
+
id := newID()
|
|
51
|
+
ids = append(ids, id)
|
|
52
|
+
cwd := "/tmp/project-b"
|
|
53
|
+
if i%2 == 0 {
|
|
54
|
+
cwd = "/tmp/project a"
|
|
55
|
+
}
|
|
56
|
+
writeFakeSession(t, root, id, fakeSession{cwd: cwd, firstUserMessage: "Message number " + itoa(i), mtimeSeconds: 1_700_000_000 + int64(i)})
|
|
57
|
+
}
|
|
58
|
+
catalogue := pi.NewCatalogue(root)
|
|
59
|
+
|
|
60
|
+
twin.Run(t, "session-catalogue", "returns most-recently-modified first, across working directories", func(t *testing.T) {
|
|
61
|
+
result := list(t, catalogue, 0, nil, nil)
|
|
62
|
+
if len(result.Items) != 5 {
|
|
63
|
+
t.Fatalf("%d items", len(result.Items))
|
|
64
|
+
}
|
|
65
|
+
var want []string
|
|
66
|
+
for i := len(ids) - 1; i >= 0; i-- {
|
|
67
|
+
want = append(want, wire.SessionURI(ids[i]))
|
|
68
|
+
}
|
|
69
|
+
if got := resources(result.Items); !reflect.DeepEqual(got, want) {
|
|
70
|
+
t.Fatalf("order = %v", got)
|
|
71
|
+
}
|
|
72
|
+
if got := result.Items[0].WorkingDirectories; !reflect.DeepEqual(got, []ahptypes.URI{wire.PathToFileURI("/tmp/project a")}) {
|
|
73
|
+
t.Fatalf("workingDirectories = %v", got)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
twin.Run(t, "session-catalogue", "paginates with an opaque cursor and stops at the end", func(t *testing.T) {
|
|
78
|
+
first := list(t, catalogue, 2, nil, nil)
|
|
79
|
+
if len(first.Items) != 2 || first.NextCursor == nil {
|
|
80
|
+
t.Fatalf("first page = %+v", first)
|
|
81
|
+
}
|
|
82
|
+
second := list(t, catalogue, 2, first.NextCursor, nil)
|
|
83
|
+
if len(second.Items) != 2 || second.NextCursor == nil {
|
|
84
|
+
t.Fatalf("second page = %+v", second)
|
|
85
|
+
}
|
|
86
|
+
third := list(t, catalogue, 2, second.NextCursor, nil)
|
|
87
|
+
// A missing nextCursor is what signals the end of the catalogue.
|
|
88
|
+
if len(third.Items) != 1 || third.NextCursor != nil {
|
|
89
|
+
t.Fatalf("third page = %+v", third)
|
|
90
|
+
}
|
|
91
|
+
seen := map[string]bool{}
|
|
92
|
+
for _, page := range [][]ahptypes.SessionSummary{first.Items, second.Items, third.Items} {
|
|
93
|
+
for _, item := range page {
|
|
94
|
+
seen[item.Resource] = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if len(seen) != 5 {
|
|
98
|
+
t.Fatalf("pages overlap: %d distinct", len(seen))
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
twin.Run(t, "session-catalogue", "rejects a malformed cursor with InvalidParams", func(t *testing.T) {
|
|
103
|
+
bad := "not-a-cursor"
|
|
104
|
+
_, err := catalogue.List(context.Background(), ptr(int64(2)), &bad, nil)
|
|
105
|
+
var werr *wire.Error
|
|
106
|
+
if !errors.As(err, &werr) || werr.Code != wire.CodeInvalidParams {
|
|
107
|
+
t.Fatalf("err = %v", err)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
twin.Run(t, "session-catalogue", "titles a session by its name, falling back to the first user message", func(t *testing.T) {
|
|
112
|
+
writeFakeSession(t, root, newID(), fakeSession{cwd: "/tmp/project-c", firstUserMessage: "This should lose to the explicit name", name: "Nightly refactor", mtimeSeconds: 1_700_001_000})
|
|
113
|
+
result := list(t, catalogue, 1, nil, nil)
|
|
114
|
+
// Same rule Pi's own /resume picker uses: name ?? firstMessage.
|
|
115
|
+
if result.Items[0].Title != "Nightly refactor" {
|
|
116
|
+
t.Fatalf("title = %q", result.Items[0].Title)
|
|
117
|
+
}
|
|
118
|
+
unnamed := list(t, catalogue, 2, nil, nil)
|
|
119
|
+
if unnamed.Items[1].Title != "Message number 4" {
|
|
120
|
+
t.Fatalf("title = %q", unnamed.Items[1].Title)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
twin.Run(t, "session-catalogue", "skips files that are not readable pi sessions", func(t *testing.T) {
|
|
125
|
+
before := list(t, catalogue, 0, nil, nil)
|
|
126
|
+
dir := filepath.Join(root, "--tmp-project-broken--")
|
|
127
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
if err := os.WriteFile(filepath.Join(dir, "1700002000_broken.jsonl"), []byte("not json at all\n"), 0o644); err != nil {
|
|
131
|
+
t.Fatal(err)
|
|
132
|
+
}
|
|
133
|
+
// One corrupt file must neither appear nor take down the catalogue.
|
|
134
|
+
if after := list(t, catalogue, 0, nil, nil); !reflect.DeepEqual(after, before) {
|
|
135
|
+
t.Fatalf("catalogue changed: %+v vs %+v", after, before)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
twin.Run(t, "session-catalogue", "returns an empty catalogue when nothing exists yet", func(t *testing.T) {
|
|
140
|
+
empty := pi.NewCatalogue(filepath.Join(root, "does-not-exist"))
|
|
141
|
+
if got := list(t, empty, 0, nil, nil); len(got.Items) != 0 || got.NextCursor != nil {
|
|
142
|
+
t.Fatalf("got %+v", got)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
twin.Run(t, "session-catalogue", "does not return a cached path after its session file is removed", func(t *testing.T) {
|
|
147
|
+
isolatedRoot := t.TempDir()
|
|
148
|
+
id := newID()
|
|
149
|
+
file := writeFakeSession(t, isolatedRoot, id, fakeSession{cwd: "/tmp/cached", firstUserMessage: "cache me", mtimeSeconds: 1_700_003_000})
|
|
150
|
+
isolated := pi.NewCatalogue(isolatedRoot)
|
|
151
|
+
list(t, isolated, 0, nil, nil)
|
|
152
|
+
if got, _ := isolated.FindSessionFile(id); got != file {
|
|
153
|
+
t.Fatalf("found %q, want %q", got, file)
|
|
154
|
+
}
|
|
155
|
+
if err := os.Remove(file); err != nil {
|
|
156
|
+
t.Fatal(err)
|
|
157
|
+
}
|
|
158
|
+
if got, _ := isolated.FindSessionFile(id); got != "" {
|
|
159
|
+
t.Fatalf("found a removed file: %q", got)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func ptr[T any](v T) *T { return &v }
|
|
165
|
+
|
|
166
|
+
func liveSummary(id, title string, status ahptypes.SessionStatus, modified time.Time) ahptypes.SessionSummary {
|
|
167
|
+
return ahptypes.SessionSummary{
|
|
168
|
+
Resource: wire.SessionURI(id), Provider: "pi", Title: title, Status: status,
|
|
169
|
+
CreatedAt: time.UnixMilli(0).UTC().Format("2006-01-02T15:04:05.000Z"), ModifiedAt: modified.UTC().Format("2006-01-02T15:04:05.000Z"),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func TestLiveSessionCatalogueOverlays(t *testing.T) {
|
|
174
|
+
twin.Run(t, "session-catalogue", "reads live status after disk discovery rather than freezing request-start state", func(t *testing.T) {
|
|
175
|
+
// Upstream races an async scan against a state change. The Go scan is synchronous, so the
|
|
176
|
+
// property kept is that live state is read exactly once, when the page is built.
|
|
177
|
+
root := t.TempDir()
|
|
178
|
+
current := liveSummary(newID(), "Live", ahptypes.SessionStatusInProgress, time.UnixMilli(1000))
|
|
179
|
+
reads := 0
|
|
180
|
+
source := func() []pi.LiveCatalogueEntry {
|
|
181
|
+
reads++
|
|
182
|
+
return []pi.LiveCatalogueEntry{{Summary: current}}
|
|
183
|
+
}
|
|
184
|
+
current.Status, current.ModifiedAt = ahptypes.SessionStatusIdle, "1970-01-01T00:00:02.000Z"
|
|
185
|
+
got := list(t, pi.NewCatalogue(root), 1, nil, source)
|
|
186
|
+
if !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{current}) || got.NextCursor != nil {
|
|
187
|
+
t.Fatalf("got %+v", got)
|
|
188
|
+
}
|
|
189
|
+
if reads != 1 {
|
|
190
|
+
t.Fatalf("live source read %d times", reads)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
twin.Run(t, "session-catalogue", "keeps a live entry stable as its file appears, without caching a nonexistent file", func(t *testing.T) {
|
|
195
|
+
root := t.TempDir()
|
|
196
|
+
id := newID()
|
|
197
|
+
options := fakeSession{cwd: "/tmp/live", firstUserMessage: "disk", mtimeSeconds: 100}
|
|
198
|
+
file := writeFakeSession(t, root, id, options)
|
|
199
|
+
if err := os.Remove(file); err != nil {
|
|
200
|
+
t.Fatal(err)
|
|
201
|
+
}
|
|
202
|
+
live := liveSummary(id, "Live", ahptypes.SessionStatusInProgress, time.UnixMilli(200000))
|
|
203
|
+
catalogue := pi.NewCatalogue(root)
|
|
204
|
+
source := func() []pi.LiveCatalogueEntry { return []pi.LiveCatalogueEntry{{File: file, Summary: live}} }
|
|
205
|
+
if got := list(t, catalogue, 1, nil, source); !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{live}) {
|
|
206
|
+
t.Fatalf("got %+v", got)
|
|
207
|
+
}
|
|
208
|
+
if _, cached := catalogue.FileFor(live.Resource); cached {
|
|
209
|
+
t.Fatal("cached a nonexistent file")
|
|
210
|
+
}
|
|
211
|
+
writeFakeSession(t, root, id, options)
|
|
212
|
+
if got := list(t, catalogue, 1, nil, source); !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{live}) {
|
|
213
|
+
t.Fatalf("got %+v", got)
|
|
214
|
+
}
|
|
215
|
+
if got, _ := catalogue.FileFor(live.Resource); got != file {
|
|
216
|
+
t.Fatalf("cached %q, want %q", got, file)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
twin.Run(t, "session-catalogue", "orders and paginates live summaries without duplicating their files", func(t *testing.T) {
|
|
221
|
+
root := t.TempDir()
|
|
222
|
+
replacedID, olderID, liveOnlyID := newID(), newID(), newID()
|
|
223
|
+
replacedFile := writeFakeSession(t, root, replacedID, fakeSession{cwd: "/tmp/replaced", firstUserMessage: "stale disk title", mtimeSeconds: 100})
|
|
224
|
+
writeFakeSession(t, root, olderID, fakeSession{cwd: "/tmp/older", firstUserMessage: "older", mtimeSeconds: 200})
|
|
225
|
+
summary := func(id, title string, seconds int64) ahptypes.SessionSummary {
|
|
226
|
+
return liveSummary(id, title, ahptypes.SessionStatusInProgress, time.Unix(seconds, 0))
|
|
227
|
+
}
|
|
228
|
+
liveEntries := []pi.LiveCatalogueEntry{
|
|
229
|
+
{File: replacedFile, Summary: summary(replacedID, "live replacement", 400)},
|
|
230
|
+
{Summary: summary(liveOnlyID, "not on disk", 300)},
|
|
231
|
+
}
|
|
232
|
+
source := func() []pi.LiveCatalogueEntry { return liveEntries }
|
|
233
|
+
catalogue := pi.NewCatalogue(root)
|
|
234
|
+
first := list(t, catalogue, 2, nil, source)
|
|
235
|
+
if len(first.Items) != 2 || first.NextCursor == nil {
|
|
236
|
+
t.Fatalf("first = %+v", first)
|
|
237
|
+
}
|
|
238
|
+
second := list(t, catalogue, 2, first.NextCursor, source)
|
|
239
|
+
items := append(append([]ahptypes.SessionSummary{}, first.Items...), second.Items...)
|
|
240
|
+
want := []string{wire.SessionURI(replacedID), wire.SessionURI(liveOnlyID), wire.SessionURI(olderID)}
|
|
241
|
+
if got := resources(items); !reflect.DeepEqual(got, want) {
|
|
242
|
+
t.Fatalf("order = %v", got)
|
|
243
|
+
}
|
|
244
|
+
if items[0].Title != "live replacement" || items[0].Status != ahptypes.SessionStatusInProgress {
|
|
245
|
+
t.Fatalf("first item = %+v", items[0])
|
|
246
|
+
}
|
|
247
|
+
if second.NextCursor != nil {
|
|
248
|
+
t.Fatal("expected the end of the catalogue")
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func TestListSessionsOverTheWire(t *testing.T) {
|
|
254
|
+
twin.Run(t, "session-catalogue", "serves a schema-conforming page", func(t *testing.T) {
|
|
255
|
+
root := t.TempDir()
|
|
256
|
+
writeFakeSession(t, root, newID(), fakeSession{cwd: "/tmp/wire", firstUserMessage: "Explain this repository", mtimeSeconds: 1_700_100_000})
|
|
257
|
+
h := startHarness(t, harnessOptions{sessionRoot: root})
|
|
258
|
+
client := testkit.Connect(t, h.host)
|
|
259
|
+
t.Cleanup(client.Close)
|
|
260
|
+
client.Initialize(nextClientID(), nil)
|
|
261
|
+
raw := client.Must("listSessions", obj{"channel": wire.RootChannel})
|
|
262
|
+
var result struct{ Items []obj }
|
|
263
|
+
if err := json.Unmarshal(raw, &result); err != nil {
|
|
264
|
+
t.Fatal(err)
|
|
265
|
+
}
|
|
266
|
+
if len(result.Items) != 1 || result.Items[0]["title"] != "Explain this repository" {
|
|
267
|
+
t.Fatalf("result = %s", raw)
|
|
268
|
+
}
|
|
269
|
+
testkit.AssertValid(t, "commands", "ListSessionsResult", testkit.Normalize(t, raw))
|
|
270
|
+
})
|
|
271
|
+
}
|