-
Notifications
You must be signed in to change notification settings - Fork 1
feat(canvas): local documents and offline-style document scripts (APP-037) #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d0b8e95
d9bb0bc
e1cf934
7180f6e
3bb47c5
c25337a
c69acba
68c43e7
d4baa99
12443de
39dffb9
20219be
c749a22
f8717ff
1271756
f53052c
5bd2139
4911b95
4e44bc3
529453b
5f8e847
252e92a
942a442
06074c7
e5b8ad0
5cf7a69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,40 +1,178 @@ | ||
| use axum::{extract::State, Json}; | ||
| use axum::{ | ||
| extract::{Path, Query, State}, | ||
| Json, | ||
| }; | ||
|
|
||
| use crate::{ | ||
| api::dto::{ApiResponse, CanvasBoardResponse, UpdateCanvasBoardPayload}, | ||
| api::dto::{ | ||
| ApiResponse, AtmosCanvasFilePayload, CanvasDocumentFileResponse, CanvasDocumentListItemDto, | ||
| CanvasDocumentListResponse, CanvasDocumentWriteResponse, | ||
| }, | ||
| app_state::AppState, | ||
| error::ApiResult, | ||
| }; | ||
| use core_service::SaveCanvasBoardReq; | ||
| use core_service::{AtmosCanvasFile, AtmosCanvasScript}; | ||
|
|
||
| fn item_dto(item: core_service::CanvasDocumentListItem) -> CanvasDocumentListItemDto { | ||
| CanvasDocumentListItemDto { | ||
| file_name: item.file_name, | ||
| title: item.title, | ||
| modified_at: item.modified_at, | ||
| size_bytes: item.size_bytes, | ||
| } | ||
| } | ||
|
|
||
| pub async fn get_default_board( | ||
| pub async fn list_documents( | ||
| State(state): State<AppState>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasBoardResponse>>> { | ||
| let board = state.canvas_service.get_default_board().await?; | ||
| Ok(Json(ApiResponse::success(CanvasBoardResponse { | ||
| guid: board.guid, | ||
| slug: board.slug, | ||
| name: board.name, | ||
| document_json: board.document_json, | ||
| updated_at: board.updated_at, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentListResponse>>> { | ||
| let dir = state.canvas_service.canvas_dir()?; | ||
| let items = state.canvas_service.list_documents()?; | ||
| Ok(Json(ApiResponse::success(CanvasDocumentListResponse { | ||
| dir: dir.display().to_string(), | ||
| items: items.into_iter().map(item_dto).collect(), | ||
| }))) | ||
| } | ||
|
Comment on lines
+25
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift Blocking file I/O operations on the Tokio async reactor thread. These handlers invoke synchronous file system methods from
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| pub async fn update_default_board( | ||
| pub async fn get_document( | ||
| State(state): State<AppState>, | ||
| Json(payload): Json<UpdateCanvasBoardPayload>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasBoardResponse>>> { | ||
| let board = state | ||
| Path(file_name): Path<String>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentFileResponse>>> { | ||
| // Axum Path is already percent-decoded — do not decode again. | ||
| let doc = state.canvas_service.read_document(&file_name)?; | ||
| let abs = state | ||
| .canvas_service | ||
| .save_default_board(SaveCanvasBoardReq { | ||
| document_json: payload.document_json, | ||
| }) | ||
| .await?; | ||
| Ok(Json(ApiResponse::success(CanvasBoardResponse { | ||
| guid: board.guid, | ||
| slug: board.slug, | ||
| name: board.name, | ||
| document_json: board.document_json, | ||
| updated_at: board.updated_at, | ||
| .absolute_path(&file_name) | ||
| .map(|p| p.display().to_string()) | ||
| .ok(); | ||
| Ok(Json(ApiResponse::success(CanvasDocumentFileResponse { | ||
| file_name: doc.file_name, | ||
| title: doc.title, | ||
| modified_at: doc.modified_at, | ||
| size_bytes: doc.size_bytes, | ||
| absolute_path: abs, | ||
| body: AtmosCanvasFilePayload { | ||
| schema: doc.body.schema, | ||
| title: doc.body.title, | ||
| tldraw_document: doc.body.tldraw_document, | ||
| session: doc.body.session, | ||
| script: doc.body.script.map(|s| crate::api::dto::AtmosCanvasScriptPayload { | ||
| entry: s.entry, | ||
| files: s.files, | ||
| }), | ||
| }, | ||
| }))) | ||
| } | ||
|
|
||
| #[derive(Debug, Default, serde::Deserialize)] | ||
| pub struct PutDocumentQuery { | ||
| /// When true, allow replacing an existing file (normal Save of the open doc). | ||
| /// When false/omitted, refuse if the file already exists (Save As / create). | ||
| #[serde(default)] | ||
| pub overwrite: bool, | ||
| } | ||
|
|
||
| pub async fn put_document( | ||
| State(state): State<AppState>, | ||
| Path(file_name): Path<String>, | ||
| Query(query): Query<PutDocumentQuery>, | ||
| Json(payload): Json<AtmosCanvasFilePayload>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> { | ||
| let body = AtmosCanvasFile { | ||
| schema: payload.schema, | ||
| title: payload.title, | ||
| tldraw_document: payload.tldraw_document, | ||
| session: payload.session, | ||
| script: payload.script.map(|s| AtmosCanvasScript { | ||
| entry: s.entry, | ||
| files: s.files, | ||
| }), | ||
| }; | ||
| let item = state | ||
| .canvas_service | ||
| .write_document(&file_name, &body, query.overwrite)?; | ||
| Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse { | ||
| item: item_dto(item), | ||
| }))) | ||
| } | ||
|
|
||
| pub async fn delete_document( | ||
| State(state): State<AppState>, | ||
| Path(file_name): Path<String>, | ||
| ) -> ApiResult<Json<ApiResponse<DeleteDocumentResponse>>> { | ||
| state.canvas_service.delete_document(&file_name)?; | ||
| Ok(Json(ApiResponse::success(DeleteDocumentResponse { | ||
| deleted: file_name, | ||
| }))) | ||
| } | ||
|
|
||
| pub async fn rename_document( | ||
| State(state): State<AppState>, | ||
| Path(file_name): Path<String>, | ||
| Json(payload): Json<RenameDocumentPayload>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> { | ||
| let item = state | ||
| .canvas_service | ||
| .rename_document(&file_name, &payload.name)?; | ||
| Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse { | ||
| item: item_dto(item), | ||
| }))) | ||
| } | ||
|
|
||
| pub async fn duplicate_document( | ||
| State(state): State<AppState>, | ||
| Path(file_name): Path<String>, | ||
| Json(payload): Json<DuplicateDocumentPayload>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> { | ||
| let item = state | ||
| .canvas_service | ||
| .duplicate_document(&file_name, payload.name.as_deref())?; | ||
| Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse { | ||
| item: item_dto(item), | ||
| }))) | ||
| } | ||
|
|
||
| pub async fn sanitize_name( | ||
| State(_state): State<AppState>, | ||
| Json(payload): Json<SanitizeNamePayload>, | ||
| ) -> ApiResult<Json<ApiResponse<SanitizeNameResponse>>> { | ||
| let file_name = core_service::CanvasDocumentService::sanitize_file_name(&payload.name)?; | ||
| Ok(Json(ApiResponse::success(SanitizeNameResponse { | ||
| file_name, | ||
| }))) | ||
| } | ||
|
|
||
| /// POST /api/canvas/documents/new — create Untitled / Untitled-1 / … and return it. | ||
| pub async fn create_new_document( | ||
| State(state): State<AppState>, | ||
| ) -> ApiResult<Json<ApiResponse<CanvasDocumentWriteResponse>>> { | ||
| let item = state.canvas_service.create_untitled_document()?; | ||
| Ok(Json(ApiResponse::success(CanvasDocumentWriteResponse { | ||
| item: item_dto(item), | ||
| }))) | ||
| } | ||
|
|
||
| #[derive(Debug, serde::Deserialize)] | ||
| pub struct SanitizeNamePayload { | ||
| pub name: String, | ||
| } | ||
|
|
||
| #[derive(Debug, serde::Serialize)] | ||
| pub struct SanitizeNameResponse { | ||
| pub file_name: String, | ||
| } | ||
|
|
||
| #[derive(Debug, serde::Deserialize)] | ||
| pub struct RenameDocumentPayload { | ||
| pub name: String, | ||
| } | ||
|
|
||
| #[derive(Debug, serde::Deserialize, Default)] | ||
| pub struct DuplicateDocumentPayload { | ||
| #[serde(default)] | ||
| pub name: Option<String>, | ||
| } | ||
|
|
||
| #[derive(Debug, serde::Serialize)] | ||
| pub struct DeleteDocumentResponse { | ||
| pub deleted: String, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,23 @@ use crate::app_state::AppState; | |
|
|
||
| pub fn routes() -> Router<AppState> { | ||
| Router::new() | ||
| // APP-037: file-backed documents only (no legacy /default board). | ||
| .route("/documents", get(handlers::list_documents)) | ||
| .route("/documents/new", post(handlers::create_new_document)) | ||
| .route("/documents/sanitize-name", post(handlers::sanitize_name)) | ||
| .route( | ||
| "/default", | ||
| get(handlers::get_default_board).put(handlers::update_default_board), | ||
| "/documents/{file_name}", | ||
| get(handlers::get_document) | ||
| .put(handlers::put_document) | ||
| .delete(handlers::delete_document), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: With LAN-without-token enabled, a host on the private network can overwrite or delete any canvas document. Route document mutations through the destructive loopback-or-token guard (rename/duplicate should be covered too). Prompt for AI agents |
||
| ) | ||
| .route( | ||
| "/documents/{file_name}/rename", | ||
| post(handlers::rename_document), | ||
| ) | ||
| .route( | ||
| "/documents/{file_name}/duplicate", | ||
| post(handlers::duplicate_document), | ||
| ) | ||
| .route("/agent/invoke", post(agent::invoke)) | ||
| .route("/agent/status", get(agent::status)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,16 +39,57 @@ pub struct TerminalLayoutResponse { | |
| pub maximized_terminal_id: Option<String>, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize, Deserialize, Default, Clone)] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Programmatic Prompt for AI agents |
||
| pub struct AtmosCanvasScriptPayload { | ||
| #[serde(default = "default_script_entry")] | ||
| pub entry: String, | ||
| #[serde(default)] | ||
| pub files: std::collections::BTreeMap<String, String>, | ||
| } | ||
|
|
||
| fn default_script_entry() -> String { | ||
| "main.js".to_string() | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize, Deserialize)] | ||
| pub struct AtmosCanvasFilePayload { | ||
| pub schema: String, | ||
| pub title: String, | ||
| #[serde(rename = "tldrawDocument")] | ||
| pub tldraw_document: Option<serde_json::Value>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub session: Option<serde_json::Value>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub script: Option<AtmosCanvasScriptPayload>, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct CanvasBoardResponse { | ||
| pub guid: String, | ||
| pub slug: String, | ||
| pub name: String, | ||
| pub document_json: String, | ||
| pub updated_at: String, | ||
| pub struct CanvasDocumentListItemDto { | ||
| pub file_name: String, | ||
| pub title: String, | ||
| pub modified_at: String, | ||
| pub size_bytes: u64, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| pub struct UpdateCanvasBoardPayload { | ||
| pub document_json: String, | ||
| #[derive(Debug, Serialize)] | ||
| pub struct CanvasDocumentListResponse { | ||
| /// Absolute canvas directory path (e.g. ~/.atmos/canvas). | ||
| pub dir: String, | ||
| pub items: Vec<CanvasDocumentListItemDto>, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct CanvasDocumentFileResponse { | ||
| pub file_name: String, | ||
| pub title: String, | ||
| pub modified_at: String, | ||
| pub size_bytes: u64, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub absolute_path: Option<String>, | ||
| pub body: AtmosCanvasFilePayload, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct CanvasDocumentWriteResponse { | ||
| pub item: CanvasDocumentListItemDto, | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The
statusfunction now reads the canvas directory and lists all documents, but thedirfield always serializes as an empty string on error (unwrap_or_else(|_| String::new())). Ifcanvas_dir()fails (e.g. home dir missing), the response silently omits the error — callers may assume a valid empty directory instead of a misconfiguration.Prompt for AI agents