From 47183621af5aae7d1df5dc050b8394e2e2750c2e Mon Sep 17 00:00:00 2001 From: hongyu9 Date: Tue, 28 Jul 2026 21:24:18 +0800 Subject: [PATCH 1/9] feat(wework): support DingTalk AI Table projects --- backend/app/models/delivery.py | 3 +- backend/app/schemas/cloud_project.py | 16 +- .../schemas/test_cloud_project_schema.py | 36 + executor/src/local/app_ipc.rs | 127 +++ executor/src/task_runtime/aitable_provider.rs | 949 ++++++++++++++++++ .../task_runtime/aitable_provider_tests.rs | 95 ++ executor/src/task_runtime/credentials.rs | 40 + executor/src/task_runtime/issue_provider.rs | 1 + executor/src/task_runtime/mcp.rs | 310 +++++- executor/src/task_runtime/mod.rs | 1 + executor/src/task_runtime/model.rs | 1 + executor/src/task_runtime/router.rs | 227 ++++- executor/src/task_runtime/store.rs | 48 +- pnpm-lock.yaml | 10 + pnpm-workspace.yaml | 1 + wework/package.json | 4 +- wework/scripts/build-mac-app.sh | 1 + wework/scripts/build-windows-app.sh | 1 + wework/scripts/prepare-dws-binary.mjs | 73 ++ wework/src-tauri/tauri.conf.json | 13 +- wework/src/api/aitable.ts | 137 +++ wework/src/api/deliveries.ts | 32 +- wework/src/api/dws.ts | 28 + wework/src/api/hybrid/hybridServices.ts | 2 + wework/src/api/local/localDelivery.ts | 17 +- wework/src/api/local/localServices.ts | 6 + .../features/todo/AITableTaskFields.test.tsx | 71 ++ .../src/features/todo/AITableTaskFields.tsx | 285 ++++++ wework/src/features/todo/AITableView.test.tsx | 178 ++++ wework/src/features/todo/AITableView.tsx | 541 ++++++++++ .../features/todo/CloudProjectManageView.tsx | 349 ++++++- .../features/todo/CloudTodoWorkspace.test.tsx | 114 +++ .../src/features/todo/CloudTodoWorkspace.tsx | 251 ++++- wework/src/features/todo/TodoEditor.tsx | 7 + .../todo/projectProviderConfig.test.ts | 20 +- .../features/todo/projectProviderConfig.ts | 28 + .../features/workbench/workbenchServices.ts | 6 + 37 files changed, 3948 insertions(+), 81 deletions(-) create mode 100644 backend/tests/schemas/test_cloud_project_schema.py create mode 100644 executor/src/task_runtime/aitable_provider.rs create mode 100644 executor/src/task_runtime/aitable_provider_tests.rs create mode 100644 wework/scripts/prepare-dws-binary.mjs create mode 100644 wework/src/api/aitable.ts create mode 100644 wework/src/api/dws.ts create mode 100644 wework/src/features/todo/AITableTaskFields.test.tsx create mode 100644 wework/src/features/todo/AITableTaskFields.tsx create mode 100644 wework/src/features/todo/AITableView.test.tsx create mode 100644 wework/src/features/todo/AITableView.tsx diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index e11729b6b9..a8db94cbc8 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -165,7 +165,8 @@ def task_provider(self) -> str: if not isinstance(metadata, dict): return "local" provider = metadata.get("task_provider") - return provider if provider in {"local", "github", "gitlab"} else "local" + known = {"local", "github", "gitlab", "dingtalk_aitable"} + return provider if provider in known else "local" @property def provider_config(self) -> dict[str, object]: diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index e4086c53aa..4b6bd14e72 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -21,7 +21,7 @@ from app.schemas.tagging import MAX_TAGS_PER_ITEM, normalize_tags SnowflakeId = Annotated[str, BeforeValidator(str)] -TaskProvider = Literal["local", "github", "gitlab"] +TaskProvider = Literal["local", "github", "gitlab", "dingtalk_aitable"] ProjectVisibility = Literal["private", "public"] @@ -40,6 +40,20 @@ def normalize_provider_config( config = dict(provider_config) if task_provider == "local": return {} + if task_provider == "dingtalk_aitable": + if "credential" in config: + raise ValueError("encrypted provider credentials cannot be supplied") + for key in ("base_id", "table_id"): + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"provider_config.{key} is required") + config[key] = value.strip() + if "token" in config: + raise ValueError("DingTalk authentication is managed by the local Executor") + mapping = config.get("board_mapping") + if mapping is not None and not isinstance(mapping, dict): + raise ValueError("provider_config.board_mapping must be an object") + return config repository = config.get("repository") if not isinstance(repository, str) or not repository.strip(): raise ValueError("provider_config.repository is required") diff --git a/backend/tests/schemas/test_cloud_project_schema.py b/backend/tests/schemas/test_cloud_project_schema.py new file mode 100644 index 0000000000..72c9fb7c23 --- /dev/null +++ b/backend/tests/schemas/test_cloud_project_schema.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from pydantic import ValidationError + +from app.schemas.cloud_project import CloudProjectCreate + + +def test_aitable_project_keeps_only_non_sensitive_locator_config() -> None: + project = CloudProjectCreate( + name="AI Table", + task_provider="dingtalk_aitable", + provider_config={ + "base_id": " base-1 ", + "table_id": " table-1 ", + "source_url": "https://alidocs.dingtalk.com/i/nodes/base-1", + }, + ) + + assert project.provider_config["base_id"] == "base-1" + assert project.provider_config["table_id"] == "table-1" + + +def test_aitable_project_rejects_access_tokens() -> None: + with pytest.raises(ValidationError, match="managed by the local Executor"): + CloudProjectCreate( + name="AI Table", + task_provider="dingtalk_aitable", + provider_config={ + "base_id": "base-1", + "table_id": "table-1", + "token": "must-not-be-stored", + }, + ) diff --git a/executor/src/local/app_ipc.rs b/executor/src/local/app_ipc.rs index 72c004c30d..ea92f40afb 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -379,6 +379,8 @@ impl AppIpcServer { if method.starts_with("projects.") || method.starts_with("external_projects.") + || method.starts_with("dws.") + || method.starts_with("aitable.") || method.starts_with("todos.") || method.starts_with("external_todos.") || method.starts_with("external_attachments.") @@ -677,6 +679,22 @@ impl AppIpcServer { async fn handle_task_runtime_request(method: &str, params: Value) -> Result { let runtime = TaskRuntime::from_env().map_err(task_runtime_error)?; match method { + "dws.auth_status" => serialize_task_value( + runtime + .dws_auth_status() + .await + .map_err(task_runtime_error)?, + ), + "dws.auth_login" => { + serialize_task_value(runtime.dws_auth_login().await.map_err(task_runtime_error)?) + } + "dws.auth_logout" => { + runtime + .dws_auth_logout() + .await + .map_err(task_runtime_error)?; + Ok(json!({})) + } "projects.list" => { serialize_task_value(runtime.list_projects().map_err(task_runtime_error)?) } @@ -726,6 +744,115 @@ async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let project_id = required_task_string(¶ms, "project_id")?; + serialize_task_value( + runtime + .aitable_describe(project_id) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.list_records" => { + let project_id = required_task_string(¶ms, "project_id")?; + let query = params.get("query").and_then(Value::as_str); + let cursor = params.get("cursor").and_then(Value::as_str); + let limit = params.get("limit").and_then(Value::as_i64).unwrap_or(100); + serialize_task_value( + runtime + .aitable_list_records(project_id, query, limit, cursor) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.get_record" => { + let project_id = required_task_string(¶ms, "project_id")?; + let record_id = required_task_string(¶ms, "record_id")?; + serialize_task_value( + runtime + .aitable_get_record(project_id, record_id) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.create_record" => { + let project_id = required_task_string(¶ms, "project_id")?; + let cells = params + .get("cells") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| AppIpcError::new("bad_request", "cells must be an object"))?; + serialize_task_value( + runtime + .aitable_create_record(project_id, cells) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.update_record" => { + let project_id = required_task_string(¶ms, "project_id")?; + let record_id = required_task_string(¶ms, "record_id")?; + let cells = params + .get("cells") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| AppIpcError::new("bad_request", "cells must be an object"))?; + serialize_task_value( + runtime + .aitable_update_record(project_id, record_id, cells) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.delete_record" => { + let project_id = required_task_string(¶ms, "project_id")?; + let record_id = required_task_string(¶ms, "record_id")?; + runtime + .aitable_delete_record(project_id, record_id) + .await + .map_err(task_runtime_error)?; + Ok(json!({})) + } + "aitable.create_field" => { + let project_id = required_task_string(¶ms, "project_id")?; + let name = required_task_string(¶ms, "name")?; + let field_type = required_task_string(¶ms, "field_type")?; + let property = params + .get("config") + .or_else(|| params.get("property")) + .cloned() + .unwrap_or_else(|| json!({})); + serialize_task_value( + runtime + .aitable_create_field(project_id, name, field_type, property) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.update_field" => { + let project_id = required_task_string(¶ms, "project_id")?; + let field_id = required_task_string(¶ms, "field_id")?; + let payload = params + .get("field") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| AppIpcError::new("bad_request", "field must be an object"))?; + serialize_task_value( + runtime + .aitable_update_field(project_id, field_id, payload) + .await + .map_err(task_runtime_error)?, + ) + } + "aitable.delete_field" => { + let project_id = required_task_string(¶ms, "project_id")?; + let field_id = required_task_string(¶ms, "field_id")?; + runtime + .aitable_delete_field(project_id, field_id) + .await + .map_err(task_runtime_error)?; + Ok(json!({})) + } "external_todos.list" => { let project = task_input::(¶ms, "project")?; serialize_task_value( diff --git a/executor/src/task_runtime/aitable_provider.rs b/executor/src/task_runtime/aitable_provider.rs new file mode 100644 index 0000000000..bc46d5bfda --- /dev/null +++ b/executor/src/task_runtime/aitable_provider.rs @@ -0,0 +1,949 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Local DingTalk AI Table schema, field, and record operations. +//! +//! Owns DingTalk access for both backend-catalogued and local projects through +//! an isolated DWS session. Records are patched cell-by-cell; raw payloads are +//! preserved so unknown field types round-trip without loss. + +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +use super::{LoopItem, TaskProviderKind, TaskRuntimeError}; +use super::{TaskCreate, TaskUpdate}; + +#[derive(Clone)] +pub(crate) struct AITableProvider { + dws_binary: PathBuf, + dws_home: PathBuf, + dws_config_dir: PathBuf, +} + +struct AITableConfig { + base_id: String, + table_id: String, + mapping: Map, + status_mode: String, + status_mapping: Map, +} + +impl AITableProvider { + pub(crate) fn new(database_path: PathBuf) -> Result { + let executor_home = database_path.parent().unwrap_or_else(|| Path::new(".")); + Ok(Self { + dws_binary: resolve_dws_binary(), + dws_home: executor_home.join("dws-home"), + dws_config_dir: executor_home.join("dws-home").join(".dws"), + }) + } + + pub(crate) async fn auth_status(&self) -> Result { + self.run(&["auth", "status"]).await + } + + pub(crate) async fn auth_login(&self) -> Result { + self.run(&["auth", "login"]).await?; + self.auth_status().await + } + + pub(crate) async fn auth_logout(&self) -> Result<(), TaskRuntimeError> { + self.run(&["auth", "logout", "--yes"]).await?; + Ok(()) + } + + pub(crate) async fn describe(&self, project: &LoopItem) -> Result { + let config = self.config(project)?; + let base = self + .run(&["aitable", "base", "get", "--base-id", &config.base_id]) + .await?; + let table = self + .run(&[ + "aitable", + "table", + "get", + "--base-id", + &config.base_id, + "--table-ids", + &config.table_id, + ]) + .await?; + let fields = self + .run(&[ + "aitable", + "field", + "get", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + ]) + .await?; + let tables = list_from(&table, &["tables", "sheets", "items", "data"]); + let active_table = tables + .iter() + .find(|table| { + table + .get("id") + .or_else(|| table.get("tableId")) + .and_then(Value::as_str) + == Some(config.table_id.as_str()) + }) + .cloned() + .unwrap_or(Value::Null); + Ok(json!({ + "base": base, + "tables": tables, + "active_table": active_table, + "fields": list_from(&fields, &["fields", "items", "data", "results"]) + .iter() + .map(normalize_field) + .collect::>(), + })) + } + + pub(crate) async fn list_records( + &self, + project: &LoopItem, + query: Option<&str>, + limit: i64, + cursor: Option<&str>, + ) -> Result { + let config = self.config(project)?; + let page_limit = limit.clamp(1, 100) as usize; + let limit_value = page_limit.to_string(); + let mut args = vec![ + "aitable", + "record", + "query", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--limit", + &limit_value, + ]; + if let Some(query) = query.filter(|value| !value.trim().is_empty()) { + args.extend(["--query", query]); + } + if let Some(cursor) = cursor.filter(|value| !value.trim().is_empty()) { + args.extend(["--cursor", cursor]); + } + let response = self.run(&args).await?; + let items = list_from(&response, &["records", "items", "data", "results"]) + .iter() + .map(normalize_record) + .collect::>(); + // DWS returns the last record cursor even when the result is shorter + // than the requested page. Only continue when a full page was read. + let next = string_field( + &response, + &["nextToken", "next_token", "nextCursor", "cursor"], + ) + .filter(|_| items.len() >= page_limit); + Ok(json!({ + "items": items, + "cursor": next.clone(), + "has_more": next.is_some(), + })) + } + + pub(crate) async fn get_record( + &self, + project: &LoopItem, + record_id: &str, + ) -> Result { + let config = self.config(project)?; + let response = self + .run(&[ + "aitable", + "record", + "query", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--record-ids", + record_id, + ]) + .await?; + list_from(&response, &["records", "items", "data", "results"]) + .first() + .map(normalize_record) + .ok_or(TaskRuntimeError::TaskNotFound) + } + + pub(crate) async fn create_record( + &self, + project: &LoopItem, + cells: Map, + ) -> Result { + if cells.is_empty() { + return Err(invalid("cells must not be empty")); + } + let config = self.config(project)?; + let records = serde_json::to_string(&json!([{"cells": cells}])) + .map_err(|error| invalid(error.to_string()))?; + let response = self + .run(&[ + "aitable", + "record", + "create", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--records", + &records, + ]) + .await?; + Ok(normalize_write_record(response, None, &records)) + } + + pub(crate) async fn update_record( + &self, + project: &LoopItem, + record_id: &str, + cells: Map, + ) -> Result { + if cells.is_empty() { + return Err(invalid("cells must not be empty")); + } + let config = self.config(project)?; + let records = serde_json::to_string(&json!([{"recordId": record_id, "cells": cells}])) + .map_err(|error| invalid(error.to_string()))?; + let response = self + .run(&[ + "aitable", + "record", + "update", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--records", + &records, + ]) + .await?; + Ok(normalize_write_record(response, Some(record_id), &records)) + } + + pub(crate) async fn delete_record( + &self, + project: &LoopItem, + record_id: &str, + ) -> Result<(), TaskRuntimeError> { + let config = self.config(project)?; + self.run(&[ + "aitable", + "record", + "delete", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--record-ids", + record_id, + "--yes", + ]) + .await?; + Ok(()) + } + + pub(crate) async fn create_field( + &self, + project: &LoopItem, + name: &str, + field_type: &str, + property: Value, + ) -> Result { + let config = self.config(project)?; + let property = serde_json::to_string(&property).map_err(|e| invalid(e.to_string()))?; + let response = self + .run(&[ + "aitable", + "field", + "create", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--name", + name, + "--type", + field_type, + "--config", + &property, + ]) + .await?; + Ok(normalize_field(&unwrap(response))) + } + + pub(crate) async fn update_field( + &self, + project: &LoopItem, + field_id: &str, + payload: Map, + ) -> Result { + let config = self.config(project)?; + let mut args = vec![ + "aitable", + "field", + "update", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--field-id", + field_id, + ]; + let name = payload.get("name").and_then(Value::as_str); + let config_json = payload + .get("config") + .map(serde_json::to_string) + .transpose() + .map_err(|e| invalid(e.to_string()))?; + if let Some(name) = name { + args.extend(["--name", name]); + } + if let Some(value) = config_json.as_deref() { + args.extend(["--config", value]); + } + let response = self.run(&args).await?; + Ok(normalize_field(&unwrap(response))) + } + + pub(crate) async fn delete_field( + &self, + project: &LoopItem, + field_id: &str, + ) -> Result<(), TaskRuntimeError> { + let config = self.config(project)?; + self.run(&[ + "aitable", + "field", + "delete", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + "--field-ids", + field_id, + "--yes", + ]) + .await?; + Ok(()) + } + + fn config(&self, project: &LoopItem) -> Result { + let value = project + .metadata + .get("provider_config") + .and_then(Value::as_object) + .ok_or_else(|| invalid("provider_config must be an object"))?; + let base_id = required(value, "base_id")?; + let table_id = value + .get("table_id") + .or_else(|| value.get("sheet_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| invalid("provider_config.table_id is required"))? + .to_owned(); + let mapping = value + .get("board_mapping") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let status_mode = value + .get("status_mode") + .and_then(Value::as_str) + .unwrap_or("mapped") + .to_owned(); + let status_mapping = value + .get("status_mapping") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + Ok(AITableConfig { + base_id, + table_id, + mapping, + status_mode, + status_mapping, + }) + } + + async fn run(&self, args: &[&str]) -> Result { + std::fs::create_dir_all(&self.dws_config_dir) + .map_err(|error| TaskRuntimeError::ProviderRequest(error.to_string()))?; + let output = Command::new(&self.dws_binary) + .args(args) + .args(["--format", "json"]) + // DWS 1.0.32 keeps OAuth credentials below the user home even when + // DWS_CONFIG_DIR is set. Override both so Wework never consumes a + // developer's global DWS session. + .env("HOME", &self.dws_home) + .env("USERPROFILE", &self.dws_home) + .env("DWS_CONFIG_DIR", &self.dws_config_dir) + // Wework owns this isolated DWS home. File-backed DEKs avoid + // repeated macOS Keychain prompts when a stale `dek` item exists. + .env("DWS_DISABLE_KEYCHAIN", "1") + .output() + .await + .map_err(|error| { + TaskRuntimeError::ProviderRequest(format!( + "DWS is unavailable at {}: {error}", + self.dws_binary.display() + )) + })?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let value = serde_json::from_str::(stdout.trim()) + .or_else(|_| serde_json::from_str::(stderr.trim())) + .map_err(|_| { + TaskRuntimeError::ProviderRequest(format!( + "DWS returned invalid JSON: {}", + stderr.trim() + )) + })?; + if !output.status.success() || dws_response_failed(&value) { + return Err(TaskRuntimeError::ProviderRequest(format!( + "DWS request failed: {value}" + ))); + } + Ok(value) + } + + /// Project records onto LoopItems using the optional board mapping. + pub(crate) async fn list_board( + &self, + project: &LoopItem, + ) -> Result, TaskRuntimeError> { + let mut config = self.config(project)?; + if mapping_get(&config.mapping, "parent_field_id").is_none() { + let fields = self + .run(&[ + "aitable", + "field", + "get", + "--base-id", + &config.base_id, + "--table-id", + &config.table_id, + ]) + .await?; + let parent_field = list_from(&fields, &["fields", "items", "data", "results"]) + .iter() + .map(normalize_field) + .filter(|field| field.get("name").and_then(Value::as_str) == Some("父记录")) + .min_by_key(|field| { + (field.get("type").and_then(Value::as_str) != Some("text")) as u8 + }); + if let Some(field_id) = parent_field + .as_ref() + .and_then(|field| field.get("id")) + .and_then(Value::as_str) + { + config + .mapping + .insert("parent_field_id".to_owned(), json!(field_id)); + } + } + let mut records = Vec::new(); + let mut cursor: Option = None; + for _ in 0..50 { + let page = self + .list_records(project, None, 100, cursor.as_deref()) + .await?; + if let Some(items) = page.get("items").and_then(Value::as_array) { + records.extend(items.iter().cloned()); + } + cursor = page + .get("cursor") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + if cursor.is_none() { + break; + } + } + let title_records = records + .iter() + .filter_map(|candidate| { + let title = cell_text(candidate, mapping_get(&config.mapping, "title_field_id")); + (!title.is_empty()).then(|| (title, string_of(candidate.get("id")))) + }) + .collect::>(); + Ok(records + .iter() + .enumerate() + .map(|(index, record)| { + board_loop_item(project, &config, record, index + 1, &title_records) + }) + .collect()) + } + + pub(crate) async fn get_board( + &self, + project: &LoopItem, + task_id: &str, + ) -> Result { + self.list_board(project) + .await? + .into_iter() + .find(|item| item.id == task_id) + .ok_or(TaskRuntimeError::TaskNotFound) + } + + pub(crate) async fn create_board( + &self, + project: &LoopItem, + input: TaskCreate, + ) -> Result { + let config = self.config(project)?; + let mut cells = Map::new(); + insert_mapped( + &mut cells, + &config.mapping, + "title_field_id", + json!(input.title), + ); + insert_mapped( + &mut cells, + &config.mapping, + "description_field_id", + json!(input.description), + ); + insert_mapped( + &mut cells, + &config.mapping, + "status_field_id", + json!(source_status_for_write(&config, &input.status, None)), + ); + insert_mapped( + &mut cells, + &config.mapping, + "priority_field_id", + json!(input.priority), + ); + let record = self.create_record(project, cells).await?; + Ok(board_loop_item( + project, + &config, + &record, + 1, + &HashMap::new(), + )) + } + + pub(crate) async fn update_board( + &self, + project: &LoopItem, + task_id: &str, + input: TaskUpdate, + ) -> Result { + let config = self.config(project)?; + let record_id = task_id + .rsplit(':') + .next() + .filter(|value| !value.is_empty()) + .ok_or(TaskRuntimeError::TaskNotFound)?; + let mut cells = Map::new(); + if let Some(value) = input.title { + insert_mapped(&mut cells, &config.mapping, "title_field_id", json!(value)); + } + if let Some(value) = input.description { + insert_mapped( + &mut cells, + &config.mapping, + "description_field_id", + json!(value), + ); + } + if let Some(status) = input.status { + let value = source_status_for_write(&config, &status, None); + insert_mapped(&mut cells, &config.mapping, "status_field_id", json!(value)); + } + if let Some(value) = input.priority { + insert_mapped( + &mut cells, + &config.mapping, + "priority_field_id", + json!(value), + ); + } + if cells.is_empty() { + return self.get_board(project, task_id).await; + } + let record = self.update_record(project, record_id, cells).await?; + Ok(board_loop_item( + project, + &config, + &record, + 1, + &HashMap::new(), + )) + } +} + +fn resolve_dws_binary() -> PathBuf { + if let Some(path) = std::env::var_os("DWS_BINARY_PATH") { + return PathBuf::from(path); + } + if let Ok(executable) = std::env::current_exe() { + let sibling = executable.with_file_name(if cfg!(windows) { "dws.exe" } else { "dws" }); + if sibling.is_file() { + return sibling; + } + } + PathBuf::from(if cfg!(windows) { "dws.exe" } else { "dws" }) +} + +fn insert_mapped( + cells: &mut Map, + mapping: &Map, + mapping_key: &str, + value: Value, +) { + if let Some(field_id) = mapping_get(mapping, mapping_key) { + cells.insert(field_id.to_owned(), value); + } +} + +fn required(value: &Map, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| invalid(&format!("provider_config.{key} is required"))) +} + +fn unwrap(response: Value) -> Value { + if let Value::Object(map) = &response { + for key in ["data", "result"] { + if let Some(inner) = map.get(key).filter(|value| !value.is_null()) { + return inner.clone(); + } + } + } + response +} + +fn dws_response_failed(response: &Value) -> bool { + if response.get("success").and_then(Value::as_bool) == Some(false) { + return true; + } + if response + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| !matches!(status.to_ascii_lowercase().as_str(), "success" | "ok")) + { + return true; + } + response.get("error").is_some_and(|error| match error { + Value::Null => false, + Value::Bool(flag) => *flag, + Value::String(message) => !message.trim().is_empty(), + Value::Array(items) => !items.is_empty(), + Value::Object(fields) => !fields.is_empty(), + Value::Number(_) => true, + }) +} + +fn list_from(value: &Value, keys: &[&str]) -> Vec { + let unwrapped = unwrap(value.clone()); + if let Value::Object(map) = &unwrapped { + for key in keys { + if let Some(Value::Array(items)) = map.get(*key) { + return items.clone(); + } + } + } + if let Value::Array(items) = unwrapped { + return items; + } + Vec::new() +} + +fn string_field(value: &Value, keys: &[&str]) -> Option { + let unwrapped = unwrap(value.clone()); + unwrapped.as_object().and_then(|map| { + keys.iter().find_map(|key| { + map.get(*key) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned) + }) + }) +} + +fn normalize_field(field: &Value) -> Value { + let map = field.as_object().cloned().unwrap_or_default(); + let pick = |keys: &[&str]| { + keys.iter() + .find_map(|key| map.get(*key).cloned()) + .unwrap_or(Value::Null) + }; + json!({ + "id": string_of(map.get("id").or_else(|| map.get("fieldId"))), + "name": string_of(map.get("name").or_else(|| map.get("fieldName"))), + "type": string_of(map.get("type").or_else(|| map.get("uiType"))), + "config": pick(&["property", "config"]), + "ai_config": pick(&["aiConfig", "ai_config"]), + "raw": field, + }) +} + +fn normalize_record(record: &Value) -> Value { + let map = record.as_object().cloned().unwrap_or_default(); + let cells = map + .get("fields") + .or_else(|| map.get("cells")) + .or_else(|| map.get("values")) + .cloned() + .unwrap_or_else(|| json!({})); + json!({ + "id": string_of(map.get("id").or_else(|| map.get("recordId"))), + "cells": cells, + "raw": record, + }) +} + +fn normalize_write_record(response: Value, record_id: Option<&str>, records_json: &str) -> Value { + let input = serde_json::from_str::(records_json).unwrap_or_else(|_| json!([])); + let input_record = input + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or_else(|| json!({})); + let response_record = list_from(&response, &["records", "items", "data"]) + .into_iter() + .next(); + if let Some(record) = response_record { + return normalize_record(&record); + } + let created_id = string_field(&response, &["recordId", "record_id"]) + .or_else(|| { + list_from(&response, &["newRecordIds", "recordIds"]) + .first() + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .or_else(|| record_id.map(ToOwned::to_owned)) + .unwrap_or_default(); + let cells = input_record + .get("cells") + .cloned() + .unwrap_or_else(|| json!({})); + json!({"id": created_id, "cells": cells, "raw": response}) +} + +fn string_of(value: Option<&Value>) -> String { + value.and_then(Value::as_str).unwrap_or_default().to_owned() +} + +fn invalid(message: impl Into) -> TaskRuntimeError { + TaskRuntimeError::Invalid(message.into()) +} + +fn mapping_get<'a>(mapping: &'a Map, key: &str) -> Option<&'a str> { + mapping + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) +} + +fn cell_text(record: &Value, field_id: Option<&str>) -> String { + let Some(field_id) = field_id else { + return String::new(); + }; + let value = record.get("cells").and_then(|cells| cells.get(field_id)); + stringify_cell(value) +} + +fn stringify_cell(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(text)) => text.clone(), + Some(Value::Number(number)) => number.to_string(), + Some(Value::Bool(flag)) => flag.to_string(), + Some(Value::Array(items)) => items + .iter() + .map(|item| match item { + Value::Object(map) => map + .get("name") + .or_else(|| map.get("title")) + .or_else(|| map.get("text")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + other => stringify_cell(Some(other)), + }) + .filter(|part| !part.is_empty()) + .collect::>() + .join(", "), + Some(Value::Object(map)) => map + .get("name") + .or_else(|| map.get("title")) + .or_else(|| map.get("text")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + } +} + +const STATUS_OPTIONS: &[(&str, &[&str])] = &[ + ("inbox", &["inbox", "todo", "backlog", "待办", "收集箱"]), + ("pending", &["pending", "未开始", "待处理"]), + ( + "in_progress", + &["in progress", "in_progress", "doing", "进行中"], + ), + ( + "in_review", + &["in review", "in_review", "review", "评审中", "待评审"], + ), + ( + "completed", + &["completed", "done", "complete", "已完成", "完成"], + ), +]; + +const PRIORITY_OPTIONS: &[(&str, &[&str])] = &[ + ("urgent", &["urgent", "紧急"]), + ("high", &["high", "高"]), + ("medium", &["medium", "normal", "中", "普通"]), + ("low", &["low", "低"]), + ("none", &["none", "无"]), +]; + +fn map_option(value: &str, table: &[(&str, &[&str])], default: &str) -> String { + let normalized = value.trim().to_lowercase(); + if normalized.is_empty() { + return default.to_owned(); + } + for (key, candidates) in table { + if candidates + .iter() + .any(|candidate| candidate.to_lowercase() == normalized) + { + return (*key).to_owned(); + } + } + default.to_owned() +} + +fn mapped_status(config: &AITableConfig, source_status: &str) -> String { + config + .status_mapping + .get(source_status) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| map_option(source_status, STATUS_OPTIONS, "inbox")) +} + +fn source_status_for_write( + config: &AITableConfig, + status: &str, + source_status: Option<&str>, +) -> String { + if config.status_mode == "custom" { + return source_status.unwrap_or(status).to_owned(); + } + config + .status_mapping + .iter() + .find_map(|(source, mapped)| (mapped.as_str() == Some(status)).then(|| source.clone())) + .unwrap_or_else(|| status.to_owned()) +} + +fn board_loop_item( + project: &LoopItem, + config: &AITableConfig, + record: &Value, + position: usize, + title_records: &HashMap, +) -> LoopItem { + let mapping = &config.mapping; + let record_id = string_of(record.get("id")); + let title_field = mapping_get(mapping, "title_field_id"); + let title = { + let value = cell_text(record, title_field); + if value.is_empty() { + record_id.clone() + } else { + value + } + }; + let source_status = cell_text(record, mapping_get(mapping, "status_field_id")); + let status = mapped_status(config, &source_status); + let priority = map_option( + &cell_text(record, mapping_get(mapping, "priority_field_id")), + PRIORITY_OPTIONS, + "none", + ); + let now = chrono::Utc::now().to_rfc3339(); + LoopItem { + id: format!("aitable:{}:{record_id}", project_key_of(project)), + resource_type: "task".to_owned(), + project_space: project.project_space.clone(), + cloud_project_id: Some(project.id.clone()), + parent_id: parent_record_id(record, mapping, title_records) + .map(|parent| format!("aitable:{}:{parent}", project_key_of(project))), + public_id: None, + project_key: project.project_key.clone(), + name: None, + title: Some(title), + description: cell_text(record, mapping_get(mapping, "description_field_id")), + created_by_user_id: 0, + sequence_number: Some(position as i64), + next_item_number: None, + status: Some(status), + priority: Some(priority), + sort_order: position as i64, + current_delivery_id: None, + metadata: json!({ + "task_provider": TaskProviderKind::DingtalkAitable, + "record_id": record_id, + "source_status": source_status, + }), + version: 1, + created_at: now.clone(), + updated_at: now, + completed_at: None, + } +} + +fn parent_record_id( + record: &Value, + mapping: &Map, + title_records: &HashMap, +) -> Option { + let field_id = mapping_get(mapping, "parent_field_id")?; + let value = record.get("cells")?.get(field_id)?; + if let Some(record_id) = value + .get("linkedRecordIds") + .and_then(Value::as_array) + .and_then(|ids| ids.first()) + .and_then(Value::as_str) + { + return Some(record_id.to_owned()); + } + let parent_title = stringify_cell(Some(value)); + title_records.get(&parent_title).cloned() +} + +fn project_key_of(project: &LoopItem) -> &str { + project.project_key.as_deref().unwrap_or("AITABLE") +} + +#[cfg(test)] +#[path = "aitable_provider_tests.rs"] +mod tests; diff --git a/executor/src/task_runtime/aitable_provider_tests.rs b/executor/src/task_runtime/aitable_provider_tests.rs new file mode 100644 index 0000000000..626580da1c --- /dev/null +++ b/executor/src/task_runtime/aitable_provider_tests.rs @@ -0,0 +1,95 @@ +use super::*; + +#[test] +fn reads_dws_result_arrays_and_normalizes_records() { + let response = json!({ + "success": true, + "result": [{"recordId": "rec-1", "cells": {"fld": "value"}}] + }); + let records = list_from(&response, &["records", "items", "data"]); + assert_eq!(records.len(), 1); + assert_eq!(normalize_record(&records[0])["id"], json!("rec-1")); + assert_eq!( + normalize_record(&records[0])["cells"]["fld"], + json!("value") + ); +} + +#[test] +fn accepts_dws_success_envelopes_with_empty_error_objects() { + assert!(!dws_response_failed(&json!({ + "success": true, "status": "success", "error": {}, "data": {"records": []} + }))); + assert!(dws_response_failed(&json!({ + "success": false, "status": "error", "error": {"message": "permission denied"} + }))); +} + +#[test] +fn normalizes_created_record_ids_from_dws_envelope() { + let record = normalize_write_record( + json!({"data": {"newRecordIds": ["rec-new"]}}), + None, + r#"[{"cells":{"fld":"value"}}]"#, + ); + assert_eq!(record["id"], json!("rec-new")); + assert_eq!(record["cells"]["fld"], json!("value")); +} + +#[test] +fn maps_localized_board_options() { + assert_eq!(map_option("进行中", STATUS_OPTIONS, "inbox"), "in_progress"); + assert_eq!(map_option("紧急", PRIORITY_OPTIONS, "none"), "urgent"); +} + +#[test] +fn uses_explicit_status_mapping_and_reverses_it_for_writes() { + let config = AITableConfig { + base_id: "base-1".to_owned(), + table_id: "table-1".to_owned(), + mapping: Map::new(), + status_mode: "mapped".to_owned(), + status_mapping: Map::from_iter([ + ("开发中".to_owned(), json!("in_progress")), + ("执行中".to_owned(), json!("in_review")), + ]), + }; + assert_eq!(mapped_status(&config, "开发中"), "in_progress"); + assert_eq!( + source_status_for_write(&config, "in_review", None), + "执行中" + ); +} + +#[test] +fn preserves_source_status_in_custom_lane_mode() { + let config = AITableConfig { + base_id: "base-1".to_owned(), + table_id: "table-1".to_owned(), + mapping: Map::new(), + status_mode: "custom".to_owned(), + status_mapping: Map::new(), + }; + assert_eq!(source_status_for_write(&config, "开发中", None), "开发中"); +} + +#[test] +fn resolves_parent_tasks_from_link_ids_or_parent_titles() { + let mapping = Map::from_iter([ + ("title_field_id".to_owned(), json!("title")), + ("parent_field_id".to_owned(), json!("parent")), + ]); + let titles = HashMap::from([("父任务".to_owned(), "parent-record".to_owned())]); + assert_eq!( + parent_record_id(&json!({"cells": {"parent": "父任务"}}), &mapping, &titles), + Some("parent-record".to_owned()) + ); + assert_eq!( + parent_record_id( + &json!({"cells": {"parent": {"linkedRecordIds": ["linked-parent"]}}}), + &mapping, + &titles + ), + Some("linked-parent".to_owned()) + ); +} diff --git a/executor/src/task_runtime/credentials.rs b/executor/src/task_runtime/credentials.rs index 9bcc6be501..47852c6b87 100644 --- a/executor/src/task_runtime/credentials.rs +++ b/executor/src/task_runtime/credentials.rs @@ -39,6 +39,11 @@ pub(crate) fn encrypt_provider_config( "encrypted provider credentials cannot be supplied by project input", )); } + if provider == TaskProviderKind::DingtalkAitable && config.contains_key(TOKEN_INPUT_KEY) { + return Err(invalid( + "DingTalk authentication is managed by DWS and cannot be stored on a project", + )); + } let token = match config.remove(TOKEN_INPUT_KEY) { Some(Value::String(value)) => { let value = value.trim().to_owned(); @@ -68,6 +73,11 @@ pub(crate) fn update_provider_config( "encrypted provider credentials cannot be supplied by project input", )); } + if provider == TaskProviderKind::DingtalkAitable && config.contains_key(TOKEN_INPUT_KEY) { + return Err(invalid( + "DingTalk authentication is managed by DWS and cannot be stored on a project", + )); + } config.remove("credential_configured"); let token = match config.remove(TOKEN_INPUT_KEY) { Some(Value::String(value)) => { @@ -198,6 +208,23 @@ fn credential_context( provider: TaskProviderKind, provider_config: &Map, ) -> Result { + if provider == TaskProviderKind::DingtalkAitable { + let domain = provider_config + .get("domain") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("api.dingtalk.com"); + let base_id = required_config_string(provider_config, "base_id")?; + let table_id = provider_config + .get("table_id") + .or_else(|| provider_config.get("sheet_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid("provider_config.table_id is required"))?; + return Ok(format!("{provider:?}:{domain}:{base_id}:{table_id}")); + } let repository = required_config_string(provider_config, "repository")?; let domain = provider_config .get("domain") @@ -516,6 +543,19 @@ mod tests { ); } + #[test] + fn rejects_project_tokens_for_dws_managed_aitable() { + let directory = tempfile::tempdir().unwrap(); + let error = encrypt_provider_config( + &database_path(&directory), + TaskProviderKind::DingtalkAitable, + json!({"base_id": "base", "table_id": "table", "token": "secret"}), + ) + .unwrap_err(); + + assert!(error.to_string().contains("managed by DWS")); + } + #[cfg(unix)] #[test] fn creates_master_key_with_owner_only_permissions() { diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs index a20469e5d2..e34492b76c 100644 --- a/executor/src/task_runtime/issue_provider.rs +++ b/executor/src/task_runtime/issue_provider.rs @@ -889,6 +889,7 @@ fn provider_key(provider: TaskProviderKind) -> &'static str { TaskProviderKind::Gitlab => "gitlab", TaskProviderKind::Local => "local", TaskProviderKind::Backend => "backend", + TaskProviderKind::DingtalkAitable => "dingtalk_aitable", } } diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs index 0d15a3bd10..f8155ba2f4 100644 --- a/executor/src/task_runtime/mcp.rs +++ b/executor/src/task_runtime/mcp.rs @@ -328,6 +328,124 @@ async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value (Err(error), _) | (_, Err(error)) => Err(error), } } + "aitable_describe" => match string_argument(&arguments, "project_id") { + Ok(project_id) => runtime + .aitable_describe(&project_id) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + Err(error) => Err(error), + }, + "aitable_list_records" => { + let project_id = string_argument(&arguments, "project_id"); + let query = arguments + .get("query") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let cursor = arguments + .get("cursor") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let limit = arguments + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(100); + match project_id { + Ok(project_id) => runtime + .aitable_list_records(&project_id, query.as_deref(), limit, cursor.as_deref()) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + Err(error) => Err(error), + } + } + "aitable_create_record" => { + let project_id = string_argument(&arguments, "project_id"); + let cells = cells_argument(&arguments); + match (project_id, cells) { + (Ok(project_id), Ok(cells)) => runtime + .aitable_create_record(&project_id, cells) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "aitable_update_record" => { + let project_id = string_argument(&arguments, "project_id"); + let record_id = string_argument(&arguments, "record_id"); + let cells = cells_argument(&arguments); + match (project_id, record_id, cells) { + (Ok(project_id), Ok(record_id), Ok(cells)) => runtime + .aitable_update_record(&project_id, &record_id, cells) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "aitable_delete_record" => { + let project_id = string_argument(&arguments, "project_id"); + let record_id = string_argument(&arguments, "record_id"); + match (project_id, record_id) { + (Ok(project_id), Ok(record_id)) => runtime + .aitable_delete_record(&project_id, &record_id) + .await + .map(|_| json!({"deleted": true})), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "aitable_create_field" => { + let project_id = string_argument(&arguments, "project_id"); + let name = string_argument(&arguments, "name"); + let field_type = string_argument(&arguments, "field_type"); + let property = arguments + .get("config") + .or_else(|| arguments.get("property")) + .cloned() + .unwrap_or_else(|| json!({})); + match (project_id, name, field_type) { + (Ok(project_id), Ok(name), Ok(field_type)) => runtime + .aitable_create_field(&project_id, &name, &field_type, property) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "aitable_update_field" => { + let project_id = string_argument(&arguments, "project_id"); + let field_id = string_argument(&arguments, "field_id"); + let payload = arguments + .get("field") + .or_else(|| arguments.get("config")) + .cloned() + .unwrap_or_else(|| arguments.clone()); + let payload = payload.as_object().cloned().map(|mut map| { + map.remove("project_id"); + map.remove("field_id"); + if let Some(config) = map.remove("config") { + map.insert("property".to_owned(), config); + } + map + }); + match (project_id, field_id, payload) { + (Ok(project_id), Ok(field_id), Some(payload)) => runtime + .aitable_update_field(&project_id, &field_id, payload) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _, _) | (_, Err(error), _) => Err(error), + (_, _, None) => Err(super::TaskRuntimeError::Invalid( + "field update payload must be an object".to_owned(), + )), + } + } + "aitable_delete_field" => { + let project_id = string_argument(&arguments, "project_id"); + let field_id = string_argument(&arguments, "field_id"); + match (project_id, field_id) { + (Ok(project_id), Ok(field_id)) => runtime + .aitable_delete_field(&project_id, &field_id) + .await + .map(|_| json!({"deleted": true})), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } _ => return text_result(format!("Unknown task tool: {name}"), true), }; match result { @@ -407,6 +525,85 @@ async fn call_backend_tool( "create_project" | "update_project" => { return Err("Cloud project management is not available through task MCP".to_owned()) } + "aitable_describe" => { + client.get(format!("{base}/cloud-projects/{project_id}/aitable/table")) + } + "aitable_list_records" => { + let mut request = client.get(format!( + "{base}/cloud-projects/{project_id}/aitable/records" + )); + if let Some(query) = arguments.get("query").and_then(Value::as_str) { + request = request.query(&[("query", query)]); + } + if let Some(cursor) = arguments.get("cursor").and_then(Value::as_str) { + request = request.query(&[("cursor", cursor)]); + } + if let Some(limit) = arguments.get("limit").and_then(Value::as_i64) { + request = request.query(&[("limit", limit.to_string())]); + } + return backend_json(request.bearer_auth(auth_token).send().await.map_err(|e| e.to_string())?).await; + } + "aitable_create_record" => client + .post(format!("{base}/cloud-projects/{project_id}/aitable/records")) + .json(&json!({"cells": arguments.get("cells").cloned().unwrap_or_else(|| json!({}))})), + "aitable_update_record" => { + let record_id = string_argument(arguments, "record_id").map_err(|e| e.to_string())?; + client + .patch(format!( + "{base}/cloud-projects/{project_id}/aitable/records/{}", + encode_segment(record_id) + )) + .json(&json!({"cells": arguments.get("cells").cloned().unwrap_or_else(|| json!({}))})) + } + "aitable_delete_record" => { + let record_id = string_argument(arguments, "record_id").map_err(|e| e.to_string())?; + let response = client + .delete(format!( + "{base}/cloud-projects/{project_id}/aitable/records/{}", + encode_segment(record_id) + )) + .bearer_auth(auth_token) + .send() + .await + .map_err(|e| e.to_string())?; + return backend_json(response).await; + } + "aitable_create_field" => client + .post(format!("{base}/cloud-projects/{project_id}/aitable/fields")) + .json(&json!({ + "name": arguments.get("name").and_then(Value::as_str).unwrap_or_default(), + "type": arguments.get("field_type").or_else(|| arguments.get("type")).and_then(Value::as_str).unwrap_or_default(), + "config": arguments.get("config").cloned().unwrap_or_else(|| json!({})), + })), + "aitable_update_field" => { + let field_id = string_argument(arguments, "field_id").map_err(|e| e.to_string())?; + let mut payload = json!({}); + if let Some(name) = arguments.get("name").and_then(Value::as_str) { + payload["name"] = json!(name); + } + if let Some(config) = arguments.get("config") { + payload["config"] = config.clone(); + } + client + .patch(format!( + "{base}/cloud-projects/{project_id}/aitable/fields/{}", + encode_segment(field_id) + )) + .json(&payload) + } + "aitable_delete_field" => { + let field_id = string_argument(arguments, "field_id").map_err(|e| e.to_string())?; + let response = client + .delete(format!( + "{base}/cloud-projects/{project_id}/aitable/fields/{}", + encode_segment(field_id) + )) + .bearer_auth(auth_token) + .send() + .await + .map_err(|e| e.to_string())?; + return backend_json(response).await; + } "upload_todo_attachment" => { let file_path = string_argument(arguments, "file_path").map_err(|error| error.to_string())?; @@ -602,7 +799,7 @@ fn tools() -> Vec { "name": {"type": "string"}, "project_key": {"type": "string"}, "description": {"type": "string"}, - "task_provider": {"enum": ["local", "github", "gitlab"]}, + "task_provider": {"enum": ["local", "github", "gitlab", "dingtalk_aitable"]}, "provider_config": {"type": "object"} }, "required": ["name", "task_provider"] @@ -787,6 +984,106 @@ fn tools() -> Vec { "required": ["project_id", "reorder"] }), ), + tool( + "aitable_describe", + "Describe a DingTalk AI Table: base, tables, and the dynamic field schema", + json!({ + "type": "object", + "properties": {"project_id": {"type": "string"}}, + "required": ["project_id"] + }), + ), + tool( + "aitable_list_records", + "Query AI Table records with optional full-text keyword and cursor pagination", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100}, + "cursor": {"type": "string"} + }, + "required": ["project_id"] + }), + ), + tool( + "aitable_create_record", + "Create an AI Table record. cells keys must be fieldIds, only provided cells are written", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "cells": {"type": "object", "description": "Map of fieldId to cell value"} + }, + "required": ["project_id", "cells"] + }), + ), + tool( + "aitable_update_record", + "Patch an AI Table record cell-by-cell. Only the provided fieldIds are modified", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "record_id": {"type": "string"}, + "cells": {"type": "object", "description": "Map of fieldId to cell value"} + }, + "required": ["project_id", "record_id", "cells"] + }), + ), + tool( + "aitable_delete_record", + "Delete an AI Table record (irreversible)", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "record_id": {"type": "string"} + }, + "required": ["project_id", "record_id"] + }), + ), + tool( + "aitable_create_field", + "Add a field (column) to an AI Table", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "name": {"type": "string"}, + "field_type": {"type": "string", "description": "AI Table field type such as text, number, singleSelect, multipleSelect, date, user, checkbox, url"}, + "config": {"type": "object", "description": "Field property/options configuration"} + }, + "required": ["project_id", "name", "field_type"] + }), + ), + tool( + "aitable_update_field", + "Rename or reconfigure an AI Table field. Field type cannot be changed; options are fully replaced", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "field_id": {"type": "string"}, + "name": {"type": "string"}, + "config": {"type": "object"} + }, + "required": ["project_id", "field_id"] + }), + ), + tool( + "aitable_delete_field", + "Delete an AI Table field and clear its values in every record (irreversible)", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "field_id": {"type": "string"} + }, + "required": ["project_id", "field_id"] + }), + ), ] } @@ -807,6 +1104,17 @@ fn string_argument<'a>(value: &'a Value, key: &str) -> Result<&'a str, super::Ta .ok_or_else(|| super::TaskRuntimeError::Invalid(format!("{key} is required"))) } +fn cells_argument( + value: &Value, +) -> Result, super::TaskRuntimeError> { + value + .get("cells") + .and_then(Value::as_object) + .cloned() + .filter(|cells| !cells.is_empty()) + .ok_or_else(|| super::TaskRuntimeError::Invalid("cells must not be empty".to_owned())) +} + fn binary_input_from_path( arguments: &Value, file_path: &str, diff --git a/executor/src/task_runtime/mod.rs b/executor/src/task_runtime/mod.rs index 235f148695..d0882b5e25 100644 --- a/executor/src/task_runtime/mod.rs +++ b/executor/src/task_runtime/mod.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 +mod aitable_provider; mod content; mod credentials; mod issue_provider; diff --git a/executor/src/task_runtime/model.rs b/executor/src/task_runtime/model.rs index 252239d05a..fcc8b1892b 100644 --- a/executor/src/task_runtime/model.rs +++ b/executor/src/task_runtime/model.rs @@ -19,6 +19,7 @@ pub enum TaskProviderKind { Backend, Github, Gitlab, + DingtalkAitable, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/executor/src/task_runtime/router.rs b/executor/src/task_runtime/router.rs index a331be4c00..0bf71e6753 100644 --- a/executor/src/task_runtime/router.rs +++ b/executor/src/task_runtime/router.rs @@ -3,11 +3,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ - credentials::mask_provider_config, issue_provider::IssueProvider, store::task_provider, - BinaryInput, Delivery, DeliveryAsset, DeliveryCreate, DeliveryDetail, IssueComment, - LocalTaskStore, LoopItem, ProjectCreate, ProjectDescriptor, ProjectFile, ProjectUpdate, - RuntimeTaskAddress, TaskAttachment, TaskBinding, TaskCreate, TaskProviderKind, TaskReorder, - TaskRuntimeError, TaskUpdate, + aitable_provider::AITableProvider, credentials::mask_provider_config, + issue_provider::IssueProvider, store::task_provider, BinaryInput, Delivery, DeliveryAsset, + DeliveryCreate, DeliveryDetail, IssueComment, LocalTaskStore, LoopItem, ProjectCreate, + ProjectDescriptor, ProjectFile, ProjectUpdate, RuntimeTaskAddress, TaskAttachment, TaskBinding, + TaskCreate, TaskProviderKind, TaskReorder, TaskRuntimeError, TaskUpdate, }; /// Routes project and task operations to the provider configured on each project. @@ -19,6 +19,7 @@ use super::{ pub struct TaskRuntime { local_store: LocalTaskStore, issue_provider: IssueProvider, + aitable_provider: AITableProvider, } impl TaskRuntime { @@ -28,9 +29,11 @@ impl TaskRuntime { pub fn new(local_store: LocalTaskStore) -> Result { let issue_provider = IssueProvider::new(local_store.path().to_owned())?; + let aitable_provider = AITableProvider::new(local_store.path().to_owned())?; Ok(Self { local_store, issue_provider, + aitable_provider, }) } @@ -77,7 +80,10 @@ impl TaskRuntime { ) -> Result, TaskRuntimeError> { let provider = project.task_provider; let project = self.local_store.external_project(project)?; - self.issue_provider.list(&project, provider).await + match provider { + TaskProviderKind::DingtalkAitable => self.aitable_provider.list_board(&project).await, + provider => self.issue_provider.list(&project, provider).await, + } } pub async fn get_external_task( @@ -174,12 +180,133 @@ impl TaskRuntime { provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { self.issue_provider.list(&project, provider).await } + TaskProviderKind::DingtalkAitable => self.aitable_provider.list_board(&project).await, provider => Err(TaskRuntimeError::UnsupportedProvider(format!( "{provider:?}" ))), } } + // Native DingTalk AI Table operations (dynamic schema, records, fields). + + pub async fn dws_auth_status(&self) -> Result { + self.aitable_provider.auth_status().await + } + + pub async fn dws_auth_login(&self) -> Result { + self.aitable_provider.auth_login().await + } + + pub async fn dws_auth_logout(&self) -> Result<(), TaskRuntimeError> { + self.aitable_provider.auth_logout().await + } + + pub async fn aitable_describe( + &self, + project_id: &str, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider.describe(&project).await + } + + pub async fn aitable_list_records( + &self, + project_id: &str, + query: Option<&str>, + limit: i64, + cursor: Option<&str>, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .list_records(&project, query, limit, cursor) + .await + } + + pub async fn aitable_get_record( + &self, + project_id: &str, + record_id: &str, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider.get_record(&project, record_id).await + } + + pub async fn aitable_create_record( + &self, + project_id: &str, + cells: serde_json::Map, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider.create_record(&project, cells).await + } + + pub async fn aitable_update_record( + &self, + project_id: &str, + record_id: &str, + cells: serde_json::Map, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .update_record(&project, record_id, cells) + .await + } + + pub async fn aitable_delete_record( + &self, + project_id: &str, + record_id: &str, + ) -> Result<(), TaskRuntimeError> { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .delete_record(&project, record_id) + .await + } + + pub async fn aitable_create_field( + &self, + project_id: &str, + name: &str, + field_type: &str, + property: serde_json::Value, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .create_field(&project, name, field_type, property) + .await + } + + pub async fn aitable_update_field( + &self, + project_id: &str, + field_id: &str, + payload: serde_json::Map, + ) -> Result { + let project = self.aitable_project(project_id)?; + self.aitable_provider + .update_field(&project, field_id, payload) + .await + } + + pub async fn aitable_delete_field( + &self, + project_id: &str, + field_id: &str, + ) -> Result<(), TaskRuntimeError> { + let project = self.aitable_project(project_id)?; + self.aitable_provider.delete_field(&project, field_id).await + } + + fn aitable_project(&self, project_id: &str) -> Result { + let project = self.local_store.get_project(project_id)?; + if task_provider(&project)? != TaskProviderKind::DingtalkAitable { + return Err(TaskRuntimeError::UnsupportedProvider( + "project is not a DingTalk AI Table project".to_owned(), + )); + } + Ok(project) + } + pub async fn search_tasks( &self, input: super::TaskSearch, @@ -266,6 +393,9 @@ impl TaskRuntime { provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { self.issue_provider.get(&project, provider, task_id).await } + TaskProviderKind::DingtalkAitable => { + self.aitable_provider.get_board(&project, task_id).await + } provider => Err(TaskRuntimeError::UnsupportedProvider(format!( "{provider:?}" ))), @@ -283,6 +413,9 @@ impl TaskRuntime { provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { self.issue_provider.create(&project, provider, input).await } + TaskProviderKind::DingtalkAitable => { + self.aitable_provider.create_board(&project, input).await + } provider => Err(TaskRuntimeError::UnsupportedProvider(format!( "{provider:?}" ))), @@ -303,6 +436,11 @@ impl TaskRuntime { .update(&project, provider, task_id, input) .await } + TaskProviderKind::DingtalkAitable => { + self.aitable_provider + .update_board(&project, task_id, input) + .await + } provider => Err(TaskRuntimeError::UnsupportedProvider(format!( "{provider:?}" ))), @@ -336,9 +474,9 @@ impl TaskRuntime { let project = self.local_store.get_project(project_id)?; match task_provider(&project)? { TaskProviderKind::Local => self.local_store.reorder_tasks(project_id, input), - TaskProviderKind::Github | TaskProviderKind::Gitlab => { - self.list_tasks(project_id).await - } + TaskProviderKind::Github + | TaskProviderKind::Gitlab + | TaskProviderKind::DingtalkAitable => self.list_tasks(project_id).await, provider => Err(TaskRuntimeError::UnsupportedProvider(format!( "{provider:?}" ))), @@ -1320,4 +1458,75 @@ mod tests { .is_empty()); server.abort(); } + + fn aitable_project(store: &LocalTaskStore) -> LoopItem { + store + .create_project(ProjectCreate { + name: "AI Table".to_owned(), + project_key: Some("AIT".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::DingtalkAitable, + provider_config: json!({ + "base_id": "base-1", + "table_id": "table-1", + "board_mapping": { + "title_field_id": "fld_title", + "status_field_id": "fld_status" + } + }), + }) + .unwrap() + } + + #[tokio::test] + async fn routes_aitable_board_projection_without_task_rows() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = aitable_project(&store); + let runtime = TaskRuntime::new(store).unwrap(); + assert!(matches!( + runtime.list_tasks(&project.id).await, + Err(TaskRuntimeError::ProviderRequest(_)) + )); + } + + #[tokio::test] + async fn aitable_native_describe_returns_dynamic_fields() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = aitable_project(&store); + let runtime = TaskRuntime::new(store).unwrap(); + assert!(matches!( + runtime.aitable_describe(&project.id).await, + Err(TaskRuntimeError::ProviderRequest(_)) + )); + } + + #[tokio::test] + async fn aitable_record_write_requires_dws_authentication() { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = aitable_project(&store); + let runtime = TaskRuntime::new(store).unwrap(); + + let mut cells = serde_json::Map::new(); + cells.insert("fld_title".to_owned(), json!("x")); + let result = runtime.aitable_create_record(&project.id, cells).await; + + assert!(matches!(result, Err(TaskRuntimeError::ProviderRequest(_)))); + } + + #[test] + fn aitable_provider_combinations_are_valid() { + assert!(crate::task_runtime::store::validate_provider( + ProjectStoreKind::Local, + TaskProviderKind::DingtalkAitable + ) + .is_ok()); + assert!(crate::task_runtime::store::validate_provider( + ProjectStoreKind::Backend, + TaskProviderKind::DingtalkAitable + ) + .is_ok()); + } } diff --git a/executor/src/task_runtime/store.rs b/executor/src/task_runtime/store.rs index 92faff1a9a..c0cec693bf 100644 --- a/executor/src/task_runtime/store.rs +++ b/executor/src/task_runtime/store.rs @@ -137,10 +137,10 @@ impl LocalTaskStore { validate_provider(project.project_store, project.task_provider)?; if !matches!( project.task_provider, - TaskProviderKind::Github | TaskProviderKind::Gitlab + TaskProviderKind::Github | TaskProviderKind::Gitlab | TaskProviderKind::DingtalkAitable ) { return Err(TaskRuntimeError::Invalid( - "external project requires github or gitlab".to_owned(), + "external project requires github, gitlab, or dingtalk_aitable".to_owned(), )); } let connection = self.connection()?; @@ -906,7 +906,7 @@ pub(crate) fn task_provider(project: &LoopItem) -> Result Result<(), TaskRuntimeError> { @@ -915,9 +915,11 @@ fn validate_provider( (ProjectStoreKind::Local, TaskProviderKind::Local) | (ProjectStoreKind::Local, TaskProviderKind::Github) | (ProjectStoreKind::Local, TaskProviderKind::Gitlab) + | (ProjectStoreKind::Local, TaskProviderKind::DingtalkAitable) | (ProjectStoreKind::Backend, TaskProviderKind::Backend) | (ProjectStoreKind::Backend, TaskProviderKind::Github) | (ProjectStoreKind::Backend, TaskProviderKind::Gitlab) + | (ProjectStoreKind::Backend, TaskProviderKind::DingtalkAitable) ); valid .then_some(()) @@ -937,6 +939,7 @@ fn task_provider_key(provider: TaskProviderKind) -> &'static str { TaskProviderKind::Backend => "backend", TaskProviderKind::Github => "github", TaskProviderKind::Gitlab => "gitlab", + TaskProviderKind::DingtalkAitable => "dingtalk_aitable", } } @@ -1248,6 +1251,45 @@ mod tests { ); } + #[test] + fn caches_backend_dingtalk_aitable_projects() { + let (_directory, store) = store(); + let configured = store + .configure_external_project(ProjectDescriptor { + id: "aitable-cloud-1".to_owned(), + public_id: Some("aitable-public-1".to_owned()), + project_key: "AITABLE".to_owned(), + name: "DingTalk AI Table".to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::DingtalkAitable, + provider_config: json!({ + "base_id": "base-1", + "table_id": "table-1", + "source_url": "https://alidocs.dingtalk.com/i/nodes/base-1", + }), + version: 1, + }) + .unwrap(); + + assert_eq!( + configured.metadata["task_provider"], + json!(TaskProviderKind::DingtalkAitable) + ); + assert_eq!( + configured.metadata["provider_config"]["base_id"], + json!("base-1") + ); + assert_eq!( + store + .get_project("aitable-cloud-1") + .unwrap() + .name + .as_deref(), + Some("DingTalk AI Table") + ); + } + #[test] fn removes_backend_external_project_credentials_and_catalog_entry() { let directory = tempfile::tempdir().unwrap(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 079d26dc45..b671363a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -740,6 +740,9 @@ importers: autoprefixer: specifier: ^10.5.0 version: 10.5.0(postcss@8.5.15) + dingtalk-workspace-cli: + specifier: 1.0.32 + version: 1.0.32 eslint: specifier: ^10.3.0 version: 10.4.1(jiti@1.21.7) @@ -5135,6 +5138,11 @@ packages: dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dingtalk-workspace-cli@1.0.32: + resolution: {integrity: sha512-GaddHutdGn269vUmRt7TsHtycO526piUrq5IUqV6BLkimUoAjp2bqt1yI1/dZYr95ebwGCNwQJp30BkS9E39ug==} + engines: {node: '>=16'} + hasBin: true + direction@2.0.1: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true @@ -13825,6 +13833,8 @@ snapshots: dingbat-to-unicode@1.0.1: {} + dingtalk-workspace-cli@1.0.32: {} + direction@2.0.1: {} dlv@1.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98a272d436..2672f238af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: confirmModulesPurge: false allowBuilds: core-js: true + dingtalk-workspace-cli: true esbuild: true msw: true protobufjs: true diff --git a/wework/package.json b/wework/package.json index ed2f492e34..a41d53f3b2 100644 --- a/wework/package.json +++ b/wework/package.json @@ -8,6 +8,7 @@ "dev:mac": "bash scripts/dev-mac-app.sh", "tauri": "tauri", "prepare:codex": "node scripts/prepare-codex-binary.mjs", + "prepare:dws": "node scripts/prepare-dws-binary.mjs", "build": "tsc -b && vite build", "build:mac": "bash scripts/build-mac-app.sh", "build:windows": "bash scripts/build-windows-app.sh", @@ -16,7 +17,7 @@ "ios:init": "tauri ios init", "dev:ios": "bash scripts/dev-ios-app.sh", "build:ios": "bash scripts/build-ios-app.sh", - "tauri:build": "pnpm run prepare:codex && tauri build", + "tauri:build": "pnpm run prepare:codex && pnpm run prepare:dws && tauri build", "lint": "eslint . && pnpm run lint:typography && pnpm run lint:task-lifecycle", "lint:typography": "node scripts/check-typography.mjs", "lint:task-lifecycle": "node scripts/check-runtime-task-lifecycle.mjs", @@ -126,6 +127,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-istanbul": "^4.1.8", "autoprefixer": "^10.5.0", + "dingtalk-workspace-cli": "1.0.32", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/wework/scripts/build-mac-app.sh b/wework/scripts/build-mac-app.sh index acda8bfc45..28efd3a047 100755 --- a/wework/scripts/build-mac-app.sh +++ b/wework/scripts/build-mac-app.sh @@ -240,6 +240,7 @@ if [ "$NO_SIGN" = "1" ]; then fi WEWORK_CODEX_TARGET="${MACOS_BUILD_TARGET:-}" pnpm run prepare:codex +WEWORK_DWS_TARGET="${MACOS_BUILD_TARGET:-}" pnpm run prepare:dws wework_sign_prepared_codex_macos_binaries \ "$WEWORK_DIR" \ "$MACOS_BUILD_TARGET" \ diff --git a/wework/scripts/build-windows-app.sh b/wework/scripts/build-windows-app.sh index edfffa3dd2..b5734ed44e 100755 --- a/wework/scripts/build-windows-app.sh +++ b/wework/scripts/build-windows-app.sh @@ -207,6 +207,7 @@ if [ -n "$TAURI_BUNDLES" ]; then fi WEWORK_CODEX_TARGET="$WINDOWS_BUILD_TARGET" pnpm run prepare:codex +WEWORK_DWS_TARGET="$WINDOWS_BUILD_TARGET" pnpm run prepare:dws pnpm exec tauri "${TAURI_ARGS[@]}" # Patch the generated NSIS installer to create the desktop shortcut via COM. diff --git a/wework/scripts/prepare-dws-binary.mjs b/wework/scripts/prepare-dws-binary.mjs new file mode 100644 index 0000000000..7a96e58762 --- /dev/null +++ b/wework/scripts/prepare-dws-binary.mjs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { chmod, copyFile, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { arch, platform } from 'node:process' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const require = createRequire(import.meta.url) +const packageJson = require.resolve('dingtalk-workspace-cli/package.json') +const packageRoot = dirname(packageJson) +const target = + process.env.WEWORK_DWS_TARGET?.trim() || + ({ + 'darwin-arm64': 'aarch64-apple-darwin', + 'darwin-x64': 'x86_64-apple-darwin', + 'linux-x64': 'x86_64-unknown-linux-gnu', + 'linux-arm64': 'aarch64-unknown-linux-gnu', + 'win32-x64': 'x86_64-pc-windows-msvc', + }[`${platform}-${arch}`]) + +if (!target) throw new Error(`Unsupported DWS build platform: ${platform}-${arch}`) + +const archives = { + 'aarch64-apple-darwin': 'dws-darwin-arm64.tar.gz', + 'x86_64-apple-darwin': 'dws-darwin-amd64.tar.gz', + 'x86_64-unknown-linux-gnu': 'dws-linux-amd64.tar.gz', + 'aarch64-unknown-linux-gnu': 'dws-linux-arm64.tar.gz', + 'x86_64-pc-windows-msvc': 'dws-windows-amd64.zip', +} +const archiveName = archives[target] +if (!archiveName) throw new Error(`Unsupported DWS target: ${target}`) +const isWindowsTarget = target.includes('windows') +const executable = isWindowsTarget ? 'dws.exe' : 'dws' +const temporaryDirectory = await mkdtemp(join(tmpdir(), 'wework-dws-')) + +async function findBinary(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + const nested = await findBinary(path) + if (nested) return nested + } else if (entry.name === executable) { + return path + } + } + return null +} + +try { + const archive = join(packageRoot, 'assets', archiveName) + const command = archiveName.endsWith('.zip') ? 'unzip' : 'tar' + const args = archiveName.endsWith('.zip') + ? ['-q', archive, '-d', temporaryDirectory] + : ['-xzf', archive, '-C', temporaryDirectory] + const result = spawnSync(command, args, { stdio: 'inherit' }) + if (result.status !== 0) throw new Error(`Failed to extract ${archiveName}`) + const source = await findBinary(temporaryDirectory) + if (!source) throw new Error(`DWS binary is missing from ${archiveName}`) + const destination = resolve( + 'src-tauri', + 'binaries', + `dws-${target}${isWindowsTarget ? '.exe' : ''}` + ) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination) + if (!isWindowsTarget) await chmod(destination, 0o755) + console.log(`Prepared DWS sidecar: ${destination}`) +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }) +} diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index b06933d8c1..fe618c1b21 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -37,9 +37,7 @@ "enable": true, "scope": { "requireLiteralLeadingDot": false, - "allow": [ - "**/*" - ] + "allow": ["**/*"] } } } @@ -55,13 +53,8 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "binaries/wegent-executor" - ], - "resources": [ - "binaries/codex/**/*", - "bundled-hooks/**/*" - ], + "externalBin": ["binaries/wegent-executor", "binaries/dws"], + "resources": ["binaries/codex/**/*", "bundled-hooks/**/*"], "android": { "debugApplicationIdSuffix": ".debug" } diff --git a/wework/src/api/aitable.ts b/wework/src/api/aitable.ts new file mode 100644 index 0000000000..f277950fc8 --- /dev/null +++ b/wework/src/api/aitable.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// DingTalk AI Table dynamic schema, record, and field APIs. +// +// Cloud projects call the backend-owned routes over HTTP; local projects call +// the Executor over IPC. Both return the same normalized shapes and always +// preserve the raw DingTalk payloads so unknown field types round-trip. + +import type { CloudProject } from '@/api/deliveries' + +export interface AITableField { + id: string + name: string + type: string + config: Record + ai_config?: Record | null + raw: Record +} + +export interface AITableRecord { + id: string + cells: Record + raw: Record +} + +export interface AITableDescription { + base: Record + tables: Array> + active_table: Record + fields: AITableField[] +} + +export interface AITableRecordPage { + items: AITableRecord[] + cursor: string | null + has_more: boolean +} + +export interface AITableApi { + configureProject(project: CloudProject): Promise + describe(projectId: string): Promise + listRecords( + projectId: string, + options?: { query?: string; limit?: number; cursor?: string } + ): Promise + getRecord?(projectId: string, recordId: string): Promise + createRecord(projectId: string, cells: Record): Promise + updateRecord( + projectId: string, + recordId: string, + cells: Record + ): Promise + deleteRecord(projectId: string, recordId: string): Promise + createField( + projectId: string, + data: { name: string; type: string; config?: Record } + ): Promise + updateField( + projectId: string, + fieldId: string, + data: { name?: string; config?: Record } + ): Promise + deleteField(projectId: string, fieldId: string): Promise +} + +type LocalRequest = ( + method: string, + params?: Record, + deviceId?: string +) => Promise + +export function createLocalAITableApi(request: LocalRequest): AITableApi { + return { + async configureProject(project) { + await request('external_projects.configure', { + project: { + id: project.id, + public_id: project.public_id, + project_key: project.project_key, + name: project.name, + description: project.description, + project_store: project.project_store, + task_provider: project.task_provider, + provider_config: project.provider_config, + version: project.version, + }, + }) + }, + describe(projectId) { + return request('aitable.describe', { project_id: projectId }) + }, + listRecords(projectId, options = {}) { + return request('aitable.list_records', { + project_id: projectId, + query: options.query, + limit: options.limit, + cursor: options.cursor, + }) + }, + getRecord(projectId, recordId) { + return request('aitable.get_record', { project_id: projectId, record_id: recordId }) + }, + createRecord(projectId, cells) { + return request('aitable.create_record', { project_id: projectId, cells }) + }, + updateRecord(projectId, recordId, cells) { + return request('aitable.update_record', { + project_id: projectId, + record_id: recordId, + cells, + }) + }, + async deleteRecord(projectId, recordId) { + await request('aitable.delete_record', { project_id: projectId, record_id: recordId }) + }, + createField(projectId, data) { + return request('aitable.create_field', { + project_id: projectId, + name: data.name, + field_type: data.type, + config: data.config ?? {}, + }) + }, + updateField(projectId, fieldId, data) { + return request('aitable.update_field', { + project_id: projectId, + field_id: fieldId, + field: data, + }) + }, + async deleteField(projectId, fieldId) { + await request('aitable.delete_field', { project_id: projectId, field_id: fieldId }) + }, + } +} diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index 763b52a0f4..f2ec69806f 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -59,6 +59,7 @@ export interface CloudLoopItem { created_at: string updated_at: string completed_at: string | null + source_status?: string | null } export interface CloudLoopItemAttachment { @@ -79,12 +80,21 @@ export interface CloudProject { name: string description: string project_store: 'local' | 'backend' - task_provider: 'local' | 'github' | 'gitlab' + task_provider: 'local' | 'github' | 'gitlab' | 'dingtalk_aitable' provider_config: { repository?: string domain?: string api_base?: string credential_configured?: boolean + base_id?: string + table_id?: string + sheet_id?: string + source_url?: string + view_id?: string + board_mapping?: Record + status_mode?: 'mapped' | 'custom' + status_mapping?: Record + custom_statuses?: string[] } created_by_user_id: number current_user_id?: number @@ -192,13 +202,22 @@ export function createDeliveryApi(client: HttpClient) { project_key?: string name: string description?: string - task_provider?: 'local' | 'github' | 'gitlab' + task_provider?: 'local' | 'github' | 'gitlab' | 'dingtalk_aitable' visibility?: 'private' | 'public' provider_config?: { repository?: string domain?: string api_base?: string token?: string + base_id?: string + table_id?: string + sheet_id?: string + source_url?: string + view_id?: string + board_mapping?: Record + status_mode?: 'mapped' | 'custom' + status_mapping?: Record + custom_statuses?: string[] } }): Promise { return client.post('/v1/cloud-projects', data) @@ -215,6 +234,15 @@ export function createDeliveryApi(client: HttpClient) { domain?: string api_base?: string token?: string + base_id?: string + table_id?: string + sheet_id?: string + source_url?: string + view_id?: string + board_mapping?: Record + status_mode?: 'mapped' | 'custom' + status_mapping?: Record + custom_statuses?: string[] } version: number } diff --git a/wework/src/api/dws.ts b/wework/src/api/dws.ts new file mode 100644 index 0000000000..c2149e8790 --- /dev/null +++ b/wework/src/api/dws.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// SPDX-License-Identifier: Apache-2.0 + +export interface DwsAuthStatus { + authenticated: boolean + corp_id?: string + corp_name?: string + token_valid?: boolean + expires_at?: string +} + +type LocalRequest = (method: string, params?: Record) => Promise + +export interface DwsApi { + authStatus(): Promise + login(): Promise + logout(): Promise +} + +export function createDwsApi(request: LocalRequest): DwsApi { + return { + authStatus: () => request('dws.auth_status'), + login: () => request('dws.auth_login'), + async logout() { + await request('dws.auth_logout') + }, + } +} diff --git a/wework/src/api/hybrid/hybridServices.ts b/wework/src/api/hybrid/hybridServices.ts index a42286f3de..a27079c24d 100644 --- a/wework/src/api/hybrid/hybridServices.ts +++ b/wework/src/api/hybrid/hybridServices.ts @@ -891,6 +891,8 @@ export function createHybridWorkbenchServices( return { ...cloudServices, + aitableApi: localServices.aitableApi, + dwsApi: localServices.dwsApi, projectSpaceApis: { local: localServices.deliveryApi, cloud: cloudProjectSpaceApi, diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 366230b1e3..1da326dba8 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -83,7 +83,9 @@ function stringList(value: unknown): string[] { function localProject(record: LocalLoopItemRecord): CloudProject { const taskProvider = - record.metadata.task_provider === 'github' || record.metadata.task_provider === 'gitlab' + record.metadata.task_provider === 'github' || + record.metadata.task_provider === 'gitlab' || + record.metadata.task_provider === 'dingtalk_aitable' ? record.metadata.task_provider : 'local' return { @@ -235,6 +237,8 @@ function localTask(record: LocalLoopItemRecord, project?: CloudProject): CloudLo created_at: record.created_at, updated_at: record.updated_at, completed_at: record.completed_at, + source_status: + typeof record.metadata.source_status === 'string' ? record.metadata.source_status : null, } } @@ -317,12 +321,21 @@ export function createLocalDeliveryApi( project_key?: string name: string description?: string - task_provider?: 'local' | 'github' | 'gitlab' + task_provider?: 'local' | 'github' | 'gitlab' | 'dingtalk_aitable' provider_config?: { repository?: string domain?: string api_base?: string token?: string + base_id?: string + table_id?: string + sheet_id?: string + source_url?: string + view_id?: string + board_mapping?: Record + status_mode?: 'mapped' | 'custom' + status_mapping?: Record + custom_statuses?: string[] } }) { const record = await request('projects.create', { diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 85689a77be..526191070b 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -109,6 +109,8 @@ import { getLocalProxyUrl } from '@/features/model-settings/localProxySettings' import { createRuntimeChatStream } from '../runtime/runtimeChatStream' import { createLocalAttachmentApi } from './localAttachments' import { createExternalIssueApi, createLocalDeliveryApi } from './localDelivery' +import { createLocalAITableApi } from '@/api/aitable' +import { createDwsApi } from '@/api/dws' import { LOCAL_USER, saveLocalUserPreferences } from './localSession' import type { KeybindingOverride } from '@/lib/keybindings' import { @@ -2383,6 +2385,8 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben ) as unknown as NonNullable const deliveryApi = createLocalDeliveryApi(request) const externalIssueApi = createExternalIssueApi(request) + const aitableApi = createLocalAITableApi(request) + const dwsApi = createDwsApi(request) return { teamApi: { @@ -2437,6 +2441,8 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben deviceApi, deliveryApi, externalIssueApi, + aitableApi, + dwsApi, projectSpaceApis: { local: deliveryApi, defaultLocation: 'local', diff --git a/wework/src/features/todo/AITableTaskFields.test.tsx b/wework/src/features/todo/AITableTaskFields.test.tsx new file mode 100644 index 0000000000..a598849e1e --- /dev/null +++ b/wework/src/features/todo/AITableTaskFields.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import type { AITableApi } from '@/api/aitable' +import type { CloudLoopItem, CloudProject } from '@/api/deliveries' +import { AITableTaskFields } from './AITableTaskFields' + +const project = { + id: 'project-1', + task_provider: 'dingtalk_aitable', +} as CloudProject + +const item = { + id: 'aitable:PRJ:record-1', + can_edit: true, +} as CloudLoopItem + +describe('AITableTaskFields', () => { + it('shows every field, expands long content, and writes editable values back', async () => { + const longText = '第一行\n' + '很长的内容'.repeat(40) + const updateRecord = vi.fn(async (_projectId, recordId, cells) => ({ + id: recordId, + cells, + raw: {}, + })) + const api: AITableApi = { + configureProject: vi.fn(), + describe: vi.fn(async () => ({ + base: {}, + tables: [], + active_table: {}, + fields: [ + { id: 'title', name: '需求名称', type: 'text', config: {}, raw: {} }, + { id: 'notes', name: '备注', type: 'text', config: {}, raw: {} }, + { id: 'formula', name: '延期情况', type: 'formula', config: {}, raw: {} }, + ], + })), + getRecord: vi.fn(async () => ({ + id: 'record-1', + cells: { title: '旧标题', notes: longText, formula: '正常' }, + raw: {}, + })), + listRecords: vi.fn(), + createRecord: vi.fn(), + updateRecord, + deleteRecord: vi.fn(), + createField: vi.fn(), + updateField: vi.fn(), + deleteField: vi.fn(), + } + + render() + + expect(await screen.findByText('需求名称')).toBeInTheDocument() + expect(screen.getByText('延期情况')).toBeInTheDocument() + await userEvent.click(screen.getByTestId('aitable-detail-expand-notes')) + expect(screen.getByTestId('aitable-detail-value-notes')).not.toHaveClass('line-clamp-3') + + await userEvent.click(screen.getByTestId('aitable-detail-edit-button-title')) + const editor = screen.getByTestId('aitable-detail-edit-title') + await userEvent.clear(editor) + await userEvent.type(editor, '新标题') + await userEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => + expect(updateRecord).toHaveBeenCalledWith('project-1', 'record-1', { title: '新标题' }) + ) + expect(screen.queryByTestId('aitable-detail-edit-button-formula')).not.toBeInTheDocument() + }) +}) diff --git a/wework/src/features/todo/AITableTaskFields.tsx b/wework/src/features/todo/AITableTaskFields.tsx new file mode 100644 index 0000000000..53ca25ac9e --- /dev/null +++ b/wework/src/features/todo/AITableTaskFields.tsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from 'react' +import { ChevronDown, ChevronRight, ExternalLink, Loader2, Pencil, X } from 'lucide-react' + +import type { AITableApi, AITableField, AITableRecord } from '@/api/aitable' +import type { CloudLoopItem, CloudProject } from '@/api/deliveries' +import { cn } from '@/lib/utils' + +const READONLY_TYPES = new Set([ + 'formula', + 'creator', + 'createdTime', + 'lastModifier', + 'lastModifiedTime', + 'autoNumber', + 'primaryDoc', +]) +const EDITABLE_TYPES = new Set([ + 'text', + 'singleLineText', + 'multiLineText', + 'number', + 'singleSelect', + 'date', + 'checkbox', + 'url', + 'phone', + 'email', +]) + +function textValue(value: unknown): string { + if (value === null || value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) return value.map(textValue).filter(Boolean).join(', ') + if (typeof value === 'object') { + const object = value as Record + return String(object.name ?? object.title ?? object.text ?? object.link ?? '') + } + return String(value) +} + +function fieldOptions(field: AITableField): string[] { + return Array.isArray(field.config?.options) + ? field.config.options + .map(option => + typeof option === 'object' && option !== null && 'name' in option + ? String(option.name) + : '' + ) + .filter(Boolean) + : [] +} + +function editableField(field: AITableField): boolean { + return EDITABLE_TYPES.has(field.type) && !READONLY_TYPES.has(field.type) && !field.ai_config +} + +function FieldRow({ + field, + value, + canEdit, + onSave, +}: { + field: AITableField + value: unknown + canEdit: boolean + onSave: (value: unknown) => Promise +}) { + const text = textValue(value) + const [expanded, setExpanded] = useState(false) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(text) + const [saving, setSaving] = useState(false) + const long = text.length > 160 || text.includes('\n') + const options = fieldOptions(field) + + async function save(next: unknown = draft) { + let normalized = next + if (field.type === 'number' && typeof next === 'string' && next.trim()) { + normalized = Number(next) + } + if (field.type === 'url' && typeof next === 'string') { + normalized = + typeof value === 'object' && value !== null + ? { ...(value as Record), link: next } + : { text: next, link: next } + } + setSaving(true) + try { + await onSave(normalized) + setEditing(false) + } finally { + setSaving(false) + } + } + + return ( +
+
+
+ {field.name} +
+
+ {field.type} + {!editableField(field) ? ' · 只读' : ''} +
+
+
+ {editing ? ( +
+ {field.type === 'singleSelect' && options.length > 0 ? ( + + ) : ( +