Skip to content
Closed
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
130 changes: 130 additions & 0 deletions .github/workflows/collaboration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# CI/CD for the cmux Multiplayer collaboration relay (workers/collaboration).
#
# PRs touching the service run typecheck + unit tests + a wrangler dry-run
# build. Pushes to main additionally deploy to Cloudflare. `wrangler deploy`
# applies the Durable Object migrations declared in wrangler.toml atomically
# with the code upload, so the service's schema can never lag a deploy.
#
# Required repository secrets (deploy job):
# CLOUDFLARE_API_TOKEN API token with Workers Scripts:Edit on the account
# CLOUDFLARE_ACCOUNT_ID the Cloudflare account id

name: collaboration

on:
pull_request:
paths:
- "workers/collaboration/**"
- ".github/workflows/collaboration.yml"
push:
branches: [main]
paths:
- "workers/collaboration/**"
- ".github/workflows/collaboration.yml"
workflow_dispatch:
inputs:
relay_url:
description: "Relay URL to smoke-test"
required: false
default: "https://collaboration.cmux.dev"

permissions:
contents: read

jobs:
test:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

# Wrangler requires Node >= 22; the Blacksmith ubuntu-2404 image ships
# Node 20, so pin it explicitly instead of relying on the runner's
# ambient version.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"

- name: Install dependencies
run: bun install --cwd workers/collaboration --frozen-lockfile

- name: Typecheck
run: bun run --cwd workers/collaboration typecheck

- name: Unit tests
run: bun run --cwd workers/collaboration test

- name: Wrangler dry-run build
run: bun run --cwd workers/collaboration check

deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
concurrency:
group: collaboration-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"

- name: Install dependencies
run: bun install --cwd workers/collaboration --frozen-lockfile

- name: Check Cloudflare secrets
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
missing=()
[ -n "$CLOUDFLARE_API_TOKEN" ] || missing+=(CLOUDFLARE_API_TOKEN)
[ -n "$CLOUDFLARE_ACCOUNT_ID" ] || missing+=(CLOUDFLARE_ACCOUNT_ID)
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Collaboration deploy needs repository secrets ${missing[*]}." \
"Create an API token with Workers Scripts:Edit on the Cloudflare account" \
"and add both secrets in repo Settings -> Secrets and variables -> Actions" \
"(see workers/collaboration/README.md, Deploy)."
exit 1
fi

- name: Deploy (applies DO migrations atomically)
run: bun run --cwd workers/collaboration deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Smoke production relay create/connect
run: bun run --cwd workers/collaboration smoke:relay
env:
CMUX_COLLABORATION_RELAY_URL: https://collaboration.cmux.dev

live-smoke:
if: github.event_name == 'workflow_dispatch'
needs: test
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

- name: Install dependencies
run: bun install --cwd workers/collaboration --frozen-lockfile

- name: Smoke relay create/connect
run: bun run --cwd workers/collaboration smoke:relay
env:
CMUX_COLLABORATION_RELAY_URL: ${{ inputs.relay_url }}
2 changes: 2 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ jobs:
CMUX_SMOKE_ALLOW_UNSUPPORTED_GUI=1 ./scripts/smoke-launch-macos-app.sh "$app_path"
CMUX_SMOKE_DIRECT_EXEC=1 ./scripts/smoke-launch-macos-app.sh "$app_path"
./scripts/verify-app-bundle-channel-metadata.sh "$app_path" nightly
./scripts/smoke-installable-artifact.sh --channel nightly "$app_path"
rm -f "$zip_submit"

dmg_tmp_dir="$(mktemp -d)"
Expand Down Expand Up @@ -588,6 +589,7 @@ jobs:
fi
xcrun stapler staple "$dmg_release"
xcrun stapler validate "$dmg_release"
./scripts/smoke-installable-artifact.sh --channel nightly "$dmg_release"
cp "$dmg_release" "$dmg_immutable"
}

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ jobs:
CMUX_SMOKE_ALLOW_UNSUPPORTED_GUI=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$APP_PATH"
CMUX_SMOKE_DIRECT_EXEC=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$APP_PATH"
./scripts/verify-app-bundle-channel-metadata.sh "$APP_PATH" stable
./scripts/smoke-installable-artifact.sh --channel stable "$APP_PATH"
rm -f "$ZIP_SUBMIT"
# create-dmg generates a styled drag-to-install DMG
create-dmg \
Expand All @@ -466,6 +467,7 @@ jobs:
fi
xcrun stapler staple "$DMG_RELEASE"
xcrun stapler validate "$DMG_RELEASE"
./scripts/smoke-installable-artifact.sh --channel stable "$DMG_RELEASE"

- name: Upload dSYMs to Sentry
if: steps.guard_release_assets.outputs.skip_all != 'true'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ zig-out/
# Node
node_modules/
.next/
workers/**/.wrangler/

# Test outputs
tests/visual_output/
Expand Down
77 changes: 77 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3522,6 +3522,83 @@ struct CMUXCLI {
let response = try client.sendV2(method: "system.capabilities")
print(jsonString(formatIDs(response, mode: idFormat)))

case "collaboration", "collab", "multiplayer":
let sub = commandArgs.first?.lowercased() ?? "status"
let rest = Array(commandArgs.dropFirst())
switch sub {
case "status":
let response = try client.sendV2(method: "collaboration.status")
if jsonOutput {
print(jsonString(response))
break
}
print("Collaboration: \((response["status"] as? String) ?? "unknown")")
if let code = response["session_code"] as? String {
print(" session_code: \(code)")
}
if let token = response["invite_token"] as? String {
print(" invite_token: \(token)")
}
print(" relay_url: \((response["relay_url"] as? String) ?? "?")")
print(" shared_files: \((response["shared_documents"] as? Int) ?? 0)")

case "create", "new":
let (relayURL, remaining) = parseOption(rest, name: "--relay-url")
if let extra = remaining.first {
throw CLIError(message: "cmux collaboration create: unexpected argument '\(extra)'")
}
var params: [String: Any] = [:]
if let relayURL { params["relay_url"] = relayURL }
let response = try client.sendV2(
method: "collaboration.session.create",
params: params,
responseTimeout: 35
)
if jsonOutput {
print(jsonString(response))
break
}
print("Created collaboration session.")
print(" relay_url: \((response["relay_url"] as? String) ?? "?")")
print(" session_code: \((response["session_code"] as? String) ?? "?")")
print(" invite_token: \((response["invite_token"] as? String) ?? "?")")

case "join":
let (relayURL, afterRelay) = parseOption(rest, name: "--relay-url")
let (sessionCode, afterCode) = parseOption(afterRelay, name: "--session-code")
let (inviteToken, remaining) = parseOption(afterCode, name: "--invite-token")
if let extra = remaining.first {
throw CLIError(message: "cmux collaboration join: unexpected argument '\(extra)'")
}
guard let sessionCode, let inviteToken else {
throw CLIError(message: "Usage: cmux collaboration join --session-code <code> --invite-token <token> [--relay-url <url>]")
}
var params: [String: Any] = [
"session_code": sessionCode,
"invite_token": inviteToken,
]
if let relayURL { params["relay_url"] = relayURL }
let response = try client.sendV2(method: "collaboration.session.join", params: params)
if jsonOutput {
print(jsonString(response))
break
}
print("Joined collaboration session.")
print(" relay_url: \((response["relay_url"] as? String) ?? "?")")
print(" session_code: \((response["session_code"] as? String) ?? "?")")

case "leave":
let response = try client.sendV2(method: "collaboration.session.leave")
if jsonOutput {
print(jsonString(response))
break
}
print("Left collaboration session.")

default:
throw CLIError(message: "Usage: cmux collaboration <status|create|join|leave>")
}

case "agent-hibernation":
try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput)

Expand Down
36 changes: 36 additions & 0 deletions Packages/Shared/CmuxCollaboration/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// swift-tools-version: 6.0

import PackageDescription

let package = Package(
name: "CmuxCollaboration",
platforms: [
.iOS(.v18),
.macOS(.v14),
],
products: [
.library(
name: "CmuxCollaboration",
targets: ["CmuxCollaboration"]
),
],
targets: [
.target(
name: "CmuxCollaboration",
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
),
.testTarget(
name: "CmuxCollaborationTests",
dependencies: ["CmuxCollaboration"],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// A stable identifier for one CRDT text element.
public struct CharacterID: Codable, Comparable, Hashable, Sendable {
/// The peer that created the element.
public let peerID: String
/// The peer-local monotonically increasing counter.
public let counter: Int

/// Creates a character identifier.
/// - Parameters:
/// - peerID: The peer that created the element.
/// - counter: The peer-local monotonically increasing counter.
public init(peerID: String, counter: Int) {
self.peerID = peerID
self.counter = counter
}

public static func < (lhs: CharacterID, rhs: CharacterID) -> Bool {
if lhs.counter != rhs.counter {
return lhs.counter < rhs.counter
}
return lhs.peerID < rhs.peerID
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

/// High-level relay connection state.
public enum CollaborationConnectionState: Equatable, Sendable {
/// The session has not connected to a relay.
case idle
/// The client is connected to the relay.
case connected
/// The relay is unavailable at session start.
case relayUnavailable
/// The relay disconnected after a session had started.
case disconnected
/// The client reconnected and is resynchronizing documents.
case resynchronizing
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// A visible snapshot of a collaboration document.
public struct CollaborationDocumentSnapshot: Codable, Equatable, Sendable {
/// The stable document identifier.
public let documentID: String
/// The current CRDT-resolved text.
public let text: String
/// A deterministic hash of the current text.
public let textHash: String

/// Creates a document snapshot.
/// - Parameters:
/// - documentID: The stable document identifier.
/// - text: The current CRDT-resolved text.
/// - textHash: A deterministic hash of the current text.
public init(documentID: String, text: String, textHash: String) {
self.documentID = documentID
self.text = text
self.textHash = textHash
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

/// Events emitted by a collaboration session.
public enum CollaborationEvent: Equatable, Sendable {
/// A local or remote document changed.
case documentChanged(CollaborationDocumentSnapshot)
/// A peer's ephemeral presence changed.
case presenceChanged(PresenceState)
/// A peer left or timed out.
case presenceCleared(peerID: String)
/// The relay connection state changed.
case connectionChanged(CollaborationConnectionState)
/// Disk reconciliation produced a result for a file.
case diskReconciled(DiskReconciliationResult)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
public import Foundation

/// Filesystem operations used by collaboration disk reconciliation.
public protocol CollaborationFileStoring: Sendable {
/// Reads a UTF-8 text file.
/// - Parameter url: The file URL to read.
/// - Returns: The file contents.
func readText(at url: URL) async throws -> String

/// Writes a UTF-8 text file atomically.
/// - Parameters:
/// - text: The text to write.
/// - url: The destination file URL.
func writeText(_ text: String, to url: URL) async throws

/// Returns whether a file exists.
/// - Parameter url: The file URL to check.
/// - Returns: Whether the file exists.
func fileExists(at url: URL) async -> Bool
}
Loading