use super::super::*;
pub(crate) const DESIGN_DOCUMENT_PATH: &str = ".a3s/workflows/main.design.json";
pub(crate) struct FlowPanel {
pub(crate) root: std::path::PathBuf,
pub(crate) flows: Vec<String>,
pub(crate) sel: usize,
}
pub(crate) fn list_flows(root: &std::path::Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
(!n.starts_with('.') && n.to_ascii_lowercase().ends_with(".json")).then_some(n)
})
.collect();
v.sort();
v
}
pub(crate) fn flow_asset_name(file_stem: &str) -> String {
format!("flow-{}", super::repos::slug(file_stem))
}
pub(crate) fn designer_url(origin: &str, asset_id: &str) -> String {
format!(
"{}/workflow-designer/{}",
origin.trim_end_matches('/'),
asset_id
)
}
pub(crate) fn flow_gen_prompt(description: &str, dir: &str) -> String {
format!(
"Create a basic workflow DAG JSON from the description below and save it under \
{dir}. This is a SMALL single-file task: do it directly in this turn — do NOT \
plan, delegate, or fan out subagents.\n\
Description: {description}\n\
IMPORTANT: {dir} is OUTSIDE this session's workspace, so the path-scoped file \
tools will reject it — use the `bash` tool (`mkdir -p {dir}`, then write the \
file with a heredoc).\n\
The file MUST follow the OS workflow-designer document schema exactly — this \
minimal example shows every required field:\n\
{{\"version\":\"a3s.workflow.design.v1\",\"name\":\"<name>\",\"description\":\
\"<one line>\",\"triggerEvents\":[],\"variables\":[],\"outputs\":[],\
\"nodes\":[{{\"id\":\"start\",\"kind\":\"start\",\"name\":\"Start\",\"data\":{{}},\
\"x\":0,\"y\":0}},{{\"id\":\"step-1\",\"kind\":\"llm\",\"name\":\"<step>\",\
\"data\":{{}},\"x\":320,\"y\":0}},{{\"id\":\"end\",\"kind\":\"end\",\
\"name\":\"End\",\"data\":{{}},\"x\":640,\"y\":0}}],\
\"edges\":[{{\"id\":\"e1\",\"sourceNodeID\":\"start\",\"targetNodeID\":\
\"step-1\"}},{{\"id\":\"e2\",\"sourceNodeID\":\"step-1\",\"targetNodeID\":\
\"end\"}}]}}\n\
Rules: exactly one `start` and one `end` node; node kinds ONLY from: start, \
end, llm, http, code, condition, loop, template, answer, knowledge-retrieval, \
question-classifier, parameter-extractor, aggregator; unique kebab-case ids; \
edges use sourceNodeID/targetNodeID (capital D); lay nodes left-to-right \
(x += 320 per step, y ±180 for branches); keep it BASIC — 3-7 nodes that \
match the description, no speculative extras.\n\
Save as {dir}/<kebab-case-name>.json (if that file exists, append -2, -3, …). \
Validate it with `python3 -m json.tool \"$FILE\" > /dev/null && echo OK` \
(always pass the file path — never run a command that waits on stdin). Then \
report the saved path and tell the user `/flow` opens it in the OS workflow \
designer."
)
}
fn http() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())
}
fn items_of(v: &serde_json::Value) -> Vec<serde_json::Value> {
v.pointer("/data/items")
.or_else(|| v.pointer("/data"))
.or_else(|| v.pointer("/items"))
.and_then(|d| d.as_array().cloned())
.unwrap_or_default()
}
pub(crate) async fn ensure_flow_asset(
origin: &str,
token: &str,
name: &str,
) -> Result<String, String> {
let client = http()?;
let base = format!("{}/api/v1/assets", origin.trim_end_matches('/'));
let found: serde_json::Value = client
.get(format!("{base}?search={name}&category=workflow&limit=50"))
.bearer_auth(token)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
if let Some(id) = items_of(&found)
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name))
.and_then(|a| a.get("id").and_then(|i| i.as_str()))
{
return Ok(id.to_string());
}
let resp = client
.post(&base)
.bearer_auth(token)
.json(&serde_json::json!({
"name": name,
"ownerType": "user",
"category": "workflow",
"visibility": "private",
"description": "Created by a3s code /flow",
}))
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!(
"create asset failed ({status}): {}",
body.get("message").and_then(|m| m.as_str()).unwrap_or("?")
));
}
body.pointer("/data/id")
.or_else(|| body.get("id"))
.and_then(|i| i.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "create asset: no id in response".to_string())
}
pub(crate) async fn upload_flow_document(
origin: &str,
token: &str,
asset_id: &str,
design_json: &str,
) -> Result<(), String> {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(design_json.as_bytes());
let resp = http()?
.post(format!(
"{}/api/v1/assets/{asset_id}/repository/files",
origin.trim_end_matches('/')
))
.bearer_auth(token)
.json(&serde_json::json!({
"overwrite": true,
"message": "a3s code /flow: update workflow design",
"files": [{ "path": DESIGN_DOCUMENT_PATH, "contentBase64": b64 }],
}))
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
Err(format!(
"upload failed ({status}): {}",
truncate(&body, 200)
))
}
impl App {
pub(crate) fn open_flow_panel(&mut self) {
let root = flow_dir();
let flows = list_flows(&root);
if flows.is_empty() {
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" no flows in {} — draft one with `/flow <description>` first",
root.display()
)));
return;
}
self.flow = Some(FlowPanel {
root,
flows,
sel: 0,
});
}
pub(crate) fn handle_flow_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
let p = self.flow.as_mut()?;
let last = p.flows.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => p.sel = p.sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => p.sel = (p.sel + 1).min(last),
KeyCode::Esc => self.flow = None,
KeyCode::Enter => {
let panel = self.flow.take()?;
let file = panel.flows.get(panel.sel.min(last))?.clone();
let path = panel.root.join(&file);
let design = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" could not read {}: {e}", path.display())),
);
return None;
}
};
if serde_json::from_str::<serde_json::Value>(&design).is_err() {
self.push_line(&Style::new().fg(TN_RED).render(&format!(
" {} is not valid JSON — fix it (or redraft with /flow <description>)",
file
)));
return None;
}
let Some(session) = self.os_session.clone() else {
return None; };
let stem = file.trim_end_matches(".json").to_string();
let asset_name = flow_asset_name(&stem);
let origin = crate::a3s_os::os_origin(&session.address);
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" ⧉ {file} → OS workflow asset `{asset_name}` → designer…"
)));
return Some(cmd::cmd(move || async move {
let res = async {
let id =
ensure_flow_asset(&origin, &session.access_token, &asset_name).await?;
upload_flow_document(&origin, &session.access_token, &id, &design).await?;
Ok(designer_url(&origin, &id))
}
.await;
Msg::FlowOpened(res.map(|url| (stem, url)))
}));
}
_ => {}
}
None
}
pub(crate) fn on_flow_opened(&mut self, res: Result<(String, String), String>) {
match res {
Ok((name, url)) => {
let spec = remote_ui::ViewSpec {
url,
width: Some(1440),
height: Some(900),
embeddable: true,
};
self.last_view = Some(spec.clone());
self.push_line(&gutter(
TN_CYAN,
&format!("🔗 {VIEW_BUTTON_MARKER} workflow designer · {name} (click or /view reopens · edit + debug run)"),
));
self.open_remote_view(&spec);
}
Err(e) => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" /flow failed: {e}")),
);
}
}
}
pub(crate) fn overlay_flow_menu(&self, composed: String) -> String {
let Some(p) = self.flow.as_ref() else {
return composed;
};
let width = self.width as usize;
let total = p.flows.len();
let mut menu = vec![
pad_to(
&Style::new().fg(ACCENT).bold().render(&format!(
" ⧉ flow — pick a DAG ({} in {})",
total,
truncate(&p.root.to_string_lossy(), width.saturating_sub(24))
)),
width,
),
pad_to(
&Style::new()
.fg(TN_GRAY)
.render(" ↑/↓ select · Enter open in the OS workflow designer · Esc cancel"),
width,
),
];
let sel = p.sel.min(total.saturating_sub(1));
let max_rows = (self.height as usize).saturating_sub(8).clamp(3, 12);
let start = if sel < max_rows {
0
} else {
sel + 1 - max_rows
};
let end = (start + max_rows).min(total);
for (row, name) in p.flows.iter().enumerate().take(end).skip(start) {
let raw = pad_to(&format!(" {name}"), width);
menu.push(if row == sel {
Style::new().fg(Color::Black).bg(ACCENT).render(&raw)
} else {
Style::new().fg(TN_FG).render(&raw)
});
}
if total > max_rows {
menu.push(pad_to(
&Style::new()
.fg(TN_GRAY)
.render(&format!(" {}/{total}", sel + 1)),
width,
));
}
self.overlay_list(composed, &menu)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_json_flows_sorted_skipping_dotfiles_and_nonjson() {
let root = std::env::temp_dir().join(format!("a3s-flows-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("zeta.json"), "{}").unwrap();
std::fs::write(root.join("alpha.json"), "{}").unwrap();
std::fs::write(root.join(".hidden.json"), "{}").unwrap();
std::fs::write(root.join("notes.txt"), "x").unwrap();
std::fs::create_dir_all(root.join("dir.json")).unwrap(); let fs = list_flows(&root);
assert_eq!(fs, vec!["alpha.json", "zeta.json"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn designer_url_and_asset_name_follow_the_rules() {
assert_eq!(
designer_url("http://180.163.156.38:49164/", "abc-123"),
"http://180.163.156.38:49164/workflow-designer/abc-123"
);
assert_eq!(flow_asset_name("Daily Report 2"), "flow-daily-report-2");
}
#[test]
fn flow_gen_prompt_carries_schema_rules_and_dir() {
let p = flow_gen_prompt("fetch news daily and summarize", "/Users/x/.a3s/flows");
assert!(p.contains("fetch news daily and summarize"));
assert!(p.contains("/Users/x/.a3s/flows"));
assert!(p.contains("a3s.workflow.design.v1")); assert!(p.contains("sourceNodeID")); assert!(p.contains("exactly one `start` and one `end`"));
assert!(p.contains("OUTSIDE this session's workspace") && p.contains("bash"));
let start = p.find("{\"version\"").expect("example present");
let end = p[start..].find("}]}").expect("example closes") + start + 3;
assert!(serde_json::from_str::<serde_json::Value>(&p[start..end]).is_ok());
}
#[test]
fn items_of_reads_paginated_and_bare_shapes() {
let paged: serde_json::Value =
serde_json::json!({"code":200,"data":{"items":[{"id":"a"}],"total":1}});
assert_eq!(items_of(&paged).len(), 1);
let bare: serde_json::Value = serde_json::json!({"data":[{"id":"b"}]});
assert_eq!(items_of(&bare).len(), 1);
assert!(items_of(&serde_json::json!({"data":{}})).is_empty());
}
}