diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c70e063..d2f7604 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1780,6 +1780,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-shell", "tokio", ] @@ -2048,6 +2049,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2550,6 +2552,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3288,6 +3314,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b433f27..8f77f32 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-shell = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 48e887c..927aeee 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -4,6 +4,7 @@ "windows": ["main"], "permissions": [ "core:default", + "dialog:default", { "identifier": "shell:allow-execute", "allow": [ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 70133e7..66cb707 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ //! the OS-keychain secret storage the engine consumes via its environment. mod engine; +mod repo; mod secrets; use engine::EngineManager; @@ -50,6 +51,7 @@ fn list_secret_keys(app: tauri::AppHandle) -> Result, String> { pub fn run() { let app = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Start the engine sidecar up front so the UI has an endpoint as // soon as it loads. A startup failure aborts the app with a clear @@ -67,7 +69,10 @@ pub fn run() { restart_engine, set_secret, delete_secret, - list_secret_keys + list_secret_keys, + repo::pick_directory, + repo::check_git_repo, + repo::which_commands ]) .build(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src-tauri/src/repo.rs b/desktop/src-tauri/src/repo.rs new file mode 100644 index 0000000..a6522ec --- /dev/null +++ b/desktop/src-tauri/src/repo.rs @@ -0,0 +1,98 @@ +//! Repository selection + local environment checks (desktop only). +//! +//! These commands back the Start screen's "select a repo" flow and the provider +//! onboarding panel: a native folder picker, a fast "is this a git repo?" probe, +//! and detection of which coding/CLI tools are installed. They run in the +//! trusted Rust shell (not the webview) so they can touch the filesystem and +//! PATH directly; the engine still re-validates a selected repo server-side +//! before a run starts. + +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +/// Opens the native folder picker and returns the chosen absolute path (or +/// `None` if the user cancelled). Blocking is fine here: Tauri runs command +/// handlers off the main thread, and the picker is modal by design. +#[tauri::command] +pub fn pick_directory(app: AppHandle) -> Result, String> { + let picked = app.dialog().file().blocking_pick_folder(); + Ok(picked + .and_then(|p| p.into_path().ok()) + .map(|p| p.to_string_lossy().to_string())) +} + +/// True when `path` is inside a git working tree. Mirrors the engine's +/// `isGitRepo` so the UI can validate a selection before a run is started. +#[tauri::command] +pub fn check_git_repo(path: String) -> bool { + Command::new("git") + .args(["-C", &path, "rev-parse", "--is-inside-work-tree"]) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false) +} + +/// For each requested tool name, reports whether it is found on PATH. Used by +/// the provider-onboarding panel to show "installed / missing" for tools like +/// `codex`, `kiro`, and `gh`. +#[tauri::command] +pub fn which_commands(names: Vec) -> HashMap { + names + .into_iter() + .map(|n| { + let found = is_on_path(&n); + (n, found) + }) + .collect() +} + +/// Resolves whether `name` exists as an executable on PATH, without running it. +/// On Windows it also tries the usual executable extensions (PATHEXT). +fn is_on_path(name: &str) -> bool { + // An explicit path (contains a separator) is checked directly. + if name.contains('/') || name.contains('\\') { + return is_executable(Path::new(name)); + } + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + let exts: Vec = if cfg!(windows) { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string()) + .split(';') + .map(|s| s.to_string()) + .collect() + } else { + vec![String::new()] + }; + for dir in std::env::split_paths(&paths) { + for ext in &exts { + let candidate = dir.join(format!("{name}{ext}")); + if is_executable(&candidate) { + return true; + } + } + } + false +} + +/// True when `path` is a regular file that is actually executable. On Unix a +/// plain readable file on PATH is not runnable, so we check the exec bits; +/// elsewhere (Windows) file existence plus a PATHEXT extension is sufficient. +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} diff --git a/desktop/src/api.ts b/desktop/src/api.ts index 619e19a..decb8e9 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -77,6 +77,8 @@ export interface StartRunBody { env?: Record; sessionId?: string; resume?: boolean; + /** absolute path to the local git repo the run should build against */ + repoDir?: string; } export async function startRun(body: StartRunBody): Promise { @@ -177,3 +179,31 @@ export async function setSecret(key: string, value: string): Promise { export async function deleteSecret(key: string): Promise { await tauriInvoke("delete_secret", { key }); } + +// --- Repo selection + environment checks (Tauri only) ---------------------- + +/** + * Opens the native folder picker and returns the chosen absolute path, or null + * if the user cancelled. Only available in the desktop app; in a browser there + * is no filesystem access, so callers fall back to a manual path input. + */ +export async function pickDirectory(): Promise { + if (!isTauri()) return null; + return tauriInvoke("pick_directory"); +} + +/** Whether the given path is a git working tree (Tauri only; null = unknown). */ +export async function checkGitRepo(path: string): Promise { + if (!isTauri()) return null; + return tauriInvoke("check_git_repo", { path }); +} + +/** + * Detects which of the named CLI tools are installed/on PATH (e.g. codex, kiro, + * gh). Used for provider onboarding ("installed / missing"). Returns an empty + * map in a browser, where local command detection isn't possible. + */ +export async function detectCommands(names: string[]): Promise> { + if (!isTauri()) return {}; + return tauriInvoke>("which_commands", { names }); +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts index e628e4c..c5f55d3 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -3,13 +3,16 @@ import { apiBase, activeRunCount, cancelRun, + checkGitRepo, deleteSecret, + detectCommands, getTrace, health, isTauri, listSecretKeys, listSessions, openStream, + pickDirectory, restartEngine, setSecret, startRun, @@ -57,8 +60,70 @@ const ICONS = { key: '', arrow: '', caret: '', + folder: '', + github: '', }; +// --- recent repos (persisted locally) -------------------------------------- + +const RECENT_REPOS_KEY = "loopwright.recentRepos"; +const MAX_RECENT_REPOS = 8; + +/** Returns the recently used repo folders, most-recent first. */ +function recentRepos(): string[] { + try { + const raw = localStorage.getItem(RECENT_REPOS_KEY); + const arr = raw ? (JSON.parse(raw) as unknown) : []; + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +} + +/** Records `dir` as the most-recently used repo folder (de-duplicated). */ +function rememberRepo(dir: string): void { + if (!dir) return; + try { + const next = [dir, ...recentRepos().filter((d) => d !== dir)].slice(0, MAX_RECENT_REPOS); + localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(next)); + } catch { + /* localStorage unavailable — recents are best-effort */ + } +} + +// --- saved run configs (for "resume in same repo") ------------------------- + +const RUN_CONFIGS_KEY = "loopwright.runConfigs"; + +interface SavedRun { + goal: string; + repoDir?: string; + env: Record; +} + +/** Persists the start payload for a session so it can be resumed in the same repo. */ +function saveRunConfig(sessionId: string, cfg: SavedRun): void { + try { + const raw = localStorage.getItem(RUN_CONFIGS_KEY); + const all = (raw ? JSON.parse(raw) : {}) as Record; + all[sessionId] = cfg; + localStorage.setItem(RUN_CONFIGS_KEY, JSON.stringify(all)); + } catch { + /* best-effort */ + } +} + +/** Loads a previously saved start payload for a session, if any. */ +function loadRunConfig(sessionId: string): SavedRun | undefined { + try { + const raw = localStorage.getItem(RUN_CONFIGS_KEY); + const all = (raw ? JSON.parse(raw) : {}) as Record; + return all[sessionId]; + } catch { + return undefined; + } +} + /** Valid POSIX-ish environment variable name (used for secret keys). */ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -134,6 +199,86 @@ const PRESETS: Record = { 2, ), }, + codex: { + label: "Codex CLI (edits files locally)", + json: JSON.stringify( + [ + { + id: "codex", + kind: "cli", + model: "", + options: { + command: "codex", + args: ["exec", "--json", "{{prompt}}"], + promptVia: "arg", + output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, + }, + }, + ], + null, + 2, + ), + }, + kiro: { + label: "Kiro CLI (edits files locally)", + json: JSON.stringify( + [ + { + id: "kiro", + kind: "cli", + model: "", + options: { + command: "kiro", + args: ["--headless", "--prompt", "{{prompt}}"], + promptVia: "arg", + output: { mode: "last-line" }, + }, + }, + ], + null, + 2, + ), + }, + codexCritic: { + label: "Codex actor + OpenAI critic", + json: JSON.stringify( + [ + { + id: "codex", + kind: "cli", + model: "", + options: { + command: "codex", + args: ["exec", "--json", "{{prompt}}"], + promptVia: "arg", + output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, + }, + }, + { id: "reviewer", kind: "http", model: "gpt-4o", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, + ], + null, + 2, + ), + }, +}; + +/** + * Presets whose actor runner edits files directly on disk (CLI agents). Only + * these can actually CHANGE code in the selected repo — HTTP runners return a + * diff string the engine does not apply. The UI nudges toward a CLI actor when + * a repo is selected so a run can produce real, committable changes (item 5). + */ +const FILE_EDITING_PRESETS = new Set(["codex", "kiro", "codexCritic"]); + +/** Default actor/critic ids per preset, so roles match the chosen profiles. */ +const PRESET_ROLES: Record = { + openai: { actor: "primary", critic: "primary" }, + anthropic: { actor: "primary", critic: "primary" }, + local: { actor: "primary", critic: "primary" }, + split: { actor: "builder", critic: "reviewer" }, + codex: { actor: "codex", critic: "codex" }, + kiro: { actor: "kiro", critic: "kiro" }, + codexCritic: { actor: "codex", critic: "reviewer" }, }; const EXAMPLE_GOALS = [ @@ -203,6 +348,80 @@ function renderStart(): void { chips, ); + // -- Repository field (folder picker + recent repos + validation) + const repoField = h("div", { class: "field" }); + const repoInput = h("input", { + type: "text", + id: "start-repo", + name: "repoDir", + placeholder: isTauri() ? "Select a local git repository…" : "/absolute/path/to/your/repo", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const browseBtn = h("button", { type: "button", class: "ghost" }, [icon(ICONS.folder), "Browse…"]); + const repoStatus = h("span", { class: "hint", id: "repo-status" }); + const repoRow = h("div", { class: "repo-row" }, [repoInput, browseBtn]); + + // Validate the path: in the desktop app we can check the filesystem directly; + // in a browser the engine validates on submit, so we just note that. + let repoValid = false; + async function validateRepo(): Promise { + const dir = repoInput.value.trim(); + if (!dir) { + repoValid = false; + repoStatus.textContent = "Optional — leave empty to build in the engine's working directory (no worktrees)."; + repoStatus.className = "hint"; + return; + } + const ok = await checkGitRepo(dir); + if (ok === null) { + // Browser: can't check locally; the engine validates on start. + repoValid = true; + repoStatus.textContent = "Will be validated as a git repository when the run starts."; + repoStatus.className = "hint"; + return; + } + repoValid = ok; + repoStatus.textContent = ok ? "✓ Git repository detected." : "Not a git repository — pick a folder that contains a .git directory."; + repoStatus.className = ok ? "hint ok" : "hint error"; + } + repoInput.addEventListener("change", () => void validateRepo()); + repoInput.addEventListener("blur", () => void validateRepo()); + + browseBtn.addEventListener("click", async () => { + const dir = await pickDirectory(); + if (dir) { + repoInput.value = dir; + await validateRepo(); + } + }); + if (!isTauri()) browseBtn.setAttribute("disabled", "true"); + + // Recent repos (item 11): quick re-selection of previously used folders. + const recents = recentRepos(); + const recentChips = h("div", { class: "chips" }); + if (recents.length) { + recentChips.append(h("span", { class: "desc" }, ["Recent:"])); + for (const dir of recents) { + const short = dir.length > 40 ? `…${dir.slice(-40)}` : dir; + const chip = h("button", { type: "button", class: "chip", title: dir }, [short]); + chip.addEventListener("click", () => { + repoInput.value = dir; + void validateRepo(); + }); + recentChips.append(chip); + } + } + + repoField.append( + h("label", { class: "label", for: "start-repo" }, ["Repository"]), + h("div", { class: "desc" }, ["The local git repo the agents will edit. Each task builds in an isolated worktree off this repo."]), + repoRow, + repoStatus, + recentChips, + ); + void validateRepo(); + // -- Model / runner field const runnerField = h("div", { class: "field" }); const presetSelect = h("select", { id: "start-preset", name: "preset" }) as HTMLSelectElement; @@ -224,11 +443,33 @@ function renderStart(): void { ]), ); + // Nudge: only CLI (file-editing) actors actually change code on disk. HTTP + // runners return a diff the engine does not apply, so they can't update a + // selected repo (item 5). + const runnerHint = h("div", { class: "hint", id: "runner-hint" }); + function updateRunnerHint(): void { + const isFileEditing = FILE_EDITING_PRESETS.has(presetSelect.value); + if (isFileEditing) { + runnerHint.textContent = "✓ This actor edits files directly in the repo, so a run can produce real, committable changes."; + runnerHint.className = "hint ok"; + } else { + runnerHint.textContent = + "Note: HTTP model runners return a diff but do NOT edit files. To actually change a selected repo, pick a CLI actor (Codex or Kiro)."; + runnerHint.className = "hint warn"; + } + } + presetSelect.addEventListener("change", () => { const p = PRESETS[presetSelect.value]; if (p) { runnersArea.value = p.json; syncRunnerIds(); + const roles = PRESET_ROLES[presetSelect.value]; + if (roles) { + actorInput.value = roles.actor; + criticInput.value = roles.critic; + } + updateRunnerHint(); } }); @@ -236,6 +477,7 @@ function renderStart(): void { h("label", { class: "label", for: "start-preset" }, ["Model provider"]), h("div", { class: "desc" }, ["Pick a preset to get started, or open Advanced to define your own runner profiles."]), presetSelect, + runnerHint, advanced, ); @@ -270,6 +512,7 @@ function renderStart(): void { } runnersArea.addEventListener("input", syncRunnerIds); syncRunnerIds(); + updateRunnerHint(); // -- Options const optionsField = h("div", { class: "field" }); @@ -297,17 +540,102 @@ function renderStart(): void { h("div", { class: "option-text" }, [h("strong", {}, ["Max parallel tasks"]), h("span", {}, ["How many tasks run at the same time."])]), stepper, ]), - optionRow("worktrees", "Use git worktrees", "Isolate each task in its own worktree so parallel work never collides.", false), + optionRow("worktrees", "Use git worktrees", "Isolate each task in its own worktree so parallel work never collides.", true), optionRow("gate", "Mechanical gate", "Run build / test / lint checks before the critic reviews each change.", true), ); optionsField.append(h("div", { class: "label" }, ["Options"]), options); + // -- Publish (GitHub) field -------------------------------------------- + // All push/PR controls live here; everything is opt-in and OFF by default so + // a run never touches a remote unless the user asks (items 7, 8, 9, 12, 13). + const publishField = h("div", { class: "field" }); + + /** Reads the checkbox inside a row built by optionRow. */ + function checkboxOf(row: HTMLElement, name: string): HTMLInputElement { + return row.querySelector(`input[name="${name}"]`) as HTMLInputElement; + } + function labeledInput(label: string, name: string, value: string, placeholder = ""): HTMLElement { + const input = h("input", { type: "text", name, value, placeholder, autocomplete: "off", spellcheck: "false" }) as HTMLInputElement; + return h("label", { class: "inline-label" }, [label, input]); + } + + // Branch naming (item 12) + dry run (item 13). + const branchPrefixField = labeledInput("Branch prefix", "branchPrefix", "loopwright", "loopwright"); + const dryRunRow = optionRow("dryRun", "Dry run", "Build a local integration branch but never push, even if pushing is enabled.", false); + const pushRow = optionRow("pushToRemote", "Push to GitHub", "After a clean, verified integration, push the integration branch to a remote.", false); + + // Push sub-panel (revealed when "Push to GitHub" is on). + const pushPanel = h("div", { class: "subpanel", hidden: "true" }); + const remoteGrid = h("div", { class: "field-grid" }, [ + labeledInput("Remote", "remote", "origin", "origin"), + labeledInput("Target branch", "pushBranch", "", "(default: generated integration branch)"), + ]); + const openPrRow = optionRow("openPr", "Open a pull request", "After pushing, open a PR with the GitHub CLI (gh).", false); + const prPanel = h("div", { class: "subpanel", hidden: "true" }); + prPanel.append( + h("div", { class: "field-grid" }, [ + labeledInput("PR base branch", "prBase", "", "(repo default branch)"), + labeledInput("PR title", "prTitle", "", "(generated from the goal)"), + ]), + optionRow("prDraft", "Open as draft", "Recommended — open the PR as a draft for review.", true), + ); + const overrideRow = optionRow( + "pushOverride", + "Override safety checks (unsafe)", + "Push even if integration failed, there were merge conflicts, or verification did not pass.", + false, + ); + + checkboxOf(pushRow, "pushToRemote").addEventListener("change", (e) => { + pushPanel.hidden = !(e.target as HTMLInputElement).checked; + }); + checkboxOf(openPrRow, "openPr").addEventListener("change", (e) => { + prPanel.hidden = !(e.target as HTMLInputElement).checked; + }); + + pushPanel.append(remoteGrid, openPrRow, prPanel, overrideRow); + + // Environment readiness (items 9, 15): show whether the CLI tools the run may + // need are installed. Desktop only; a browser can't probe local commands. + const envPanel = h("div", { class: "env-checks" }); + if (isTauri()) { + void detectCommands(["gh", "codex", "kiro"]).then((found) => { + envPanel.innerHTML = ""; + envPanel.append(h("div", { class: "desc" }, ["Detected tools:"])); + for (const name of ["codex", "kiro", "gh"]) { + const ok = found[name] === true; + envPanel.append( + h("span", { class: `tool ${ok ? "ok" : "missing"}` }, [`${name}: ${ok ? "installed" : "missing"}`]), + ); + } + envPanel.append( + h("div", { class: "desc" }, [ + "Pushing uses your local git credentials. Opening a PR needs gh installed and authenticated (gh auth status), or a GITHUB_TOKEN secret.", + ]), + ); + }); + } else { + envPanel.append( + h("div", { class: "desc" }, [ + "Pushing uses the engine host's git credentials; opening a PR needs the gh CLI authenticated or a GITHUB_TOKEN in the environment.", + ]), + ); + } + + publishField.append( + h("div", { class: "label" }, ["Branch & publishing"]), + h("div", { class: "desc" }, ["Control branch naming and whether a successful run is pushed to GitHub."]), + h("div", { class: "options" }, [branchPrefixField, dryRunRow, pushRow]), + pushPanel, + envPanel, + ); + // -- Submit const hint = h("span", { class: "hint", id: "start-hint" }); const submit = h("button", { type: "submit", class: "primary" }, [icon(ICONS.rocket), "Start run"]); const actions = h("div", { class: "actions" }, [submit, hint]); - form.append(goalField, runnerField, rolesField, optionsField, actions); + form.append(goalField, repoField, runnerField, rolesField, optionsField, publishField, actions); form.addEventListener("submit", async (e) => { e.preventDefault(); @@ -318,6 +646,31 @@ function renderStart(): void { return; } + const repoDir = String(data.get("repoDir") ?? "").trim(); + const pushToRemote = Boolean(data.get("pushToRemote")); + + // Revalidate now: `repoValid` reflects the last change/blur, but a user can + // edit or pick a path and submit before those handlers fire (stale state). + if (repoDir) await validateRepo(); + + // Guard: pushing (or worktrees) requires a repo. Fail early with a clear, + // inline message rather than a server 400. + if (pushToRemote && !repoDir) { + hint.textContent = "Select a repository before enabling “Push to GitHub”."; + hint.className = "hint error"; + repoInput.focus(); + return; + } + + // In the desktop app we know locally whether the path is a git repo; block + // a clearly-invalid selection before hitting the engine. + if (repoDir && !repoValid) { + hint.textContent = "The selected repository path is not a git repository."; + hint.className = "hint error"; + repoInput.focus(); + return; + } + const env: Record = { LOOPWRIGHT_RUNNERS: String(data.get("runners") ?? "").trim(), LOOPWRIGHT_ACTOR_RUNNER: String(data.get("actor") ?? "").trim(), @@ -325,6 +678,16 @@ function renderStart(): void { LOOPWRIGHT_MAX_PARALLEL: String(data.get("maxParallel") ?? "2"), LOOPWRIGHT_USE_WORKTREES: data.get("worktrees") ? "true" : "false", LOOPWRIGHT_MECHANICAL_GATE: data.get("gate") ? "true" : "false", + LOOPWRIGHT_BRANCH_PREFIX: String(data.get("branchPrefix") ?? "loopwright").trim() || "loopwright", + LOOPWRIGHT_DRY_RUN: data.get("dryRun") ? "true" : "false", + LOOPWRIGHT_PUSH_TO_REMOTE: pushToRemote ? "true" : "false", + LOOPWRIGHT_REMOTE: String(data.get("remote") ?? "origin").trim() || "origin", + LOOPWRIGHT_PUSH_BRANCH: String(data.get("pushBranch") ?? "").trim(), + LOOPWRIGHT_OPEN_PR: data.get("openPr") ? "true" : "false", + LOOPWRIGHT_PR_BASE: String(data.get("prBase") ?? "").trim(), + LOOPWRIGHT_PR_TITLE: String(data.get("prTitle") ?? "").trim(), + LOOPWRIGHT_PR_DRAFT: data.get("prDraft") ? "true" : "false", + LOOPWRIGHT_PUSH_OVERRIDE_SAFETY: data.get("pushOverride") ? "true" : "false", }; try { @@ -341,7 +704,9 @@ function renderStart(): void { hint.className = "hint"; submit.setAttribute("disabled", "true"); try { - const sessionId = await startRun({ goal, env }); + const sessionId = await startRun({ goal, env, ...(repoDir ? { repoDir } : {}) }); + if (repoDir) rememberRepo(repoDir); + saveRunConfig(sessionId, { goal, env, ...(repoDir ? { repoDir } : {}) }); renderMonitor(sessionId, goal); } catch (err) { submit.removeAttribute("disabled"); @@ -599,6 +964,55 @@ async function renderResults(sessionId: string): Promise { page.append(card); } + // Publish (push + PR) card — surfaces what reached GitHub, or why it didn't. + const publishEvent = trace.events.find((e) => e.type === "publish"); + if (publishEvent) { + const d = publishEvent.data as { + pushed?: boolean; + remote?: string; + branch?: string; + pushBranch?: string; + remoteUrl?: string; + refused?: string; + reason?: string; + error?: string; + pr?: { created?: boolean; url?: string; error?: string }; + }; + const pushed = d.pushed === true; + const card = h("div", { class: `card${pushed ? "" : " integration-bad"}` }); + card.append(h("h3", {}, [icon(ICONS.github), " Push & pull request"])); + + if (pushed) { + card.append( + h("div", { class: "phase done" }, [ + `Pushed ${String(d.branch ?? "")} → ${String(d.remote ?? "origin")}/${String(d.pushBranch ?? d.branch ?? "")}`, + ]), + ); + if (d.remoteUrl) card.append(h("div", { class: "hint" }, [`remote: ${d.remoteUrl}`])); + if (d.pr?.created) { + const prLine = h("div", { class: "hint ok" }, ["Pull request opened"]); + if (d.pr.url) { + prLine.append(" — "); + prLine.append(h("a", { href: d.pr.url, target: "_blank", rel: "noreferrer" }, [d.pr.url])); + } + card.append(prLine); + } else if (d.pr?.error) { + card.append(h("div", { class: "hint error" }, [`PR not opened: ${d.pr.error}`])); + } + } else if (d.refused) { + card.append( + h("div", { class: "phase error" }, ["Push refused by safety checks"]), + h("pre", { class: "log" }, [d.reason ?? d.refused]), + ); + } else if (d.error) { + card.append( + h("div", { class: "phase error" }, ["Push failed"]), + h("pre", { class: "log" }, [d.error]), + ); + } + page.append(card); + } + // Usage const u = trace.usage; const usage = h("div", { class: "card" }); @@ -705,7 +1119,38 @@ async function renderSessions(): Promise { icon(ICONS.arrow, "s-arrow"), ]); btn.addEventListener("click", () => renderResults(s.id)); - li.append(btn); + + const row = h("div", { class: "session-row" }, [btn]); + + // Resume-in-same-repo (item 14): only when we have the original start + // payload (goal, runners, repo) saved locally and the run isn't still going. + const saved = loadRunConfig(s.id); + if (saved && s.status !== "running") { + const resume = h("button", { class: "ghost small", title: saved.repoDir ?? "" }, [ + icon(ICONS.loop), + "Resume", + ]); + resume.addEventListener("click", async (ev) => { + ev.stopPropagation(); + resume.setAttribute("disabled", "true"); + try { + await startRun({ + goal: saved.goal, + env: saved.env, + ...(saved.repoDir ? { repoDir: saved.repoDir } : {}), + sessionId: s.id, + resume: true, + }); + renderMonitor(s.id, saved.goal); + } catch (err) { + resume.removeAttribute("disabled"); + alert(`Resume failed: ${(err as Error).message}`); + } + }); + row.append(resume); + } + + li.append(row); list.append(li); } card.append(list); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index f77a63e..e3c1db4 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -628,3 +628,43 @@ button.linkish:hover .s-arrow { color: var(--accent); } .field-grid, .secret-form { grid-template-columns: 1fr; } .page { padding: 24px 18px 60px; } } + + +/* =========================================================================== + Repo picker, publish controls, env checks (Start screen additions) + =========================================================================== */ +.hint.ok { color: var(--green); } +.hint.warn { color: var(--yellow); } + +.repo-row { display: flex; gap: 8px; align-items: stretch; } +.repo-row input { flex: 1 1 auto; } +.repo-row button { white-space: nowrap; } + +/* Revealed sub-panels (push options, PR options). */ +.subpanel { + margin-top: 12px; + padding: 14px; + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + background: var(--bg); + display: flex; + flex-direction: column; + gap: 12px; +} +.subpanel[hidden] { display: none; } + +/* Tool detection chips (installed / missing). */ +.env-checks { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; } +.env-checks .tool { + font-size: 12px; + padding: 3px 10px; + border-radius: var(--r-pill, 999px); + border: 1px solid var(--border); +} +.env-checks .tool.ok { color: var(--green); border-color: var(--green); background: var(--green-soft); } +.env-checks .tool.missing { color: var(--muted); border-color: var(--border); } + +/* Session row: results link + optional Resume button side by side. */ +.session-row { display: flex; align-items: stretch; gap: 8px; } +.session-row .linkish { flex: 1 1 auto; } +.session-row .ghost.small { align-self: center; white-space: nowrap; } diff --git a/src/adapters/agents.ts b/src/adapters/agents.ts index d985e89..2ee179c 100644 --- a/src/adapters/agents.ts +++ b/src/adapters/agents.ts @@ -39,15 +39,23 @@ export interface Actor { /** Draft or revise the plan. `feedback` carries critic blockers on revision. */ draftPlan(goal: string, feedback?: Finding[]): Promise; - /** Build the task, or fix it given feedback from a previous failed attempt. */ - build(task: TaskSpec, feedback?: BuildFeedback): Promise; + /** + * Build the task, or fix it given feedback from a previous failed attempt. + * `cwd` is the working directory the build should operate in — the task's + * isolated git worktree when worktrees are enabled — so a file-editing runner + * edits inside that worktree rather than a shared/default directory. It is + * passed per call (not bound to the role) so parallel tasks in different + * worktrees never collide. + */ + build(task: TaskSpec, feedback?: BuildFeedback, cwd?: string): Promise; /** * Degraded self-review used ONLY when the critic is unavailable (quota dry). * Returns raw text in the same contract the critic uses, so it parses - * identically -- but the result is recorded as UNVERIFIED_BY_CRITIC. + * identically -- but the result is recorded as UNVERIFIED_BY_CRITIC. `cwd` is + * the task's working directory (see {@link Actor.build}). */ - selfReview(bundle: TaskArtifactBundle): Promise; + selfReview(bundle: TaskArtifactBundle, cwd?: string): Promise; } export type CriticRequest = @@ -63,5 +71,6 @@ export interface CriticRawResponse { /** The CRITIC role: reviews the plan + each task. The scarce, gating resource. */ export interface Critic { - review(req: CriticRequest): Promise; + /** Review a plan or a task. `cwd` is the task's working directory (worktree). */ + review(req: CriticRequest, cwd?: string): Promise; } diff --git a/src/adapters/runnerRoles.ts b/src/adapters/runnerRoles.ts index 1940b3a..f9d57ea 100644 --- a/src/adapters/runnerRoles.ts +++ b/src/adapters/runnerRoles.ts @@ -102,12 +102,13 @@ export class RunnerActor implements Actor { return out.notes !== undefined ? { plan, notes: out.notes } : { plan }; } - async build(task: TaskSpec, feedback?: BuildFeedback): Promise { + async build(task: TaskSpec, feedback?: BuildFeedback, cwd?: string): Promise { const prompt = this.prompts.build(task, feedback); const out = await this.runStructured( prompt, ActorBuildOutputSchema, `build of task "${task.id}"`, + cwd ?? this.cwd, ); return { diff: out.diff, @@ -121,10 +122,10 @@ export class RunnerActor implements Actor { * contract so the engine parses it identically and records the outcome as * UNVERIFIED_BY_CRITIC. */ - async selfReview(bundle: TaskArtifactBundle): Promise { + async selfReview(bundle: TaskArtifactBundle, cwd?: string): Promise { const res = await this.runner.run({ prompt: this.prompts.selfReview(bundle), - cwd: this.cwd, + cwd: cwd ?? this.cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), }); @@ -136,10 +137,11 @@ export class RunnerActor implements Actor { prompt: string, schema: S, what: string, + cwd: string = this.cwd, ): Promise> { let res = await this.runner.run({ prompt, - cwd: this.cwd, + cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), }); @@ -154,7 +156,7 @@ export class RunnerActor implements Actor { this.log?.(`actor ${what} output unparseable (${parsed.error}); retrying once`); res = await this.runner.run({ prompt: `${prompt}\n\n${PARSE_REPAIR_NUDGE}`, - cwd: this.cwd, + cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), }); @@ -193,14 +195,14 @@ export class RunnerCritic implements Critic { * `repairHint` the engine passes on its single retry is forwarded into the * prompt so the backend gets the corrective nudge. */ - async review(req: CriticRequest): Promise { + async review(req: CriticRequest, cwd?: string): Promise { const prompt = req.kind === "plan" ? this.prompts.planReview(req.goal, req.plan, req.repairHint) : this.prompts.taskReview(req.bundle, req.repairHint); const res = await this.runner.run({ prompt, - cwd: this.cwd, + cwd: cwd ?? this.cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), }); diff --git a/src/config.ts b/src/config.ts index fc5873b..84d9005 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,26 @@ function defaultDbPath(): string { ); } +/** + * Validates a string as a safe git ref name (branch/prefix). This is the + * fail-fast boundary: a ref with whitespace or an invalid token would otherwise + * blow up much later during branch creation / push / PR, after a whole session + * has run. Mirrors the core rules `git check-ref-format` enforces. An empty + * string is allowed (callers treat it as "use the default"). + */ +const GIT_REF_CHARS = /^[A-Za-z0-9._/-]+$/; +function isSafeRefName(s: string): boolean { + if (s === "") return true; // empty => use default downstream + if (!GIT_REF_CHARS.test(s)) return false; // rejects spaces, ~^:?*[\ , etc. + if (s.includes("..") || s.includes("//")) return false; + if (s.startsWith("/") || s.endsWith("/")) return false; + if (s.startsWith(".") || s.endsWith(".")) return false; + if (s.endsWith(".lock")) return false; + return true; +} +const REF_NAME_MESSAGE = + "must be a valid git ref name (no spaces or any of ~^:?*[\\, no .. // leading/trailing . or /)"; + /** * Runner profiles supplied as a JSON array string (env-friendly), e.g. * `LOOPWRIGHT_RUNNERS='[{"id":"primary","kind":"cli","model":"m","options":{...}}]'`. @@ -85,6 +105,46 @@ const RawConfigSchema = z.object({ stuckThresholdMs: z.coerce.number().int().min(1000).default(120_000), useWorktrees: EnvBoolean.default(true), + // repository + branch naming + /** + * Default repository directory for runs. Usually supplied per-run via the API + * body (`repoDir`); this env fallback lets the headless server target a repo + * without a UI. Empty means "no repo" (runs build in cwd without worktrees). + */ + repoDir: z.string().default(""), + /** prefix for task + integration branch names: `//` */ + branchPrefix: z + .string() + .default("loopwright") + .refine((s) => s !== "" && isSafeRefName(s), { message: REF_NAME_MESSAGE }), + + // publishing (push + PR). All opt-in and OFF by default so a run never + // touches a remote unless the user explicitly asked for it. + /** when false, build + integrate locally but never push (Req: dry run) */ + dryRun: EnvBoolean.default(false), + /** push the integration branch to a remote after a successful integration */ + pushToRemote: EnvBoolean.default(false), + /** git remote to push to */ + remote: z.string().default("origin"), + /** override the remote branch name to push to (default: the integration branch) */ + pushBranch: z.string().default("").refine(isSafeRefName, { message: REF_NAME_MESSAGE }), + /** open a pull request after pushing (requires the gh CLI or a token) */ + openPr: EnvBoolean.default(false), + /** base branch for the pull request (default: the remote's default branch) */ + prBase: z.string().default("").refine(isSafeRefName, { message: REF_NAME_MESSAGE }), + /** pull request title (default: derived from the goal) */ + prTitle: z.string().default(""), + /** pull request body (default: a generated summary) */ + prBody: z.string().default(""), + /** open the pull request as a draft (recommended default) */ + prDraft: EnvBoolean.default(true), + /** + * Push even when the safety gate would refuse (failed integration, merge + * conflicts, failed verification, or tasks needing a human). Off by default + * so a broken run never reaches a remote without an explicit override. + */ + pushOverrideSafety: EnvBoolean.default(false), + dbPath: z.string().default(defaultDbPath()), }); @@ -108,6 +168,18 @@ export function loadConfig( maxParallel: env[`${ENV_PREFIX}MAX_PARALLEL`], stuckThresholdMs: env[`${ENV_PREFIX}STUCK_THRESHOLD_MS`], useWorktrees: env[`${ENV_PREFIX}USE_WORKTREES`], + repoDir: env[`${ENV_PREFIX}REPO_DIR`], + branchPrefix: env[`${ENV_PREFIX}BRANCH_PREFIX`], + dryRun: env[`${ENV_PREFIX}DRY_RUN`], + pushToRemote: env[`${ENV_PREFIX}PUSH_TO_REMOTE`], + remote: env[`${ENV_PREFIX}REMOTE`], + pushBranch: env[`${ENV_PREFIX}PUSH_BRANCH`], + openPr: env[`${ENV_PREFIX}OPEN_PR`], + prBase: env[`${ENV_PREFIX}PR_BASE`], + prTitle: env[`${ENV_PREFIX}PR_TITLE`], + prBody: env[`${ENV_PREFIX}PR_BODY`], + prDraft: env[`${ENV_PREFIX}PR_DRAFT`], + pushOverrideSafety: env[`${ENV_PREFIX}PUSH_OVERRIDE_SAFETY`], dbPath: env[`${ENV_PREFIX}DB_PATH`], }); } diff --git a/src/engine/loop.ts b/src/engine/loop.ts index 4c9fbee..74ebd3e 100644 --- a/src/engine/loop.ts +++ b/src/engine/loop.ts @@ -118,8 +118,9 @@ type ReviewOutcome = async function askCritic( critic: Critic, baseReq: CriticRequest, + cwd: string, ): Promise<{ resp: CriticRawResponse; req: CriticRequest }> { - const resp = await critic.review(baseReq); + const resp = await critic.review(baseReq, cwd); return { resp, req: baseReq }; } @@ -133,7 +134,7 @@ async function fallbackUnavailable( } // actor self-review: best-effort, clearly marked as NOT a real critic pass. try { - const resp = await deps.actor.selfReview(bundle); + const resp = await deps.actor.selfReview(bundle, deps.cwd); const parsed = parseCriticResponse(resp.text); // Only carry NON-blocking findings forward: in the degraded path these // become informational nits, and a blocker mislabeled as a nit would be @@ -158,7 +159,7 @@ async function obtainTaskReview( bundle: TaskArtifactBundle, ): Promise { // attempt 1 - let { resp } = await askCritic(deps.critic, { kind: "task", bundle }); + let { resp } = await askCritic(deps.critic, { kind: "task", bundle }, deps.cwd); if (resp.quotaExhausted) return fallbackUnavailable(deps, bundle); let parsed = parseCriticResponse(resp.text); @@ -169,7 +170,7 @@ async function obtainTaskReview( kind: "task", bundle, repairHint: REPAIR_HINT, - })).resp; + }, deps.cwd)).resp; if (resp.quotaExhausted) return fallbackUnavailable(deps, bundle); parsed = parseCriticResponse(resp.text); if (!parsed.ok) { @@ -255,7 +256,7 @@ export async function runTask( buildAttempts++; let guardedBuild: Guarded; try { - guardedBuild = await guardProgress(deps.actor.build(task, feedback), stuckThresholdMs); + guardedBuild = await guardProgress(deps.actor.build(task, feedback, deps.cwd), stuckThresholdMs); } catch (err) { // An actor/model failure (quota, unusable output, transport) is // task-fatal, not session-fatal: degrade this task to NEEDS_HUMAN @@ -412,13 +413,13 @@ async function obtainPlanReview( goal: string, plan: Plan, ): Promise { - let resp = await deps.critic.review({ kind: "plan", goal, plan }); + let resp = await deps.critic.review({ kind: "plan", goal, plan }, deps.cwd); if (resp.quotaExhausted) { return { kind: "unavailable", selfReviewNotes: [], reason: "Critic unavailable for plan review." }; } let parsed = parseCriticResponse(resp.text); if (!parsed.ok) { - resp = await deps.critic.review({ kind: "plan", goal, plan, repairHint: REPAIR_HINT }); + resp = await deps.critic.review({ kind: "plan", goal, plan, repairHint: REPAIR_HINT }, deps.cwd); if (resp.quotaExhausted) { return { kind: "unavailable", selfReviewNotes: [], reason: "Critic unavailable for plan review." }; } diff --git a/src/engine/publisher.ts b/src/engine/publisher.ts new file mode 100644 index 0000000..c364c33 --- /dev/null +++ b/src/engine/publisher.ts @@ -0,0 +1,298 @@ +import { spawn } from "node:child_process"; +import { git, GitError, remoteUrl, spawnGit, type GitExec } from "../workspace/git.js"; +import type { IntegrationResult } from "./integrator.js"; + +/** + * Publisher (push + pull request). + * + * After a run integrates its per-task branches onto a single integration branch + * (see integrator.ts), this module optionally pushes that branch to a git + * remote and opens a pull request. It is the only place that talks to a REMOTE, + * and it does so behind a hard SAFETY GATE: a run that did not cleanly merge and + * verify must never reach a remote unless the user explicitly overrides. + * + * Design rules: + * - Never pushes to a default/protected branch. We push the integration + * branch under its own name; opening (and merging) a PR is the human's call. + * - All errors are turned into a structured `PublishResult`, never thrown, so + * a failed push (missing remote, auth) surfaces as a clear status the UI can + * render rather than crashing the run after the work succeeded. + * - git and the PR creator are injectable so this is unit-testable without a + * network or the gh CLI. + */ + +/** Why a publish was refused by the safety gate (item 10), or undefined if safe. */ +export type PublishRefusal = + | "integration-failed" + | "merge-conflicts" + | "verification-failed" + | "needs-human" + | "no-remote"; + +export interface PublishOptions { + repoDir: string; + /** the local integration branch to publish */ + branch: string; + /** remote name (default "origin") */ + remote?: string; + /** remote branch name to push to (default: same as `branch`) */ + pushBranch?: string; + /** whether to push at all */ + push: boolean; + /** whether to open a PR after a successful push */ + openPr?: boolean; + prBase?: string; + prTitle?: string; + prBody?: string; + prDraft?: boolean; + /** push even when the safety gate would refuse */ + overrideSafety?: boolean; + /** integration outcome used by the safety gate */ + integration?: IntegrationResult; + /** task ids still needing a human (blocks push unless overridden) */ + needsHuman?: string[]; + git?: GitExec; + prCreator?: PrCreator; + signal?: AbortSignal; + log?: (line: string) => void; +} + +export interface PrInfo { + created: boolean; + url?: string; + error?: string; +} + +export interface PublishResult { + /** true once the branch was pushed to the remote */ + pushed: boolean; + remote: string; + /** local branch published */ + branch: string; + /** remote branch it was pushed to */ + pushBranch: string; + remoteUrl?: string; + /** set when the safety gate (or a missing remote) blocked the push */ + refused?: PublishRefusal; + /** human-readable reason, including failed-command output when relevant */ + reason?: string; + /** present when openPr was requested and a push succeeded */ + pr?: PrInfo; + /** push error message (auth, network, protected branch, …) */ + error?: string; +} + +/** Creates a pull request for an already-pushed branch. Injectable for tests. */ +export type PrCreator = (input: { + repoDir: string; + head: string; + base?: string; + title: string; + body: string; + draft: boolean; + signal?: AbortSignal; +}) => Promise<{ url?: string }>; + +/** Result of the safety gate: a refusal code + message, or `undefined` if safe. */ +export function publishSafety(opts: { + integration?: IntegrationResult; + needsHuman?: string[]; +}): { refusal: PublishRefusal; reason: string } | undefined { + const { integration, needsHuman } = opts; + if (integration) { + if (integration.conflicts.length > 0) { + const branches = integration.conflicts.map((c) => `${c.taskId} (${c.branch})`).join(", "); + return { + refusal: "merge-conflicts", + reason: `Refusing to push: ${integration.conflicts.length} branch(es) had merge conflicts: ${branches}.`, + }; + } + if (integration.verification && integration.verification.passed === false) { + const failed = integration.verification.steps.find((s) => !s.passed); + const detail = failed + ? `\nFailed command: ${failed.command} (exit ${failed.exitCode})\n${failed.output}` + : ""; + return { + refusal: "verification-failed", + reason: `Refusing to push: full-tree verification failed after merge.${detail}`, + }; + } + if (!integration.ok) { + return { refusal: "integration-failed", reason: "Refusing to push: integration did not succeed." }; + } + } + if (needsHuman && needsHuman.length > 0) { + return { + refusal: "needs-human", + reason: `Refusing to push: ${needsHuman.length} task(s) need a human: ${needsHuman.join(", ")}.`, + }; + } + return undefined; +} + +/** + * Pushes the integration branch and (optionally) opens a PR, after enforcing + * the safety gate. Always resolves with a structured result; never throws. + */ +export async function publish(opts: PublishOptions): Promise { + const exec = opts.git ?? spawnGit; + const remote = opts.remote || "origin"; + const branch = opts.branch; + const pushBranch = opts.pushBranch || branch; + const base: PublishResult = { pushed: false, remote, branch, pushBranch }; + + if (!opts.push) { + return { ...base, refused: undefined, reason: "push not requested" }; + } + + // Safety gate (item 10): refuse a push for a run that did not cleanly merge + // and verify, or that has tasks needing a human — unless explicitly overridden. + const safety = publishSafety({ + ...(opts.integration ? { integration: opts.integration } : {}), + ...(opts.needsHuman ? { needsHuman: opts.needsHuman } : {}), + }); + if (safety && !opts.overrideSafety) { + opts.log?.(safety.reason); + return { ...base, refused: safety.refusal, reason: safety.reason }; + } + if (safety && opts.overrideSafety) { + opts.log?.(`Safety gate overridden: ${safety.reason}`); + } + + // A push to a non-existent remote fails with an opaque git error; check first + // so we can return a precise, actionable message. A real git failure (broken + // repo, missing binary) throws and is surfaced as an error, not "no remote". + let url: string | undefined; + try { + url = await remoteUrl(opts.repoDir, remote, exec); + } catch (err) { + const message = err instanceof GitError ? err.message : String((err as Error)?.message ?? err); + opts.log?.(`could not resolve remote "${remote}": ${message}`); + return { ...base, error: message }; + } + if (!url) { + const reason = `Remote "${remote}" is not configured. Add it (e.g. git remote add ${remote} ) and try again.`; + opts.log?.(reason); + return { ...base, refused: "no-remote", reason }; + } + // Never persist credentials: HTTPS remotes can embed user:token, and this URL + // is recorded verbatim as a publish event (store + UI). Strip any userinfo. + const safeUrl = redactRemoteUrl(url); + + if (opts.signal?.aborted) { + return { ...base, remoteUrl: safeUrl, error: "cancelled before push" }; + } + + try { + // Push the integration branch under the chosen remote name. We never push + // to a default/protected branch here; the user merges via the PR. + await git(exec, ["push", "--set-upstream", remote, `${branch}:${pushBranch}`], opts.repoDir); + opts.log?.(`pushed ${branch} -> ${remote}/${pushBranch}`); + } catch (err) { + const message = err instanceof GitError ? err.message : String((err as Error)?.message ?? err); + opts.log?.(`push failed: ${message}`); + return { ...base, remoteUrl: safeUrl, error: message }; + } + + const result: PublishResult = { ...base, pushed: true, remoteUrl: safeUrl }; + + if (opts.openPr) { + // If the run was cancelled after the push, don't still open a PR. + if (opts.signal?.aborted) { + result.pr = { created: false, error: "cancelled before PR creation" }; + return result; + } + const creator = opts.prCreator ?? ghPrCreator; + const title = opts.prTitle?.trim() || `Loopwright: ${branch}`; + const body = opts.prBody?.trim() || "Opened by Loopwright after a verified actor-critic run."; + try { + const { url: prUrl } = await creator({ + repoDir: opts.repoDir, + head: pushBranch, + ...(opts.prBase ? { base: opts.prBase } : {}), + title, + body, + draft: opts.prDraft ?? true, + ...(opts.signal ? { signal: opts.signal } : {}), + }); + result.pr = { created: true, ...(prUrl ? { url: prUrl } : {}) }; + opts.log?.(`opened PR${prUrl ? `: ${prUrl}` : ""}`); + } catch (err) { + const message = String((err as Error)?.message ?? err); + result.pr = { created: false, error: message }; + opts.log?.(`PR creation failed: ${message}`); + } + } + + return result; +} + +const GH_URL_RE = /https?:\/\/\S+/; + +/** + * Strips any embedded credentials (`user:token@`) from a remote URL so they are + * never written to the event store or shown in the UI. Returns the input + * unchanged when it isn't a parseable URL (e.g. an SSH `git@host:org/repo` + * form, which carries no secret). + */ +export function redactRemoteUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + } + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Default PR creator: shells out to the GitHub CLI (`gh pr create`). Requires + * `gh` to be installed and authenticated (`gh auth status`). Throws a clear + * error if `gh` is missing or the command fails, which `publish` captures into + * `pr.error` for the UI. + */ +export const ghPrCreator: PrCreator = ({ repoDir, head, base, title, body, draft, signal }) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("cancelled before PR creation")); + return; + } + const args = ["pr", "create", "--head", head, "--title", title, "--body", body]; + if (base) args.push("--base", base); + if (draft) args.push("--draft"); + + const child = spawn("gh", args, { cwd: repoDir, shell: false }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (b: Buffer) => (stdout += b.toString())); + child.stderr.on("data", (b: Buffer) => (stderr += b.toString())); + const onAbort = (): void => { + child.kill(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + child.on("error", (err) => { + signal?.removeEventListener("abort", onAbort); + reject( + new Error( + err.message.includes("ENOENT") + ? "GitHub CLI (gh) not found. Install it or open the PR manually." + : err.message, + ), + ); + }); + child.on("close", (code, signalName) => { + signal?.removeEventListener("abort", onAbort); + if (code === 0) { + const match = stdout.match(GH_URL_RE); + resolve({ ...(match ? { url: match[0] } : {}) }); + } else if (signal?.aborted || signalName) { + // Killed by our abort handler (or any signal) rather than a real failure. + reject(new Error("cancelled during PR creation")); + } else { + reject(new Error(stderr.trim() || stdout.trim() || `gh pr create exited ${code}`)); + } + }); + }); diff --git a/src/observability/events.ts b/src/observability/events.ts index 862e561..dcb6bc9 100644 --- a/src/observability/events.ts +++ b/src/observability/events.ts @@ -38,6 +38,7 @@ export const EVENT_TYPES = { runnerCall: "runner_call", sessionFinished: "session_finished", integration: "integration", + publish: "publish", sessionFailed: "session_failed", sessionInterrupted: "session_interrupted", } as const; diff --git a/src/server/server.ts b/src/server/server.ts index 23c05d7..163b63e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -11,6 +11,7 @@ import type { Rates } from "../observability/usage.js"; import type { LoopObserver } from "../engine/loop.js"; import { RunHub, type RunStatusData } from "./hub.js"; import { tapStoreEvents } from "./store-tap.js"; +import { isGitRepo } from "../workspace/git.js"; /** * The engine HTTP/SSE server (Task 25.1). @@ -44,6 +45,13 @@ export interface StartRunBody { /** resume an existing session id (reuses completed tasks) */ sessionId?: string; resume?: boolean; + /** + * Absolute path to a local git repository the run should build against. When + * set (and worktrees are enabled), tasks build in isolated worktrees off this + * repo and the result is integrated (and optionally pushed). Validated to be + * a git working tree before the run starts. Falls back to LOOPWRIGHT_REPO_DIR. + */ + repoDir?: string; } export type RunGoalImpl = ( @@ -354,6 +362,32 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { } runConfig.dbPath = config.dbPath; + // Effective repo: the per-run body value wins, falling back to the env + // default. The body is untyped JSON, so reject a non-string up front (a + // number would otherwise throw on .trim() and turn a bad request into a + // 500). It must be an absolute path (the field is documented as such, and a + // relative path would resolve against the server's cwd, not the caller's), + // and it must be a git working tree — validate before the run so it fails + // with a clear 400 rather than deep inside worktree setup. + if (body.repoDir !== undefined && typeof body.repoDir !== "string") { + return send(res, 400, { error: "repoDir must be a string" }, cors); + } + const repoDir = + (typeof body.repoDir === "string" ? body.repoDir.trim() : "") || runConfig.repoDir.trim(); + if (repoDir) { + if (!path.isAbsolute(repoDir)) { + return send(res, 400, { error: `repoDir must be an absolute path (got "${repoDir}")` }, cors); + } + if (!(await isGitRepo(repoDir))) { + return send( + res, + 400, + { error: `repoDir "${repoDir}" is not a git repository (run git init or pick another folder)` }, + cors, + ); + } + } + // A caller-supplied session id becomes part of git branch names and // worktree paths, so reject anything outside the bounded safe format before // it reaches the filesystem/git. New runs without an id get a safe UUID. @@ -423,6 +457,7 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { observer, log: (line) => void hub.publish(sessionId, "log", { line }), signal: controller.signal, + ...(repoDir ? { repoDir } : {}), }) .then((result) => { hub.publish(sessionId, "status", { phase: "done", result } satisfies RunStatusData); diff --git a/src/session.ts b/src/session.ts index 960c9df..a61656d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -10,7 +10,9 @@ import { } from "./engine/loop.js"; import { runScheduledTasks, type SchedulerDeps } from "./engine/scheduler.js"; import { integrate, type IntegrationResult } from "./engine/integrator.js"; +import { publish, type PublishResult, type PrCreator } from "./engine/publisher.js"; import { GitWorktreeManager } from "./workspace/worktrees.js"; +import type { GitExec } from "./workspace/git.js"; import type { IntegrationBranch } from "./engine/integrator.js"; import type { CommandExecutor } from "./engine/mechanicalGate.js"; import type { Store, SessionStatus } from "./storage/store.js"; @@ -56,6 +58,8 @@ export interface SessionResult { allVerified: boolean; /** present when worktrees were used: merge + full-verification result */ integration?: IntegrationResult; + /** present when publishing was requested: push + PR result */ + publish?: PublishResult; } export interface RunGoalOptions extends CreateRolesOptions { @@ -81,6 +85,10 @@ export interface RunGoalOptions extends CreateRolesOptions { repoDir?: string; /** cooperative cancellation: aborts scheduling, gate commands, and the run */ signal?: AbortSignal; + /** injectable git transport for worktrees/integration/publish (tests) */ + git?: GitExec; + /** injectable PR creator for the publisher (defaults to the gh CLI) */ + prCreator?: PrCreator; } export async function runGoal( @@ -98,6 +106,8 @@ export async function runGoal( onTaskSettled, repoDir, signal, + git: gitExec, + prCreator, ...roleOpts } = opts; const cwd = opts.cwd ?? "."; @@ -185,8 +195,14 @@ export async function runGoal( // const so its non-undefined narrowing holds inside the closures below; // `wtManager` mirrors it for the `finally` cleanup. const useWorktrees = Boolean(repoDir) && config.useWorktrees; + const wtSessionId = sessionId ?? randomUUID(); const wt = useWorktrees - ? new GitWorktreeManager({ repoDir: repoDir as string, sessionId: sessionId ?? randomUUID() }) + ? new GitWorktreeManager({ + repoDir: repoDir as string, + sessionId: wtSessionId, + branchPrefix: config.branchPrefix, + ...(gitExec ? { git: gitExec } : {}), + }) : undefined; wtManager = wt; const greenBranches: IntegrationBranch[] = []; @@ -258,16 +274,55 @@ export async function runGoal( .flatMap((t) => t.verifyCommands), ), ]; + // Named integration branch (item 12): `//`, + // overridable via the branch prefix. This is the branch the publisher + // pushes, so a meaningful name is what shows up on the remote / in a PR. + const integrationBranch = `${config.branchPrefix}/${slug(wtSessionId)}/${goalSlug(goal)}`; integration = await integrate({ repoDir: repoDir as string, branches: greenBranches, + integrationBranch, ...(verifyCommands.length ? { verifyCommands } : {}), ...(executor ? { executor } : {}), + ...(gitExec ? { git: gitExec } : {}), ...(signal ? { signal } : {}), ...(opts.log ? { log: opts.log } : {}), }); } + // Publish (push + optional PR), opt-in via config and gated by the safety + // checks in the publisher. A dry run builds + integrates locally but never + // pushes (item 13). Publishing only applies to worktree runs, which produce + // the integration branch the publisher pushes. + let publishResult: PublishResult | undefined; + const shouldPublish = + Boolean(integration) && config.pushToRemote && !config.dryRun && Boolean(repoDir); + if (integration && shouldPublish) { + publishResult = await publish({ + repoDir: repoDir as string, + branch: integration.integrationBranch, + remote: config.remote, + ...(config.pushBranch ? { pushBranch: config.pushBranch } : {}), + push: true, + openPr: config.openPr, + ...(config.prBase ? { prBase: config.prBase } : {}), + prTitle: config.prTitle || `Loopwright: ${goal}`, + ...(config.prBody ? { prBody: config.prBody } : {}), + prDraft: config.prDraft, + overrideSafety: config.pushOverrideSafety, + integration, + needsHuman, + ...(gitExec ? { git: gitExec } : {}), + ...(prCreator ? { prCreator } : {}), + ...(signal ? { signal } : {}), + ...(opts.log ? { log: opts.log } : {}), + }); + } else if (integration && config.dryRun && config.pushToRemote) { + opts.log?.( + `dry run: integration branch ${integration.integrationBranch} created locally; skipping push`, + ); + } + // Durable final status is decided LAST, so an integration that surfaced // conflicts or failed verification (integration.ok === false) marks the // session needs_human rather than leaving the earlier "completed" optimism. @@ -286,6 +341,14 @@ export async function runGoal( }, }); } + if (publishResult) { + await store.recordEvent({ + sessionId, + at: new Date().toISOString(), + type: EVENT_TYPES.publish, + data: publishResult as unknown as Record, + }); + } await store.updateSession(sessionId, { status: finalSessionStatus(needsHuman.length, integration), }); @@ -302,6 +365,7 @@ export async function runGoal( skipped, allVerified: green.length === plan.plan.tasks.length, ...(integration ? { integration } : {}), + ...(publishResult ? { publish: publishResult } : {}), }; } catch (err) { // Any throw (planning, runner execution, worktree setup, integration, @@ -352,6 +416,26 @@ export function finalSessionStatus( return "completed"; } +/** Filesystem/branch-safe slug for an arbitrary token (e.g. a session id). */ +function slug(token: string): string { + return token.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +/** + * Turns a goal sentence into a short, branch-safe slug for the integration + * branch name (item 12), e.g. "Add a /healthz endpoint" -> "add-a-healthz-endpoint". + * Bounded so a long goal can't produce an unwieldy ref. + */ +function goalSlug(goal: string): string { + const s = goal + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50) + .replace(/-+$/g, ""); + return s || "run"; +} + /** * Marks any session left as `running` (e.g. because the engine process was * killed/restarted mid-run, bypassing the normal failure path) as `failed`, diff --git a/src/workspace/git.ts b/src/workspace/git.ts index 98c4ad3..c04e2a7 100644 --- a/src/workspace/git.ts +++ b/src/workspace/git.ts @@ -47,3 +47,72 @@ export async function git(exec: GitExec, args: string[], cwd: string): Promise { + try { + const res = await exec(["rev-parse", "--is-inside-work-tree"], dir); + return res.exitCode === 0 && res.stdout.trim() === "true"; + } catch { + return false; + } +} + +/** + * Returns the configured fetch URL for a named remote, or `undefined` when the + * remote does not exist. Distinguishes a genuinely-missing remote (returns + * `undefined`) from a real git execution failure (throws `GitError`), so the + * publisher doesn't misreport a broken repo / missing binary as "no remote". + */ +export async function remoteUrl( + dir: string, + remote: string, + exec: GitExec = spawnGit, +): Promise { + const res = await exec(["remote", "get-url", remote], dir); + if (res.exitCode === 0) { + const url = res.stdout.trim(); + return url === "" ? undefined : url; + } + // "No such remote" is the expected not-configured signal; anything else is a + // real failure we must surface rather than mask as a missing remote. + const message = `${res.stderr}\n${res.stdout}`.toLowerCase(); + if (message.includes("no such remote")) return undefined; + throw new GitError(`git remote get-url ${remote} failed (exit ${res.exitCode})`, res); +} + +/** Whether the named remote is configured on the repo at `dir`. */ +export async function hasRemote( + dir: string, + remote: string, + exec: GitExec = spawnGit, +): Promise { + try { + return (await remoteUrl(dir, remote, exec)) !== undefined; + } catch { + // A predicate must stay total: an execution failure means "not usable". + return false; + } +} + +/** The short subject line of the latest commit on `ref` (default HEAD). */ +export async function commitCount( + dir: string, + ref: string, + baseRef: string | undefined, + exec: GitExec = spawnGit, +): Promise { + try { + const range = baseRef ? `${baseRef}..${ref}` : ref; + const res = await exec(["rev-list", "--count", range], dir); + if (res.exitCode !== 0) return 0; + return Number.parseInt(res.stdout.trim(), 10) || 0; + } catch { + return 0; + } +} diff --git a/test/config.test.ts b/test/config.test.ts index 80b3dfb..35a9815 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -26,3 +26,19 @@ describe("config env boolean parsing", () => { expect(c.useWorktrees).toBe(true); }); }); + +describe("config git-ref validation (fail-fast)", () => { + it("accepts valid ref names and applies defaults", () => { + const c = loadConfig({ LOOPWRIGHT_BRANCH_PREFIX: "team/loopwright", LOOPWRIGHT_PR_BASE: "release/v1" }); + expect(c.branchPrefix).toBe("team/loopwright"); + expect(c.prBase).toBe("release/v1"); + expect(loadConfig({}).branchPrefix).toBe("loopwright"); + }); + + it("rejects a branch prefix with whitespace or invalid ref tokens", () => { + expect(() => loadConfig({ LOOPWRIGHT_BRANCH_PREFIX: "bad prefix" })).toThrow(); + expect(() => loadConfig({ LOOPWRIGHT_BRANCH_PREFIX: "" })).toThrow(); + expect(() => loadConfig({ LOOPWRIGHT_PUSH_BRANCH: "feat..x" })).toThrow(); + expect(() => loadConfig({ LOOPWRIGHT_PR_BASE: "what?*" })).toThrow(); + }); +}); diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..280789f --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { + commitCount, + hasRemote, + isGitRepo, + remoteUrl, + spawnGit, +} from "../src/workspace/git.js"; + +/** Initializes a throwaway git repo and returns its path. */ +async function initRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "loopwright-git-")); + const run = (args: string[]) => spawnGit(args, dir); + await run(["init", "-q", "-b", "main"]); + await run(["config", "user.email", "test@example.com"]); + await run(["config", "user.name", "Test"]); + await writeFile(join(dir, "base.txt"), "base\n"); + await run(["add", "-A"]); + await run(["commit", "-q", "-m", "init"]); + return dir; +} + +describe("git helpers", () => { + let repo: string; + beforeEach(async () => { + repo = await initRepo(); + }); + + it("isGitRepo is true inside a repo and false elsewhere", async () => { + expect(await isGitRepo(repo)).toBe(true); + const plain = await mkdtemp(join(tmpdir(), "loopwright-plain-")); + expect(await isGitRepo(plain)).toBe(false); + // A non-existent path must resolve to false, not throw. + expect(await isGitRepo(join(plain, "does-not-exist"))).toBe(false); + }); + + it("reports remotes via hasRemote / remoteUrl", async () => { + expect(await hasRemote(repo, "origin")).toBe(false); + expect(await remoteUrl(repo, "origin")).toBeUndefined(); + + await spawnGit(["remote", "add", "origin", "https://example.com/x.git"], repo); + expect(await hasRemote(repo, "origin")).toBe(true); + expect(await remoteUrl(repo, "origin")).toBe("https://example.com/x.git"); + }); + + it("counts commits in a range", async () => { + // One commit on HEAD so far. + expect(await commitCount(repo, "HEAD", undefined)).toBe(1); + + await spawnGit(["checkout", "-q", "-b", "feature"], repo); + await writeFile(join(repo, "feature.txt"), "x\n"); + await spawnGit(["add", "-A"], repo); + await spawnGit(["commit", "-q", "-m", "feature work"], repo); + + // feature is one commit ahead of main. + expect(await commitCount(repo, "feature", "main")).toBe(1); + }); +}); diff --git a/test/publisher.test.ts b/test/publisher.test.ts new file mode 100644 index 0000000..0a2ded8 --- /dev/null +++ b/test/publisher.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { publish, publishSafety, redactRemoteUrl, type PrCreator } from "../src/engine/publisher.js"; +import { spawnGit } from "../src/workspace/git.js"; +import type { IntegrationResult } from "../src/engine/integrator.js"; + +const okIntegration = (branch: string): IntegrationResult => ({ + merged: ["a"], + conflicts: [], + integrationBranch: branch, + verification: { passed: true, steps: [] }, + ok: true, +}); + +describe("publishSafety", () => { + it("passes for a clean integration with no human-needed tasks", () => { + expect(publishSafety({ integration: okIntegration("b"), needsHuman: [] })).toBeUndefined(); + expect(publishSafety({})).toBeUndefined(); + }); + + it("refuses on merge conflicts", () => { + const r = publishSafety({ + integration: { + merged: ["a"], + conflicts: [{ taskId: "b", branch: "br-b", files: ["x.ts"] }], + integrationBranch: "i", + ok: false, + }, + }); + expect(r?.refusal).toBe("merge-conflicts"); + }); + + it("refuses on failed verification and includes the failed command output", () => { + const r = publishSafety({ + integration: { + merged: ["a"], + conflicts: [], + integrationBranch: "i", + verification: { + passed: false, + steps: [{ command: "npm test", exitCode: 1, passed: false, durationMs: 5, output: "boom" }], + }, + ok: false, + }, + }); + expect(r?.refusal).toBe("verification-failed"); + expect(r?.reason).toContain("npm test"); + expect(r?.reason).toContain("boom"); + }); + + it("refuses when tasks need a human", () => { + const r = publishSafety({ integration: okIntegration("b"), needsHuman: ["t3"] }); + expect(r?.refusal).toBe("needs-human"); + }); +}); + +describe("redactRemoteUrl", () => { + it("strips embedded credentials from https remotes", () => { + expect(redactRemoteUrl("https://user:ghp_secret@github.com/o/r.git")).toBe( + "https://github.com/o/r.git", + ); + expect(redactRemoteUrl("https://x-access-token:tok@github.com/o/r.git")).not.toContain("tok"); + }); + + it("leaves credential-free and SSH remotes unchanged", () => { + expect(redactRemoteUrl("https://github.com/o/r.git")).toBe("https://github.com/o/r.git"); + // SSH scp-like syntax isn't a parseable URL and carries no secret. + expect(redactRemoteUrl("git@github.com:o/r.git")).toBe("git@github.com:o/r.git"); + }); +}); + +describe("publish (real git, bare remote)", () => { + let repo: string; + let remote: string; + + beforeEach(async () => { + remote = await mkdtemp(join(tmpdir(), "loopwright-remote-")); + await spawnGit(["init", "-q", "--bare", "-b", "main"], remote); + + repo = await mkdtemp(join(tmpdir(), "loopwright-pub-")); + await spawnGit(["init", "-q", "-b", "main"], repo); + await spawnGit(["config", "user.email", "t@example.com"], repo); + await spawnGit(["config", "user.name", "T"], repo); + await writeFile(join(repo, "base.txt"), "base\n"); + await spawnGit(["add", "-A"], repo); + await spawnGit(["commit", "-q", "-m", "init"], repo); + // Build an integration branch to publish. + await spawnGit(["checkout", "-q", "-b", "loopwright/s/feature"], repo); + await writeFile(join(repo, "feature.txt"), "x\n"); + await spawnGit(["add", "-A"], repo); + await spawnGit(["commit", "-q", "-m", "feature"], repo); + await spawnGit(["checkout", "-q", "main"], repo); + }); + + it("pushes the integration branch to the remote", async () => { + await spawnGit(["remote", "add", "origin", remote], repo); + const result = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + integration: okIntegration("loopwright/s/feature"), + }); + expect(result.pushed).toBe(true); + expect(result.error).toBeUndefined(); + + // The branch now exists on the bare remote. + const branches = await spawnGit(["branch", "--list"], remote); + expect(branches.stdout).toContain("loopwright/s/feature"); + }); + + it("returns a clear error when the remote is missing (no push)", async () => { + const result = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + integration: okIntegration("loopwright/s/feature"), + }); + expect(result.pushed).toBe(false); + expect(result.refused).toBe("no-remote"); + expect(result.reason).toContain("origin"); + }); + + it("refuses to push a failed integration unless overridden", async () => { + await spawnGit(["remote", "add", "origin", remote], repo); + const failed: IntegrationResult = { + merged: [], + conflicts: [{ taskId: "b", branch: "br", files: ["f"] }], + integrationBranch: "loopwright/s/feature", + ok: false, + }; + + const refused = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + integration: failed, + }); + expect(refused.pushed).toBe(false); + expect(refused.refused).toBe("merge-conflicts"); + + // The same push goes through with the explicit override. + const overridden = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + overrideSafety: true, + integration: failed, + }); + expect(overridden.pushed).toBe(true); + }); + + it("opens a PR via the injected creator after a successful push", async () => { + await spawnGit(["remote", "add", "origin", remote], repo); + const calls: Array> = []; + const prCreator: PrCreator = async (input) => { + calls.push(input as unknown as Record); + return { url: "https://example.com/pr/1" }; + }; + + const result = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + openPr: true, + prBase: "main", + prTitle: "My PR", + prDraft: true, + integration: okIntegration("loopwright/s/feature"), + prCreator, + }); + + expect(result.pushed).toBe(true); + expect(result.pr?.created).toBe(true); + expect(result.pr?.url).toBe("https://example.com/pr/1"); + expect(calls).toHaveLength(1); + expect(calls[0]!.head).toBe("loopwright/s/feature"); + expect(calls[0]!.base).toBe("main"); + expect(calls[0]!.draft).toBe(true); + }); + + it("captures a PR creation failure without failing the push", async () => { + await spawnGit(["remote", "add", "origin", remote], repo); + const prCreator: PrCreator = async () => { + throw new Error("gh not authenticated"); + }; + const result = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + openPr: true, + integration: okIntegration("loopwright/s/feature"), + prCreator, + }); + expect(result.pushed).toBe(true); + expect(result.pr?.created).toBe(false); + expect(result.pr?.error).toContain("gh not authenticated"); + }); + + it("redacts credentials from the remote URL it reports on a failed push", async () => { + // A credentialed HTTPS remote that cannot actually be pushed to; the push + // fails, but the reported remoteUrl must not leak the embedded token. + await spawnGit( + ["remote", "add", "origin", "https://user:supersecret@127.0.0.1:1/o/r.git"], + repo, + ); + const result = await publish({ + repoDir: repo, + branch: "loopwright/s/feature", + remote: "origin", + push: true, + integration: okIntegration("loopwright/s/feature"), + }); + expect(result.pushed).toBe(false); + expect(result.error).toBeTruthy(); + expect(result.remoteUrl ?? "").not.toContain("supersecret"); + }); +}); diff --git a/test/sessionPublish.test.ts b/test/sessionPublish.test.ts new file mode 100644 index 0000000..c7dda6e --- /dev/null +++ b/test/sessionPublish.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import { runGoal } from "../src/session.js"; +import { loadConfig } from "../src/config.js"; +import { MockRunner } from "../src/runners/mockRunner.js"; +import type { AgentRunner, RunnerProfile, RunRequest } from "../src/runners/agentRunner.js"; +import { criticGreen, scriptedExecutor } from "../src/adapters/mocks.js"; +import { spawnGit } from "../src/workspace/git.js"; +import { MemoryStore } from "../src/storage/store.js"; + +/** + * End-to-end worktree + integrate + publish test. The actor runner ACTUALLY + * edits a file inside the per-task worktree (proving the cwd now reaches the + * runner), the changes are committed on a task branch, integrated onto a named + * integration branch, and pushed to a bare remote. This exercises the full + * "select repo, run goal, update code, push to GitHub" path the desktop app + * drives, minus the UI. + */ + +const TASK_ID_RE = /id:\s*(\S+)/; + +const planJson = JSON.stringify({ + tasks: [ + { + id: "t1", + title: "Add a file", + description: "d", + acceptanceCriteria: ["file exists"], + verifyCommands: ["check"], + dependencies: [], + }, + ], +}); + +/** + * A runner that plays both roles. On a BUILD prompt it writes a real file into + * the task's worktree (req.cwd) and returns a build descriptor; plan + reviews + * return green. The file write is what the worktree later commits + integrates. + */ +function fileEditingRunner(profile: RunnerProfile): AgentRunner { + const respond = (req: RunRequest): string => { + const p = req.prompt; + if (p.includes("Decompose this goal")) return planJson; + if (p.includes("Build the following task")) { + const id = TASK_ID_RE.exec(p)?.[1] ?? "t?"; + // The crucial assertion of item 3/5: a file-editing runner edits files + // INSIDE the worktree it was handed, not a shared/default directory. + writeFileSync(join(req.cwd, `${id}.txt`), `built by ${id}\n`); + return JSON.stringify({ + diff: `--- /dev/null\n+++ b/${id}.txt`, + touchedFiles: [`${id}.txt`], + summary: `created ${id}.txt`, + }); + } + // plan review + task review both green + return criticGreen("looks good").text; + }; + return new MockRunner(profile, { respond }); +} + +const factory = (profile: RunnerProfile): AgentRunner => fileEditingRunner(profile); +const okExecutor = scriptedExecutor(() => ({ exitCode: 0, output: "ok" })); + +async function initRepoWithRemote(): Promise<{ repo: string; remote: string }> { + const remote = await mkdtemp(join(tmpdir(), "loopwright-e2e-remote-")); + await spawnGit(["init", "-q", "--bare", "-b", "main"], remote); + + const repo = await mkdtemp(join(tmpdir(), "loopwright-e2e-")); + await spawnGit(["init", "-q", "-b", "main"], repo); + await spawnGit(["config", "user.email", "t@example.com"], repo); + await spawnGit(["config", "user.name", "T"], repo); + await writeFile(join(repo, "README.md"), "# repo\n"); + await spawnGit(["add", "-A"], repo); + await spawnGit(["commit", "-q", "-m", "init"], repo); + await spawnGit(["remote", "add", "origin", remote], repo); + return { repo, remote }; +} + +function publishConfig(extra: Record = {}) { + return loadConfig({ + LOOPWRIGHT_RUNNERS: '[{"id":"primary","kind":"mock","model":"m"}]', + LOOPWRIGHT_ACTOR_RUNNER: "primary", + LOOPWRIGHT_CRITIC_RUNNER: "primary", + LOOPWRIGHT_USE_WORKTREES: "true", + LOOPWRIGHT_MAX_PARALLEL: "1", + ...extra, + }); +} + +describe("runGoal: worktree -> integrate -> publish", () => { + let repo: string; + let remote: string; + beforeEach(async () => { + ({ repo, remote } = await initRepoWithRemote()); + }); + + it("edits files in the worktree, integrates, and pushes to the remote", async () => { + const store = new MemoryStore(); + const config = publishConfig({ LOOPWRIGHT_PUSH_TO_REMOTE: "true" }); + + const result = await runGoal("Add a healthz file", config, { + factory, + executor: okExecutor, + store, + repoDir: repo, + }); + + expect(result.green).toEqual(["t1"]); + expect(result.integration?.ok).toBe(true); + expect(result.integration?.merged).toEqual(["t1"]); + // Branch is named from the goal slug (item 12). + expect(result.integration?.integrationBranch).toContain("add-a-healthz-file"); + + // Publish happened and pushed the integration branch to the bare remote. + expect(result.publish?.pushed).toBe(true); + const branches = await spawnGit(["branch", "--list"], remote); + expect(branches.stdout).toContain(result.integration!.integrationBranch); + + // The actor's file edit made it onto the integration branch. + const tree = await spawnGit( + ["ls-tree", "--name-only", result.integration!.integrationBranch], + repo, + ); + expect(tree.stdout).toContain("t1.txt"); + + // A publish event was recorded for the trace/UI. + const events = await store.listEvents(result.sessionId!); + expect(events.some((e) => e.type === "publish")).toBe(true); + }); + + it("dry run integrates locally but does not push", async () => { + const config = publishConfig({ + LOOPWRIGHT_PUSH_TO_REMOTE: "true", + LOOPWRIGHT_DRY_RUN: "true", + }); + + const result = await runGoal("Add a file", config, { + factory, + executor: okExecutor, + repoDir: repo, + }); + + expect(result.integration?.ok).toBe(true); + // No publish result on a dry run. + expect(result.publish).toBeUndefined(); + // Nothing was pushed to the remote. + const branches = await spawnGit(["branch", "--list"], remote); + expect(branches.stdout.trim()).toBe(""); + }); +});