Skip to content

Commit e850b81

Browse files
committed
stop/start/remove lifecycle for gateways
Stop now keeps the saved config (bot token) so the gateway can be restarted without re-entering credentials. Pairings are still cleared on stop so users must re-pair. Three states in the UI: - Unconfigured: shows token input + Start - Configured + stopped: shows Start button + trash to remove - Running: shows Pair Device + Stop New API endpoints: - POST /gateway/restart - restart a stopped gateway from saved config - POST /gateway/remove - stop + delete saved config entirely Status endpoint now includes configured-but-stopped gateways.
1 parent 3bb1874 commit e850b81

3 files changed

Lines changed: 186 additions & 3 deletions

File tree

crates/goose-server/src/gateway/manager.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub struct PairedUserInfo {
4848
pub struct GatewayStatus {
4949
pub gateway_type: String,
5050
pub running: bool,
51+
pub configured: bool,
5152
pub paired_users: Vec<PairedUserInfo>,
5253
#[serde(skip_serializing_if = "HashMap::is_empty")]
5354
pub info: HashMap<String, String>,
@@ -122,7 +123,7 @@ impl GatewayManager {
122123
Ok(())
123124
}
124125

125-
/// Stop a gateway, clear its pairings, and remove its persisted config.
126+
/// Stop a gateway and clear its pairings. Config is kept so it can be restarted.
126127
pub async fn stop_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
127128
let instance = self
128129
.gateways
@@ -148,11 +149,49 @@ impl GatewayManager {
148149
_ => {}
149150
}
150151

151-
Self::remove_saved_config(gateway_type);
152152
tracing::info!(gateway = %gateway_type, "gateway stopped");
153153
Ok(())
154154
}
155155

156+
/// Stop a gateway (if running), clear pairings, and remove its saved config entirely.
157+
pub async fn remove_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
158+
// Stop if running (ignore error if not running).
159+
if let Some(instance) = self.gateways.write().await.remove(gateway_type) {
160+
instance.cancel.cancel();
161+
let _ = instance.handle.await;
162+
}
163+
164+
if let Err(e) = self
165+
.pairing_store
166+
.remove_all_for_platform(gateway_type)
167+
.await
168+
{
169+
tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on remove");
170+
}
171+
172+
Self::remove_saved_config(gateway_type);
173+
tracing::info!(gateway = %gateway_type, "gateway removed");
174+
Ok(())
175+
}
176+
177+
/// Restart a stopped gateway using its saved config.
178+
pub async fn restart_gateway(&self, gateway_type: &str) -> anyhow::Result<()> {
179+
if self.gateways.read().await.contains_key(gateway_type) {
180+
anyhow::bail!("Gateway '{}' is already running", gateway_type);
181+
}
182+
183+
let configs = Self::load_saved_configs()?;
184+
let mut config = configs
185+
.into_iter()
186+
.find(|c| c.gateway_type == gateway_type)
187+
.ok_or_else(|| anyhow::anyhow!("No saved config for gateway '{}'", gateway_type))?;
188+
189+
let gateway = super::create_gateway(&mut config)?;
190+
self.start_gateway_internal(config, gateway).await?;
191+
tracing::info!(gateway = %gateway_type, "gateway restarted");
192+
Ok(())
193+
}
194+
156195
async fn start_gateway_internal(
157196
&self,
158197
config: GatewayConfig,
@@ -226,8 +265,10 @@ impl GatewayManager {
226265
pub async fn status(&self) -> Vec<GatewayStatus> {
227266
let running = self.gateways.read().await;
228267
let mut statuses = Vec::new();
268+
let mut seen = std::collections::HashSet::new();
229269

230270
for (gw_type, instance) in running.iter() {
271+
seen.insert(gw_type.clone());
231272
let paired_users = self
232273
.pairing_store
233274
.list_paired_users(gw_type)
@@ -246,11 +287,28 @@ impl GatewayManager {
246287
statuses.push(GatewayStatus {
247288
gateway_type: gw_type.clone(),
248289
running: true,
290+
configured: true,
249291
paired_users,
250292
info: instance.gateway.info(),
251293
});
252294
}
253295

296+
// Include configured-but-stopped gateways.
297+
if let Ok(saved) = Self::load_saved_configs() {
298+
for config in saved {
299+
if seen.contains(&config.gateway_type) {
300+
continue;
301+
}
302+
statuses.push(GatewayStatus {
303+
gateway_type: config.gateway_type,
304+
running: false,
305+
configured: true,
306+
paired_users: Vec::new(),
307+
info: HashMap::new(),
308+
});
309+
}
310+
}
311+
254312
statuses.sort_by(|a, b| a.gateway_type.cmp(&b.gateway_type));
255313
statuses
256314
}

crates/goose-server/src/routes/gateway.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ pub struct StopGatewayRequest {
2626
pub gateway_type: String,
2727
}
2828

29+
#[derive(Deserialize, ToSchema)]
30+
pub struct RestartGatewayRequest {
31+
pub gateway_type: String,
32+
}
33+
34+
#[derive(Deserialize, ToSchema)]
35+
pub struct RemoveGatewayRequest {
36+
pub gateway_type: String,
37+
}
38+
2939
#[derive(Deserialize, ToSchema)]
3040
pub struct CreatePairingRequest {
3141
pub gateway_type: String,
@@ -91,6 +101,53 @@ pub async fn stop_gateway(
91101
}
92102
}
93103

104+
#[utoipa::path(
105+
post,
106+
path = "/gateway/restart",
107+
request_body = RestartGatewayRequest,
108+
responses(
109+
(status = 200, description = "Gateway restarted"),
110+
(status = 400, description = "Bad request", body = ErrorResponse),
111+
(status = 404, description = "No saved config", body = ErrorResponse)
112+
)
113+
)]
114+
pub async fn restart_gateway(
115+
State(state): State<Arc<AppState>>,
116+
Json(request): Json<RestartGatewayRequest>,
117+
) -> Response {
118+
match state
119+
.gateway_manager
120+
.restart_gateway(&request.gateway_type)
121+
.await
122+
{
123+
Ok(()) => StatusCode::OK.into_response(),
124+
Err(e) => ErrorResponse::bad_request(e.to_string()).into_response(),
125+
}
126+
}
127+
128+
#[utoipa::path(
129+
post,
130+
path = "/gateway/remove",
131+
request_body = RemoveGatewayRequest,
132+
responses(
133+
(status = 200, description = "Gateway removed"),
134+
(status = 500, description = "Internal server error", body = ErrorResponse)
135+
)
136+
)]
137+
pub async fn remove_gateway(
138+
State(state): State<Arc<AppState>>,
139+
Json(request): Json<RemoveGatewayRequest>,
140+
) -> Response {
141+
match state
142+
.gateway_manager
143+
.remove_gateway(&request.gateway_type)
144+
.await
145+
{
146+
Ok(()) => StatusCode::OK.into_response(),
147+
Err(e) => ErrorResponse::internal(e.to_string()).into_response(),
148+
}
149+
}
150+
94151
#[utoipa::path(
95152
get,
96153
path = "/gateway/status",
@@ -159,6 +216,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
159216
Router::new()
160217
.route("/gateway/start", post(start_gateway))
161218
.route("/gateway/stop", post(stop_gateway))
219+
.route("/gateway/restart", post(restart_gateway))
220+
.route("/gateway/remove", post(remove_gateway))
162221
.route("/gateway/status", get(gateway_status))
163222
.route("/gateway/pair", post(create_pairing_code))
164223
.route("/gateway/pair/{platform}/{user_id}", delete(unpair_user))

ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface PairedUserInfo {
2323
interface GatewayStatus {
2424
gateway_type: string;
2525
running: boolean;
26+
configured: boolean;
2627
paired_users: PairedUserInfo[];
2728
info?: Record<string, string>;
2829
}
@@ -117,6 +118,40 @@ export default function GatewaySettingsSection() {
117118
}
118119
};
119120

121+
const handleRestartGateway = async (gatewayType: string) => {
122+
setError(null);
123+
try {
124+
const response = await gatewayFetch('/gateway/restart', {
125+
method: 'POST',
126+
body: JSON.stringify({ gateway_type: gatewayType }),
127+
});
128+
if (!response.ok) {
129+
const data = await response.json().catch(() => ({}));
130+
throw new Error(data.message || 'Failed to restart gateway');
131+
}
132+
await fetchStatus();
133+
} catch (err) {
134+
setError(err instanceof Error ? err.message : 'Failed to restart gateway');
135+
}
136+
};
137+
138+
const handleRemoveGateway = async (gatewayType: string) => {
139+
setError(null);
140+
try {
141+
const response = await gatewayFetch('/gateway/remove', {
142+
method: 'POST',
143+
body: JSON.stringify({ gateway_type: gatewayType }),
144+
});
145+
if (!response.ok) {
146+
const data = await response.json().catch(() => ({}));
147+
throw new Error(data.message || 'Failed to remove gateway');
148+
}
149+
await fetchStatus();
150+
} catch (err) {
151+
setError(err instanceof Error ? err.message : 'Failed to remove gateway');
152+
}
153+
};
154+
120155
const handleGeneratePairingCode = async (gatewayType: string) => {
121156
setError(null);
122157
try {
@@ -183,6 +218,8 @@ export default function GatewaySettingsSection() {
183218
status={findGateway('telegram')}
184219
onStart={(config) => handleStartGateway('telegram', config)}
185220
onStop={() => handleStopGateway('telegram')}
221+
onRestart={() => handleRestartGateway('telegram')}
222+
onRemove={() => handleRemoveGateway('telegram')}
186223
onGenerateCode={() => handleGeneratePairingCode('telegram')}
187224
onUnpairUser={handleUnpairUser}
188225
/>
@@ -245,22 +282,35 @@ function RunningBadge() {
245282
);
246283
}
247284

285+
function StoppedBadge() {
286+
return (
287+
<span className="inline-flex items-center gap-1 text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 px-2 py-0.5 rounded-full">
288+
Stopped
289+
</span>
290+
);
291+
}
292+
248293
function TelegramGatewayCard({
249294
status,
250295
onStart,
251296
onStop,
297+
onRestart,
298+
onRemove,
252299
onGenerateCode,
253300
onUnpairUser,
254301
}: {
255302
status: GatewayStatus | undefined;
256303
onStart: (config: Record<string, unknown>) => Promise<void>;
257304
onStop: () => void;
305+
onRestart: () => void;
306+
onRemove: () => void;
258307
onGenerateCode: () => void;
259308
onUnpairUser: (platform: string, userId: string) => void;
260309
}) {
261310
const [botToken, setBotToken] = useState('');
262311
const [starting, setStarting] = useState(false);
263312
const running = status?.running ?? false;
313+
const configured = status?.configured ?? false;
264314

265315
const handleStart = async () => {
266316
if (!botToken.trim()) return;
@@ -277,6 +327,7 @@ function TelegramGatewayCard({
277327
<CardTitle className="flex items-center gap-2">
278328
Telegram
279329
{running && <RunningBadge />}
330+
{!running && configured && <StoppedBadge />}
280331
</CardTitle>
281332
{running && (
282333
<div className="flex items-center gap-2">
@@ -289,10 +340,25 @@ function TelegramGatewayCard({
289340
</Button>
290341
</div>
291342
)}
343+
{!running && configured && (
344+
<div className="flex items-center gap-2">
345+
<Button size="sm" onClick={onRestart}>
346+
Start
347+
</Button>
348+
<Button
349+
variant="ghost"
350+
size="sm"
351+
onClick={onRemove}
352+
className="text-text-muted hover:text-red-600"
353+
>
354+
<Trash2 className="h-3 w-3" />
355+
</Button>
356+
</div>
357+
)}
292358
</div>
293359
</CardHeader>
294360
<CardContent className="pt-3 space-y-2">
295-
{!running && (
361+
{!running && !configured && (
296362
<>
297363
<div className="flex items-center gap-2">
298364
<Input

0 commit comments

Comments
 (0)