anvilsign in

collin/anvil

main / crates / anvil-web / src / agent.rs
1//! Web surface for agent sessions: the session list and page, the websocket
2//! that bridges a browser terminal to the container's tmux, and the vendored
3//! terminal emulator assets.
4//!
5//! The wire format on the websocket is ours rather than wterm's built-in
6//! transport, which is a raw byte pass-through with no control channel and so
7//! no way to carry a resize:
8//!
9//! - **binary frames** are raw terminal bytes, in both directions;
10//! - **text frames** are JSON control messages — `{"t":"resize",…}` from the
11//! browser, `{"t":"exit",…}` back.
12
13use anvil_agent::StartError;
14use anvil_core::{
15 App,
16 User,
17 access,
18 agent::{
19 self,
20 kind,
21 status,
22 },
23};
24use axum::{
25 Router,
26 extract::{
27 Path,
28 State,
29 ws::{
30 Message,
31 WebSocket,
32 WebSocketUpgrade,
33 },
34 },
35 http::header,
36 response::{
37 IntoResponse,
38 Redirect,
39 Response,
40 },
41 routing::{
42 get,
43 post,
44 },
45};
46use futures_util::{
47 SinkExt,
48 StreamExt,
49};
50use maud::{
51 Markup,
52 PreEscaped,
53 html,
54};
55use tokio::io::AsyncWriteExt;
56
57use crate::{
58 auth::{
59 Csrf,
60 CsrfForm,
61 CurrentUser,
62 verify_csrf,
63 },
64 ui::{
65 self,
66 forbidden,
67 layout,
68 not_found,
69 server_error,
70 },
71};
72
73pub fn routes(router: Router<App>) -> Router<App> {
74 router
75 .route("/{owner}/{repo}/-/agent", get(list).post(create))
76 .route("/{owner}/{repo}/-/agent/{id}", get(show))
77 .route("/{owner}/{repo}/-/agent/{id}/stop", post(stop))
78 .route("/{owner}/{repo}/-/agent/{id}/ws", get(socket))
79 .route("/-/static/wterm/{*path}", get(wterm_asset))
80}
81
82// --- vendored terminal emulator --------------------------------------------
83
84/// wterm (Apache-2.0), vendored as published ESM and embedded in the binary,
85/// exactly as htmx is. No bundler is involved: the tree has a single bare
86/// specifier (`@wterm/core`), which the page resolves with an import map.
87///
88/// The built-in core's WASM is inlined as base64 inside `wasm-inline.js`, so
89/// there is no separate binary to fetch and no `import.meta.url` resolution to
90/// get wrong.
91const WTERM_ASSETS: &[(&str, &str, &str)] = &[
92 (
93 "core/index.js",
94 include_str!("../assets/wterm/core/index.js"),
95 JS,
96 ),
97 (
98 "core/terminal-core.js",
99 include_str!("../assets/wterm/core/terminal-core.js"),
100 JS,
101 ),
102 (
103 "core/transport.js",
104 include_str!("../assets/wterm/core/transport.js"),
105 JS,
106 ),
107 (
108 "core/wasm-bridge.js",
109 include_str!("../assets/wterm/core/wasm-bridge.js"),
110 JS,
111 ),
112 (
113 "core/wasm-inline.js",
114 include_str!("../assets/wterm/core/wasm-inline.js"),
115 JS,
116 ),
117 (
118 "dom/index.js",
119 include_str!("../assets/wterm/dom/index.js"),
120 JS,
121 ),
122 (
123 "dom/debug.js",
124 include_str!("../assets/wterm/dom/debug.js"),
125 JS,
126 ),
127 (
128 "dom/hyperlink.js",
129 include_str!("../assets/wterm/dom/hyperlink.js"),
130 JS,
131 ),
132 (
133 "dom/input.js",
134 include_str!("../assets/wterm/dom/input.js"),
135 JS,
136 ),
137 (
138 "dom/renderer.js",
139 include_str!("../assets/wterm/dom/renderer.js"),
140 JS,
141 ),
142 (
143 "dom/wterm.js",
144 include_str!("../assets/wterm/dom/wterm.js"),
145 JS,
146 ),
147 (
148 "terminal.css",
149 include_str!("../assets/wterm/terminal.css"),
150 CSS,
151 ),
152];
153
154const JS: &str = "application/javascript; charset=utf-8";
155const CSS: &str = "text/css; charset=utf-8";
156
157/// Serve one vendored wterm file. A `match` over embedded constants rather than
158/// a route each, since it is a dozen files that only ever change together.
159async fn wterm_asset(Path(path): Path<String>) -> Response {
160 let Some((_, body, content_type)) = WTERM_ASSETS.iter().find(|(name, _, _)| *name == path)
161 else {
162 return not_found("no such asset");
163 };
164 (
165 [
166 (header::CONTENT_TYPE, *content_type),
167 // Vendored at a fixed version and only replaced by a redeploy.
168 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
169 ],
170 *body,
171 )
172 .into_response()
173}
174
175// --- pages ------------------------------------------------------------------
176
177/// Resolve the repo and require write access: starting a session runs code
178/// against the repository and (from M2) pushes to it, so it is an owner action,
179/// not a reader one.
180async fn resolve_writable(
181 app: &App,
182 viewer: Option<&User>,
183 owner: &str,
184 name: &str,
185) -> Result<anvil_core::Repository, Response> {
186 let (_, repo) = ui::resolve_repo(app, viewer, owner, name).await?;
187 if !access::can_write(&repo, viewer) {
188 return Err(forbidden());
189 }
190 Ok(repo)
191}
192
193/// `GET /{owner}/{repo}/-/agent` — this repository's sessions.
194async fn list(
195 State(app): State<App>,
196 CurrentUser(user): CurrentUser,
197 Path((owner, name)): Path<(String, String)>,
198) -> Result<Markup, Response> {
199 let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
200 let sessions = agent::list_by_repo(&app.db, repo.id, 50)
201 .await
202 .map_err(server_error)?;
203 let enabled = app.config.agent.enabled;
204 let csrf = crate::auth::current_csrf();
205 let user_secrets = match &user {
206 Some(u) => anvil_core::secrets::list_for_user(&app.db, u.id)
207 .await
208 .map_err(server_error)?,
209 None => Vec::new(),
210 };
211
212 Ok(layout(
213 &format!("{owner}/{name} · Agent"),
214 user.as_ref(),
215 html! {
216 h1 { a href=(format!("/{owner}/{name}")) { (owner) "/" (name) } " · Agent" }
217 @if !enabled {
218 p.muted {
219 "Agent sessions are disabled. Set "
220 code { "agent.enabled" }
221 " in anvil.toml to turn them on."
222 }
223 } @else {
224 // hx-boost off: a boosted submit swaps the redirected page's
225 // markup into the DOM via innerHTML, and per spec an
226 // <script type="importmap"> inserted that way never takes
227 // effect — the terminal page's module script would fail to
228 // resolve "@wterm/core". Starting a session needs a real
229 // navigation.
230 form.stack hx-boost="false" method="post" action={"/" (owner) "/" (name) "/-/agent"} style="margin-bottom:24px" {
231 (ui::csrf_input(&csrf))
232 p {
233 label { "Start from branch" br; input type="text" name="base_ref" value="main" required; }
234 }
235 p {
236 label {
237 "Opening prompt (optional — leave empty to drive it yourself)" br;
238 textarea name="prompt" rows="3" {}
239 }
240 }
241 @if !user_secrets.is_empty() {
242 p {
243 "Secrets for this session"
244 @for secret in &user_secrets {
245 br;
246 label {
247 input type="checkbox" name="secret_names" value=(secret.name);
248 " " code { (secret.name) }
249 " (" (secret.kind)
250 @if !secret.dest_path.is_empty() { " → " code { (secret.dest_path) } }
251 ")"
252 }
253 }
254 br;
255 span.muted style="font-size:12px" {
256 "Only what's checked reaches this session — everything else stays out of reach, "
257 "even if it's unlocked. Manage them in " a href="/-/settings" { "settings" } "."
258 }
259 }
260 }
261 p { button.btn type="submit" { "Start session" } }
262 }
263 }
264 @if sessions.is_empty() {
265 p.muted { "No sessions yet." }
266 } @else {
267 div.box {
268 @for session in &sessions {
269 div.row {
270 // Same reasoning as the create form above: this can
271 // land on a live session's page, which needs a real
272 // navigation for its import map to take effect.
273 a.entry hx-boost="false" href={"/" (owner) "/" (name) "/-/agent/" (session.id)} {
274 (agent_status_badge(&session.status, session.exit_code))
275 span.sha { "#" (session.id) }
276 span { (session.base_ref) }
277 }
278 span.muted { (ui::fmt_relative(session.created_at)) }
279 }
280 }
281 }
282 }
283 },
284 ))
285}
286
287/// Like [`ui::status_badge`], but an agent session's ended state splits on the
288/// exit code rather than being its own status string: `exited` alone doesn't
289/// say whether the agent finished cleanly.
290fn agent_status_badge(status: &str, exit_code: i64) -> Markup {
291 let class = if status == agent::status::EXITED && exit_code != 0 {
292 "failed"
293 } else {
294 status
295 };
296 html! { span class=(format!("st {class}")) { (status) } }
297}
298
299/// `POST /{owner}/{repo}/-/agent` — start a session.
300async fn create(
301 State(app): State<App>,
302 CurrentUser(user): CurrentUser,
303 csrf: Csrf,
304 Path((owner, name)): Path<(String, String)>,
305 axum::Form(form): axum::Form<CreateForm>,
306) -> Result<Response, Response> {
307 verify_csrf(&csrf, &form.csrf)?;
308 let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
309 let Some(user) = user else {
310 return Err(forbidden());
311 };
312
313 let base_ref = if form.base_ref.trim().is_empty() {
314 repo.default_branch.clone()
315 } else {
316 form.base_ref.trim().to_string()
317 };
318 let prompt = form.prompt.trim();
319 let session_kind = if prompt.is_empty() {
320 kind::INTERACTIVE
321 } else {
322 kind::AUTONOMOUS
323 };
324
325 match anvil_agent::start(
326 &app,
327 repo.id,
328 user.id,
329 session_kind,
330 &base_ref,
331 prompt,
332 &form.secret_names,
333 )
334 .await
335 {
336 Ok(id) => Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response()),
337 // Being at capacity, or switched off, is a normal answer rather than a
338 // fault — "something went wrong" would send someone hunting a bug that
339 // isn't there.
340 Err(e @ (StartError::Disabled | StartError::AtCapacity(_))) => Err(unavailable(&e)),
341 Err(e) => Err(server_error(e)),
342 }
343}
344
345/// A session could not be started for a reason the caller can act on.
346fn unavailable(reason: &StartError) -> Response {
347 (
348 axum::http::StatusCode::SERVICE_UNAVAILABLE,
349 layout(
350 "Cannot start a session",
351 None,
352 html! {
353 h1 { "Cannot start a session" }
354 p.muted { (reason.to_string()) }
355 @if matches!(reason, StartError::AtCapacity(_)) {
356 p { "Stop a running session, or raise " code { "agent.max_concurrent" } "." }
357 }
358 },
359 ),
360 )
361 .into_response()
362}
363
364#[derive(serde::Deserialize)]
365struct CreateForm {
366 #[serde(default)]
367 csrf: String,
368 #[serde(default)]
369 base_ref: String,
370 #[serde(default)]
371 prompt: String,
372 /// Names of the signed-in user's own secrets (`secrets::list_for_user`)
373 /// this session opts into — an HTML checkbox group, so absent means
374 /// none were checked, not "field missing".
375 #[serde(default)]
376 secret_names: Vec<String>,
377}
378
379/// `POST /{owner}/{repo}/-/agent/{id}/stop`
380async fn stop(
381 State(app): State<App>,
382 CurrentUser(user): CurrentUser,
383 csrf: Csrf,
384 Path((owner, name, id)): Path<(String, String, i64)>,
385 axum::Form(form): axum::Form<CsrfForm>,
386) -> Result<Response, Response> {
387 verify_csrf(&csrf, &form.csrf)?;
388 let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
389 let session = session_of(&app, repo.id, id).await?;
390 anvil_agent::stop(&app, session.id).await;
391 Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response())
392}
393
394/// Load a session, checking it really belongs to this repository — otherwise
395/// the id alone would read across repos.
396async fn session_of(
397 app: &App,
398 repo_id: i64,
399 id: i64,
400) -> Result<anvil_core::AgentSession, Response> {
401 let session = agent::get(&app.db, id)
402 .await
403 .map_err(server_error)?
404 .ok_or_else(|| not_found("no such session"))?;
405 if session.repo_id != repo_id {
406 return Err(not_found("no such session"));
407 }
408 Ok(session)
409}
410
411/// `GET /{owner}/{repo}/-/agent/{id}` — the terminal.
412async fn show(
413 State(app): State<App>,
414 CurrentUser(user): CurrentUser,
415 Path((owner, name, id)): Path<(String, String, i64)>,
416) -> Result<Markup, Response> {
417 let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
418 let session = session_of(&app, repo.id, id).await?;
419 let live = status::is_live(&session.status);
420 let csrf = crate::auth::current_csrf();
421 let ws_path = format!("/{owner}/{name}/-/agent/{id}/ws");
422
423 Ok(layout(
424 &format!("{owner}/{name} · Agent #{id}"),
425 user.as_ref(),
426 html! {
427 h1 {
428 a href=(format!("/{owner}/{name}")) { (owner) "/" (name) }
429 " · " a href=(format!("/{owner}/{name}/-/agent")) { "Agent" }
430 " · #" (id)
431 }
432 p {
433 (agent_status_badge(&session.status, session.exit_code))
434 " " span.sha { (ui::short_commit(&session.base_commit)) }
435 " " span.muted { (session.base_ref) }
436 @if live {
437 " " form style="display:inline" method="post" action={(ws_path.trim_end_matches("/ws")) "/stop"} {
438 (ui::csrf_input(&csrf))
439 button.linkbtn type="submit" { "stop" }
440 }
441 }
442 }
443 p.muted {
444 "started " (ui::fmt_time(session.created_at))
445 @if session.finished_at > 0 { " · finished " (ui::fmt_time(session.finished_at)) }
446 @if !session.error.is_empty() { " · " (session.error) }
447 }
448 @if live {
449 link rel="stylesheet" href="/-/static/wterm/terminal.css";
450 div.box style="margin-top:16px" {
451 div #terminal style="height:70vh" {}
452 }
453 script type="importmap" {
454 (PreEscaped(IMPORT_MAP))
455 }
456 script type="module" {
457 (PreEscaped(format!("const WS_PATH = {};\n{}",
458 serde_json::to_string(&ws_path).unwrap_or_else(|_| "\"\"".into()),
459 TERMINAL_JS)))
460 }
461 } @else {
462 p.muted { "This session has ended." }
463 }
464 },
465 ))
466}
467
468/// Resolves wterm's single bare specifier. Everything else in the vendored
469/// tree imports by relative path.
470const IMPORT_MAP: &str = r#"{"imports":{"@wterm/core":"/-/static/wterm/core/index.js"}}"#;
471
472/// Browser half of the terminal.
473///
474/// Note the `htmx:beforeSwap` teardown: the layout sets `hx-boost` on `<body>`,
475/// so navigating away swaps the DOM without a page load. Without this the
476/// websocket and the WASM instance would leak on every navigation.
477const TERMINAL_JS: &str = r#"
478import { WTerm } from "/-/static/wterm/dom/index.js";
479
480const url = new URL(WS_PATH, location.href);
481url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
482const ws = new WebSocket(url);
483ws.binaryType = "arraybuffer";
484
485const encode = new TextEncoder();
486let torndown = false;
487
488// A live WebSocket delivers `message`/`close` the instant they arrive — with
489// no listener attached yet, per spec that event is dropped, not queued. WASM
490// init below (`term.init()`) is async, so the connection can open and the
491// server's initial full-screen repaint can land before that finishes. Handlers
492// go on immediately; anything that arrives before the terminal exists is
493// buffered and flushed once it does, so nothing is silently lost.
494let ready = false;
495const pending = [];
496
497const sendResize = () => {
498 if (ws.readyState === WebSocket.OPEN) {
499 ws.send(JSON.stringify({ t: "resize", cols: term.cols, rows: term.rows }));
500 }
501};
502
503const renderMessage = (event) => {
504 if (event.data instanceof ArrayBuffer) {
505 term.write(new Uint8Array(event.data));
506 return;
507 }
508 let msg;
509 try {
510 msg = JSON.parse(event.data);
511 } catch {
512 return;
513 }
514 if (msg.t === "exit") {
515 term.write("\r\n\x1b[2m[session ended, status " + msg.code + "]\x1b[0m\r\n");
516 } else if (msg.t === "error") {
517 term.write("\r\n\x1b[31m[" + msg.message + "]\x1b[0m\r\n");
518 }
519};
520
521ws.onopen = sendResize;
522ws.onmessage = (event) => {
523 if (ready) renderMessage(event);
524 else pending.push(event);
525};
526ws.onclose = () => {
527 if (torndown) return;
528 const note = () => term.write("\r\n\x1b[2m[disconnected]\x1b[0m\r\n");
529 if (ready) note();
530 else pending.push({ data: null, note });
531};
532
533// Callbacks are read off the instance at call time, so setting them here (in
534// the constructor options) is the same as setting them later — this is just
535// the clearer place. `wasmUrl` is left unset on purpose: the built-in core's
536// WASM is inlined as base64, so there is no second request to make.
537const term = new WTerm(document.getElementById("terminal"), {
538 autoResize: true,
539 cursorBlink: true,
540 // Keystrokes out. onData hands us a string; the wire carries bytes.
541 onData: (data) => {
542 if (ws.readyState === WebSocket.OPEN) ws.send(encode.encode(data));
543 },
544 // Fires on the ResizeObserver as well as an explicit resize(), so the
545 // container's pty follows the browser window.
546 onResize: sendResize,
547});
548await term.init();
549
550ready = true;
551for (const item of pending.splice(0)) {
552 if (item.note) item.note();
553 else renderMessage(item);
554}
555
556// The layout sets hx-boost on <body>, so navigating away swaps the DOM without
557// a page load. Without this teardown the socket and the WASM instance would
558// leak on every navigation.
559const teardown = () => {
560 if (torndown) return;
561 torndown = true;
562 try { ws.close(); } catch {}
563 try { term.destroy(); } catch {}
564};
565document.body.addEventListener("htmx:beforeSwap", teardown, { once: true });
566window.addEventListener("pagehide", teardown, { once: true });
567
568term.focus();
569"#;
570
571// --- the websocket ----------------------------------------------------------
572
573/// `GET /{owner}/{repo}/-/agent/{id}/ws` — bridge the browser to the
574/// container's tmux.
575async fn socket(
576 State(app): State<App>,
577 CurrentUser(user): CurrentUser,
578 Path((owner, name, id)): Path<(String, String, i64)>,
579 upgrade: WebSocketUpgrade,
580) -> Result<Response, Response> {
581 let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
582 let session = session_of(&app, repo.id, id).await?;
583 if !status::is_live(&session.status) {
584 return Err(not_found("session is not running"));
585 }
586 Ok(upgrade.on_upgrade(move |socket| bridge(app, session.id, socket)))
587}
588
589/// Pump bytes between one websocket and one tmux client.
590///
591/// Each browser gets its own `docker exec … tmux attach`, so a dropped socket
592/// takes down that client and nothing else — the agent is a process inside
593/// tmux, not a child of the exec. tmux repaints a newly attached client, which
594/// is what makes reconnect show the current screen rather than a blank one.
595async fn bridge(app: App, session_id: i64, socket: WebSocket) {
596 let (mut sink, mut stream) = socket.split();
597
598 let (terminal, docker) = match anvil_agent::attach_stream(&app, session_id, 80, 24).await {
599 Ok(attached) => attached,
600 Err(e) => {
601 let _ = sink
602 .send(Message::Text(
603 format!(r#"{{"t":"error","message":{}}}"#, json_string(&e)).into(),
604 ))
605 .await;
606 return;
607 }
608 };
609 let anvil_agent::container::Terminal {
610 exec_id,
611 mut output,
612 mut input,
613 } = terminal;
614
615 tracing::info!("agent: session {session_id} viewer attached (exec {exec_id})");
616
617 // Container -> browser.
618 let mut to_browser = tokio::spawn(async move {
619 let mut total_bytes: u64 = 0;
620 loop {
621 match output.next().await {
622 Some(Ok(log)) => {
623 let bytes = log.into_bytes();
624 if bytes.is_empty() {
625 continue;
626 }
627 total_bytes += bytes.len() as u64;
628 if let Err(e) = sink.send(Message::Binary(bytes.to_vec().into())).await {
629 tracing::info!(
630 "agent: session {session_id} viewer socket send failed after {total_bytes} byte(s): {e}"
631 );
632 break;
633 }
634 }
635 Some(Err(e)) => {
636 tracing::warn!(
637 "agent: session {session_id} exec output ended after {total_bytes} byte(s): {e}"
638 );
639 break;
640 }
641 None => {
642 tracing::info!(
643 "agent: session {session_id} exec output closed after {total_bytes} byte(s) (tmux client detached or the container is gone)"
644 );
645 break;
646 }
647 }
648 }
649 let _ = sink.close().await;
650 });
651
652 // Browser -> container, plus the control channel.
653 let control_docker = docker.clone();
654 let control_exec = exec_id.clone();
655 let mut to_container = tokio::spawn(async move {
656 loop {
657 match stream.next().await {
658 Some(Ok(Message::Binary(data))) => {
659 if let Err(e) = input.write_all(&data).await {
660 tracing::info!("agent: session {session_id} exec input closed: {e}");
661 break;
662 }
663 let _ = input.flush().await;
664 }
665 Some(Ok(Message::Text(text))) => {
666 if let Some((cols, rows)) = parse_resize(&text) {
667 anvil_agent::container::resize(&control_docker, &control_exec, cols, rows)
668 .await;
669 }
670 }
671 Some(Ok(Message::Close(frame))) => {
672 tracing::info!(
673 "agent: session {session_id} viewer socket sent close: {frame:?}"
674 );
675 break;
676 }
677 Some(Err(e)) => {
678 tracing::info!("agent: session {session_id} viewer socket error: {e}");
679 break;
680 }
681 None => {
682 tracing::info!("agent: session {session_id} viewer socket ended");
683 break;
684 }
685 _ => {}
686 }
687 }
688 // Detach this tmux client explicitly.
689 //
690 // Docker has no "kill exec" call, and dropping bollard's streams does
691 // not reliably tear the exec down — without this, every page view left
692 // a tmux client attached forever, counting against the container's pids
693 // limit and taking part in tmux's window sizing.
694 //
695 // Writing the prefix (C-b) followed by `d` into the exec's own stdin
696 // detaches precisely the client on the other end of it, with no need to
697 // work out which of several clients is ours. `tmux.conf` keeps the
698 // default prefix, so this stays in step with it.
699 let _ = input.write_all(b"\x02d").await;
700 let _ = input.flush().await;
701 });
702
703 // Either direction ending means this viewer is gone. Abort BOTH halves
704 // rather than letting the survivor linger: each holds one end of bollard's
705 // hijacked connection, and while either is alive the `docker exec` — and so
706 // the tmux client behind it — stays up. Leaving them accumulated a stale
707 // client per page view, which counts against the container's pids limit and
708 // takes part in tmux's window sizing.
709 tokio::select! {
710 _ = &mut to_browser => {}
711 _ = &mut to_container => {}
712 }
713 to_browser.abort();
714 to_container.abort();
715 anvil_agent::detached(&app, session_id);
716}
717
718/// Parse `{"t":"resize","cols":N,"rows":M}`.
719fn parse_resize(text: &str) -> Option<(u16, u16)> {
720 let value: serde_json::Value = serde_json::from_str(text).ok()?;
721 if value.get("t")?.as_str()? != "resize" {
722 return None;
723 }
724 let cols = value.get("cols")?.as_u64()?.try_into().ok()?;
725 let rows = value.get("rows")?.as_u64()?.try_into().ok()?;
726 Some((cols, rows))
727}
728
729/// JSON-encode a string, for the small hand-built control messages.
730fn json_string(s: &str) -> String {
731 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
732}