Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions frontend/src/Riptide/App.purs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module Riptide.App
( component
( commandsForSocketOpen
, component
, sessionFromApp
) where

import Prelude
Expand All @@ -13,7 +15,7 @@ import Effect.Aff.Class (class MonadAff)
import Halogen as H
import Riptide.Action (ControlKey)
import Riptide.ImportExport as ImportExport
import Riptide.Model (App, Block, BlockId, CellId, ConnectionState(..), DropTarget, Page(..), Song, SongId, Toolbox, ToolboxId, Track, TrackId, canUseBackend)
import Riptide.Model (App, Block, BlockId, Cell, CellId, ConnectionState(..), DropTarget, Page(..), Song, SongId, Toolbox, ToolboxId, Track, TrackId, canUseBackend, totalBars)
import Riptide.Protocol.Client as Protocol
import Riptide.Reducer as Reducer
import Riptide.Validation (authoritativeValidation)
Expand Down Expand Up @@ -576,8 +578,10 @@ handleSocketEvent :: forall output m. MonadAff m => H.SubscriptionId -> WebSocke
handleSocketEvent subscriptionId = case _ of
WebSocket.WebSocketReady socket ->
H.modify_ (Reducer.setWebSocket (Just socket))
WebSocket.WebSocketOpened ->
WebSocket.WebSocketOpened -> do
H.modify_ (Reducer.setConnection Connected)
app <- H.get
traverse_ sendWhenConnected (commandsForSocketOpen app)
WebSocket.WebSocketClosed -> do
H.unsubscribe subscriptionId
H.modify_ (Reducer.setWebSocket Nothing <<< Reducer.setConnection Disconnected)
Expand Down Expand Up @@ -612,6 +616,42 @@ sendWhenConnected command = do
_ ->
pure unit

commandsForSocketOpen :: App -> Array Protocol.ClientCommand
commandsForSocketOpen app =
[ Protocol.SetSession (sessionFromApp app) ]

sessionFromApp :: App -> Protocol.Session
sessionFromApp app =
{ sessionSlotCapacity: totalBars
, sessionTracks:
Array.mapWithIndex trackFromApp (currentTracks app)
, sessionDefinitions: blockFromApp <$> currentBlocks app
}

trackFromApp :: Int -> Track -> Protocol.Track
trackFromApp index track =
{ trackId: track.id
, trackName: track.name
, trackSlot: index + 1
, trackTexts: textFromApp <$> track.cells
, trackActiveText: track.active
, trackSelectedText: track.selected
}

textFromApp :: Cell -> Protocol.TrackText
textFromApp cell =
{ trackTextId: cell.id
, trackTextSource: cell.code
}

blockFromApp :: Block -> Protocol.Block
blockFromApp block =
{ blockId: block.id
, blockName: block.name
, blockCode: block.code
, blockApplied: block.applied
}

sendPlaybackTransitions :: forall output m. MonadAff m => App -> App -> H.HalogenM App Action () output m Unit
sendPlaybackTransitions before after =
traverse_ sendWhenConnected
Expand Down
71 changes: 63 additions & 8 deletions frontend/src/Riptide/Protocol/Client.purs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import Data.Argonaut.Core (Json, fromString, stringify)
import Data.Argonaut.Decode (class DecodeJson, JsonDecodeError(..), decodeJson, fromJsonString, printJsonDecodeError, (.:))
import Data.Argonaut.Encode (class EncodeJson, encodeJson)
import Data.Either (Either(..))
import Data.Maybe (Maybe)
import Data.Maybe (Maybe(..))
import Data.String.Common as String
import Foreign.Object (Object)

type TrackId = String
Expand Down Expand Up @@ -56,6 +57,7 @@ type Session =

data ClientCommand
= ValidateText String
| SetSession Session
| ActivateTrackText TrackId CellId
| SilenceTrack TrackId
| SaveTrackText TrackId CellId String
Expand All @@ -67,6 +69,7 @@ derive instance eqClientCommand :: Eq ClientCommand
instance showClientCommand :: Show ClientCommand where
show = case _ of
ValidateText text -> "(ValidateText " <> show text <> ")"
SetSession session -> "(SetSession " <> show session <> ")"
ActivateTrackText trackId cellId -> "(ActivateTrackText " <> show trackId <> " " <> show cellId <> ")"
SilenceTrack trackId -> "(SilenceTrack " <> show trackId <> ")"
SaveTrackText trackId cellId text -> "(SaveTrackText " <> show trackId <> " " <> show cellId <> " " <> show text <> ")"
Expand All @@ -80,6 +83,11 @@ encodeClientCommand = case _ of
[ field "type" "validateText"
, field "text" text
]
SetSession session ->
object
[ field "type" "setSession"
, fieldJson "session" (sessionJson session)
]
ActivateTrackText trackId cellId ->
object
[ field "type" "activateTrackText"
Expand Down Expand Up @@ -124,6 +132,8 @@ instance decodeJsonClientCommand :: DecodeJson ClientCommand where
case commandType of
"validateText" ->
ValidateText <$> value .: "text"
"setSession" ->
SetSession <$> value .: "session"
"activateTrackText" ->
ActivateTrackText <$> value .: "trackId" <*> value .: "textId"
"silenceTrack" ->
Expand Down Expand Up @@ -202,18 +212,63 @@ field :: String -> String -> String
field key value =
quote key <> ":" <> quote value

fieldJson :: String -> String -> String
fieldJson key value =
quote key <> ":" <> value

object :: Array String -> String
object fields =
"{" <> joinWithComma fields <> "}"

sessionJson :: Session -> String
sessionJson session =
object
[ fieldJson "sessionSlotCapacity" (show session.sessionSlotCapacity)
, fieldJson "sessionTracks" (arrayJson trackJson session.sessionTracks)
, fieldJson "sessionDefinitions" (arrayJson blockJson session.sessionDefinitions)
]

trackJson :: Track -> String
trackJson track =
object
[ field "trackId" track.trackId
, field "trackName" track.trackName
, fieldJson "trackSlot" (show track.trackSlot)
, fieldJson "trackTexts" (arrayJson trackTextJson track.trackTexts)
, fieldJson "trackActiveText" (maybeStringJson track.trackActiveText)
, fieldJson "trackSelectedText" (maybeStringJson track.trackSelectedText)
]

trackTextJson :: TrackText -> String
trackTextJson text =
object
[ field "trackTextId" text.trackTextId
, field "trackTextSource" text.trackTextSource
]

blockJson :: Block -> String
blockJson block =
object
[ field "blockId" block.blockId
, field "blockName" block.blockName
, field "blockCode" block.blockCode
, field "blockApplied" block.blockApplied
]

arrayJson :: forall a. (a -> String) -> Array a -> String
arrayJson encode =
case _ of
[] -> "[]"
values -> "[" <> joinWithComma (map encode values) <> "]"

maybeStringJson :: Maybe String -> String
maybeStringJson = case _ of
Just value -> quote value
Nothing -> "null"

joinWithComma :: Array String -> String
joinWithComma = case _ of
[] -> ""
[ one ] -> one
[ one, two ] -> one <> "," <> two
[ one, two, three ] -> one <> "," <> two <> "," <> three
[ one, two, three, four ] -> one <> "," <> two <> "," <> three <> "," <> four
fields -> stringify (encodeJson fields)
joinWithComma =
String.joinWith ","

quote :: String -> String
quote =
Expand Down
65 changes: 65 additions & 0 deletions frontend/test/Main.purs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Data.Number as Number
import Effect (Effect)
import Effect.Aff (launchAff_)
import Riptide.Action (ControlKey(..))
import Riptide.App as App
import Riptide.Helpers (cascade, collectIds, definedNames, duplicateIds, effectiveSelected, normalizeScore)
import Riptide.Model (Cell, Song, Track, totalBars)
import Riptide.Model as Model
Expand Down Expand Up @@ -75,6 +76,8 @@ main =
it "encodes client commands with backend tags and field names" do
Protocol.encodeClientCommand (Protocol.ValidateText "d1 $ sound \"bd\"") `shouldEqual`
"{\"type\":\"validateText\",\"text\":\"d1 $ sound \\\"bd\\\"\"}"
Protocol.encodeClientCommand (Protocol.SetSession sampleProtocolSession) `shouldEqual`
"{\"type\":\"setSession\",\"session\":{\"sessionSlotCapacity\":16,\"sessionTracks\":[{\"trackId\":\"t1\",\"trackName\":\"drums\",\"trackSlot\":1,\"trackTexts\":[{\"trackTextId\":\"c1\",\"trackTextSource\":\"d1 $ sound \\\"bd\\\"\"},{\"trackTextId\":\"c2\",\"trackTextSource\":\"d1 $ sound \\\"cp\\\"\"}],\"trackActiveText\":\"c1\",\"trackSelectedText\":\"c2\"}],\"sessionDefinitions\":[{\"blockId\":\"b1\",\"blockName\":\"feel\",\"blockCode\":\"feel = (# room 0.4)\",\"blockApplied\":\"feel = (# room 0.35)\"}]}}"
Protocol.encodeClientCommand (Protocol.ActivateTrackText "track-a" "cell-1") `shouldEqual`
"{\"type\":\"activateTrackText\",\"trackId\":\"track-a\",\"textId\":\"cell-1\"}"
Protocol.encodeClientCommand (Protocol.SilenceTrack "track-a") `shouldEqual`
Expand Down Expand Up @@ -121,6 +124,10 @@ main =
Protocol.decodeServerEvent "{\"type\":\"commandFailed\",\"failure\":{\"command\":{\"type\":\"silenceTrack\",\"trackId\":\"track-a\"},\"message\":\"track not found\"}}" `shouldEqual`
Right (Protocol.CommandFailed { command: Protocol.SilenceTrack "track-a", message: "track not found" })

it "prepares the full current editor state as the first open command" do
App.commandsForSocketOpen appWithSongAndToolbox `shouldEqual`
[ Protocol.SetSession sampleProtocolSession ]

describe "definition names" do
it "parses optional let bindings only at line starts" do
definedNames "feel = (# room 0.4)\n let swing_2 = nudge 0.01\nx feel = ignored\n_kit = sound \"bd\"" `shouldEqual`
Expand Down Expand Up @@ -553,6 +560,64 @@ appWithToolbox =
, currentToolboxId = Just "tbx1"
}

appWithSongAndToolbox :: Model.App
appWithSongAndToolbox =
Model.defaultApp
{ songs =
[ { id: "s-open"
, name: "open song"
, tracks:
[ ( track "t1" "drums"
[ cell "c1" "d1 $ sound \"bd\""
, cell "c2" "d1 $ sound \"cp\""
]
)
{ active = Just "c1"
, selected = Just "c2"
}
]
}
]
, currentSongId = Just "s-open"
, toolboxes =
[ { id: "tb-open"
, name: "open defs"
, blocks:
[ { id: "b1"
, name: "feel"
, code: "feel = (# room 0.4)"
, applied: "feel = (# room 0.35)"
}
]
}
]
, currentToolboxId = Just "tb-open"
}

sampleProtocolSession :: Protocol.Session
sampleProtocolSession =
{ sessionSlotCapacity: Model.totalBars
, sessionTracks:
[ { trackId: "t1"
, trackName: "drums"
, trackSlot: 1
, trackTexts:
[ { trackTextId: "c1", trackTextSource: "d1 $ sound \"bd\"" }
, { trackTextId: "c2", trackTextSource: "d1 $ sound \"cp\"" }
]
, trackActiveText: Just "c1"
, trackSelectedText: Just "c2"
}
]
, sessionDefinitions:
[ { blockId: "b1"
, blockName: "feel"
, blockCode: "feel = (# room 0.4)"
, blockApplied: "feel = (# room 0.35)"
}
]
}

sampleSong :: Song
sampleSong =
{ id: "s1"
Expand Down
67 changes: 67 additions & 0 deletions specs/031-state-reconcile/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Plan: Client State Reconcile On Connect

## Context

The backend websocket sends a server snapshot immediately after accept, then
processes commands. The frontend ignores that snapshot and continues from
`seedApp`. On a fresh server store, playback transitions can reference client
track ids such as `t2` before the server has any corresponding tracks.

The chosen direction is client-as-editor/source-of-truth. The client pushes its
current editable state to the server on websocket open.

## Wire Shape

Add a `ClientCommand` constructor:

```text
SetSession Session
```

JSON:

```json
{
"type": "setSession",
"session": {
"sessionSlotCapacity": 16,
"sessionTracks": [],
"sessionDefinitions": []
}
}
```

Server handling preserves its configured `sessionSlotCapacity` and replaces only
`sessionTracks` and `sessionDefinitions` from the payload before calling
`saveSession`.

## Slice 1: Backend Protocol And Handler

Owned files:

- `src/Riptide/Protocol.hs`
- `src/Riptide/Server.hs`
- `test/Riptide/ProtocolSpec.hs`
- `test/Riptide/ServerSpec.hs`

Add the command, JSON round-trip coverage, and server persistence test. Do not
edit `Session.hs`, `Store.hs`, `Eval.hs`, or `Playback.hs`.

## Slice 2: Frontend Command And Connect Handshake

Owned files:

- `frontend/src/Riptide/Protocol/Client.purs`
- `frontend/src/Riptide/App.purs`
- `frontend/test/Main.purs`

Add the PureScript command, encoder/decoder support, app-to-session mapping,
and send it on `WebSocketOpened` before any connected playback command path can
run. Tests should assert the encoded JSON shape and that connect-open commands
start with `SetSession`.

## Verification

Run `./gate.sh` at the end of each slice. After the final slice, also run a
fresh `riptide serve` smoke when practical and confirm no `CommandFailed` text
appears in the served UI.
34 changes: 34 additions & 0 deletions specs/031-state-reconcile/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Specification: Client State Reconcile On Connect

## User Story

When a performer opens the served riptide UI, the server session must mirror the
editor state that the client boots with before any playback command is sent.
Fresh page load must not surface `PlaybackTrackMissing` for tracks that exist in
the UI seed but not yet in the server store.

## Functional Requirements

- Add a client-to-server command that carries the client's current tracks and
definitions as one state payload.
- On receipt, the server replaces its session tracks and definitions with the
payload, preserves the server-configured slot capacity, persists both stores,
and broadcasts a state snapshot.
- On every websocket connect or reconnect, the frontend sends the state command
before any activate or silence command can be emitted from the connected
session.
- The command JSON shape is stable across Haskell and PureScript encoders and
decoders.
- Existing validation, save, apply, activate, and silence behavior remains
unchanged.

## Success Criteria

- Backend protocol round-trip tests cover the new command.
- Backend server tests prove the command replaces tracks and definitions and
writes both stores.
- Frontend tests cover the new command encoding and the connect-handshake command
order.
- `./gate.sh` passes, including frontend render smoke.
- Opening a served UI against an empty state directory does not show a
`Command failed: PlaybackTrackMissing ...` toast/banner on fresh connect.
Loading