Merge pull request #197 from bnema/feat/demo-remote-sessions
feat(demo): showcase remote session workflows
20 files · +257 -105
A .dockerignore +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | .git | |
| 2 | + | .worktrees | |
| 3 | + | scripts/demo/out | |
| 4 | + | docs/assets/demo.gif | |
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | .git | |||
| 2 | + | .worktrees | |||
| 3 | + | scripts/demo/out | |||
| 4 | + | docs/assets/demo.gif | |||
M Makefile +3 -4
| @@ -53,10 +53,9 @@ demo: | |||
| 53 | 53 | -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \ | |
| 54 | 54 | -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \ | |
| 55 | 55 | /out/demo-masked.gif | |
| 56 | - | # --lossy above 30 smears the near-identical darks of the window chrome | |
| 57 | - | # (window bar vs terminal background) into visible banding, and the | |
| 58 | - | # conserve-memory fallback for huge inputs roughly doubles the output. | |
| 59 | - | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif | |
| 56 | + | # Keep the final GIF lossless: the streaming FFmpeg pass has already | |
| 57 | + | # bounded memory, and preserving terminal chrome avoids color banding. | |
| 58 | + | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 -o /out/demo.gif /out/demo-masked.gif | |
| 60 | 59 | # Temp files are owned by the container's subuid, so remove them from | |
| 61 | 60 | # inside a container rather than the host. | |
| 62 | 61 | docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png' | |
| @@ -53,10 +53,9 @@ demo: | |||||
| 53 | -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \ | 53 | -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \ | ||
| 54 | -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \ | 54 | -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \ | ||
| 55 | /out/demo-masked.gif | 55 | /out/demo-masked.gif | ||
| 56 | - | # --lossy above 30 smears the near-identical darks of the window chrome | 56 | + | # Keep the final GIF lossless: the streaming FFmpeg pass has already |
| 57 | - | # (window bar vs terminal background) into visible banding, and the | 57 | + | # bounded memory, and preserving terminal chrome avoids color banding. |
| 58 | - | # conserve-memory fallback for huge inputs roughly doubles the output. | 58 | + | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 -o /out/demo.gif /out/demo-masked.gif |
| 59 | - | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif | |||
| 60 | # Temp files are owned by the container's subuid, so remove them from | 59 | # Temp files are owned by the container's subuid, so remove them from | ||
| 61 | # inside a container rather than the host. | 60 | # inside a container rather than the host. | ||
| 62 | docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png' | 61 | docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png' | ||
M README.md +9 -2
| @@ -1,11 +1,18 @@ | |||
| 1 | 1 | <h1 align="center">vev</h1> | |
| 2 | 2 | ||
| 3 | + | <p align="center"> | |
| 4 | + | <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a> | |
| 5 | + | <a href="https://github.com/bnema/vev/releases"><img src="https://img.shields.io/badge/platforms-Linux%20%7C%20macOS-blue?style=flat-square" alt="Platforms: Linux and macOS"></a> | |
| 6 | + | <a href="https://github.com/bnema/vev/commits/main"><img src="https://badgen.net/github/last-commit/bnema/vev/main?icon=github" alt="Last commit"></a> | |
| 7 | + | <a href="https://github.com/bnema/vev/stargazers"><img src="https://badgen.net/github/stars/bnema/vev?icon=github" alt="GitHub stars"></a> | |
| 8 | + | </p> | |
| 9 | + | ||
| 3 | 10 | <p align="center"><em>Norwegian: to weave</em></p> | |
| 4 | 11 | ||
| 5 | 12 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | |
| 6 | 13 | Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p> | |
| 7 | 14 | ||
| 8 | - | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p> | |
| 15 | + | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: local and remote sessions, splits, stacked panes, floating window, notifications, detach and re-attach" width="800"></p> | |
| 9 | 16 | ||
| 10 | 17 | --- | |
| 11 | 18 | ||
| @@ -133,5 +140,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa | |||
| 133 | 140 | make test # go test ./... -race | |
| 134 | 141 | make lint # goimports check, go vet | |
| 135 | 142 | make mocks # regenerate mocks | |
| 136 | - | make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials) | |
| 143 | + | make demo # regenerate docs/assets/demo.gif (needs Docker) | |
| 137 | 144 | ``` | |
| @@ -1,11 +1,18 @@ | |||||
| 1 | <h1 align="center">vev</h1> | 1 | <h1 align="center">vev</h1> | ||
| 2 | 2 | ||||
| 3 | + | <p align="center"> | |||
| 4 | + | <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a> | |||
| 5 | + | <a href="https://github.com/bnema/vev/releases"><img src="https://img.shields.io/badge/platforms-Linux%20%7C%20macOS-blue?style=flat-square" alt="Platforms: Linux and macOS"></a> | |||
| 6 | + | <a href="https://github.com/bnema/vev/commits/main"><img src="https://badgen.net/github/last-commit/bnema/vev/main?icon=github" alt="Last commit"></a> | |||
| 7 | + | <a href="https://github.com/bnema/vev/stargazers"><img src="https://badgen.net/github/stars/bnema/vev?icon=github" alt="GitHub stars"></a> | |||
| 8 | + | </p> | |||
| 9 | + | ||||
| 3 | <p align="center"><em>Norwegian: to weave</em></p> | 10 | <p align="center"><em>Norwegian: to weave</em></p> | ||
| 4 | 11 | ||||
| 5 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | 12 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | ||
| 6 | Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p> | 13 | Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p> | ||
| 7 | 14 | ||||
| 8 | - | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p> | 15 | + | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: local and remote sessions, splits, stacked panes, floating window, notifications, detach and re-attach" width="800"></p> |
| 9 | 16 | ||||
| 10 | --- | 17 | --- | ||
| 11 | 18 | ||||
| @@ -133,5 +140,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa | |||||
| 133 | make test # go test ./... -race | 140 | make test # go test ./... -race | ||
| 134 | make lint # goimports check, go vet | 141 | make lint # goimports check, go vet | ||
| 135 | make mocks # regenerate mocks | 142 | make mocks # regenerate mocks | ||
| 136 | - | make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials) | 143 | + | make demo # regenerate docs/assets/demo.gif (needs Docker) |
| 137 | ``` | 144 | ``` | ||
M docs/assets/demo.gif +0 -0 binary file: no text diff
binary file: no text diff
M internal/usecase/client/routes.go +4 -0
| @@ -549,6 +549,10 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT | |||
| 549 | 549 | } | |
| 550 | 550 | request := cloneAttachRequest(entry.request) | |
| 551 | 551 | request.Intent = target.Intent | |
| 552 | + | // The active dialer already reaches this daemon. Retaining the route's | |
| 553 | + | // original discovery target would make its Hello request a second, | |
| 554 | + | // incorrect remote handoff instead of a same-peer session switch. | |
| 555 | + | request.RemoteTarget = nil | |
| 552 | 556 | request.EnvironmentPolicy = target.EnvironmentPolicy | |
| 553 | 557 | request.ExactTarget = target.ExactTarget | |
| 554 | 558 | if target.ExactTarget == nil { | |
| @@ -549,6 +549,10 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT | |||||
| 549 | } | 549 | } | ||
| 550 | request := cloneAttachRequest(entry.request) | 550 | request := cloneAttachRequest(entry.request) | ||
| 551 | request.Intent = target.Intent | 551 | request.Intent = target.Intent | ||
| 552 | + | // The active dialer already reaches this daemon. Retaining the route's | |||
| 553 | + | // original discovery target would make its Hello request a second, | |||
| 554 | + | // incorrect remote handoff instead of a same-peer session switch. | |||
| 555 | + | request.RemoteTarget = nil | |||
| 552 | request.EnvironmentPolicy = target.EnvironmentPolicy | 556 | request.EnvironmentPolicy = target.EnvironmentPolicy | ||
| 553 | request.ExactTarget = target.ExactTarget | 557 | request.ExactTarget = target.ExactTarget | ||
| 554 | if target.ExactTarget == nil { | 558 | if target.ExactTarget == nil { | ||
M internal/usecase/client/routes_test.go +33 -0
| @@ -148,6 +148,39 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||
| 148 | 148 | } | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | + | func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) { | |
| 152 | + | ledger := newRouteLedger() | |
| 153 | + | work := routeTestCandidate(1, ports.RouteOriginRemote) | |
| 154 | + | work.originKey = "remote" | |
| 155 | + | work.request.Remote = true | |
| 156 | + | work.request.OriginKey = "remote" | |
| 157 | + | work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned | |
| 158 | + | work.request.RemoteTarget = &domain.RemoteSessionTarget{ | |
| 159 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID, | |
| 160 | + | SessionName: work.target.SessionName, LiveTabID: "tab-work", | |
| 161 | + | } | |
| 162 | + | _, err := ledger.commit(work) | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | ||
| 165 | + | agents := routeTestCandidate(2, ports.RouteOriginRemote) | |
| 166 | + | agents.originKey = "remote" | |
| 167 | + | agents.request.Remote = true | |
| 168 | + | agents.request.OriginKey = "remote" | |
| 169 | + | agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned | |
| 170 | + | ||
| 171 | + | request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{ | |
| 172 | + | Session: work.target.SessionName, | |
| 173 | + | Intent: ports.IntentAttach, | |
| 174 | + | ExactTarget: &work.target, | |
| 175 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 176 | + | }) | |
| 177 | + | ||
| 178 | + | require.True(t, request.Remote) | |
| 179 | + | require.Equal(t, ports.RouteOriginRemote, request.Origin) | |
| 180 | + | require.Equal(t, "remote", request.OriginKey) | |
| 181 | + | require.Nil(t, request.RemoteTarget) | |
| 182 | + | } | |
| 183 | + | ||
| 151 | 184 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | |
| 152 | 185 | ledger := newRouteLedger() | |
| 153 | 186 | first := routeTestCandidate(0, ports.RouteOriginRemote) | |
| @@ -148,6 +148,39 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||||
| 148 | } | 148 | } | ||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | + | func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) { | |||
| 152 | + | ledger := newRouteLedger() | |||
| 153 | + | work := routeTestCandidate(1, ports.RouteOriginRemote) | |||
| 154 | + | work.originKey = "remote" | |||
| 155 | + | work.request.Remote = true | |||
| 156 | + | work.request.OriginKey = "remote" | |||
| 157 | + | work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned | |||
| 158 | + | work.request.RemoteTarget = &domain.RemoteSessionTarget{ | |||
| 159 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID, | |||
| 160 | + | SessionName: work.target.SessionName, LiveTabID: "tab-work", | |||
| 161 | + | } | |||
| 162 | + | _, err := ledger.commit(work) | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | ||||
| 165 | + | agents := routeTestCandidate(2, ports.RouteOriginRemote) | |||
| 166 | + | agents.originKey = "remote" | |||
| 167 | + | agents.request.Remote = true | |||
| 168 | + | agents.request.OriginKey = "remote" | |||
| 169 | + | agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned | |||
| 170 | + | ||||
| 171 | + | request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{ | |||
| 172 | + | Session: work.target.SessionName, | |||
| 173 | + | Intent: ports.IntentAttach, | |||
| 174 | + | ExactTarget: &work.target, | |||
| 175 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 176 | + | }) | |||
| 177 | + | ||||
| 178 | + | require.True(t, request.Remote) | |||
| 179 | + | require.Equal(t, ports.RouteOriginRemote, request.Origin) | |||
| 180 | + | require.Equal(t, "remote", request.OriginKey) | |||
| 181 | + | require.Nil(t, request.RemoteTarget) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 151 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | 184 | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | ||
| 152 | ledger := newRouteLedger() | 185 | ledger := newRouteLedger() | ||
| 153 | first := routeTestCandidate(0, ports.RouteOriginRemote) | 186 | first := routeTestCandidate(0, ports.RouteOriginRemote) | ||
M internal/usecase/daemon/client_frame_routing.go +3 -2
| @@ -132,10 +132,11 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 132 | 132 | d.log.Warn("malformed recent route snapshot", "err", derr) | |
| 133 | 133 | break | |
| 134 | 134 | } | |
| 135 | - | if !token.ac.setRouteSnapshot(snapshot) { | |
| 135 | + | replayIdentity := token.ac.setRouteSnapshot(snapshot) | |
| 136 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |
| 137 | + | if !replayIdentity { | |
| 136 | 138 | break | |
| 137 | 139 | } | |
| 138 | - | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |
| 139 | 140 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | |
| 140 | 141 | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 141 | 142 | } | |
| @@ -132,10 +132,11 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 132 | d.log.Warn("malformed recent route snapshot", "err", derr) | 132 | d.log.Warn("malformed recent route snapshot", "err", derr) | ||
| 133 | break | 133 | break | ||
| 134 | } | 134 | } | ||
| 135 | - | if !token.ac.setRouteSnapshot(snapshot) { | 135 | + | replayIdentity := token.ac.setRouteSnapshot(snapshot) |
| 136 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |||
| 137 | + | if !replayIdentity { | |||
| 136 | break | 138 | break | ||
| 137 | } | 139 | } | ||
| 138 | - | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |||
| 139 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | 140 | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | ||
| 140 | d.detachOnAttachmentSendError(token, token.transport.transport) | 141 | d.detachOnAttachmentSendError(token, token.transport.transport) | ||
| 141 | } | 142 | } | ||
M internal/usecase/daemon/palette.go +10 -3
| @@ -107,11 +107,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 107 | 107 | continue | |
| 108 | 108 | } | |
| 109 | 109 | if _, represented := localNames[entry.Name]; represented { | |
| 110 | - | localRoute := entry.Kind == ports.RouteKindLocal | |
| 111 | - | currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin | |
| 112 | - | if localRoute || currentRemoteRoute { | |
| 110 | + | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || | |
| 111 | + | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) | |
| 112 | + | if sameOrigin { | |
| 113 | 113 | continue | |
| 114 | 114 | } | |
| 115 | + | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |
| 116 | + | label = formatRecentRouteName(recentRoutePresentation{ | |
| 117 | + | name: entry.Name, | |
| 118 | + | kind: recentRouteLocal, | |
| 119 | + | ephemeral: entry.Ephemeral, | |
| 120 | + | }, true) | |
| 121 | + | } | |
| 115 | 122 | } | |
| 116 | 123 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| 117 | 124 | SnapshotGeneration: routeSnapshot.Generation, | |
| @@ -107,11 +107,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 107 | continue | 107 | continue | ||
| 108 | } | 108 | } | ||
| 109 | if _, represented := localNames[entry.Name]; represented { | 109 | if _, represented := localNames[entry.Name]; represented { | ||
| 110 | - | localRoute := entry.Kind == ports.RouteKindLocal | 110 | + | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || |
| 111 | - | currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin | 111 | + | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) |
| 112 | - | if localRoute || currentRemoteRoute { | 112 | + | if sameOrigin { |
| 113 | continue | 113 | continue | ||
| 114 | } | 114 | } | ||
| 115 | + | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |||
| 116 | + | label = formatRecentRouteName(recentRoutePresentation{ | |||
| 117 | + | name: entry.Name, | |||
| 118 | + | kind: recentRouteLocal, | |||
| 119 | + | ephemeral: entry.Ephemeral, | |||
| 120 | + | }, true) | |||
| 121 | + | } | |||
| 115 | } | 122 | } | ||
| 116 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | 123 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | ||
| 117 | SnapshotGeneration: routeSnapshot.Generation, | 124 | SnapshotGeneration: routeSnapshot.Generation, | ||
M internal/usecase/daemon/palette_test.go +7 -2
| @@ -438,13 +438,18 @@ func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) | |||
| 438 | 438 | want []string | |
| 439 | 439 | }{ | |
| 440 | 440 | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | |
| 441 | + | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | |
| 441 | 442 | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |
| 442 | 443 | } { | |
| 443 | 444 | t.Run(test.name, func(t *testing.T) { | |
| 444 | - | results := d.paletteResults(current, nil, snapshot, test.currentOrigin) | |
| 445 | + | testSnapshot := snapshot | |
| 446 | + | if test.name == "remote daemon keeps distinct local route" { | |
| 447 | + | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | |
| 448 | + | } | |
| 449 | + | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | |
| 445 | 450 | var matching []string | |
| 446 | 451 | for _, result := range results { | |
| 447 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" { | |
| 452 | + | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { | |
| 448 | 453 | matching = append(matching, result.DisplayText()) | |
| 449 | 454 | } | |
| 450 | 455 | } | |
| @@ -438,13 +438,18 @@ func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) | |||||
| 438 | want []string | 438 | want []string | ||
| 439 | }{ | 439 | }{ | ||
| 440 | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | 440 | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | ||
| 441 | + | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | |||
| 441 | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | 442 | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | ||
| 442 | } { | 443 | } { | ||
| 443 | t.Run(test.name, func(t *testing.T) { | 444 | t.Run(test.name, func(t *testing.T) { | ||
| 444 | - | results := d.paletteResults(current, nil, snapshot, test.currentOrigin) | 445 | + | testSnapshot := snapshot |
| 446 | + | if test.name == "remote daemon keeps distinct local route" { | |||
| 447 | + | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | |||
| 448 | + | } | |||
| 449 | + | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | |||
| 445 | var matching []string | 450 | var matching []string | ||
| 446 | for _, result := range results { | 451 | for _, result := range results { | ||
| 447 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" { | 452 | + | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { |
| 448 | matching = append(matching, result.DisplayText()) | 453 | matching = append(matching, result.DisplayText()) | ||
| 449 | } | 454 | } | ||
| 450 | } | 455 | } | ||
M internal/usecase/daemon/picker.go +0 -5
| @@ -896,11 +896,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 896 | 896 | if target.RemoteKey != nil { | |
| 897 | 897 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | |
| 898 | 898 | } | |
| 899 | - | token.effect.bindActionEnd(d, action) | |
| 900 | - | token.effect.End() | |
| 901 | - | if !token.attachmentCurrent() { | |
| 902 | - | return errAttachmentTransition | |
| 903 | - | } | |
| 904 | 899 | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) | |
| 905 | 900 | } | |
| 906 | 901 | ||
| @@ -896,11 +896,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||||
| 896 | if target.RemoteKey != nil { | 896 | if target.RemoteKey != nil { | ||
| 897 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | 897 | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | ||
| 898 | } | 898 | } | ||
| 899 | - | token.effect.bindActionEnd(d, action) | |||
| 900 | - | token.effect.End() | |||
| 901 | - | if !token.attachmentCurrent() { | |||
| 902 | - | return errAttachmentTransition | |||
| 903 | - | } | |||
| 904 | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) | 899 | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) | ||
| 905 | } | 900 | } | ||
| 906 | 901 | ||||
M internal/usecase/daemon/recent_route_snapshot_test.go +13 -0
| @@ -62,6 +62,19 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |
| 66 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 67 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 68 | + | ac.publishAttachmentCapability(token) | |
| 69 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 70 | + | ||
| 71 | + | payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2}) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})) | |
| 74 | + | ||
| 75 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 76 | + | } | |
| 77 | + | ||
| 65 | 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| 66 | 79 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 67 | 80 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | |
| @@ -62,6 +62,19 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |||
| 66 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 67 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 68 | + | ac.publishAttachmentCapability(token) | |||
| 69 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |||
| 70 | + | ||||
| 71 | + | payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2}) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})) | |||
| 74 | + | ||||
| 75 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 65 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | ||
| 66 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | 79 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 67 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | 80 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | ||
M internal/usecase/daemon/remote_attach_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) { | |||
| 28 | 28 | token.effect = effect | |
| 29 | 29 | defer effect.End() | |
| 30 | 30 | ||
| 31 | - | require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | |
| 31 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | |
| 32 | 32 | frame := receiveRemotePicker(t, sends, "local attach target") | |
| 33 | 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 34 | 34 | require.NoError(t, err) | |
| @@ -28,7 +28,7 @@ func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) { | |||||
| 28 | token.effect = effect | 28 | token.effect = effect | ||
| 29 | defer effect.End() | 29 | defer effect.End() | ||
| 30 | 30 | ||||
| 31 | - | require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) | 31 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select")) |
| 32 | frame := receiveRemotePicker(t, sends, "local attach target") | 32 | frame := receiveRemotePicker(t, sends, "local attach target") | ||
| 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | 33 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | ||
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
M internal/usecase/daemon/session_rename_purge_test.go +19 -0
| @@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) | |||
| 65 | 65 | require.False(t, identity.Ephemeral) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | func TestEphemeralPromotionPreservesIncarnation(t *testing.T) { | |
| 69 | + | p, release := newBlockingPTY(t) | |
| 70 | + | defer release() | |
| 71 | + | store, state := newMockStore(t) | |
| 72 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 73 | + | WithStore(t, store)(d) | |
| 74 | + | ||
| 75 | + | sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | sess.mu.Lock() | |
| 78 | + | original := sess.incarnation | |
| 79 | + | sess.mu.Unlock() | |
| 80 | + | require.NotZero(t, original) | |
| 81 | + | ||
| 82 | + | require.NoError(t, d.renameSession(sess, "local")) | |
| 83 | + | require.Equal(t, original, sess.incarnation) | |
| 84 | + | require.Equal(t, original, state.record(t, "local").IncarnationID) | |
| 85 | + | } | |
| 86 | + | ||
| 68 | 87 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |
| 69 | 88 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 70 | 89 | repository := &retryablePurgeRepository{} | |
| @@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) | |||||
| 65 | require.False(t, identity.Ephemeral) | 65 | require.False(t, identity.Ephemeral) | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | func TestEphemeralPromotionPreservesIncarnation(t *testing.T) { | |||
| 69 | + | p, release := newBlockingPTY(t) | |||
| 70 | + | defer release() | |||
| 71 | + | store, state := newMockStore(t) | |||
| 72 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 73 | + | WithStore(t, store)(d) | |||
| 74 | + | ||||
| 75 | + | sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | sess.mu.Lock() | |||
| 78 | + | original := sess.incarnation | |||
| 79 | + | sess.mu.Unlock() | |||
| 80 | + | require.NotZero(t, original) | |||
| 81 | + | ||||
| 82 | + | require.NoError(t, d.renameSession(sess, "local")) | |||
| 83 | + | require.Equal(t, original, sess.incarnation) | |||
| 84 | + | require.Equal(t, original, state.record(t, "local").IncarnationID) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 68 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | 87 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | ||
| 69 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 88 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 70 | repository := &retryablePurgeRepository{} | 89 | repository := &retryablePurgeRepository{} | ||
M internal/usecase/recovery/coordinator.go +6 -4
| @@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||
| 115 | 115 | } else if exists { | |
| 116 | 116 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name) | |
| 117 | 117 | } | |
| 118 | - | id, err := domain.NewIncarnationID(c.random) | |
| 119 | - | if err != nil { | |
| 120 | - | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | |
| 118 | + | if record.IncarnationID == (domain.IncarnationID{}) { | |
| 119 | + | id, err := domain.NewIncarnationID(c.random) | |
| 120 | + | if err != nil { | |
| 121 | + | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | |
| 122 | + | } | |
| 123 | + | record.IncarnationID = id | |
| 121 | 124 | } | |
| 122 | - | record.IncarnationID = id | |
| 123 | 125 | record.Committed = nil | |
| 124 | 126 | record.DegradedReason = "" | |
| 125 | 127 | if err := record.Validate(); err != nil { | |
| @@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||||
| 115 | } else if exists { | 115 | } else if exists { | ||
| 116 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name) | 116 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name) | ||
| 117 | } | 117 | } | ||
| 118 | - | id, err := domain.NewIncarnationID(c.random) | 118 | + | if record.IncarnationID == (domain.IncarnationID{}) { |
| 119 | - | if err != nil { | 119 | + | id, err := domain.NewIncarnationID(c.random) |
| 120 | - | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | 120 | + | if err != nil { |
| 121 | + | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | |||
| 122 | + | } | |||
| 123 | + | record.IncarnationID = id | |||
| 121 | } | 124 | } | ||
| 122 | - | record.IncarnationID = id | |||
| 123 | record.Committed = nil | 125 | record.Committed = nil | ||
| 124 | record.DegradedReason = "" | 126 | record.DegradedReason = "" | ||
| 125 | if err := record.Validate(); err != nil { | 127 | if err := record.Validate(); err != nil { | ||
M scripts/demo/Dockerfile +23 -9
| @@ -7,23 +7,37 @@ COPY . . | |||
| 7 | 7 | RUN CGO_ENABLED=0 go build -o /out/vev . | |
| 8 | 8 | ||
| 9 | 9 | FROM archlinux:latest | |
| 10 | - | # The base image ships a pacman.conf that skips extracting man/info pages | |
| 11 | - | # (NoExtract) to stay small; the demo wants a real `man ls`, so drop that | |
| 12 | - | # rule and reinstall coreutils to pull its man page onto disk. | |
| 13 | - | RUN pacman -Sy --noconfirm archlinux-keyring \ | |
| 10 | + | RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \ | |
| 11 | + | pacman -Sy --noconfirm archlinux-keyring \ | |
| 14 | 12 | && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \ | |
| 15 | 13 | && pacman -Syu --noconfirm \ | |
| 16 | 14 | vhs ttyd ffmpeg chromium \ | |
| 17 | 15 | htop fastfetch gifsicle imagemagick \ | |
| 18 | 16 | eza neovim man-db man-pages coreutils \ | |
| 19 | - | nodejs npm ncurses \ | |
| 20 | - | ttf-jetbrains-mono-nerd \ | |
| 21 | - | && pacman -Scc --noconfirm | |
| 22 | - | RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code | |
| 17 | + | ncurses openssh util-linux \ | |
| 18 | + | ttf-jetbrains-mono-nerd | |
| 23 | 19 | RUN useradd -m -u 1000 demo | |
| 24 | 20 | COPY --from=build /out/vev /usr/local/bin/vev | |
| 25 | 21 | COPY scripts/demo/ticker.sh /usr/local/bin/ticker | |
| 26 | - | RUN chmod +x /usr/local/bin/ticker | |
| 22 | + | COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell | |
| 23 | + | RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \ | |
| 24 | + | && install -d -o demo -g demo -m 0700 /home/demo/.ssh \ | |
| 25 | + | && install -d -m 0755 /run/sshd \ | |
| 26 | + | && ssh-keygen -A \ | |
| 27 | + | && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \ | |
| 28 | + | && cp /home/demo/.ssh/id_ed25519.pub /home/demo/.ssh/authorized_keys \ | |
| 29 | + | && { printf 'remote '; cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub; } > /home/demo/.ssh/known_hosts \ | |
| 30 | + | && printf '%s\n' \ | |
| 31 | + | 'Host remote' \ | |
| 32 | + | ' User demo' \ | |
| 33 | + | ' IdentityFile ~/.ssh/id_ed25519' \ | |
| 34 | + | ' IdentitiesOnly yes' \ | |
| 35 | + | ' StrictHostKeyChecking yes' \ | |
| 36 | + | ' UserKnownHostsFile ~/.ssh/known_hosts' \ | |
| 37 | + | ' LogLevel ERROR' \ | |
| 38 | + | > /home/demo/.ssh/config \ | |
| 39 | + | && chmod 0600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/authorized_keys /home/demo/.ssh/known_hosts /home/demo/.ssh/config \ | |
| 40 | + | && chown -R demo:demo /home/demo/.ssh | |
| 27 | 41 | USER demo | |
| 28 | 42 | WORKDIR /home/demo | |
| 29 | 43 | ENV VHS_NO_SANDBOX=true | |
| @@ -7,23 +7,37 @@ COPY . . | |||||
| 7 | RUN CGO_ENABLED=0 go build -o /out/vev . | 7 | RUN CGO_ENABLED=0 go build -o /out/vev . | ||
| 8 | 8 | ||||
| 9 | FROM archlinux:latest | 9 | FROM archlinux:latest | ||
| 10 | - | # The base image ships a pacman.conf that skips extracting man/info pages | 10 | + | RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \ |
| 11 | - | # (NoExtract) to stay small; the demo wants a real `man ls`, so drop that | 11 | + | pacman -Sy --noconfirm archlinux-keyring \ |
| 12 | - | # rule and reinstall coreutils to pull its man page onto disk. | |||
| 13 | - | RUN pacman -Sy --noconfirm archlinux-keyring \ | |||
| 14 | && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \ | 12 | && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \ | ||
| 15 | && pacman -Syu --noconfirm \ | 13 | && pacman -Syu --noconfirm \ | ||
| 16 | vhs ttyd ffmpeg chromium \ | 14 | vhs ttyd ffmpeg chromium \ | ||
| 17 | htop fastfetch gifsicle imagemagick \ | 15 | htop fastfetch gifsicle imagemagick \ | ||
| 18 | eza neovim man-db man-pages coreutils \ | 16 | eza neovim man-db man-pages coreutils \ | ||
| 19 | - | nodejs npm ncurses \ | 17 | + | ncurses openssh util-linux \ |
| 20 | - | ttf-jetbrains-mono-nerd \ | 18 | + | ttf-jetbrains-mono-nerd |
| 21 | - | && pacman -Scc --noconfirm | |||
| 22 | - | RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code | |||
| 23 | RUN useradd -m -u 1000 demo | 19 | RUN useradd -m -u 1000 demo | ||
| 24 | COPY --from=build /out/vev /usr/local/bin/vev | 20 | COPY --from=build /out/vev /usr/local/bin/vev | ||
| 25 | COPY scripts/demo/ticker.sh /usr/local/bin/ticker | 21 | COPY scripts/demo/ticker.sh /usr/local/bin/ticker | ||
| 26 | - | RUN chmod +x /usr/local/bin/ticker | 22 | + | COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell |
| 23 | + | RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \ | |||
| 24 | + | && install -d -o demo -g demo -m 0700 /home/demo/.ssh \ | |||
| 25 | + | && install -d -m 0755 /run/sshd \ | |||
| 26 | + | && ssh-keygen -A \ | |||
| 27 | + | && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \ | |||
| 28 | + | && cp /home/demo/.ssh/id_ed25519.pub /home/demo/.ssh/authorized_keys \ | |||
| 29 | + | && { printf 'remote '; cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub; } > /home/demo/.ssh/known_hosts \ | |||
| 30 | + | && printf '%s\n' \ | |||
| 31 | + | 'Host remote' \ | |||
| 32 | + | ' User demo' \ | |||
| 33 | + | ' IdentityFile ~/.ssh/id_ed25519' \ | |||
| 34 | + | ' IdentitiesOnly yes' \ | |||
| 35 | + | ' StrictHostKeyChecking yes' \ | |||
| 36 | + | ' UserKnownHostsFile ~/.ssh/known_hosts' \ | |||
| 37 | + | ' LogLevel ERROR' \ | |||
| 38 | + | > /home/demo/.ssh/config \ | |||
| 39 | + | && chmod 0600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/authorized_keys /home/demo/.ssh/known_hosts /home/demo/.ssh/config \ | |||
| 40 | + | && chown -R demo:demo /home/demo/.ssh | |||
| 27 | USER demo | 41 | USER demo | ||
| 28 | WORKDIR /home/demo | 42 | WORKDIR /home/demo | ||
| 29 | ENV VHS_NO_SANDBOX=true | 43 | ENV VHS_NO_SANDBOX=true | ||
D scripts/demo/claude-settings.json +0 -6
| @@ -1,6 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "preferredNotifChannel": "terminal_bell", | |
| 3 | - | "permissions": { | |
| 4 | - | "allow": ["Bash(sleep:*)"] | |
| 5 | - | } | |
| 6 | - | } | |
| @@ -1,6 +0,0 @@ | |||||
| 1 | - | { | |||
| 2 | - | "preferredNotifChannel": "terminal_bell", | |||
| 3 | - | "permissions": { | |||
| 4 | - | "allow": ["Bash(sleep:*)"] | |||
| 5 | - | } | |||
| 6 | - | } | |||
A scripts/demo/compose.yaml +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | name: vev-demo | |
| 2 | + | ||
| 3 | + | services: | |
| 4 | + | remote: | |
| 5 | + | image: vev-demo | |
| 6 | + | hostname: remote | |
| 7 | + | user: root | |
| 8 | + | entrypoint: ["/usr/bin/sshd", "-D", "-e"] | |
| 9 | + | ||
| 10 | + | client: | |
| 11 | + | image: vev-demo | |
| 12 | + | depends_on: | |
| 13 | + | - remote | |
| 14 | + | volumes: | |
| 15 | + | - ../../scripts/demo:/tape:ro | |
| 16 | + | - ../../scripts/demo/out:/home/demo/out | |
| 17 | + | - ../../scripts/demo/out/local:/home/demo/.local | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | name: vev-demo | |||
| 2 | + | ||||
| 3 | + | services: | |||
| 4 | + | remote: | |||
| 5 | + | image: vev-demo | |||
| 6 | + | hostname: remote | |||
| 7 | + | user: root | |||
| 8 | + | entrypoint: ["/usr/bin/sshd", "-D", "-e"] | |||
| 9 | + | ||||
| 10 | + | client: | |||
| 11 | + | image: vev-demo | |||
| 12 | + | depends_on: | |||
| 13 | + | - remote | |||
| 14 | + | volumes: | |||
| 15 | + | - ../../scripts/demo:/tape:ro | |||
| 16 | + | - ../../scripts/demo/out:/home/demo/out | |||
| 17 | + | - ../../scripts/demo/out/local:/home/demo/.local | |||
A scripts/demo/demo-shell.sh +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | set -euo pipefail | |
| 3 | + | session=${VEV:-} | |
| 4 | + | unset VEV | |
| 5 | + | ||
| 6 | + | case "${VEV_DEMO_SCENE:-}" in | |
| 7 | + | remote-deploys) | |
| 8 | + | work_marker=/tmp/vev-demo-work-shown | |
| 9 | + | if [[ $session != session=work,* || -e $work_marker ]]; then | |
| 10 | + | exec /bin/bash --noprofile --norc | |
| 11 | + | fi | |
| 12 | + | : >"$work_marker" | |
| 13 | + | printf '\033]0;deploys · remote\007' | |
| 14 | + | printf '\n\033[1;36mDeploy queue · remote\033[0m\n\n' | |
| 15 | + | printf ' \033[32m✓\033[0m api-gateway production 3m ago\n' | |
| 16 | + | printf ' \033[32m✓\033[0m web-console production 7m ago\n' | |
| 17 | + | printf ' \033[33m•\033[0m worker-pool staging running\n\n' | |
| 18 | + | printf 'Use the session picker to jump between this host and local work.\n\n' | |
| 19 | + | exec /bin/bash --noprofile --norc | |
| 20 | + | ;; | |
| 21 | + | remote-ticker) | |
| 22 | + | exec ticker | |
| 23 | + | ;; | |
| 24 | + | esac | |
| 25 | + | ||
| 26 | + | exec /bin/bash --noprofile --norc | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | set -euo pipefail | |||
| 3 | + | session=${VEV:-} | |||
| 4 | + | unset VEV | |||
| 5 | + | ||||
| 6 | + | case "${VEV_DEMO_SCENE:-}" in | |||
| 7 | + | remote-deploys) | |||
| 8 | + | work_marker=/tmp/vev-demo-work-shown | |||
| 9 | + | if [[ $session != session=work,* || -e $work_marker ]]; then | |||
| 10 | + | exec /bin/bash --noprofile --norc | |||
| 11 | + | fi | |||
| 12 | + | : >"$work_marker" | |||
| 13 | + | printf '\033]0;deploys · remote\007' | |||
| 14 | + | printf '\n\033[1;36mDeploy queue · remote\033[0m\n\n' | |||
| 15 | + | printf ' \033[32m✓\033[0m api-gateway production 3m ago\n' | |||
| 16 | + | printf ' \033[32m✓\033[0m web-console production 7m ago\n' | |||
| 17 | + | printf ' \033[33m•\033[0m worker-pool staging running\n\n' | |||
| 18 | + | printf 'Use the session picker to jump between this host and local work.\n\n' | |||
| 19 | + | exec /bin/bash --noprofile --norc | |||
| 20 | + | ;; | |||
| 21 | + | remote-ticker) | |||
| 22 | + | exec ticker | |||
| 23 | + | ;; | |||
| 24 | + | esac | |||
| 25 | + | ||||
| 26 | + | exec /bin/bash --noprofile --norc | |||
M scripts/demo/demo.tape +36 -26
| @@ -20,13 +20,34 @@ Set WaitTimeout 30s | |||
| 20 | 20 | # (70-160ms) and short hesitations at word boundaries so the rhythm | |
| 21 | 21 | # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed. | |
| 22 | 22 | ||
| 23 | - | # ── Session "work": a colourful split (eza + ticker) ───── | |
| 24 | - | Type@90ms "vev " | |
| 25 | - | Sleep 160ms | |
| 26 | - | Type@75ms "new " | |
| 27 | - | Type@140ms "work" | |
| 23 | + | # ── Local and remote history, shown immediately ────────── | |
| 24 | + | Type@180ms "vev" | |
| 25 | + | Sleep 600ms | |
| 28 | 26 | Enter | |
| 29 | 27 | Sleep 1.1s | |
| 28 | + | # Keep the initial local shell so the final detach/attach cycle is meaningful. | |
| 29 | + | Alt+" " | |
| 30 | + | Sleep 550ms | |
| 31 | + | Type "RNS" | |
| 32 | + | Enter | |
| 33 | + | Sleep 550ms | |
| 34 | + | Backspace | |
| 35 | + | Type@95ms "local" | |
| 36 | + | Enter | |
| 37 | + | Sleep 1s | |
| 38 | + | ||
| 39 | + | # The picker starts with the local shell plus two ready remote sessions. | |
| 40 | + | Alt+" " | |
| 41 | + | Sleep 550ms | |
| 42 | + | Type "SSP" | |
| 43 | + | Enter | |
| 44 | + | Sleep 1.5s | |
| 45 | + | Down 2 | |
| 46 | + | Sleep 700ms | |
| 47 | + | Enter | |
| 48 | + | Sleep 2.5s | |
| 49 | + | ||
| 50 | + | # The selected work session belongs to the remote container. | |
| 30 | 51 | Type@85ms "eza " | |
| 31 | 52 | Sleep 200ms | |
| 32 | 53 | Type@75ms "-la " | |
| @@ -44,9 +65,7 @@ Enter | |||
| 44 | 65 | Sleep 1.5s | |
| 45 | 66 | ||
| 46 | 67 | # ── Session "agents": launch the bell job early so its | |
| 47 | - | # sleep overlaps the middle scenes. claude -p prints the | |
| 48 | - | # answer but emits no bell of its own (verified), so the | |
| 49 | - | # pane rings it with printf. ─────────────────────────── | |
| 68 | + | # sleep overlaps the middle scenes. ───────────────────── | |
| 50 | 69 | Alt+" " | |
| 51 | 70 | Sleep 550ms | |
| 52 | 71 | Type "CNS" | |
| @@ -56,13 +75,9 @@ Type@95ms "agents" | |||
| 56 | 75 | Enter | |
| 57 | 76 | Sleep 900ms | |
| 58 | 77 | Type@80ms "sleep " | |
| 59 | - | Type@140ms "10 " | |
| 60 | - | Sleep 170ms | |
| 61 | - | Type@90ms "&& claude " | |
| 62 | - | Type@150ms "-p " | |
| 63 | - | Type@105ms `"say hello world" ` | |
| 78 | + | Type@140ms "10" | |
| 64 | 79 | Sleep 220ms | |
| 65 | - | Type@85ms "&& printf " | |
| 80 | + | Type@85ms " && printf " | |
| 66 | 81 | Type@130ms `'\a'` | |
| 67 | 82 | Enter | |
| 68 | 83 | Sleep 1s | |
| @@ -148,35 +163,30 @@ Sleep 900ms | |||
| 148 | 163 | Alt+a | |
| 149 | 164 | Sleep 1.9s | |
| 150 | 165 | ||
| 151 | - | # ── Session picker now lists all three sessions ────────── | |
| 166 | + | # ── Session picker now lists local and remote sessions ─── | |
| 152 | 167 | Alt+" " | |
| 153 | 168 | Sleep 550ms | |
| 154 | 169 | Type "SSP" | |
| 155 | 170 | Enter | |
| 156 | - | Sleep 1.2s | |
| 157 | - | Down | |
| 158 | - | Sleep 450ms | |
| 159 | - | Down | |
| 160 | - | Sleep 450ms | |
| 161 | - | Down | |
| 171 | + | Sleep 1.5s | |
| 172 | + | Escape | |
| 162 | 173 | Sleep 650ms | |
| 163 | - | Enter | |
| 164 | - | Sleep 1.2s | |
| 165 | 174 | ||
| 166 | - | # ── Detach / re-attach: three sessions survive, ticker on ─ | |
| 175 | + | # ── Detach / re-attach: local sessions survive, ticker on ─ | |
| 167 | 176 | Alt+" " | |
| 168 | 177 | Sleep 550ms | |
| 169 | 178 | Type "DET" | |
| 170 | 179 | Enter | |
| 171 | 180 | Sleep 1s | |
| 172 | 181 | Type@90ms "vev " | |
| 173 | - | Type@75ms "ls" | |
| 182 | + | Type@75ms "ls " | |
| 183 | + | Type@120ms "--all" | |
| 174 | 184 | Enter | |
| 175 | 185 | Sleep 1.8s | |
| 176 | 186 | Type@90ms "vev " | |
| 177 | 187 | Sleep 150ms | |
| 178 | 188 | Type@80ms "attach " | |
| 179 | - | Type@140ms "work" | |
| 189 | + | Type@140ms "local" | |
| 180 | 190 | Enter | |
| 181 | 191 | Sleep 2.2s | |
| 182 | 192 | Sleep 400ms | |
| @@ -20,13 +20,34 @@ Set WaitTimeout 30s | |||||
| 20 | # (70-160ms) and short hesitations at word boundaries so the rhythm | 20 | # (70-160ms) and short hesitations at word boundaries so the rhythm | ||
| 21 | # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed. | 21 | # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed. | ||
| 22 | 22 | ||||
| 23 | - | # ── Session "work": a colourful split (eza + ticker) ───── | 23 | + | # ── Local and remote history, shown immediately ────────── |
| 24 | - | Type@90ms "vev " | 24 | + | Type@180ms "vev" |
| 25 | - | Sleep 160ms | 25 | + | Sleep 600ms |
| 26 | - | Type@75ms "new " | |||
| 27 | - | Type@140ms "work" | |||
| 28 | Enter | 26 | Enter | ||
| 29 | Sleep 1.1s | 27 | Sleep 1.1s | ||
| 28 | + | # Keep the initial local shell so the final detach/attach cycle is meaningful. | |||
| 29 | + | Alt+" " | |||
| 30 | + | Sleep 550ms | |||
| 31 | + | Type "RNS" | |||
| 32 | + | Enter | |||
| 33 | + | Sleep 550ms | |||
| 34 | + | Backspace | |||
| 35 | + | Type@95ms "local" | |||
| 36 | + | Enter | |||
| 37 | + | Sleep 1s | |||
| 38 | + | ||||
| 39 | + | # The picker starts with the local shell plus two ready remote sessions. | |||
| 40 | + | Alt+" " | |||
| 41 | + | Sleep 550ms | |||
| 42 | + | Type "SSP" | |||
| 43 | + | Enter | |||
| 44 | + | Sleep 1.5s | |||
| 45 | + | Down 2 | |||
| 46 | + | Sleep 700ms | |||
| 47 | + | Enter | |||
| 48 | + | Sleep 2.5s | |||
| 49 | + | ||||
| 50 | + | # The selected work session belongs to the remote container. | |||
| 30 | Type@85ms "eza " | 51 | Type@85ms "eza " | ||
| 31 | Sleep 200ms | 52 | Sleep 200ms | ||
| 32 | Type@75ms "-la " | 53 | Type@75ms "-la " | ||
| @@ -44,9 +65,7 @@ Enter | |||||
| 44 | Sleep 1.5s | 65 | Sleep 1.5s | ||
| 45 | 66 | ||||
| 46 | # ── Session "agents": launch the bell job early so its | 67 | # ── Session "agents": launch the bell job early so its | ||
| 47 | - | # sleep overlaps the middle scenes. claude -p prints the | 68 | + | # sleep overlaps the middle scenes. ───────────────────── |
| 48 | - | # answer but emits no bell of its own (verified), so the | |||
| 49 | - | # pane rings it with printf. ─────────────────────────── | |||
| 50 | Alt+" " | 69 | Alt+" " | ||
| 51 | Sleep 550ms | 70 | Sleep 550ms | ||
| 52 | Type "CNS" | 71 | Type "CNS" | ||
| @@ -56,13 +75,9 @@ Type@95ms "agents" | |||||
| 56 | Enter | 75 | Enter | ||
| 57 | Sleep 900ms | 76 | Sleep 900ms | ||
| 58 | Type@80ms "sleep " | 77 | Type@80ms "sleep " | ||
| 59 | - | Type@140ms "10 " | 78 | + | Type@140ms "10" |
| 60 | - | Sleep 170ms | |||
| 61 | - | Type@90ms "&& claude " | |||
| 62 | - | Type@150ms "-p " | |||
| 63 | - | Type@105ms `"say hello world" ` | |||
| 64 | Sleep 220ms | 79 | Sleep 220ms | ||
| 65 | - | Type@85ms "&& printf " | 80 | + | Type@85ms " && printf " |
| 66 | Type@130ms `'\a'` | 81 | Type@130ms `'\a'` | ||
| 67 | Enter | 82 | Enter | ||
| 68 | Sleep 1s | 83 | Sleep 1s | ||
| @@ -148,35 +163,30 @@ Sleep 900ms | |||||
| 148 | Alt+a | 163 | Alt+a | ||
| 149 | Sleep 1.9s | 164 | Sleep 1.9s | ||
| 150 | 165 | ||||
| 151 | - | # ── Session picker now lists all three sessions ────────── | 166 | + | # ── Session picker now lists local and remote sessions ─── |
| 152 | Alt+" " | 167 | Alt+" " | ||
| 153 | Sleep 550ms | 168 | Sleep 550ms | ||
| 154 | Type "SSP" | 169 | Type "SSP" | ||
| 155 | Enter | 170 | Enter | ||
| 156 | - | Sleep 1.2s | 171 | + | Sleep 1.5s |
| 157 | - | Down | 172 | + | Escape |
| 158 | - | Sleep 450ms | |||
| 159 | - | Down | |||
| 160 | - | Sleep 450ms | |||
| 161 | - | Down | |||
| 162 | Sleep 650ms | 173 | Sleep 650ms | ||
| 163 | - | Enter | |||
| 164 | - | Sleep 1.2s | |||
| 165 | 174 | ||||
| 166 | - | # ── Detach / re-attach: three sessions survive, ticker on ─ | 175 | + | # ── Detach / re-attach: local sessions survive, ticker on ─ |
| 167 | Alt+" " | 176 | Alt+" " | ||
| 168 | Sleep 550ms | 177 | Sleep 550ms | ||
| 169 | Type "DET" | 178 | Type "DET" | ||
| 170 | Enter | 179 | Enter | ||
| 171 | Sleep 1s | 180 | Sleep 1s | ||
| 172 | Type@90ms "vev " | 181 | Type@90ms "vev " | ||
| 173 | - | Type@75ms "ls" | 182 | + | Type@75ms "ls " |
| 183 | + | Type@120ms "--all" | |||
| 174 | Enter | 184 | Enter | ||
| 175 | Sleep 1.8s | 185 | Sleep 1.8s | ||
| 176 | Type@90ms "vev " | 186 | Type@90ms "vev " | ||
| 177 | Sleep 150ms | 187 | Sleep 150ms | ||
| 178 | Type@80ms "attach " | 188 | Type@80ms "attach " | ||
| 179 | - | Type@140ms "work" | 189 | + | Type@140ms "local" |
| 180 | Enter | 190 | Enter | ||
| 181 | Sleep 2.2s | 191 | Sleep 2.2s | ||
| 182 | Sleep 400ms | 192 | Sleep 400ms | ||
M scripts/demo/run.sh +43 -41
| @@ -3,50 +3,52 @@ set -euo pipefail | |||
| 3 | 3 | cd "$(dirname "$0")/../.." | |
| 4 | 4 | ||
| 5 | 5 | TAPE="${1:-demo.tape}" | |
| 6 | + | COMPOSE_FILE="scripts/demo/compose.yaml" | |
| 6 | 7 | install -d -m 0777 scripts/demo/out | |
| 8 | + | # Demo containers use a rootless subuid mapping, so reset generated state | |
| 9 | + | # from the same container identity rather than relying on host ownership. | |
| 10 | + | docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local' | |
| 11 | + | ||
| 12 | + | compose() { | |
| 13 | + | docker compose -f "$COMPOSE_FILE" "$@" | |
| 14 | + | } | |
| 7 | 15 | ||
| 8 | - | CLAUDE_TMP=$(mktemp -d) | |
| 9 | - | # claude writes files into the temp HOME as the container's demo user, which | |
| 10 | - | # rootless docker maps to a subuid the host cannot delete directly. Clean up | |
| 11 | - | # through the container first, where the creating uid can unlink its own | |
| 12 | - | # files, then remove the (now-empty) temp dir from the host. This never | |
| 13 | - | # suppresses a leftover: if anything still remains, it's reported so a | |
| 14 | - | # credentials-bearing directory doesn't silently linger in /tmp. | |
| 15 | 16 | cleanup() { | |
| 16 | - | # Every step here is best-effort: under `set -e`, an unguarded failure in an | |
| 17 | - | # EXIT trap would abort the trap early and replace the script's real exit | |
| 18 | - | # status with the failing cleanup command's. Guard each step with `|| true` | |
| 19 | - | # so cleanup always runs to completion and never masks the vhs run's result; | |
| 20 | - | # the final check below is what surfaces an unrecovered leftover. | |
| 21 | - | rm -rf "$CLAUDE_TMP" 2>/dev/null || true | |
| 22 | - | if [ -d "$CLAUDE_TMP" ]; then | |
| 23 | - | docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \ | |
| 24 | - | -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true | |
| 25 | - | rmdir "$CLAUDE_TMP" 2>/dev/null || true | |
| 17 | + | compose down -v --remove-orphans >/dev/null 2>&1 || true | |
| 18 | + | } | |
| 19 | + | trap cleanup EXIT | |
| 20 | + | ||
| 21 | + | compose up -d remote | |
| 22 | + | ||
| 23 | + | ready="" | |
| 24 | + | for _ in $(seq 60); do | |
| 25 | + | if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then | |
| 26 | + | ready=yes | |
| 27 | + | break | |
| 26 | 28 | fi | |
| 27 | - | if [ -d "$CLAUDE_TMP" ]; then | |
| 28 | - | echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&2 | |
| 29 | + | sleep 0.5 | |
| 30 | + | done | |
| 31 | + | if [ -z "$ready" ]; then | |
| 32 | + | echo "error: sshd on the remote container never accepted a connection" >&2 | |
| 33 | + | compose logs remote >&2 || true | |
| 34 | + | exit 1 | |
| 35 | + | fi | |
| 36 | + | ||
| 37 | + | start_remote_session() { | |
| 38 | + | local name=$1 scene=$2 | |
| 39 | + | if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then | |
| 40 | + | echo "error: could not create remote session '$name'" >&2 | |
| 41 | + | return 1 | |
| 42 | + | fi | |
| 43 | + | if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then | |
| 44 | + | echo "error: remote session '$name' was not retained" >&2 | |
| 45 | + | compose exec -u demo -T remote vev ls >&2 || true | |
| 46 | + | return 1 | |
| 29 | 47 | fi | |
| 30 | - | return 0 | |
| 31 | 48 | } | |
| 32 | - | trap cleanup EXIT | |
| 33 | - | mkdir -p "$CLAUDE_TMP/.claude" | |
| 34 | - | cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/" | |
| 35 | - | cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json" | |
| 36 | - | printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json" | |
| 37 | - | ||
| 38 | - | # Rootless docker maps host-owned files (uid 1000) to container root, but the | |
| 39 | - | # container runs as demo (uid 1000 -> a subuid), so demo cannot read the | |
| 40 | - | # credentials or write its config dir. Open up the throwaway home's contents so | |
| 41 | - | # claude can authenticate and write transcripts, then restore the mktemp | |
| 42 | - | # parent to 0700: bind mounts hand the container the inner paths directly, but | |
| 43 | - | # other host users would have to traverse the parent, which now blocks them. | |
| 44 | - | chmod -R a+rwX "$CLAUDE_TMP" | |
| 45 | - | chmod 0700 "$CLAUDE_TMP" | |
| 46 | - | ||
| 47 | - | docker run --rm --hostname vev-demo \ | |
| 48 | - | -v "$PWD/scripts/demo:/tape:ro" \ | |
| 49 | - | -v "$PWD/scripts/demo/out:/home/demo/out" \ | |
| 50 | - | -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \ | |
| 51 | - | -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \ | |
| 52 | - | vev-demo "/tape/$TAPE" | |
| 49 | + | ||
| 50 | + | start_remote_session work remote-deploys | |
| 51 | + | start_remote_session ticker remote-ticker | |
| 52 | + | ||
| 53 | + | compose run --rm --entrypoint vev client host add remote | |
| 54 | + | compose run --rm client "/tape/$TAPE" | |
| @@ -3,50 +3,52 @@ set -euo pipefail | |||||
| 3 | cd "$(dirname "$0")/../.." | 3 | cd "$(dirname "$0")/../.." | ||
| 4 | 4 | ||||
| 5 | TAPE="${1:-demo.tape}" | 5 | TAPE="${1:-demo.tape}" | ||
| 6 | + | COMPOSE_FILE="scripts/demo/compose.yaml" | |||
| 6 | install -d -m 0777 scripts/demo/out | 7 | install -d -m 0777 scripts/demo/out | ||
| 8 | + | # Demo containers use a rootless subuid mapping, so reset generated state | |||
| 9 | + | # from the same container identity rather than relying on host ownership. | |||
| 10 | + | docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local' | |||
| 11 | + | ||||
| 12 | + | compose() { | |||
| 13 | + | docker compose -f "$COMPOSE_FILE" "$@" | |||
| 14 | + | } | |||
| 7 | 15 | ||||
| 8 | - | CLAUDE_TMP=$(mktemp -d) | |||
| 9 | - | # claude writes files into the temp HOME as the container's demo user, which | |||
| 10 | - | # rootless docker maps to a subuid the host cannot delete directly. Clean up | |||
| 11 | - | # through the container first, where the creating uid can unlink its own | |||
| 12 | - | # files, then remove the (now-empty) temp dir from the host. This never | |||
| 13 | - | # suppresses a leftover: if anything still remains, it's reported so a | |||
| 14 | - | # credentials-bearing directory doesn't silently linger in /tmp. | |||
| 15 | cleanup() { | 16 | cleanup() { | ||
| 16 | - | # Every step here is best-effort: under `set -e`, an unguarded failure in an | 17 | + | compose down -v --remove-orphans >/dev/null 2>&1 || true |
| 17 | - | # EXIT trap would abort the trap early and replace the script's real exit | 18 | + | } |
| 18 | - | # status with the failing cleanup command's. Guard each step with `|| true` | 19 | + | trap cleanup EXIT |
| 19 | - | # so cleanup always runs to completion and never masks the vhs run's result; | 20 | + | |
| 20 | - | # the final check below is what surfaces an unrecovered leftover. | 21 | + | compose up -d remote |
| 21 | - | rm -rf "$CLAUDE_TMP" 2>/dev/null || true | 22 | + | |
| 22 | - | if [ -d "$CLAUDE_TMP" ]; then | 23 | + | ready="" |
| 23 | - | docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \ | 24 | + | for _ in $(seq 60); do |
| 24 | - | -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true | 25 | + | if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then |
| 25 | - | rmdir "$CLAUDE_TMP" 2>/dev/null || true | 26 | + | ready=yes |
| 27 | + | break | |||
| 26 | fi | 28 | fi | ||
| 27 | - | if [ -d "$CLAUDE_TMP" ]; then | 29 | + | sleep 0.5 |
| 28 | - | echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&2 | 30 | + | done |
| 31 | + | if [ -z "$ready" ]; then | |||
| 32 | + | echo "error: sshd on the remote container never accepted a connection" >&2 | |||
| 33 | + | compose logs remote >&2 || true | |||
| 34 | + | exit 1 | |||
| 35 | + | fi | |||
| 36 | + | ||||
| 37 | + | start_remote_session() { | |||
| 38 | + | local name=$1 scene=$2 | |||
| 39 | + | if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then | |||
| 40 | + | echo "error: could not create remote session '$name'" >&2 | |||
| 41 | + | return 1 | |||
| 42 | + | fi | |||
| 43 | + | if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then | |||
| 44 | + | echo "error: remote session '$name' was not retained" >&2 | |||
| 45 | + | compose exec -u demo -T remote vev ls >&2 || true | |||
| 46 | + | return 1 | |||
| 29 | fi | 47 | fi | ||
| 30 | - | return 0 | |||
| 31 | } | 48 | } | ||
| 32 | - | trap cleanup EXIT | 49 | + | |
| 33 | - | mkdir -p "$CLAUDE_TMP/.claude" | 50 | + | start_remote_session work remote-deploys |
| 34 | - | cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/" | 51 | + | start_remote_session ticker remote-ticker |
| 35 | - | cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json" | 52 | + | |
| 36 | - | printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json" | 53 | + | compose run --rm --entrypoint vev client host add remote |
| 37 | - | 54 | + | compose run --rm client "/tape/$TAPE" | |
| 38 | - | # Rootless docker maps host-owned files (uid 1000) to container root, but the | |||
| 39 | - | # container runs as demo (uid 1000 -> a subuid), so demo cannot read the | |||
| 40 | - | # credentials or write its config dir. Open up the throwaway home's contents so | |||
| 41 | - | # claude can authenticate and write transcripts, then restore the mktemp | |||
| 42 | - | # parent to 0700: bind mounts hand the container the inner paths directly, but | |||
| 43 | - | # other host users would have to traverse the parent, which now blocks them. | |||
| 44 | - | chmod -R a+rwX "$CLAUDE_TMP" | |||
| 45 | - | chmod 0700 "$CLAUDE_TMP" | |||
| 46 | - | ||||
| 47 | - | docker run --rm --hostname vev-demo \ | |||
| 48 | - | -v "$PWD/scripts/demo:/tape:ro" \ | |||
| 49 | - | -v "$PWD/scripts/demo/out:/home/demo/out" \ | |||
| 50 | - | -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \ | |||
| 51 | - | -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \ | |||
| 52 | - | vev-demo "/tape/$TAPE" | |||