Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ae06ba2

Browse files
committedMar 24, 2025·
chore: add mutagen prompting gRPC
1 parent 916bec7 commit ae06ba2

7 files changed

+883
-19
lines changed
 

Diff for: ‎Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+11-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public protocol FileSyncDaemon: ObservableObject {
1919

2020
@MainActor
2121
public class MutagenDaemon: FileSyncDaemon {
22-
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
22+
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
2323

2424
@Published public var state: DaemonState = .stopped {
2525
didSet {
@@ -42,9 +42,9 @@ public class MutagenDaemon: FileSyncDaemon {
4242
private let mutagenDaemonSocket: URL
4343

4444
// Non-nil when the daemon is running
45+
var client: DaemonClient?
4546
private var group: MultiThreadedEventLoopGroup?
4647
private var channel: GRPCChannel?
47-
private var client: DaemonClient?
4848

4949
// Protect start & stop transitions against re-entrancy
5050
private let transition = AsyncSemaphore(value: 1)
@@ -171,7 +171,8 @@ public class MutagenDaemon: FileSyncDaemon {
171171
)
172172
client = DaemonClient(
173173
mgmt: Daemon_DaemonAsyncClient(channel: channel!),
174-
sync: Synchronization_SynchronizationAsyncClient(channel: channel!)
174+
sync: Synchronization_SynchronizationAsyncClient(channel: channel!),
175+
prompt: Prompting_PromptingAsyncClient(channel: channel!)
175176
)
176177
logger.info(
177178
"Successfully connected to mutagen daemon, socket: \(self.mutagenDaemonSocket.path, privacy: .public)"
@@ -301,6 +302,7 @@ public class MutagenDaemon: FileSyncDaemon {
301302
struct DaemonClient {
302303
let mgmt: Daemon_DaemonAsyncClient
303304
let sync: Synchronization_SynchronizationAsyncClient
305+
let prompt: Prompting_PromptingAsyncClient
304306
}
305307

306308
public enum DaemonState {
@@ -342,6 +344,8 @@ public enum DaemonError: Error {
342344
case connectionFailure(Error)
343345
case terminatedUnexpectedly
344346
case grpcFailure(Error)
347+
case invalidGrpcResponse(String)
348+
case unexpectedStreamClosure
345349

346350
public var description: String {
347351
switch self {
@@ -355,6 +359,10 @@ public enum DaemonError: Error {
355359
"The daemon must be started first"
356360
case let .grpcFailure(error):
357361
"Failed to communicate with daemon: \(error)"
362+
case let .invalidGrpcResponse(response):
363+
"Invalid gRPC response: \(response)"
364+
case .unexpectedStreamClosure:
365+
"Unexpected stream closure"
358366
}
359367
}
360368

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import GRPC
2+
3+
extension MutagenDaemon {
4+
typealias PromptStream = GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
5+
6+
func host(allowPrompts: Bool = true) async throws(DaemonError) -> (PromptStream, identifier: String) {
7+
let stream = client!.prompt.makeHostCall()
8+
9+
do {
10+
try await stream.requestStream.send(.with { req in req.allowPrompts = allowPrompts })
11+
} catch {
12+
throw .grpcFailure(error)
13+
}
14+
15+
// We can't make call `makeAsyncIterator` more than once
16+
// (as a for-loop would do implicitly)
17+
var iter = stream.responseStream.makeAsyncIterator()
18+
19+
// "Receive the initialization response, validate it, and extract the prompt identifier"
20+
let initResp: Prompting_HostResponse?
21+
do {
22+
initResp = try await iter.next()
23+
} catch {
24+
throw .grpcFailure(error)
25+
}
26+
guard let initResp else {
27+
throw .unexpectedStreamClosure
28+
}
29+
// TODO: we'll always accept prompts for now
30+
try initResp.ensureValid(first: true, allowPrompts: allowPrompts)
31+
32+
Task.detached(priority: .background) {
33+
do {
34+
while let msg = try await iter.next() {
35+
try msg.ensureValid(first: false, allowPrompts: allowPrompts)
36+
var reply: Prompting_HostRequest = .init()
37+
if msg.isPrompt {
38+
// Handle SSH key prompts
39+
if msg.message.contains("yes/no/[fingerprint]") {
40+
reply.response = "yes"
41+
}
42+
// Any other messages that require a non-empty response will
43+
// cause the create op to fail, showing an error. This is ok for now.
44+
}
45+
try await stream.requestStream.send(reply)
46+
}
47+
} catch {
48+
self.logger.critical("Prompt stream failed: \(error)")
49+
}
50+
}
51+
return (stream, identifier: initResp.identifier)
52+
}
53+
}

Diff for: ‎Coder-Desktop/VPNLib/FileSync/MutagenConvert.swift

+23
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,26 @@ func accumulateErrors(from state: Synchronization_State) -> [FileSyncError] {
5757
func humanReadableBytes(_ bytes: UInt64) -> String {
5858
ByteCountFormatter().string(fromByteCount: Int64(bytes))
5959
}
60+
61+
extension Prompting_HostResponse {
62+
func ensureValid(first: Bool, allowPrompts: Bool) throws(DaemonError) {
63+
if first {
64+
if identifier.isEmpty {
65+
throw .invalidGrpcResponse("empty prompter identifier")
66+
}
67+
if isPrompt {
68+
throw .invalidGrpcResponse("unexpected message type specification")
69+
}
70+
if !message.isEmpty {
71+
throw .invalidGrpcResponse("unexpected message")
72+
}
73+
} else {
74+
if !identifier.isEmpty {
75+
throw .invalidGrpcResponse("unexpected prompter identifier")
76+
}
77+
if isPrompt, !allowPrompts {
78+
throw .invalidGrpcResponse("disallowed prompt message type")
79+
}
80+
}
81+
}
82+
}
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: ‎scripts/mutagen-proto.sh

+16-16
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
# It is very similar to `Update-Proto.ps1` on `coder/coder-desktop-windows`.
55
# It's very unlikely that we'll use this script regularly.
66
#
7-
# Unlike the Go compiler, the Swift compiler does not support multiple files
8-
# with the same name in different directories.
9-
# To handle this, this script flattens the directory structure of the proto
7+
# Unlike the Go compiler, the Swift compiler does not support multiple files
8+
# with the same name in different directories.
9+
# To handle this, this script flattens the directory structure of the proto
1010
# files into the filename, i.e. `service/synchronization/synchronization.proto`
1111
# becomes `service_synchronization_synchronization.proto`.
1212
# It also updates the proto imports to use these paths.
@@ -24,7 +24,7 @@ mutagen_tag="$1"
2424
repo="mutagen-io/mutagen"
2525
proto_prefix="pkg"
2626
# Right now, we only care about the synchronization and daemon management gRPC
27-
entry_files=("service/synchronization/synchronization.proto" "service/daemon/daemon.proto")
27+
entry_files=("service/synchronization/synchronization.proto" "service/daemon/daemon.proto" "service/prompting/prompting.proto")
2828

2929
out_folder="Coder-Desktop/VPNLib/FileSync/MutagenSDK"
3030

@@ -33,7 +33,7 @@ if [ -d "$clone_dir" ]; then
3333
echo "Found existing mutagen repo at $clone_dir, checking out $mutagen_tag..."
3434
pushd "$clone_dir" > /dev/null
3535
git clean -fdx
36-
36+
3737
current_tag=$(git name-rev --name-only HEAD)
3838
if [ "$current_tag" != "tags/$mutagen_tag" ]; then
3939
git fetch --all
@@ -62,27 +62,27 @@ add_file() {
6262
local proto_path="${filepath#"$clone_dir"/"$proto_prefix"/}"
6363
local flat_name
6464
flat_name=$(echo "$proto_path" | sed 's/\//_/g')
65-
65+
6666
# Skip if already processed
6767
if [[ -n "${file_map[$proto_path]:-}" ]]; then
6868
return
6969
fi
70-
70+
7171
echo "Adding $proto_path -> $flat_name"
7272
file_map[$proto_path]=$flat_name
7373
file_paths+=("$filepath")
74-
74+
7575
# Process imports
7676
while IFS= read -r line; do
7777
if [[ $line =~ ^import\ \"(.+)\" ]]; then
7878
import_path="${BASH_REMATCH[1]}"
79-
79+
8080
# Ignore google imports, as they're not vendored
8181
if [[ $import_path =~ ^google/ ]]; then
8282
echo "Skipping $import_path"
8383
continue
8484
fi
85-
85+
8686
import_file_path="$clone_dir/$proto_prefix/$import_path"
8787
if [ -f "$import_file_path" ]; then
8888
add_file "$import_file_path"
@@ -109,24 +109,24 @@ for file_path in "${file_paths[@]}"; do
109109
proto_path="${file_path#"$clone_dir"/"$proto_prefix"/}"
110110
flat_name="${file_map[$proto_path]}"
111111
dst_path="$out_folder/$flat_name"
112-
112+
113113
cp -f "$file_path" "$dst_path"
114-
114+
115115
file_header="/*\n * This file was taken from\n * https://github.com/$repo/tree/$mutagen_tag/$proto_prefix/$proto_path\n *\n$license_header\n */\n\n"
116116
content=$(cat "$dst_path")
117117
echo -e "$file_header$content" > "$dst_path"
118-
118+
119119
tmp_file=$(mktemp)
120120
while IFS= read -r line; do
121121
if [[ $line =~ ^import\ \"(.+)\" ]]; then
122122
import_path="${BASH_REMATCH[1]}"
123-
123+
124124
# Retain google imports
125125
if [[ $import_path =~ ^google/ ]]; then
126126
echo "$line" >> "$tmp_file"
127127
continue
128128
fi
129-
129+
130130
# Convert import path to flattened format
131131
flat_import=$(echo "$import_path" | sed 's/\//_/g')
132132
echo "import \"$flat_import\";" >> "$tmp_file"
@@ -135,7 +135,7 @@ for file_path in "${file_paths[@]}"; do
135135
fi
136136
done < "$dst_path"
137137
mv "$tmp_file" "$dst_path"
138-
138+
139139
echo "Processed $proto_path -> $flat_name"
140140
done
141141

0 commit comments

Comments
 (0)
Please sign in to comment.