Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ oneshot orchestrate "Build a full-stack Trello clone with auth, Postgres, and re

If you use the launch scripts below, OneShot will auto-check/install most local requirements for you (Bun/Rust/Tauri deps/Temporal where possible).

Manual setup requirements:
Manual setup requirements (source/dev builds):
- [Bun](https://bun.sh) ≥ 1.0
- Rust + Cargo (for desktop/Tauri)
- [Temporal Server](https://github.com/temporalio/temporal) reachable at `127.0.0.1:7233` (or set `TEMPORAL_ADDRESS`)
- [Temporal CLI](https://github.com/temporalio/cli) (`temporal`) installed on your PATH

> OneShot orchestration uses Temporal for durable workflow execution. The runtime starts a worker in-process and uses the `temporal` CLI to start/signal/query workflows.
>
> Desktop release bundles include both `oneshot-cli` and `temporal` sidecars and will auto-start local Temporal by default, so end users do not need a separate Temporal install.

---

Expand Down
30 changes: 28 additions & 2 deletions packages/app/src/context/orchestrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2339,7 +2339,7 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
) {
return "retrying"
}
if (status === "completed") return "done"
if (status === "completed" || status === "skipped") return "done"
if (status === "failed") return "failed"
if (status === "in_progress") return "busy"
if (status === "blocked") return "blocked"
Expand Down Expand Up @@ -2461,7 +2461,7 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
const runPaused = state.runStatus === "paused"
const hasAnyInProgress = state.tasks.some((task) => task.status === "in_progress")
const hasAnyBlocked = state.tasks.some((task) => task.status === "blocked")
const hasAnyCompleted = state.tasks.some((task) => task.status === "completed")
const hasAnyCompleted = state.tasks.some((task) => task.status === "completed" || task.status === "skipped")
const hasAnyOutstanding = state.tasks.some((task) =>
task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
)
Expand All @@ -2482,6 +2482,15 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
state.phase === "researching" ||
!!state.researchContext ||
!!state.researchSessionId
const shouldShowPlannerNode =
!!state.planningSessionId
&& (
state.phase === "researching"
|| state.phase === "planning"
|| state.tasks.length === 0
|| runPaused
|| runFailed
)

const researchStatus: AgentNode["status"] =
state.phase === "researching"
Expand All @@ -2491,6 +2500,14 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
: state.phase === "failed" && !state.researchContext
? "failed"
: "done"
const plannerStatus: AgentNode["status"] =
runFailed
? "failed"
: runPaused
? "paused"
: state.phase === "planning" || (state.runStatus === "active" && state.tasks.length === 0)
? "busy"
: "done"

// During planning phase, show the planner session; during execution, show the supervisor session
const orchestratorSessionId = resolveMasterSessionId({
Expand Down Expand Up @@ -2532,6 +2549,15 @@ export const { use: useOrchestrate, provider: OrchestrateProvider } = createSimp
status: researchStatus,
children: [],
}] : []),
...(shouldShowPlannerNode ? [{
id: "root-planner",
role: "task" as const,
label: "Root Planner",
sessionId: state.planningSessionId,
status: plannerStatus,
kind: "planning",
children: [],
}] : []),
...rootNodes,
// Merge agent — uses distinct role for special UI rendering
...(shouldShowIngestNode ? [{
Expand Down
363 changes: 228 additions & 135 deletions packages/app/src/pages/deploy.tsx

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions packages/app/src/pages/orchestrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4802,7 +4802,7 @@ export default function OrchestratePage() {
),
)

// When planningSessionId becomes available, auto-select orchestrator to show planner stream
// When planningSessionId becomes available, auto-select planner stream
createEffect(
on(
() => orch.state.planningSessionId,
Expand All @@ -4814,7 +4814,8 @@ export default function OrchestratePage() {
if (panel.target?.sessionId) return
const tree = orch.agentTree()
if (!tree) return
selectAgent(tree) // select orchestrator node which shows planner session
const planner = findNodeById(tree, "root-planner")
selectAgent(planner ?? tree)
},
),
)
Expand Down
Binary file not shown.
83 changes: 83 additions & 0 deletions packages/desktop/scripts/predev.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { $ } from "bun"
import { copyFile, cp, mkdir, mkdtemp, rm } from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "node:os"

import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"

const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE

const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const TEMPORAL_CLI_VERSION = Bun.env.TEMPORAL_CLI_VERSION ?? "1.5.1"

const binaryPath = windowsify(`../oneshot/dist/${sidecarConfig.ocBinary}/bin/oneshot`)

Expand All @@ -13,3 +17,82 @@ await (sidecarConfig.ocBinary.includes("-baseline")
: $`cd ../oneshot && bun run build --single`)

await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

const workflowSourcePath = path.resolve("../oneshot/src/orchestrator/runtime/workflow.ts")
const modelConfigSourcePath = path.resolve("../oneshot/src/orchestrator/model-config.ts")
const workflowResourcesDir = path.join(process.cwd(), "src-tauri", "resources", "orchestrator", "runtime")
const modelConfigResourcesDir = path.join(process.cwd(), "src-tauri", "resources", "orchestrator")
const workflowResourcePath = path.join(workflowResourcesDir, "workflow.ts")
const modelConfigResourcePath = path.join(modelConfigResourcesDir, "model-config.ts")
await mkdir(workflowResourcesDir, { recursive: true })
await mkdir(modelConfigResourcesDir, { recursive: true })
await copyFile(workflowSourcePath, workflowResourcePath)
await copyFile(modelConfigSourcePath, modelConfigResourcePath)

const oneshotPackage = await Bun.file(path.resolve("../oneshot/package.json")).json() as {
dependencies?: Record<string, string>
}
const temporalWorkflowVersion = oneshotPackage.dependencies?.["@temporalio/workflow"]
const temporalWorkerVersion = oneshotPackage.dependencies?.["@temporalio/worker"]
if (!temporalWorkflowVersion || !temporalWorkerVersion) {
throw new Error("Missing @temporalio/workflow or @temporalio/worker version in packages/oneshot/package.json")
}

const temporalModulesBuildDir = path.join(process.cwd(), "src-tauri", "resources", ".temporal-runtime")
const bundledNodeModulesPath = path.join(process.cwd(), "src-tauri", "resources", "node_modules")
await rm(temporalModulesBuildDir, { recursive: true, force: true })
await rm(bundledNodeModulesPath, { recursive: true, force: true })
await mkdir(temporalModulesBuildDir, { recursive: true })

await Bun.write(
path.join(temporalModulesBuildDir, "package.json"),
JSON.stringify(
{
private: true,
name: "oneshot-desktop-temporal-runtime",
dependencies: {
"@temporalio/workflow": temporalWorkflowVersion,
"@temporalio/worker": temporalWorkerVersion,
},
},
null,
2,
) + "\n",
)
await $`bun install --production`.cwd(temporalModulesBuildDir)
await cp(path.join(temporalModulesBuildDir, "node_modules"), bundledNodeModulesPath, {
recursive: true,
dereference: true,
force: true,
})
await rm(temporalModulesBuildDir, { recursive: true, force: true })

const temporalArchiveExt = sidecarConfig.temporalAsset.startsWith("windows_") ? "zip" : "tar.gz"
const archiveName = `temporal_cli_${TEMPORAL_CLI_VERSION}_${sidecarConfig.temporalAsset}.${temporalArchiveExt}`
const downloadURL = `https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/${archiveName}`
const cacheDir = path.join(process.cwd(), ".cache", "temporal")
const archivePath = path.join(cacheDir, archiveName)

await mkdir(cacheDir, { recursive: true })

const cachedArchive = Bun.file(archivePath)
if (!(await cachedArchive.exists())) {
const response = await fetch(downloadURL)
if (!response.ok) {
throw new Error(`Failed to download Temporal CLI (${response.status}): ${downloadURL}`)
}

await Bun.write(archivePath, await response.arrayBuffer())
}

const extractDir = await mkdtemp(path.join(tmpdir(), "oneshot-temporal-"))
if (temporalArchiveExt === "zip") {
await $`tar -xf ${archivePath} -C ${extractDir}`
} else {
await $`tar -xzf ${archivePath} -C ${extractDir}`
}

const temporalBinary = windowsify(path.join(extractDir, "temporal"))
await copyBinaryToSidecarFolder(temporalBinary, RUST_TARGET, "temporal")

await rm(extractDir, { recursive: true, force: true })
16 changes: 13 additions & 3 deletions packages/desktop/scripts/utils.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
import { $ } from "bun"

export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
export const SIDECAR_BINARIES: Array<{
rustTarget: string
ocBinary: string
assetExt: string
temporalAsset: `${"darwin" | "linux" | "windows"}_${"amd64" | "arm64"}`
}> = [
{
rustTarget: "aarch64-apple-darwin",
ocBinary: "oneshot-darwin-arm64",
assetExt: "zip",
temporalAsset: "darwin_arm64",
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "oneshot-darwin-x64-baseline",
assetExt: "zip",
temporalAsset: "darwin_amd64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "oneshot-windows-x64",
assetExt: "zip",
temporalAsset: "windows_amd64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "oneshot-linux-x64-baseline",
assetExt: "tar.gz",
temporalAsset: "linux_amd64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
ocBinary: "oneshot-linux-arm64",
assetExt: "tar.gz",
temporalAsset: "linux_arm64",
},
]

Expand All @@ -39,9 +49,9 @@ export function getCurrentSidecar(target = RUST_TARGET) {
return binaryConfig
}

export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET, name = "oneshot-cli") {
await $`mkdir -p src-tauri/sidecars`
const dest = windowsify(`src-tauri/sidecars/oneshot-cli-${target}`)
const dest = windowsify(`src-tauri/sidecars/${name}-${target}`)
await $`cp ${source} ${dest}`

console.log(`Copied ${source} to ${dest}`)
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src-tauri/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
/gen/schemas

sidecars
resources/node_modules
resources/orchestrator
50 changes: 48 additions & 2 deletions packages/desktop/src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,33 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
}

pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
get_sidecar_dir(app).join("oneshot-cli")
}

pub fn get_temporal_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
get_sidecar_dir(app).join("temporal")
}

fn get_sidecar_dir(app: &tauri::AppHandle) -> std::path::PathBuf {
// Get binary with symlinks support
tauri::process::current_binary(&app.env())
.expect("Failed to get current binary")
.parent()
.expect("Failed to get parent dir")
.join("oneshot-cli")
.to_path_buf()
}

fn get_workflow_resource_path(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
let resource_path = app
.path()
.resolve("orchestrator/runtime/workflow.ts", BaseDirectory::Resource)
.ok()?;
resource_path.exists().then_some(resource_path)
}

fn get_node_modules_resource_path(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
let resource_path = app.path().resolve("node_modules", BaseDirectory::Resource).ok()?;
resource_path.exists().then_some(resource_path)
}

/// Walk up from a given path to find a `node_modules` directory that contains
Expand Down Expand Up @@ -292,8 +313,33 @@ pub fn spawn_command(
.map(|(key, value)| (key.to_string(), value.clone())),
);

let use_wsl = cfg!(windows) && is_wsl_enabled(app);
if !use_wsl {
let temporal_sidecar = get_temporal_sidecar_path(app);
if temporal_sidecar.exists() {
envs.push((
"ONESHOT_TEMPORAL_CLI_PATH".to_string(),
temporal_sidecar.to_string_lossy().to_string(),
));
}

if let Some(workflow_path) = get_workflow_resource_path(app) {
envs.push((
"ONESHOT_WORKFLOW_PATH".to_string(),
workflow_path.to_string_lossy().to_string(),
));
}

if let Some(node_modules_path) = get_node_modules_resource_path(app) {
envs.push((
"ONESHOT_NODE_MODULES".to_string(),
node_modules_path.to_string_lossy().to_string(),
));
}
}

let mut cmd = if cfg!(windows) {
if is_wsl_enabled(app) {
if use_wsl {
tracing::info!("WSL is enabled, spawning CLI server in WSL");
let version = app.package_info().version.to_string();
let mut script = vec![
Expand Down
20 changes: 20 additions & 0 deletions packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod linux_windowing;
mod logging;
mod markdown;
mod server;
mod temporal;
mod window_customizer;
mod windows;

Expand Down Expand Up @@ -35,6 +36,7 @@ use tokio::{
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
use crate::constants::*;
use crate::server::get_saved_server_url;
use crate::temporal::TemporalState;
use crate::windows::{LoadingWindow, MainWindow};

#[derive(Clone, serde::Serialize, specta::Type, Debug)]
Expand Down Expand Up @@ -107,6 +109,17 @@ fn kill_sidecar(app: AppHandle) {
tracing::info!("Killed server");
}

fn kill_temporal_server(app: AppHandle) {
let Some(temporal_state) = app.try_state::<TemporalState>() else {
tracing::info!("Temporal server not running");
return;
};

temporal_state.kill();

tracing::info!("Stopped bundled Temporal server");
}

fn get_logs() -> String {
logging::tail()
}
Expand Down Expand Up @@ -511,6 +524,7 @@ pub fn run() {
tracing::info!("Received Exit");

kill_sidecar(app.clone());
kill_temporal_server(app.clone());
}
});
}
Expand Down Expand Up @@ -719,6 +733,7 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();

app.manage(TemporalState::new());
app.manage(InitState { current: init_rx });
}

Expand Down Expand Up @@ -754,6 +769,11 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
return ServerConnection::Existing { url: url.clone() };
}

let temporal_state = app.state::<TemporalState>().inner().clone();
if let Err(err) = temporal::ensure_local_temporal_server(app.clone(), temporal_state).await {
tracing::warn!("Failed to start bundled Temporal server: {err}");
}

let local_port = get_sidecar_port();
let hostname = "127.0.0.1";
let local_url = format!("http://{hostname}:{local_port}");
Expand Down
Loading