diff --git a/docs/data-model.md b/docs/data-model.md index d8746912..55c10226 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -64,3 +64,14 @@ Only the durable default tab can inherit legacy task-level resume history. A new secondary tab starts a fresh session even if it is the first tab of a different profile. Explicit `tab --resume SESSION_ID` and restoration of a tab's own captured session continue to resume that specific session. +## Delivery metadata + +Composition members optionally persist `pr_url`, `pr_number`, and `pr_provider`, +matching the host task's identity fields. Older records default them to absent; +live provider state remains in the PR store. + +Profile-scoped `scratch//delivery.json` owns reviewed requests, saved +reply drafts, imported PR descriptions, and per-repository bulk results. Agents +write `.termic-delivery/.json` reports in the task checkout, excluded +through Git's common `info/exclude` for both repositories and linked worktrees. +Reports are size-bounded and checked against the reviewed request scope. diff --git a/docs/ipc.md b/docs/ipc.md index aed61e8d..57d8493c 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -237,3 +237,44 @@ or silently choose a reasoning level. A model-switch prompt sent after creating a tab cannot make the original launch fresh and is not an equivalent substitute for launch arguments. +## Delivery commands + +`task_delivery_repos`, `task_delivery_details`, and `task_delivery_log` provide +local inventory and on-demand provider evidence. `task_delivery_validate` binds +actions to recorded selectors, canonical paths, branches, HEADs, remotes, +working-tree fingerprints, and optional PR source revisions. All provider/Git +work runs on `spawn_blocking`. + +`task_delivery_request { id, expected, drafts, kind, scope, evidenceKeys }` stores +a reviewed request; kind is `fix`, `replies`, `prs`, or `conflicts`. Evidence +keys (`{dir}:ci:{id}` / `{dir}:review:{id}`) snapshot the selected items' +canonical JSON on the request. `task_delivery_request_check` re-probes those +items and fails the send when anything was edited or re-run since review; +`task_delivery_request_amend` rewrites drafts/evidence/scope while a request +is still `prepared` (the send dialog's item picker). Agents write bounded JSON +reports into the ignored task `.termic-delivery` directory. +`task_delivery_requests` imports only requested thread keys/repositories; a +malformed report records its error on that one request instead of failing the +listing, and a corrected rewrite imports on the next read. Reports must be +JSON objects with `drafts`/`prs` as arrays when present; each list is fully +validated before anything is applied, so a bad entry never leaves a partial +import behind. Importing creates no PRs and posts no comments. +`task_delivery_request_status` tracks handoff outcomes through a checked +transition table: `prepared → queued/sent/failed/uncertain`, +`queued|sent → sent/failed/uncertain`, `uncertain → sent/failed`, +`failed → queued/sent`, `drafted → failed` (explicit dismiss), same-status +writes are no-ops. It returns the status it replaced; the send path marks +the request `queued` before the evidence check and verifies that previous +status equals the one it read, so `request_amend` can no longer swap drafts +under a prompt about to be typed and a dismiss landing mid-claim wins. +Its optional `agent` argument records the terminal tab the prompt went to — +the request card's "Open agent" jump target. +`task_delivery_draft_save` edits a local reply; `task_delivery_reply_post` posts +one explicitly selected reply to its original thread. A stable hidden comment +marker identifies uncertain posts during readback. + +`task_delivery_pr_create` and `task_delivery_update` process selected repositories +sequentially and retain results per (repository, action) across retries and +restarts — a repo can hold an update row and a PR row at once. +`task_delivery_results` restores those results. `task_delivery_archive_ready` +keeps tasks open when a repository is missing, dirty, unknown, or undelivered. diff --git a/docs/ui.md b/docs/ui.md index f05624a0..767f037b 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1500,3 +1500,24 @@ reports this" and "not supported yet" only mean something next to each other, and it is a decision made once, not a per-agent preference. And the link out of Notifications does not gate on which agents are supported; the table is the authority on that. +## Delivery workflow + +The right panel's Delivery tab lists every recorded checkout and separates PR, +CI, and review state from agent activity. Provider details and failed-job log +excerpts load on demand. Select repositories and evidence, then review the exact +agent, checkout paths, branches, and revisions before sending or queuing. A +changed destination stops the handoff. Evidence is untrusted data; the prompt +does not authorize commits, pushes, posting replies, or merging. + +Reply drafts require Save locally or Post this reply. Saved drafts survive +restarts; unsaved edits survive tab navigation within the session. Uncertain +posting outcomes use readback rather than automatic retries. A missing reply +requires a separate explicit retry, with fresh readback before posting again. Drafting PR text +does not create PRs. Bulk creation defaults to draft PRs, reuses open PRs, and +shows each repository's result. Branch updates show autostash behavior and retain +conflict results for manual or reviewed agent resolution. + +The Board's persistent Needs action toggle filters delivery blockers without +moving cards between agent-activity columns. Compact links open Delivery. +Multi-repository auto-archive accounts for every checkout rather than relying +on the host PR merge alone. The existing opt-in comment watcher stays separate. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 1370e6fa..6a283570 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -71,6 +71,7 @@ export interface TermicApi { useRace: { getState: () => any }; /** PR/MR store (src/store/pr.ts). Specs seed `byTask` directly to render * card states without a real forge/network. */ + useDelivery: { getState: () => any; setState: (p: any) => void }; usePr: { getState: () => any; setState: (p: any) => void }; /** Per-task change summaries (src/store/diffStat.ts). Demand-driven with a * staleness floor, so a spec that changes a worktree calls `invalidate` @@ -303,7 +304,7 @@ export async function waitForAppShell(timeout = 30_000): Promise { * clickable element with text: Git" whenever an earlier spec left a file * behind, and passed when git.e2e ran alone. */ -export async function openRightTab(label: "All files" | "Git"): Promise { +export async function openRightTab(label: "All files" | "Git" | "Delivery"): Promise { await browser.execute((l) => { const el = document.querySelector( `[data-testid="right-tab"][data-tab="${l}"]`, diff --git a/e2e/specs/agent.e2e.ts b/e2e/specs/agent.e2e.ts index ba249ab9..835e1d88 100644 --- a/e2e/specs/agent.e2e.ts +++ b/e2e/specs/agent.e2e.ts @@ -1084,6 +1084,9 @@ describe("pending work defers done", () => { await waitForAppShell(); await requireTermicApi(); await requireWorkBadges(); + // A previously interrupted ceiling test can leave its 8s override behind. + // This case exercises the normal hold, so establish the default before spawn. + await browser.execute(() => localStorage.removeItem("workDoneCeilingMs")); taskId = await openTask("e2e-pending-work"); await waitForAgentReady(taskId); diff --git a/e2e/specs/board.e2e.ts b/e2e/specs/board.e2e.ts index 98c3fbd0..660f7e32 100644 --- a/e2e/specs/board.e2e.ts +++ b/e2e/specs/board.e2e.ts @@ -455,6 +455,31 @@ describe("board view", () => { await snap("board-card-pr.png"); }); + it("opens Delivery from the actionable CI status without a generic Delivery row", async () => { + await browser.execute(id => { + const store = window.__termic!.usePr; + store.setState({ byTask: { ...store.getState().byTask, [id]: { + lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 42, + url: "https://example.test/pull/42", state: "open", checks: "failing", review: "changes_requested", + title: "Review fixture", base: "main", head: "topic" } }, + loading: false, fetchedAt: Date.now(), + } } }); + const app = window.__termic!.useApp.getState(); + if (!app.rightPanelHidden) app.toggleRightPanel(); + }, t2); + const selector = CARD(t2) + ' [data-testid="board-card-delivery"]'; + await waitVisible(selector); + const text = await browser.execute(sel => document.querySelector(sel)?.textContent ?? "", selector); + expect(text).toContain("CI"); + expect(text).not.toContain("Delivery"); + await browser.execute(sel => (document.querySelector(sel) as HTMLElement).click(), selector); + await waitVisible('[data-testid="delivery-panel"]'); + // Put the suite back on its Board and keep later editor suites on Files. + await browser.execute(() => (document.querySelector('[data-testid="right-tab"][data-tab="All files"]') as HTMLElement).click()); + await clickByText("Kanban"); + await waitVisible('[data-testid="board-view"]'); + }); + it("a wide PR chip and churn never make the column scroll sideways", async () => { // Shipped broken in 1.11.2: the chip and the churn were both shrink-0 on // one line, so "#18495 - checks failing" next to "+356 -21 12 files" diff --git a/e2e/specs/delivery.e2e.ts b/e2e/specs/delivery.e2e.ts new file mode 100644 index 00000000..6fea0f80 --- /dev/null +++ b/e2e/specs/delivery.e2e.ts @@ -0,0 +1,119 @@ +import { writeFileSync } from "node:fs"; +import { archiveTask, clickByText, clickWhenVisible, clickMenuItem, createWorktreeTask, openRightTab, requireTermicApi, waitGone, waitVisible, waitForAgentReady, waitForAppShell, waitForText } from "../helpers"; + +const openActions = async () => { + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-actions"]')?.disabled)); + await browser.execute(() => { + const trigger = document.querySelector('[data-testid="delivery-actions"]')!; + const init = { bubbles: true, cancelable: true, button: 0, pointerType: "mouse", isPrimary: true }; + trigger.dispatchEvent(new PointerEvent("pointerdown", init)); + trigger.dispatchEvent(new PointerEvent("pointerup", init)); + }); + await waitVisible('[role="menu"]'); +}; +const refreshPanel = async () => { + const selector = '[data-testid="delivery-panel"] button[aria-label="Refresh"]'; + await browser.waitUntil(() => browser.execute(s => !document.querySelector(s)?.disabled, selector)); + await clickWhenVisible(selector); +}; +describe("task delivery", () => { + let taskId = ""; + after(async () => { await browser.keys("Escape"); await openRightTab("All files"); if (taskId) await archiveTask(taskId); }); + + it("shows clean repositories and retains failed branch-update results", async () => { + await waitForAppShell(); + await requireTermicApi(); + taskId = await createWorktreeTask("e2e-delivery", "e2e-delivery"); + await openRightTab("Delivery"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-repo"]')?.textContent?.includes("Clean"))); + await openActions(); + await clickMenuItem("Update branches"); + await browser.execute(() => { const select = document.querySelector('[role="dialog"] select')!; select.value = "pull"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await clickByText("Update branches"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream"))); + await openRightTab("All files"); + await openRightTab("Delivery"); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[data-testid="delivery-results"]')?.textContent?.toLowerCase().includes("upstream"))); + }); + + it("reviews exact scope and rejects a changed worktree before sending", async () => { + await waitForAgentReady(taskId); + await openActions(); + await clickMenuItem("Create PRs"); + await clickByText("Draft all with agent"); + await waitForText("Review agent handoff"); + await waitForText("does not authorize commits"); + await browser.execute(async id => { + await window.__termic!.ipc.taskFileWrite(id, "README.md", "changed after reviewing the handoff\n"); + }, taskId); + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-send"]')?.disabled)); + await clickWhenVisible('[data-testid="delivery-send"]'); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[role="dialog"]')?.textContent?.includes("working files changed"))); + await browser.keys("Escape"); + await browser.keys("Escape"); + await waitGone('[role="dialog"]'); + await browser.execute(async id => { + // Undo through git in the fixture's isolated checkout, not the shared repo. + const t = window.__termic!; + await t.ipc.taskDiscard(id, "", ["README.md"]); + }, taskId); + }); + + it("imports an agent PR report and auto-fills untouched dialog fields", async () => { + await refreshPanel(); + await browser.waitUntil(() => browser.execute(id => !window.__termic!.useDelivery.getState().byTask[id]?.loading, taskId)); + await openActions(); + await clickMenuItem("Create PRs"); + await clickByText("Draft all with agent"); + await waitForText("Review agent handoff"); + const request = await browser.execute(async id => { + const requests = await window.__termic!.ipc.taskDeliveryRequests(id); + return requests[requests.length - 1]; + }, taskId); + // Simulate an agent writing its report, using the reviewed fixture path. + writeFileSync(request.report, JSON.stringify({ drafts: [], prs: [ + { dir_name: "", title: "Proposed delivery title", body: "Proposed description, pending human review." }, + ] })); + await browser.execute(async (id, requestId) => { + await window.__termic!.ipc.taskDeliveryRequestStatus(id, requestId, "sent"); + }, taskId, request.id); + await browser.waitUntil(() => browser.execute(() => !document.querySelector('[data-testid="delivery-send"]')?.disabled)); + await browser.keys("Escape"); + // The imported draft lands in the still-open Create PRs dialog, filling + // only fields the user has not touched. + await browser.execute(async id => { await window.__termic!.useDelivery.getState().refresh(id); }, taskId); + await browser.waitUntil(() => browser.execute(() => document.querySelector('[role="dialog"] input')?.value === "Proposed delivery title")); + expect(await browser.execute(() => document.querySelector('[role="dialog"] input[type="checkbox"]')?.checked)).toBe(true); + await waitForText("pushes committed branch changes"); + await browser.keys("Escape"); + await waitGone('[role="dialog"]'); + await waitForText("Proposed delivery title"); + }); + + it("renders independent review and CI states without treating skipped jobs as passed", async () => { + await browser.execute(id => { + const store = window.__termic!.useDelivery; + const current = store.getState().byTask[id]; + const identity = current.repos[0].identity; + window.__termic!.usePr.setState({ byTask: { ...window.__termic!.usePr.getState().byTask, [id]: { + lookup: { status: "ok", provider: "github", pr: { provider: "github", number: 7, state: "open", checks: "failing", review: "changes_requested", title: "Handle missing branch", url: "https://example.test/pull/7" } }, + loading: false, fetchedAt: Date.now(), + } } }); + store.setState({ byTask: { ...store.getState().byTask, [id]: { ...current, details: { + "": { identity, revision: "abc123", pr: { provider: "github", number: 7, url: "https://example.test/pull/7" }, + ci_error: null, threads_error: null, + ci: [{ id: "run", parent: null, name: "Build workflow", status: "failed", duration: null, url: "", log_id: null }, + { id: "job", parent: "run", name: "Skipped deployment", status: "skipped", duration: null, url: "", log_id: null }], + threads: [{ id: "thread", reply_id: "1", path: "src/example.ts", line: 12, resolved: false, url: "", + comments: [{ id: "1", author: "Reviewer", body: "Please handle the missing branch." }] }], + }, + } } } }); + }, taskId); + await clickWhenVisible('[data-testid="delivery-repo-details"]'); + await waitForText("Build workflow"); + await waitForText("Skipped deployment"); + await waitForText("skipped"); + await waitForText("src/example.ts:12 · Unresolved"); + await waitForText("Please handle the missing branch."); + }); +}); diff --git a/e2e/specs/settings.e2e.ts b/e2e/specs/settings.e2e.ts index 133b59ca..a69fe122 100644 --- a/e2e/specs/settings.e2e.ts +++ b/e2e/specs/settings.e2e.ts @@ -3369,6 +3369,8 @@ describe("agent hooks", () => { "theme_mode": "dark" } `; + // A failed prior run can retain its original-config backup. + rmTree(devinDir, { bestEffort: true }); mkdirSync(devinDir, { recursive: true }); writeFileSync(devinConfig, userDevinConfig); diff --git a/src-tauri/src/delivery.rs b/src-tauri/src/delivery.rs new file mode 100644 index 00000000..c5d1e05a --- /dev/null +++ b/src-tauri/src/delivery.rs @@ -0,0 +1,1931 @@ +//! Task-scoped delivery. Public commands accept recorded repo selectors, never arbitrary paths. +use crate::*; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Identity { + pub dir_name: String, + pub path: String, + pub branch: String, + pub head: String, + pub remote: String, + pub worktree: String, + #[serde(default)] + pub pr_number: Option, + #[serde(default)] + pub pr_revision: Option, +} +#[derive(Serialize)] +pub struct Repo { + pub dir_name: String, + pub name: String, + pub mode: String, + pub base: String, + pub dirty: bool, + pub changed: Option, + pub identity: Option, + pub error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct CiNode { + pub id: String, + pub parent: Option, + pub name: String, + pub kind: String, + pub status: String, + pub duration: Option, + pub url: String, + pub log_id: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct ThreadComment { + pub id: String, + pub author: String, + pub body: String, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct ReviewThread { + pub id: String, + pub reply_id: String, + pub path: Option, + pub line: Option, + pub resolved: Option, + pub url: String, + pub comments: Vec, +} +#[derive(Serialize)] +pub struct Details { + pub identity: Identity, + pub pr: forge::PrStatus, + pub revision: String, + pub ci: Vec, + pub ci_error: Option, + pub threads: Vec, + pub threads_error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Draft { + pub key: String, + pub dir_name: String, + pub pr_number: u64, + pub thread_id: String, + pub reply_id: String, + pub body: String, + pub status: String, + pub error: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct PrText { + pub dir_name: String, + pub title: String, + pub body: String, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Request { + #[serde(default)] + pub id: String, + #[serde(default)] + pub identities: Vec, + #[serde(default)] + pub report: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub kind: String, + /// Human-readable "what this request covers" — repo names plus the + /// evidence items (check names, thread paths) selected at send time. + /// Display-only; evidence itself is embedded in the prompt text. + #[serde(default)] + pub scope: String, + /// Selected evidence item key → canonical JSON captured at request time. + /// Compared against a fresh provider probe before send so a comment + /// edited or a check re-run between review and send is caught instead + /// of silently handed to the agent. + #[serde(default)] + pub evidence: std::collections::HashMap, + /// Terminal tab the prompt was queued to or sent in — lets the request + /// card jump straight to the working agent. + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub drafts: Vec, + #[serde(default)] + pub prs: Vec, +} +#[derive(Default, Serialize, Deserialize)] +struct Saved { + #[serde(default)] + requests: Vec, + #[serde(default)] + results: Vec, +} +#[derive(Deserialize)] +pub struct PrInput { + pub identity: Identity, + pub title: String, + pub body: String, + pub base: String, + pub draft: bool, +} +#[derive(Serialize, Deserialize)] +pub struct ActionResult { + pub dir_name: String, + pub name: String, + /// "pr" | "update" — a repo can legitimately hold one row of each + /// (a conflicted update and a failed PR create); `store_results` + /// replaces only same-action rows for a directory. + #[serde(default)] + pub action: String, + pub url: Option, + pub result: Option, + pub error: Option, +} +// ponytail: serialize delivery mutations, use per-task locks if bulk throughput matters. +static LOCK: Mutex<()> = Mutex::new(()); + +fn task(id: &str) -> Result { + load_tasks_all() + .into_iter() + .find(|w| w.id == id && !w.archived) + .ok_or_else(|| "Task is missing or archived".into()) +} +fn fingerprint(cwd: &Path) -> Result { + let status = git(&["status", "--porcelain", "-uall"], cwd).map_err(|e| e.to_string())?; + let diff = git(&["diff", "--binary", "HEAD"], cwd).map_err(|e| e.to_string())?; + let mut hash = Sha256::new(); + // `.termic-delivery` is report scratch: it appears as untracked until the + // request that created it writes its info/exclude line, and must not move + // the fingerprint underneath the identities captured in between. + hash.update( + status + .lines() + .filter(|l| { + !(l.starts_with("?? ") + && (&l[3..] == ".termic-delivery" || l[3..].starts_with(".termic-delivery/"))) + }) + .collect::>() + .join("\n"), + ); + hash.update(diff.as_bytes()); + // ponytail: untracked metadata detects ordinary edits; hash contents if same-size/same-mtime edits become a real gap. + for p in git(&["ls-files", "--others", "--exclude-standard", "-z"], cwd) + .map_err(|e| e.to_string())? + .split('\0') + .filter(|p| { + !p.is_empty() && *p != ".termic-delivery" && !p.starts_with(".termic-delivery/") + }) + { + if let Ok(m) = fs::symlink_metadata(cwd.join(p)) { + hash.update(m.len().to_le_bytes()); + hash.update(format!("{:?}", m.modified()).as_bytes()); + } + } + Ok(format!("{:x}", hash.finalize())) +} +fn identity(w: &Task, dir: &str) -> Result { + let cwd = repo_cwd(w, dir)?; + let path = dunce::canonicalize(&cwd).map_err(|e| format!("Checkout missing: {e}"))?; + let branch = git(&["branch", "--show-current"], &path) + .map_err(|e| e.to_string())? + .trim() + .to_string(); + let head = git(&["rev-parse", "HEAD"], &path) + .map_err(|e| e.to_string())? + .trim() + .to_string(); + let remote = git(&["remote", "get-url", &detect_default_remote(&path)], &path) + .map(|s| forge::remote_for_display(s.trim())) + .unwrap_or_default(); + Ok(Identity { + dir_name: dir.into(), + path: path.to_string_lossy().into_owned(), + branch, + head, + remote, + worktree: fingerprint(&path)?, + pr_number: None, + pr_revision: None, + }) +} +fn validate(id: &str, expected: &Identity) -> Result<(Task, PathBuf), String> { + let w = task(id)?; + let mut current = identity(&w, &expected.dir_name)?; + if let Some(number) = expected.pr_number { + let pr = pr_for(&w, Path::new(¤t.path), &expected.dir_name)?; + if pr.number != number { + return Err("PR changed. Review again.".into()); + } + current.pr_number = Some(number); + current.pr_revision = Some(forge::delivery::revision( + &pr.provider, + Path::new(¤t.path), + number, + )?); + } + if ¤t != expected { + return Err( + "Repository, branch, revision, or working files changed. Refresh and review again." + .into(), + ); + } + Ok((w, PathBuf::from(¤t.path))) +} +fn known<'a>(w: &'a Task, dir: &str) -> (Option<&'a str>, Option) { + if dir.is_empty() { + (w.pr_provider.as_deref(), w.pr_number) + } else { + w.composition + .iter() + .find(|m| m.dir_name == dir) + .map(|m| (m.pr_provider.as_deref(), m.pr_number)) + .unwrap_or_default() + } +} +fn lookup(w: &Task, cwd: &Path, dir: &str) -> PrLookup { + let (p, n) = known(w, dir); + pr_lookup_at(cwd, n, p) +} +fn pr_for(w: &Task, cwd: &Path, dir: &str) -> Result { + let l = lookup(w, cwd, dir); + if l.status != "ok" { + return Err(l.message); + } + l.pr.ok_or_else(|| "No PR for this branch".into()) +} +fn persist_pr(id: &str, dir: &str, p: &forge::PrStatus) -> Result<(), String> { + let mut w = task(id)?; + if dir.is_empty() { + w.pr_url = Some(p.url.clone()); + w.pr_number = Some(p.number); + w.pr_provider = Some(p.provider.clone()); + } else if let Some(m) = w.composition.iter_mut().find(|m| m.dir_name == dir) { + m.pr_url = Some(p.url.clone()); + m.pr_number = Some(p.number); + m.pr_provider = Some(p.provider.clone()); + } else { + return Err("Repository removed from task".into()); + } + save_task(&w).map_err(|e| e.to_string()) +} +fn saved_path(id: &str) -> Result { + task(id)?; + Ok(scratch_dir(id)?.join("delivery.json")) +} +fn load(id: &str) -> Result { + let path = saved_path(id)?; + match fs::read(&path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(s) => Ok(s), + // A torn write or hand-edit would otherwise hard-error every + // delivery command forever — park the unreadable file next to + // the original and start with an empty store instead. + Err(_) => { + let _ = fs::rename(&path, path.with_extension("corrupt.json")); + Ok(Saved::default()) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Saved::default()), + Err(e) => Err(e.to_string()), + } +} +fn save(id: &str, s: &Saved) -> Result<(), String> { + write_atomic( + &saved_path(id)?, + &serde_json::to_vec_pretty(s).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) +} +fn safe_report(w: &Task, r: &Request) -> Result { + let root = dunce::canonicalize(&w.path).map_err(|e| e.to_string())?; + let path = root.join(".termic-delivery").join(format!("{}.json", r.id)); + if path.to_string_lossy() != r.report { + return Err("Report checkout changed".into()); + } + if !path.parent().ok_or("missing parent")?.exists() { + return Ok(path); + } + let parent = + dunce::canonicalize(path.parent().ok_or("missing parent")?).map_err(|e| e.to_string())?; + // A regular file at .termic-delivery canonicalizes to itself and passes + // the path check — without this, every poll silently finds no report. + if !parent.is_dir() { + return Err(".termic-delivery is not a directory".into()); + } + if parent != root.join(".termic-delivery") { + return Err("Report directory escapes the task".into()); + } + if fs::symlink_metadata(&path) + .map(|m| !m.is_file() || m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err("Report must be a regular file".into()); + } + Ok(path) +} +fn dirs(w: &Task) -> Vec<(String, String, String)> { + let mut out = vec![( + String::new(), + load_projects_all() + .iter() + .find(|p| p.id == w.project_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| w.name.clone()), + if w.is_main_checkout { + "repo_root" + } else { + "worktree" + } + .into(), + )]; + out.extend(w.composition.iter().map(|m| { + ( + m.dir_name.clone(), + m.dir_name.clone(), + match m.mode { + MemberMode::RepoRoot => "repo_root", + MemberMode::Worktree => "worktree", + } + .into(), + ) + })); + out +} +fn repos(id: &str) -> Result, String> { + let w = task(id)?; + let projects = load_projects_all(); + Ok(dirs(&w) + .into_iter() + .map(|(dir, name, mode)| { + let base = repo_base_branch(&w, &dir, &projects); + match identity(&w, &dir) { + Ok(i) => { + let cwd = Path::new(&i.path); + let dirty = git(&["status", "--porcelain", "-uall"], cwd) + .map(|s| !s.trim().is_empty()) + .unwrap_or(true); + // A boolean only: merge-base + name-only is two cheap + // reads, not git_compare's full stat walk (which reads up + // to 8 MiB of untracked files per repo per refresh). When + // the worktree is dirty the UI shows that instead and + // archive_ready rejects on `dirty` first, so skip. + let changed = if base.is_empty() || dirty { + None + } else { + git(&["merge-base", &base, "HEAD"], cwd) + .ok() + .and_then(|mb| { + git(&["--no-pager", "diff", "--name-only", "-M", mb.trim()], cwd) + .ok() + }) + .map(|s| !s.trim().is_empty()) + }; + Repo { + dir_name: dir, + name, + mode, + base, + dirty, + changed, + identity: Some(i), + error: None, + } + } + Err(e) => Repo { + dir_name: dir, + name, + mode, + base, + dirty: false, + changed: None, + identity: None, + error: Some(e), + }, + } + }) + .collect()) +} +fn details(id: &str, expected: Identity) -> Result { + let (w, cwd) = validate(id, &expected)?; + let pr = pr_for(&w, &cwd, &expected.dir_name)?; + let revision = forge::delivery::revision(&pr.provider, &cwd, pr.number)?; + let ci = forge::delivery::ci(&pr.provider, &cwd, pr.number, &revision); + let threads = forge::delivery::threads(&pr.provider, &cwd, pr.number); + validate(id, &expected)?; + if forge::delivery::revision(&pr.provider, &cwd, pr.number)? != revision { + return Err("PR revision changed. Refresh.".into()); + } + Ok(Details { + identity: expected, + pr, + revision, + ci_error: ci.as_ref().err().cloned(), + ci: ci.unwrap_or_default(), + threads_error: threads.as_ref().err().cloned(), + threads: threads.unwrap_or_default(), + }) +} + +#[tauri::command] +pub async fn task_delivery_repos(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || repos(&id)) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_details(id: String, expected: Identity) -> Result { + tauri::async_runtime::spawn_blocking(move || details(&id, expected)) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_validate(id: String, expected: Vec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + for i in expected { + validate(&id, &i)?; + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_log( + id: String, + expected: Identity, + log_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let d = details(&id, expected.clone())?; + if !d.ci.iter().any(|n| n.log_id.as_deref() == Some(&log_id)) { + return Err("Log no longer belongs to this PR revision".into()); + } + let result = forge::delivery::log(&d.pr.provider, Path::new(&expected.path), &log_id)?; + validate(&id, &expected)?; + Ok(result) + }) + .await + .map_err(|e| e.to_string())? +} +/// Import one request's report file. `Ok(None)` = no report on disk yet; +/// `Ok(Some(mutated))` = the file was read and applied (or had nothing new). +/// `Err` leaves the request untouched - the caller records it on that one +/// request so a malformed report cannot wedge every other request's listing. +fn import_report(w: &Task, r: &mut Request) -> Result, String> { + let path = safe_report(w, r)?; + if !path.exists() { + return Ok(None); + } + let bytes = read_report(&path)?; + let report = serde_json::from_slice::(&bytes) + .map_err(|_| "Report is not valid JSON".to_string())?; + if !report.is_object() { + return Err("Report must be a JSON object".into()); + } + let mut mutated = false; + // Validate BOTH lists fully before mutating anything: drafts only fill + // empty bodies, so a report that applied its first items then errored + // on a later one could never be repaired by a corrected rewrite. + let mut staged_drafts: Option> = None; + if let Some(v) = report.get("drafts") { + let list = v.as_array().ok_or("Report drafts must be an array")?; + let mut seen = HashSet::new(); + let mut staged = Vec::with_capacity(list.len()); + for item in list { + let key = item["key"].as_str().ok_or("Draft report has no item key")?; + let body = item["body"].as_str().ok_or("Draft report has no body")?; + if body.len() > 32000 { + return Err("Draft reply exceeds 32,000 bytes".into()); + } + if !seen.insert(key) { + return Err("Duplicate draft item".into()); + } + if !r.drafts.iter().any(|d| d.key == key) { + return Err("Draft report contains an unrequested thread".into()); + } + staged.push((key, body)); + } + staged_drafts = Some(staged); + } + let mut staged_prs: Option> = None; + if let Some(v) = report.get("prs") { + let list = v.as_array().ok_or("Report prs must be an array")?; + if !list.is_empty() && r.kind != "prs" { + return Err("Unexpected PR drafts in report".into()); + } + let mut seen = HashSet::new(); + let mut staged = Vec::with_capacity(list.len()); + for item in list { + let dir = item["dir_name"] + .as_str() + .ok_or("PR draft has no repository selector")?; + if !seen.insert(dir) { + return Err("Duplicate PR draft".into()); + } + if !r.identities.iter().any(|i| i.dir_name == dir) { + return Err("PR report contains an unrequested repository".into()); + } + let title = item["title"].as_str().ok_or("PR draft has no title")?; + let body = item["body"].as_str().ok_or("PR draft has no body")?; + if title.len() > 1000 || body.len() > 32000 { + return Err("PR draft is too large".into()); + } + staged.push(PrText { + dir_name: dir.into(), + title: title.into(), + body: body.into(), + }); + } + staged_prs = Some(staged); + } + // Both lists validated — apply. + if let Some(staged) = staged_drafts { + for (key, body) in staged { + let Some(d) = r.drafts.iter_mut().find(|d| d.key == key) else { + continue; + }; + if d.status == "draft" && d.body.is_empty() { + d.body = body.into(); + mutated = true; + } + } + } + if let Some(staged) = staged_prs { + for p in staged { + r.prs.retain(|x| x.dir_name != p.dir_name); + r.prs.push(p); + mutated = true; + } + } + // An explicit (even empty) "prs"/"drafts" list is a delivered report — + // the agent answered, it just had nothing to fill. A report lacking the + // key entirely is still "waiting". + if r.kind == "prs" && r.prs.is_empty() && report.get("prs").is_none() { + return Ok(Some(mutated)); + } + // Only 'replies' requests make the drafts list mandatory — 'fix' drafts + // are opportunistic extras (the prompt asks for reply text where a + // thread was fixed, but the fix itself is the deliverable). + if r.kind == "replies" + && r.drafts.iter().any(|d| d.body.is_empty()) + && report.get("drafts").is_none() + { + return Ok(Some(mutated)); + } + r.status = "drafted".into(); + Ok(Some(true)) +} + +#[tauri::command] +pub async fn task_delivery_requests(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + let w = task(&id)?; + let mut data = load(&id)?; + let mut changed = false; + for r in &mut data.requests { + if !matches!(r.status.as_str(), "sent" | "queued" | "uncertain") { + continue; + } + match import_report(&w, r) { + // An unread report leaves any send-time error alone; a read + // report resolves whatever import error preceded it. + Ok(None) => {} + Ok(Some(mutated)) => { + changed |= mutated; + if r.error.take().is_some() { + changed = true; + } + } + Err(e) => { + if r.error.as_deref() != Some(e.as_str()) { + r.error = Some(e); + changed = true; + } + } + } + } + if changed { + save(&id, &data)?; + } + Ok(data.requests) + }) + .await + .map_err(|e| e.to_string())? +} +/// Split `${dir}:${ci|review}:${id}` — dir names may contain ':', so the +/// repository is matched as the LONGEST prefix (dirs "a" and "a:b" listed +/// together must resolve key "a:b:ci:x" to "a:b", not first-listed "a"). +fn parse_evidence_key<'a, 'b>( + expected: &'a [Identity], + key: &'b str, +) -> Result<(&'a Identity, &'b str, &'b str), String> { + let i = expected + .iter() + .filter(|i| key.starts_with(&format!("{}:", i.dir_name))) + .max_by_key(|i| i.dir_name.len()) + .ok_or("Evidence repository not selected")?; + let rest = &key[i.dir_name.len() + 1..]; + let (which, item) = rest.split_once(':').ok_or("Malformed evidence key")?; + Ok((i, which, item)) +} + +/// Canonical JSON of one evidence item in a fresh probe — `None` when the +/// id no longer exists (or `which` is neither "ci" nor "review"). +fn evidence_json(det: &Details, which: &str, item: &str) -> Option { + match which { + "ci" => det + .ci + .iter() + .find(|n| n.id == item) + .and_then(|n| serde_json::to_string(n).ok()), + "review" => det + .threads + .iter() + .find(|t| t.id == item) + .and_then(|t| serde_json::to_string(t).ok()), + _ => None, + } +} + +/// Serialize the selected evidence items (key → canonical JSON of the CI +/// node or review thread). A fresh probe at send time compares against +/// this snapshot — anything edited or re-run in between fails the send. +fn collect_evidence( + id: &str, + expected: &[Identity], + evidence_keys: &[String], + cache: &mut HashMap, +) -> Result, String> { + if evidence_keys.len() > 200 { + return Err("Select at most 200 evidence items".into()); + } + let mut out = HashMap::new(); + for key in evidence_keys { + let (i, which, item) = parse_evidence_key(expected, key)?; + if !cache.contains_key(&i.dir_name) { + cache.insert(i.dir_name.clone(), details(id, i.clone())?); + } + let det = &cache[&i.dir_name]; + // A failed probe leaves its half empty — surface the real probe + // error rather than reporting every item as deleted. + let probe_error = match which { + "ci" => &det.ci_error, + "review" => &det.threads_error, + _ => &None, + }; + if let Some(e) = probe_error { + return Err(e.clone()); + } + let json = evidence_json(det, which, item) + .ok_or("Evidence item is gone. Refresh and select again.")?; + out.insert(key.clone(), json); + } + Ok(out) +} + +/// Validate reply drafts against a live provider probe, populating the +/// shared details cache for reuse by evidence collection. +fn check_drafts( + id: &str, + expected: &[Identity], + drafts: &[Draft], + cache: &mut HashMap, +) -> Result<(), String> { + let mut keys = HashSet::new(); + for d in drafts { + if !keys.insert(&d.key) { + return Err("Duplicate draft item".into()); + } + let i = expected + .iter() + .find(|i| i.dir_name == d.dir_name) + .ok_or("Draft repository not selected")?; + if !cache.contains_key(&d.dir_name) { + cache.insert(d.dir_name.clone(), details(id, i.clone())?); + } + let det = &cache[&d.dir_name]; + if let Some(e) = &det.threads_error { + return Err(e.clone()); + } + if det.pr.number != d.pr_number + || !det + .threads + .iter() + .any(|t| t.id == d.thread_id && t.reply_id == d.reply_id) + { + return Err("Review thread changed. Refresh.".into()); + } + } + Ok(()) +} + +#[tauri::command] +pub async fn task_delivery_request( + id: String, + expected: Vec, + drafts: Vec, + kind: String, + scope: Option, + evidence_keys: Vec, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if expected.is_empty() || expected.len() > 64 || drafts.len() > 100 { + return Err("Select 1-64 repositories and at most 100 threads".into()); + } + if !["fix", "replies", "prs", "conflicts"].contains(&kind.as_str()) { + return Err("Invalid request kind".into()); + } + let w = task(&id)?; + let mut selected = HashSet::new(); + for i in &expected { + if !selected.insert(&i.dir_name) { + return Err("Duplicate repository selection".into()); + } + } + for i in &expected { + validate(&id, i)?; + } + let mut details_cache = HashMap::new(); + check_drafts(&id, &expected, &drafts, &mut details_cache)?; + let evidence = collect_evidence(&id, &expected, &evidence_keys, &mut details_cache)?; + let root = dunce::canonicalize(&w.path).map_err(|e| e.to_string())?; + let dir = root.join(".termic-delivery"); + if let Ok(m) = fs::symlink_metadata(&dir) { + if !m.is_dir() || m.file_type().is_symlink() { + return Err("Report directory must be a real task directory".into()); + } + } + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + // A symlink planted between the check and the create could aim the + // report dir outside the worktree; re-verify after create. + if dunce::canonicalize(&dir).map_err(|e| e.to_string())? != root.join(".termic-delivery") { + return Err("Report directory escapes the task".into()); + } + // git's own common-dir resolver works for .git FILES in worktrees. + { + let common = + git(&["rev-parse", "--git-common-dir"], &root).map_err(|e| e.to_string())?; + let common = root.join(common.trim()); + let file = common.join("info/exclude"); + let mut body = fs::read_to_string(&file).unwrap_or_default(); + if !body.lines().any(|l| l == "/.termic-delivery/") { + if !body.ends_with('\n') { + body.push('\n'); + } + body.push_str("/.termic-delivery/\n"); + write_atomic(&file, body.as_bytes()).map_err(|e| e.to_string())?; + } + git(&["check-ignore", ".termic-delivery/report.json"], &root) + .map_err(|_| "Report directory could not be ignored")?; + } + let request_id = Uuid::new_v4().to_string(); + let r = Request { + id: request_id.clone(), + kind, + identities: expected, + report: dir + .join(format!("{request_id}.json")) + .to_string_lossy() + .into_owned(), + status: "prepared".into(), + error: None, + // Display-only label; cap so a hostile selection can't bloat the + // durable file. + scope: scope.unwrap_or_default().chars().take(500).collect(), + evidence, + drafts: drafts + .into_iter() + .map(|mut d| { + d.body.clear(); + d.status = "draft".into(); + d.error = None; + d + }) + .collect(), + prs: vec![], + agent: None, + }; + let mut data = load(&id)?; + data.requests.push(r.clone()); + // Bound the durable log — evidence snapshots embed thread bodies, + // so the file grows with every send otherwise. Requests append in + // order, so this drops the oldest finished ones past the cap; + // active requests are never pruned. + if data.requests.len() > 200 { + let mut excess = data.requests.len() - 200; + data.requests.retain(|r| { + if excess > 0 && matches!(r.status.as_str(), "drafted" | "failed") { + excess -= 1; + false + } else { + true + } + }); + } + save(&id, &data)?; + Ok(r) + }) + .await + .map_err(|e| e.to_string())? +} +/// Re-probe the request's evidence items and prove they are unchanged since +/// the user reviewed them — a thread edited or a check re-run in between +/// means the prompt no longer matches what was approved. +#[tauri::command] +pub async fn task_delivery_request_check(id: String, request_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + // load() may rename a corrupt file — that write needs the mutation + // lock so a concurrent save can't be moved aside underneath it. + // The provider probe itself stays lock-free: a wedged mutating + // command elsewhere must not stall send checks. + task(&id)?; + let data = { + let _guard = LOCK.lock(); + load(&id)? + }; + let r = data + .requests + .iter() + .find(|r| r.id == request_id) + .ok_or("Request is gone")?; + if r.evidence.is_empty() { + return Ok(()); + } + let mut cache = HashMap::new(); + for (key, expected_json) in &r.evidence { + let (i, which, item) = parse_evidence_key(&r.identities, key)?; + if !cache.contains_key(&i.dir_name) { + cache.insert(i.dir_name.clone(), details(&id, i.clone())?); + } + let det = &cache[&i.dir_name]; + // A probe outage empties its half of the detail — surface the + // real error, not "item gone". + let probe_error = match which { + "ci" => &det.ci_error, + "review" => &det.threads_error, + _ => &None, + }; + if let Some(e) = probe_error { + return Err(e.clone()); + } + let fresh = evidence_json(det, which, item).ok_or_else(|| { + format!("Evidence item is gone ({}). Refresh and send again.", key) + })?; + if &fresh != expected_json { + return Err("Evidence changed since review. Refresh and send again.".into()); + } + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} +/// Rewrite a still-prepared request's drafts/evidence/scope — the send +/// dialog's item picker adjusts coverage before dispatch. Anything beyond +/// `prepared` is immutable: a queued or sent prompt must match its request. +#[tauri::command] +pub async fn task_delivery_request_amend( + id: String, + request_id: String, + drafts: Vec, + evidence_keys: Vec, + scope: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + task(&id)?; + let mut data = load(&id)?; + let idx = data + .requests + .iter() + .position(|r| r.id == request_id) + .ok_or("Request is gone")?; + if data.requests[idx].status != "prepared" { + return Err("Request already left the dialog".into()); + } + if drafts.len() > 100 { + return Err("Select at most 100 threads".into()); + } + let expected = data.requests[idx].identities.clone(); + let mut details_cache = HashMap::new(); + check_drafts(&id, &expected, &drafts, &mut details_cache)?; + let mut evidence = collect_evidence(&id, &expected, &evidence_keys, &mut details_cache)?; + let r = &mut data.requests[idx]; + // Keys the user already reviewed keep their original snapshot — the + // send-time check must compare against what was approved, not a + // silently re-baselined probe. Only items newly added in the picker + // take a fresh snapshot. + for (k, v) in &mut evidence { + if let Some(old) = r.evidence.get(k) { + *v = old.clone(); + } + } + r.evidence = evidence; + // Rotating the id retires stale queue copies pinned to the + // pre-amend prompt: they fail the send-time claim with "Request is + // gone" instead of typing the old text. + let new_id = Uuid::new_v4().to_string(); + r.report = Path::new(&r.report) + .with_file_name(format!("{new_id}.json")) + .to_string_lossy() + .into_owned(); + r.id = new_id; + r.drafts = drafts + .into_iter() + .map(|mut d| { + d.body.clear(); + d.status = "draft".into(); + d.error = None; + d + }) + .collect(); + r.scope = scope.unwrap_or_default().chars().take(500).collect(); + let out = r.clone(); + save(&id, &data)?; + Ok(out) + }) + .await + .map_err(|e| e.to_string())? +} +/// Returns the status the request was in before this call, so a sender can +/// tell "I claimed it from the state I read" apart from "a dismiss or a +/// parallel send landed in between" (both read as a successful mark +/// otherwise, since loose transitions allow e.g. failed → queued). +#[tauri::command] +pub async fn task_delivery_request_status( + id: String, + request_id: String, + status: String, + error: Option, + agent: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if !["queued", "sent", "failed", "uncertain"].contains(&status.as_str()) { + return Err("Invalid delivery status".into()); + } + let mut data = load(&id)?; + let r = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .ok_or("Unknown delivery request")?; + // Loose transitions let a stale queue item re-"send" a request that + // was already sent (the prompt would be typed twice). Same-status is + // an idempotent no-op; 'drafted' is terminal except for the user's + // explicit dismiss (failed-with-no-error is the hidden state). + let legal = r.status == status + || matches!( + (r.status.as_str(), status.as_str()), + ("prepared", "queued" | "sent" | "failed" | "uncertain") + | ("queued", "sent" | "failed" | "uncertain") + | ("sent", "failed" | "uncertain") + | ("uncertain", "sent" | "failed") + | ("failed", "queued" | "sent") + | ("drafted", "failed") + ); + if !legal { + return Err(format!( + "Delivery request is '{}', cannot become '{status}'", + r.status + )); + } + let prev = r.status.clone(); + let same = prev == status; + r.status = status; + // A same-status mark is a no-op re-mark (queue drain); don't let + // its null error wipe a recorded import/send error. + if !(same && error.is_none()) { + r.error = error; + } + if let Some(tab) = agent { + r.agent = Some(tab); + } + save(&id, &data)?; + Ok(prev) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_draft_save( + id: String, + request_id: String, + key: String, + body: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if body.len() > 32000 { + return Err("Reply exceeds 32,000 bytes".into()); + } + let mut data = load(&id)?; + let d = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .and_then(|r| r.drafts.iter_mut().find(|d| d.key == key)) + .ok_or("Unknown reply draft")?; + if d.status != "draft" { + return Err("Posted or uncertain reply cannot be edited".into()); + } + d.body = body; + save(&id, &data) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_reply_post( + id: String, + request_id: String, + key: String, + expected: Identity, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + let (w, cwd) = validate(&id, &expected)?; + let mut data = load(&id)?; + let r = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .ok_or("Unknown delivery request")?; + let original = r + .identities + .iter() + .find(|i| i.dir_name == expected.dir_name) + .ok_or("Original repository unavailable")?; + if original.path != expected.path + || original.remote != expected.remote + || original.branch != expected.branch + { + return Err("Reply destination changed. Draft retained.".into()); + } + let marker = format!( + "", + r.id, + Sha256::digest(key.as_bytes()) + ); + let d = r + .drafts + .iter_mut() + .find(|d| d.key == key && d.dir_name == expected.dir_name) + .ok_or("Unknown reply draft")?; + if d.status == "posted" { + return Ok(()); + } + if d.body.trim().is_empty() { + return Err("Reply is empty".into()); + } + let pr = pr_for(&w, &cwd, &expected.dir_name)?; + if pr.number != d.pr_number { + return Err("PR changed. Reply retained.".into()); + } + let threads = forge::delivery::threads(&pr.provider, &cwd, pr.number)?; + let thread = threads + .iter() + .find(|t| t.id == d.thread_id && t.reply_id == d.reply_id) + .ok_or("Thread no longer available. Reply retained.")?; + if reconcile_reply_readback(d, thread, &marker) { + save(&id, &data)?; + return Ok(()); + } + validate(&id, &expected)?; + let reply = format!("{}\n\n{}", d.body, marker); + let thread = thread.clone(); + d.status = "posting".into(); + save(&id, &data)?; + let result = forge::delivery::post_reply(&pr.provider, &cwd, pr.number, &thread, &reply); + let d = data + .requests + .iter_mut() + .find(|r| r.id == request_id) + .and_then(|r| r.drafts.iter_mut().find(|d| d.key == key)) + .ok_or("Reply disappeared")?; + d.status = if result.is_ok() { + "posted" + } else { + "uncertain" + } + .into(); + d.error = result.as_ref().err().cloned(); + save(&id, &data)?; + result + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_pr_create( + id: String, + inputs: Vec, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if inputs.is_empty() || inputs.len() > 64 { + return Err("Select 1-64 repositories".into()); + } + let mut results = vec![]; + let mut seen = HashSet::new(); + for input in inputs { + let dir = input.identity.dir_name.clone(); + // Per-item outcome, like update(): a dup mid-list must not drop + // the results for repos already pushed/created. + let outcome = (|| { + if !seen.insert(dir.clone()) { + return Err("Duplicate repository selection".into()); + } + let (w, cwd) = validate(&id, &input.identity)?; + let l = lookup(&w, &cwd, &dir); + if l.status != "ok" { + return Err(l.message); + } + if let Some(p) = l.pr { + if matches!(p.state.as_str(), "open" | "draft") { + persist_pr(&id, &dir, &p)?; + return Ok(p.url); + } + } + if input.identity.branch.is_empty() { + return Err("Detached HEAD. Check out a branch first.".into()); + } + if !git(&["status", "--porcelain", "-uall"], &cwd) + .map_err(|e| e.to_string())? + .trim() + .is_empty() + { + return Err("Commit or discard local changes before creating a PR.".into()); + } + let base = input.base.trim(); + if base.is_empty() || input.title.trim().is_empty() { + return Err("Title and base branch are required".into()); + } + if git_compare(&cwd, base, true) + .map_err(|e| e.to_string())? + .files + .is_empty() + { + return Err("No branch changes against the selected base. Skipped.".into()); + } + let provider = l.provider.ok_or("Unsupported provider")?; + let remote = detect_default_remote(&cwd); + let prefix = format!("{remote}/"); + let base = base.strip_prefix(&prefix).unwrap_or(base); + validate(&id, &input.identity)?; + git_push(&cwd)?; + validate(&id, &input.identity)?; + let url = forge::pr_create( + &provider, + &cwd, + input.title.trim(), + input.body.trim(), + base, + input.draft, + ) + .map_err(|e| e.to_string())?; + let number = forge::pr_number_from_url(&url).ok_or( + "Created PR URL could not be read. Open the provider before retrying.", + )?; + // Persist the URL even if the immediately-following status request fails. + let p = forge::PrStatus { + provider, + number, + url: url.clone(), + title: input.title, + state: if input.draft { "draft" } else { "open" }.into(), + checks: "none".into(), + review: "none".into(), + base: base.into(), + head: input.identity.branch, + }; + persist_pr(&id, &dir, &p)?; + Ok(url) + })(); + results.push(ActionResult { + dir_name: dir.clone(), + name: if dir.is_empty() { "Host".into() } else { dir }, + action: "pr".into(), + url: outcome.as_ref().ok().cloned(), + result: None, + error: outcome.err(), + }); + } + store_results(&id, results) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_update( + id: String, + expected: Vec, + mode: UpdateMode, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + if expected.is_empty() || expected.len() > 64 { + return Err("Select 1-64 repositories".into()); + } + let mut seen = HashSet::new(); + let results = expected + .into_iter() + .map(|i| { + let dir = i.dir_name.clone(); + let outcome = (|| { + if !seen.insert(dir.clone()) { + return Err("Duplicate repository selection".into()); + } + let (w, cwd) = validate(&id, &i)?; + let base = repo_base_branch(&w, &dir, &load_projects_all()); + git_update_repo(&cwd, mode, &base) + })(); + ActionResult { + dir_name: dir.clone(), + name: if dir.is_empty() { "Host".into() } else { dir }, + action: "update".into(), + url: None, + error: outcome.as_ref().err().cloned(), + result: outcome.ok(), + } + }) + .collect(); + store_results(&id, results) + }) + .await + .map_err(|e| e.to_string())? +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preview_detects_tracked_edits_and_branch_changes() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + git(&["init", "-b", "main"], cwd).unwrap(); + git(&["config", "user.email", "user@example.com"], cwd).unwrap(); + git(&["config", "user.name", "alice"], cwd).unwrap(); + fs::write(cwd.join("a"), "one").unwrap(); + git(&["add", "a"], cwd).unwrap(); + git(&["commit", "-m", "initial"], cwd).unwrap(); + let w = Task { + path: cwd.to_string_lossy().into_owned(), + ..Default::default() + }; + let first = identity(&w, "").unwrap(); + fs::write(cwd.join("a"), "two").unwrap(); + let second = identity(&w, "").unwrap(); + assert_ne!(first.worktree, second.worktree); + git(&["checkout", "-b", "other"], cwd).unwrap(); + assert_ne!(identity(&w, "").unwrap().branch, first.branch); + } +} + +#[tauri::command] +pub async fn task_delivery_results(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _guard = LOCK.lock(); + Ok(load(&id)?.results) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn task_delivery_archive_ready(id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let w = task(&id)?; + if w.is_main_checkout { + return Ok(false); + } + let rows = repos(&id)?; + for row in rows { + let Some(i) = row.identity else { + return Ok(false); + }; + if row.dirty || row.changed.is_none() { + return Ok(false); + } + let l = lookup(&w, Path::new(&i.path), &i.dir_name); + if l.status != "ok" { + return Ok(false); + } + match l.pr { + Some(p) if p.state == "merged" => {} + None if row.changed == Some(false) => {} + _ => return Ok(false), + } + } + Ok(true) + }) + .await + .map_err(|e| e.to_string())? +} + +fn store_results(id: &str, results: Vec) -> Result, String> { + let mut data = load(id)?; + for result in results { + // One row per (dir, action): a pr_create row must not erase a + // still-conflicted update row for the same repo, or the conflicts + // handoff loses its evidence. Legacy rows carry no action and are + // replaced by the next write for their dir. + data.results.retain(|r| { + !(r.dir_name == result.dir_name && (r.action == result.action || r.action.is_empty())) + }); + data.results.push(result); + } + save(id, &data)?; + Ok(data.results) +} + +#[cfg(test)] +mod persistence_tests { + use super::*; + use crate::test_support::with_scratch_data_dir; + + #[test] + fn reports_are_local_scoped_and_results_survive_reload() { + with_scratch_data_dir(|_| { + let checkout = tempfile::tempdir().unwrap(); + let root = checkout.path(); + git(&["init", "-b", "main"], root).unwrap(); + git(&["config", "user.email", "fixture@example.test"], root).unwrap(); + git(&["config", "user.name", "Fixture"], root).unwrap(); + fs::write(root.join("README.md"), "fixture").unwrap(); + git(&["add", "README.md"], root).unwrap(); + git(&["commit", "-m", "fixture"], root).unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let reviewed = identity(&w, "").unwrap(); + let request = tauri::async_runtime::block_on(task_delivery_request( + w.id.clone(), + vec![reviewed.clone()], + vec![], + "prs".into(), + Some("app".into()), + vec![], + )) + .unwrap(); + assert_eq!( + identity(&w, "").unwrap(), + reviewed, + "ignored reports must not invalidate the preview" + ); + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + request.id.clone(), + "sent".into(), + None, + None, + )) + .unwrap(); + fs::write( + &request.report, + r#"{"prs":[{"dir_name":"outside","title":"wrong","body":""}]}"#, + ) + .unwrap(); + // A malformed report must not fail the listing: it lands on the + // offending request and leaves the status alone so a corrected + // rewrite still imports. + let listed = + tauri::async_runtime::block_on(task_delivery_requests(w.id.clone())).unwrap(); + assert_eq!( + listed[0].error.as_deref(), + Some("PR report contains an unrequested repository") + ); + assert_eq!(listed[0].status, "sent"); + assert!(listed[0].prs.is_empty()); + fs::write(&request.report, r#"{"prs":[{"dir_name":"","title":"Reviewed title","body":"Proposed body"}],"drafts":[]}"#).unwrap(); + let imported = + tauri::async_runtime::block_on(task_delivery_requests(w.id.clone())).unwrap(); + assert_eq!(imported[0].prs[0].title, "Reviewed title"); + assert_eq!(imported[0].status, "drafted"); + assert_eq!(imported[0].error, None); + assert!( + task(&w.id).unwrap().pr_number.is_none(), + "import does not create or persist a PR" + ); + store_results( + &w.id, + vec![ActionResult { + dir_name: "".into(), + name: "Host".into(), + action: "pr".into(), + url: Some("https://example.test/pull/7".into()), + result: None, + error: None, + }], + ) + .unwrap(); + store_results( + &w.id, + vec![ActionResult { + dir_name: "api".into(), + name: "api".into(), + action: "update".into(), + url: None, + result: None, + error: Some("offline".into()), + }], + ) + .unwrap(); + assert_eq!( + load(&w.id).unwrap().results.len(), + 2, + "retrying selected repos keeps other results" + ); + fs::write(root.join("README.md"), "changed").unwrap(); + assert!(validate(&w.id, &reviewed).is_err()); + }); + } + + #[test] + fn request_status_transitions_reject_re_sends_and_dead_states() { + with_scratch_data_dir(|_| { + let checkout = tempfile::tempdir().unwrap(); + let root = checkout.path(); + git(&["init", "-b", "main"], root).unwrap(); + git(&["config", "user.email", "fixture@example.test"], root).unwrap(); + git(&["config", "user.name", "Fixture"], root).unwrap(); + fs::write(root.join("README.md"), "fixture").unwrap(); + git(&["add", "README.md"], root).unwrap(); + git(&["commit", "-m", "fixture"], root).unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let request = tauri::async_runtime::block_on(task_delivery_request( + w.id.clone(), + vec![identity(&w, "").unwrap()], + vec![], + "prs".into(), + None, + vec![], + )) + .unwrap(); + let set = |to: &str| { + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + request.id.clone(), + to.into(), + None, + None, + )) + }; + set("queued").unwrap(); + set("queued").expect("same-status writes are idempotent"); + set("sent").unwrap(); + assert!(set("queued").is_err(), "a sent request cannot re-queue"); + assert!(set("sent").is_ok()); + // 'drafted' (the report-imported end state) is terminal except + // for the user's explicit dismiss, which lands on 'failed'. + let mut data = load(&w.id).unwrap(); + data.requests[0].status = "drafted".into(); + save(&w.id, &data).unwrap(); + assert!(set("sent").is_err()); + assert!(set("failed").is_ok(), "dismiss from drafted"); + }); + } + + #[test] + fn amend_rewrites_only_prepared_requests() { + with_scratch_data_dir(|_| { + let checkout = tempfile::tempdir().unwrap(); + let root = checkout.path(); + git(&["init", "-b", "main"], root).unwrap(); + git(&["config", "user.email", "fixture@example.test"], root).unwrap(); + git(&["config", "user.name", "Fixture"], root).unwrap(); + fs::write(root.join("README.md"), "fixture").unwrap(); + git(&["add", "README.md"], root).unwrap(); + git(&["commit", "-m", "fixture"], root).unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let request = tauri::async_runtime::block_on(task_delivery_request( + w.id.clone(), + vec![identity(&w, "").unwrap()], + vec![], + "prs".into(), + Some("old".into()), + vec![], + )) + .unwrap(); + let amended = tauri::async_runtime::block_on(task_delivery_request_amend( + w.id.clone(), + request.id.clone(), + vec![], + vec![], + Some("repo · branch".into()), + )) + .unwrap(); + assert_eq!(amended.scope, "repo · branch"); + assert!(amended.evidence.is_empty()); + // Amend rotates the id: a queue item or sender still holding the + // old id must fail instead of typing the pre-amend prompt. + assert_ne!(amended.id, request.id); + assert!( + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + request.id.clone(), + "failed".into(), + None, + None, + )) + .is_err(), + "the replaced request id is gone" + ); + tauri::async_runtime::block_on(task_delivery_request_status( + w.id.clone(), + amended.id.clone(), + "sent".into(), + None, + None, + )) + .unwrap(); + assert!( + tauri::async_runtime::block_on(task_delivery_request_amend( + w.id.clone(), + amended.id.clone(), + vec![], + vec![], + Some("too late".into()), + )) + .is_err(), + "a sent request's evidence is immutable" + ); + assert_eq!(load(&w.id).unwrap().requests[0].scope, "repo · branch"); + // Empty evidence snapshots always pass the send-time check. + tauri::async_runtime::block_on(task_delivery_request_check( + w.id.clone(), + amended.id.clone(), + )) + .unwrap(); + assert!(tauri::async_runtime::block_on(task_delivery_request_check( + w.id.clone(), + "missing".into(), + )) + .is_err()); + }); + } + + #[test] + fn import_report_rejects_unrequested_content_and_keeps_local_edits() { + let checkout = tempfile::tempdir().unwrap(); + // safe_report compares the stored path against the CANONICAL task + // root — /var/… resolves to /private/var/… on macOS. + let root = dunce::canonicalize(checkout.path()).unwrap(); + let w = Task { + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + let dir = root.join(".termic-delivery"); + fs::create_dir_all(&dir).unwrap(); + let draft = |key: &str, body: &str| Draft { + key: key.into(), + dir_name: String::new(), + pr_number: 1, + thread_id: "t".into(), + reply_id: "x".into(), + body: body.into(), + status: "draft".into(), + error: None, + }; + let mut r = Request { + id: "r1".into(), + identities: vec![], + report: dir.join("r1.json").to_string_lossy().into_owned(), + status: "sent".into(), + error: None, + kind: "replies".into(), + drafts: vec![draft("a", ""), draft("b", "local edit")], + prs: vec![], + scope: String::new(), + evidence: HashMap::new(), + agent: None, + }; + // A replies report cannot smuggle PR drafts past the request kind. + fs::write( + dir.join("r1.json"), + r#"{"prs":[{"dir_name":"","title":"t","body":"b"}]}"#, + ) + .unwrap(); + assert_eq!( + import_report(&w, &mut r).unwrap_err(), + "Unexpected PR drafts in report" + ); + // Reports fill empty drafts only: a body the user already saved + // locally is not overwritten by what the agent produced. + fs::write( + dir.join("r1.json"), + r#"{"drafts":[{"key":"a","body":"from agent"},{"key":"b","body":"overwrite?"}]}"#, + ) + .unwrap(); + assert_eq!(import_report(&w, &mut r), Ok(Some(true))); + assert_eq!(r.drafts[0].body, "from agent"); + assert_eq!(r.drafts[1].body, "local edit"); + assert_eq!(r.status, "drafted"); + } + + #[test] + fn import_report_validates_shape_and_applies_atomically() { + let checkout = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(checkout.path()).unwrap(); + let w = Task { + path: root.to_string_lossy().into_owned(), + ..Default::default() + }; + let dir = root.join(".termic-delivery"); + fs::create_dir_all(&dir).unwrap(); + let draft = |key: &str| Draft { + key: key.into(), + dir_name: String::new(), + pr_number: 1, + thread_id: "t".into(), + reply_id: "x".into(), + body: String::new(), + status: "draft".into(), + error: None, + }; + let request = |kind: &str, drafts: Vec| Request { + id: "r1".into(), + identities: vec![], + report: dir.join("r1.json").to_string_lossy().into_owned(), + status: "sent".into(), + error: None, + kind: kind.into(), + drafts, + prs: vec![], + scope: String::new(), + evidence: HashMap::new(), + agent: None, + }; + // Non-object and non-array payloads are errors, not silent no-ops + // (a bare `"ok"` used to count as a delivered report and wedge the + // request at 'drafted' with nothing inside). + let mut r = request("replies", vec![draft("a"), draft("b")]); + fs::write(&r.report, r#""ok""#).unwrap(); + assert_eq!( + import_report(&w, &mut r).unwrap_err(), + "Report must be a JSON object" + ); + assert_eq!(r.status, "sent"); + fs::write(&r.report, r#"{"drafts":{"a":1}}"#).unwrap(); + assert!(import_report(&w, &mut r).unwrap_err().contains("array")); + fs::write(&r.report, r#"{"prs":"done"}"#).unwrap(); + assert!(import_report(&w, &mut r).unwrap_err().contains("array")); + // A bad second item must not leave the first applied — the report + // validates fully before any draft is filled, so a corrected + // rewrite can still land (drafts only fill empty bodies). + fs::write( + &r.report, + r#"{"drafts":[{"key":"a","body":"hi"},{"key":"bogus","body":"x"}]}"#, + ) + .unwrap(); + assert!(import_report(&w, &mut r).is_err()); + assert!(r.drafts[0].body.is_empty(), "partial apply must not stick"); + fs::write( + &r.report, + r#"{"drafts":[{"key":"a","body":"hi"},{"key":"b","body":"yo"}]}"#, + ) + .unwrap(); + import_report(&w, &mut r).unwrap(); + assert_eq!(r.drafts[1].body, "yo"); + assert_eq!(r.status, "drafted"); + // An explicit empty list is a delivered report — the agent answered + // with nothing to fill; only a MISSING key still means "waiting". + let mut pr_req = request("prs", vec![]); + fs::write(&pr_req.report, r#"{"prs":[]}"#).unwrap(); + import_report(&w, &mut pr_req).unwrap(); + assert_eq!(pr_req.status, "drafted"); + let mut reply_req = request("replies", vec![draft("a")]); + fs::write(&reply_req.report, r#"{"drafts":[]}"#).unwrap(); + import_report(&w, &mut reply_req).unwrap(); + assert_eq!(reply_req.status, "drafted"); + // Drafts are mandatory only for 'replies': fix/conflicts drafts are + // optional agent extras — a report answering without one still + // completes, while a replies report missing the key keeps waiting. + let mut fix_req = request("fix", vec![draft("a")]); + // A NON-empty "prs" list doesn't belong on a fix request at all. + fs::write( + &fix_req.report, + r#"{"prs":[{"dir_name":"","title":"x","body":"y"}]}"#, + ) + .unwrap(); + import_report(&w, &mut fix_req).unwrap_err(); + fs::write( + &fix_req.report, + r#"{"drafts":[{"key":"a","body":"fixed it"}]}"#, + ) + .unwrap(); + import_report(&w, &mut fix_req).unwrap(); + assert_eq!(fix_req.drafts[0].body, "fixed it"); + assert_eq!(fix_req.status, "drafted"); + let mut fix_req2 = request("fix", vec![draft("a")]); + fs::write(&fix_req2.report, r#"{"extra":true}"#).unwrap(); + import_report(&w, &mut fix_req2).unwrap(); + assert_eq!( + fix_req2.status, "drafted", + "fix completes on any object report" + ); + let mut wait_req = request("replies", vec![draft("a")]); + fs::write(&wait_req.report, r#"{"prs":[]}"#).unwrap(); + import_report(&w, &mut wait_req).unwrap(); + assert_eq!( + wait_req.status, "sent", + "replies still waits for its drafts key" + ); + } + + #[test] + fn results_are_stored_per_repo_and_action() { + with_scratch_data_dir(|_| { + let tmp = tempfile::tempdir().unwrap(); + let w = Task { + id: Uuid::new_v4().to_string(), + name: "Fixture".into(), + path: tmp.path().to_string_lossy().into_owned(), + ..Default::default() + }; + save_task(&w).unwrap(); + let row = |action: &str, error: Option<&str>| ActionResult { + dir_name: "api".into(), + name: "api".into(), + action: action.into(), + url: None, + result: None, + error: error.map(str::to_string), + }; + // A conflicted-update row and a failed pr_create row for the + // same repo used to overwrite each other — both must survive. + store_results(&w.id, vec![row("update", Some("conflict"))]).unwrap(); + store_results(&w.id, vec![row("pr", Some("offline"))]).unwrap(); + let kept = load(&w.id).unwrap().results; + assert_eq!(kept.len(), 2, "pr and update results coexist per repo"); + // The same action still replaces its previous row. + store_results(&w.id, vec![row("pr", Some("retry failed"))]).unwrap(); + let kept = load(&w.id).unwrap().results; + assert_eq!(kept.len(), 2); + assert_eq!(kept[1].error.as_deref(), Some("retry failed")); + }); + } + + #[test] + fn evidence_keys_resolve_the_longest_dir_prefix() { + let id = |dir_name: &str| Identity { + dir_name: dir_name.into(), + path: String::new(), + branch: String::new(), + head: String::new(), + remote: String::new(), + worktree: String::new(), + pr_number: None, + pr_revision: None, + }; + let ids = vec![id("a"), id("a:b"), id("")]; + // ':' is a legal dir char on unix — "a:b:ci:x" must resolve to dir + // "a:b", not the first-listed "a" (which would split which="b"). + let (i, which, item) = parse_evidence_key(&ids, "a:b:ci:build").unwrap(); + assert_eq!(i.dir_name, "a:b"); + assert_eq!((which, item), ("ci", "build")); + // The host repo's empty dir still resolves, and only for its own + // prefix. + let (i, which, item) = parse_evidence_key(&ids, ":review:t1").unwrap(); + assert_eq!(i.dir_name, ""); + assert_eq!((which, item), ("review", "t1")); + assert!(parse_evidence_key(&ids, "nope:ci:x").is_err()); + } +} + +fn read_report(path: &Path) -> Result, String> { + use std::io::Read; + #[cfg(unix)] + let file = { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::OpenOptionsExt; + // Pin the directory before opening its child. A replacement symlink + // cannot redirect the report read between validation and open. + let directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path.parent().ok_or("Missing report parent")?) + .map_err(|e| e.to_string())?; + let name = + std::ffi::CString::new(path.file_name().ok_or("Missing report name")?.as_bytes()) + .map_err(|e| e.to_string())?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + unsafe { fs::File::from_raw_fd(fd) } + }; + #[cfg(not(unix))] + let file = fs::File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("Report must be a regular file".into()); + } + let mut bytes = Vec::new(); + file.take(256 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 256 * 1024 { + return Err("Draft report exceeds 256 KiB".into()); + } + Ok(bytes) +} + +#[cfg(test)] +mod report_read_tests { + use super::*; + #[test] + fn oversized_report_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("report.json"); + fs::write(&path, vec![b' '; 256 * 1024 + 1]).unwrap(); + assert!(read_report(&path).is_err()); + } + #[cfg(unix)] + #[test] + fn report_symlink_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("report.json"); + fs::write(tmp.path().join("other"), b"{}").unwrap(); + std::os::unix::fs::symlink(tmp.path().join("other"), &path).unwrap(); + assert!(read_report(&path).is_err()); + } +} + +// True means this call only reconciles an earlier attempt, never posts. +fn reconcile_reply_readback(d: &mut Draft, thread: &ReviewThread, marker: &str) -> bool { + let reply = format!("{}\n\n{}", d.body, marker); + if thread.comments.iter().any(|c| c.body == reply) { + d.status = "posted".into(); + d.error = None; + return true; + } + if matches!(d.status.as_str(), "posting" | "uncertain") { + d.status = "retry_ready".into(); + d.error=Some("Provider readback did not find this reply. Check the thread before explicitly retrying.".into()); + return true; + } + false +} +#[cfg(test)] +mod reply_tests { + use super::*; + #[test] + fn readback_requires_an_explicit_second_action_and_catches_late_posts() { + let mut d = Draft { + key: "thread".into(), + dir_name: "".into(), + pr_number: 7, + thread_id: "t".into(), + reply_id: "1".into(), + body: "Reviewed reply".into(), + status: "uncertain".into(), + error: None, + }; + let mut thread = ReviewThread { + id: "t".into(), + reply_id: "1".into(), + path: None, + line: None, + resolved: Some(false), + url: String::new(), + comments: vec![], + }; + assert!(reconcile_reply_readback(&mut d, &thread, "")); + assert_eq!(d.status, "retry_ready"); + assert!( + !reconcile_reply_readback(&mut d, &thread, ""), + "a second explicit action may post after fresh readback" + ); + thread.comments.push(ThreadComment { + id: "2".into(), + author: "Fixture".into(), + body: "Reviewed reply\n\n".into(), + }); + assert!(reconcile_reply_readback(&mut d, &thread, "")); + assert_eq!(d.status, "posted"); + } +} diff --git a/src-tauri/src/forge.rs b/src-tauri/src/forge.rs index d5f68273..3c4f2c55 100644 --- a/src-tauri/src/forge.rs +++ b/src-tauri/src/forge.rs @@ -145,7 +145,7 @@ fn probe_authed_hosts() -> HashMap { out } -fn auth_text(o: &std::process::Output) -> String { +fn auth_text(o: &CmdOut) -> String { format!( "{}\n{}", String::from_utf8_lossy(&o.stdout), @@ -223,7 +223,19 @@ fn reprobe_bin(name: &str) -> Option { resolved } -fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result { +/// `std::process::Output` cannot be constructed outside `Command::output()`, +/// and `output()` has no deadline — a forge CLI parked on a credential +/// prompt or a dead socket would pin its spawn_blocking thread (and, for +/// delivery commands running under the process-global lock, every other +/// delivery call) forever. Same spawn contract, same field names, one change: +/// a hard wall-clock ceiling. +pub struct CmdOut { + pub status: std::process::ExitStatus, + pub stdout: Vec, + pub stderr: Vec, +} + +fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result { let mut cmd = crate::proc_ctl::command(bin); cmd.args(args) // Login-shell PATH so the CLI can find its own helpers (git, @@ -244,7 +256,65 @@ fn run(bin: &str, args: &[&str], cwd: Option<&Path>) -> std::io::Result>>| { + h.and_then(|h| h.join().ok()).unwrap_or_default() + }; + // REST calls land in seconds; the ceiling only fires on a genuinely + // wedged child (dead host, ignored prompt-disable env, hung helper). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return Ok(CmdOut { + status, + stdout: join(stdout), + stderr: join(stderr), + }); + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + // Do NOT join the readers: a grandchild that inherited + // the pipes (credential helper, pager, daemon) keeps + // them open after the kill, so read_to_end never ends + // and the "deadline" would hang forever. The detached + // threads exit on their own once the fds close. + drop(stdout); + drop(stderr); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("{bin} did not exit within 120s"), + )); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => return Err(e), + } + } } // ───────────────────────── detection (Settings / hints) ───────────────────────── @@ -601,13 +671,26 @@ pub enum ForgeError { Other(String), } -fn stderr_of(o: &std::process::Output) -> String { - String::from_utf8_lossy(&o.stderr).trim().to_string() +fn stderr_of(o: &CmdOut) -> String { + // CLI stderr can echo a PAT-bearing remote URL; strip userinfo before + // it reaches persisted errors or toasts. + crate::scrub_url_userinfo(&String::from_utf8_lossy(&o.stderr)) + .trim() + .to_string() } /// Classify a failed CLI invocation: auth problems get their own arm so /// the UI can say "run gh auth login" instead of dumping stderr. -fn classify_failure(provider: &str, o: &std::process::Output) -> ForgeError { +impl std::fmt::Display for ForgeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CliMissing(cli) => write!(f, "The {cli} CLI is not installed"), + Self::Auth(message) | Self::Other(message) => f.write_str(message), + } + } +} + +fn classify_failure(provider: &str, o: &CmdOut) -> ForgeError { classify_stderr(provider, &stderr_of(o)) } @@ -2944,3 +3027,6 @@ code.internal.acme.com configured to use ssh protocol.\n"; assert_eq!(out[0].created_at, "2026-06-12T09:00:00Z"); } } + +#[path = "forge_delivery.rs"] +pub mod delivery; diff --git a/src-tauri/src/forge_delivery.rs b/src-tauri/src/forge_delivery.rs new file mode 100644 index 00000000..a5d9314c --- /dev/null +++ b/src-tauri/src/forge_delivery.rs @@ -0,0 +1,1089 @@ +//! Provider detail operations. Authentication remains owned by gh/glab/az. +use super::*; +use crate::delivery::{CiNode, ReviewThread, ThreadComment}; +use serde_json::{json, Value}; + +fn text(v: &Value, key: &str) -> String { + v[key].as_str().unwrap_or("").to_string() +} +fn ident(v: &Value, key: &str) -> String { + v[key] + .as_str() + .map(str::to_string) + .or_else(|| v[key].as_u64().map(|n| n.to_string())) + .unwrap_or_default() +} +fn checked_output( + provider: &str, + cwd: &Path, + args: &[String], +) -> Result { + let cli = cli_for_provider(provider); + let bin = reprobe_bin(cli).ok_or_else(|| format!("{cli} is not installed"))?; + let mut argv = args.to_vec(); + // gh api otherwise defaults to github.com even in an Enterprise checkout. + if provider == GITHUB && argv.first().map(String::as_str) == Some("api") { + let remote = crate::git( + &["remote", "get-url", &crate::detect_default_remote(cwd)], + cwd, + ) + .map_err(|e| e.to_string())?; + let host = host_of_remote(&remote).ok_or("cannot resolve GitHub host")?; + argv.extend(["--hostname".into(), host]); + } + let refs: Vec<_> = argv.iter().map(String::as_str).collect(); + let out = run(&bin, &refs, Some(cwd)).map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(format!("{}", classify_failure(provider, &out))); + } + Ok(out) +} +fn cli_json(provider: &str, cwd: &Path, args: Vec) -> Result { + let out = checked_output(provider, cwd, &args)?; + serde_json::from_slice(&out.stdout).map_err(|e| format!("invalid provider JSON: {e}")) +} +fn args(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() +} + +fn rest(provider: &str, cwd: &Path, path: &str, body: Option<&Value>) -> Result { + let mut argv = args(&["api", path]); + let temporary = body + .map(|_| std::env::temp_dir().join(format!("termic-reply-{}.json", uuid::Uuid::new_v4()))); + if let (Some(body), Some(file)) = (body, &temporary) { + private_body(file, body)?; + argv.extend([ + "--method".into(), + "POST".into(), + "--input".into(), + file.to_string_lossy().into_owned(), + ]); + } + let result = cli_json(provider, cwd, argv); + if let Some(file) = temporary { + let _ = std::fs::remove_file(file); + } + result +} +fn private_body(path: &Path, value: &Value) -> Result<(), String> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|e| e.to_string())?; + file.write_all(value.to_string().as_bytes()) + .map_err(|e| e.to_string()) +} +fn pages(provider: &str, cwd: &Path, path: &str) -> Result, String> { + let mut all = vec![]; + // Explicit ceiling prevents a large MR monopolizing the app's worker pool. + for page in 1..=20 { + let sep = if path.contains('?') { '&' } else { '?' }; + let v = rest( + provider, + cwd, + &format!("{path}{sep}per_page=100&page={page}"), + None, + )?; + let batch = v.as_array().ok_or("expected a provider list")?; + all.extend(batch.iter().cloned()); + if batch.len() < 100 { + return Ok(all); + } + } + Err("More than 2,000 items. Open the full list on the provider.".into()) +} +fn azure( + cwd: &Path, + area: &str, + resource: &str, + route: &[(&str, String)], + body: Option<&Value>, +) -> Result { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + let mut argv = args(&[ + "devops", + "invoke", + "--area", + area, + "--resource", + resource, + "--org", + &org, + "--api-version", + "7.1", + "--output", + "json", + "--route-parameters", + ]); + argv.push(format!("project={project}")); + argv.extend(route.iter().map(|(k, v)| format!("{k}={v}"))); + let file = body + .map(|_| std::env::temp_dir().join(format!("termic-reply-{}.json", uuid::Uuid::new_v4()))); + if let (Some(body), Some(file)) = (body, &file) { + private_body(file, body)?; + argv.extend([ + "--http-method".into(), + "POST".into(), + "--in-file".into(), + file.to_string_lossy().into_owned(), + ]); + } + let result = cli_json(AZURE, cwd, argv); + if let Some(file) = file { + let _ = std::fs::remove_file(file); + } + result +} + +pub fn revision(provider: &str, cwd: &Path, number: u64) -> Result { + let n = number.to_string(); + let v = match provider { + GITHUB => rest( + provider, + cwd, + &format!("repos/{{owner}}/{{repo}}/pulls/{n}"), + None, + )?, + GITLAB => rest( + provider, + cwd, + &format!("projects/:id/merge_requests/{n}"), + None, + )?, + AZURE => { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + cli_json( + provider, + cwd, + args(&[ + "repos", + "pr", + "show", + "--id", + &n, + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )? + } + _ => return Err("unsupported provider".into()), + }; + let sha = match provider { + GITHUB => text(&v["head"], "sha"), + GITLAB => text(&v, "sha"), + _ => text(&v["lastMergeSourceCommit"], "commitId"), + }; + if !crate::is_commit_ish(&sha) { + return Err("provider did not identify the PR revision".into()); + } + Ok(sha) +} +fn state(raw: &str) -> String { + match raw.to_ascii_lowercase().as_str() { + "success" | "succeeded" | "passed" | "approved" => "passed", + // Azure build *results* that mean "finished, something needs a look" + // (partiallysucceeded / abandoned) land here lowercased too. + "failure" + | "failed" + | "timed_out" + | "startup_failure" + | "rejected" + | "broken" + | "partiallysucceeded" + | "succeededwithissues" + | "abandoned" => "failed", + "cancelled" | "canceled" => "canceled", + "skipped" | "neutral" | "notapplicable" => "skipped", + "action_required" | "manual" | "waiting" => "approval", + // Azure *statuses* land here lowercased: inProgress/cancelling/postponed. + "in_progress" | "inprogress" | "running" | "cancelling" | "postponed" => "running", + "queued" + | "pending" + | "created" + | "notstarted" + | "notset" + | "preparing" + | "scheduled" + | "waiting_for_resource" => "pending", + _ => "unknown", + } + .to_string() +} +fn duration(v: &Value, start: &str, end: &str) -> Option { + let a = chrono::DateTime::parse_from_rfc3339(v[start].as_str()?).ok()?; + let b = chrono::DateTime::parse_from_rfc3339(v[end].as_str()?).ok()?; + Some((b - a).num_milliseconds().max(0) as f64 / 1000.) +} +fn node( + id: String, + parent: Option, + name: String, + kind: &str, + status: &str, + url: String, + log_id: Option, +) -> CiNode { + CiNode { + id, + parent, + name, + kind: kind.into(), + status: state(status), + url, + log_id, + duration: None, + } +} + +pub fn ci(provider: &str, cwd: &Path, number: u64, expected: &str) -> Result, String> { + match provider { + GITHUB => github_ci(cwd, number, expected), + GITLAB => gitlab_ci(cwd, number, expected), + AZURE => azure_ci(cwd, number, expected), + _ => Err("unsupported provider".into()), + } +} +fn github_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let pr = rest( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/pulls/{number}"), + None, + )?; + if text(&pr["head"], "sha") != expected { + return Err("PR revision changed. Refresh before reviewing CI.".into()); + } + let mut revisions = vec![expected.to_string()]; + if pr["mergeable"].as_bool() == Some(true) { + if let Some(sha) = pr["merge_commit_sha"] + .as_str() + .filter(|s| crate::is_commit_ish(s)) + { + if merge_revision_matches(GITHUB, cwd, sha, expected)? { + revisions.push(sha.into()); + } + } + } + let mut runs = HashSet::new(); + let mut nodes = vec![]; + for sha in &revisions { + let checks = rest( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100"), + None, + )?; + let list = checks["check_runs"] + .as_array() + .ok_or("missing GitHub checks")?; + if checks["total_count"].as_u64().unwrap_or(0) > list.len() as u64 { + return Err("More than 100 checks. Open the full checks list on GitHub.".into()); + } + for check in list { + let url = text(check, "details_url"); + let parts: Vec<_> = url.split('/').collect(); + let run = parts + .iter() + .position(|p| *p == "runs") + .and_then(|i| parts.get(i + 1)) + .and_then(|s| s.parse::().ok()); + if let Some(run) = run { + runs.insert(run); + } else { + let status = check["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| check["status"].as_str().unwrap_or("")); + nodes.push(node( + format!("check:{}", ident(check, "id")), + None, + text(check, "name"), + "check", + status, + url, + None, + )); + } + } + let statuses = pages( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}/statuses"), + )?; + let mut seen = HashSet::new(); + for status in statuses { + let name = text(&status, "context"); + if !seen.insert(name.clone()) { + continue; + } + nodes.push(node( + format!("status:{}", ident(&status, "id")), + None, + name, + "check", + &text(&status, "state"), + text(&status, "target_url"), + None, + )); + } + } + for run in runs { + let v = cli_json( + GITHUB, + cwd, + args(&[ + "run", + "view", + &run.to_string(), + "--json", + "jobs,workflowName,headSha,headBranch,url,status,conclusion", + ]), + )?; + if !github_run_matches(&v, &text(&pr["head"], "ref"), &revisions) { + continue; + } + let root = format!("gh:{run}"); + let verdict = v["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| v["status"].as_str().unwrap_or("")); + nodes.push(node( + root.clone(), + None, + text(&v, "workflowName"), + "workflow", + verdict, + text(&v, "url"), + None, + )); + for job in v["jobs"].as_array().unwrap_or(&vec![]) { + let jid = ident(job, "databaseId"); + let id = format!("gh:{run}:{jid}"); + let verdict = job["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| job["status"].as_str().unwrap_or("")); + let mut n = node( + id.clone(), + Some(root.clone()), + text(job, "name"), + "job", + verdict, + text(job, "url"), + Some(format!("gh:{jid}")), + ); + n.duration = duration(job, "startedAt", "completedAt"); + nodes.push(n); + for step in job["steps"].as_array().unwrap_or(&vec![]) { + let verdict = step["conclusion"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| step["status"].as_str().unwrap_or("")); + let mut n = node( + format!("{id}:{}", ident(step, "number")), + Some(id.clone()), + text(step, "name"), + "step", + verdict, + text(job, "url"), + Some(format!("gh:{jid}")), + ); + n.duration = duration(step, "startedAt", "completedAt"); + nodes.push(n); + } + } + } + Ok(nodes) +} +fn gitlab_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let mr = rest( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}"), + None, + )?; + if text(&mr, "sha") != expected { + return Err("MR revision changed. Refresh before reviewing CI.".into()); + } + let mut pipelines = pages( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}/pipelines"), + )?; + pipelines.sort_by_key(|p| std::cmp::Reverse(p["id"].as_u64().unwrap_or_default())); + let merge_sha = text(&mr["head_pipeline"], "sha"); + let mut nodes = vec![]; + // Keep the newest run for each pipeline ref, not superseded attempts. + let mut refs = HashSet::new(); + for p in pipelines { + let sha = text(&p, "sha"); + let reference = text(&p, "ref"); + if sha != expected + && !(sha == merge_sha + && reference == format!("refs/merge-requests/{number}/merge") + && merge_revision_matches(GITLAB, cwd, &sha, expected)?) + { + continue; + } + let pid = ident(&p, "id"); + // Pipelines with no ref would all collide on ""; fall back to the + // pipeline id so distinct ref-less runs each survive the dedupe. + if !refs.insert(if reference.is_empty() { pid.clone() } else { reference }) { + continue; + } + let root = format!("gl:{pid}"); + nodes.push(node( + root.clone(), + None, + format!("Pipeline {pid}"), + "pipeline", + &text(&p, "status"), + text(&p, "web_url"), + None, + )); + let jobs = pages( + GITLAB, + cwd, + &format!("projects/:id/pipelines/{pid}/jobs?include_retried=false"), + )?; + let mut stages = HashSet::new(); + for j in jobs { + let stage = text(&j, "stage"); + let parent = format!("{root}:stage:{stage}"); + if stages.insert(stage.clone()) { + nodes.push(node( + parent.clone(), + Some(root.clone()), + stage, + "stage", + "", + String::new(), + None, + )); + } + let jid = ident(&j, "id"); + let mut n = node( + format!("gl:{pid}:{jid}"), + Some(parent), + text(&j, "name"), + "job", + &text(&j, "status"), + text(&j, "web_url"), + Some(format!("gl:{jid}")), + ); + n.duration = j["duration"].as_f64(); + nodes.push(n); + } + } + let stages: Vec<_> = nodes + .iter() + .filter(|n| n.kind == "stage") + .map(|n| n.id.clone()) + .collect(); + for stage in stages { + let states: Vec<_> = nodes + .iter() + .filter(|n| n.parent.as_deref() == Some(&stage)) + .map(|n| n.status.as_str()) + .collect(); + let verdict = aggregate(&states).to_string(); + if let Some(n) = nodes.iter_mut().find(|n| n.id == stage) { + n.status = verdict; + } + } + Ok(nodes) +} +fn azure_ci(cwd: &Path, number: u64, expected: &str) -> Result, String> { + let (org, project, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + let pr = cli_json( + AZURE, + cwd, + args(&[ + "repos", + "pr", + "show", + "--id", + &number.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + if text(&pr["lastMergeSourceCommit"], "commitId") != expected + || !azure_pr_in_repo(&pr, &project, &repo) + { + return Err("PR identity changed. Refresh before reviewing CI.".into()); + } + let merge = text(&pr["lastMergeCommit"], "commitId"); + let repo_id = text(&pr["repository"], "id"); + let policies = cli_json( + AZURE, + cwd, + args(&[ + "repos", + "pr", + "policy", + "list", + "--id", + &number.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + let mut seen = HashSet::new(); + let mut nodes = vec![]; + for policy in policies + .as_array() + .ok_or("missing Azure policy evaluations")? + { + if policy["configuration"]["isEnabled"].as_bool() == Some(false) { + continue; + } + if policy["context"]["buildIsNotCurrent"].as_bool() == Some(true) + || policy["context"]["isExpired"].as_bool() == Some(true) + { + continue; + } + let Some(build) = policy["context"]["buildId"].as_u64() else { + nodes.push(node( + format!("policy:{}", ident(policy, "evaluationId")), + None, + text(&policy["configuration"]["type"], "displayName"), + "policy", + &text(policy, "status"), + text(&pr, "url"), + None, + )); + continue; + }; + if !seen.insert(build) { + continue; + } + let b = cli_json( + AZURE, + cwd, + args(&[ + "pipelines", + "runs", + "show", + "--id", + &build.to_string(), + "--org", + &org, + "--project", + &project, + "-o", + "json", + ]), + )?; + let sha = text(&b, "sourceVersion"); + if text(&b["repository"], "id") != repo_id + || (sha != expected + && (merge.is_empty() + || sha != merge + || !merge_revision_matches(AZURE, cwd, &sha, expected)?)) + { + continue; + } + let root = format!("az:{build}"); + let url = text(&b["_links"]["web"], "href"); + let verdict = b["result"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| b["status"].as_str().unwrap_or("")); + nodes.push(node( + root.clone(), + None, + text(&b["definition"], "name"), + "pipeline", + verdict, + url.clone(), + None, + )); + let timeline = azure( + cwd, + "build", + "timeline", + &[("buildId", build.to_string())], + None, + )?; + let records = timeline["records"] + .as_array() + .ok_or("missing Azure timeline")?; + for r in records { + let id = ident(r, "id"); + let parent = text(r, "parentId"); + let verdict = r["result"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| r["state"].as_str().unwrap_or("")); + let log = r["log"]["id"].as_u64().map(|n| format!("az:{build}:{n}")); + let kind = text(r, "type").to_lowercase(); + let mut n = node( + format!("{root}:{id}"), + Some(if parent.is_empty() { + root.clone() + } else { + format!("{root}:{parent}") + }), + text(r, "name"), + &kind, + verdict, + url.clone(), + log, + ); + n.duration = duration(r, "startTime", "finishTime"); + nodes.push(n); + } + } + Ok(nodes) +} + +pub fn threads(provider: &str, cwd: &Path, number: u64) -> Result, String> { + match provider { + GITHUB => github_threads(cwd, number), + GITLAB => { + let list = pages( + GITLAB, + cwd, + &format!("projects/:id/merge_requests/{number}/discussions"), + )?; + Ok(list + .iter() + .filter_map(|d| { + let notes: Vec<_> = d["notes"] + .as_array()? + .iter() + .filter(|n| n["system"].as_bool() != Some(true)) + .collect(); + let first = *notes.first()?; + Some(ReviewThread { + id: ident(d, "id"), + reply_id: ident(first, "id"), + path: first["position"]["new_path"].as_str().map(str::to_string), + line: first["position"]["new_line"].as_u64(), + resolved: first["resolved"].as_bool(), + url: String::new(), + comments: notes + .iter() + .map(|n| ThreadComment { + id: ident(n, "id"), + author: text(&n["author"], "username"), + body: text(n, "body"), + }) + .collect(), + }) + }) + .collect()) + } + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + let list = azure( + cwd, + "git", + "pullRequestThreads", + &[ + ("repositoryId", repo), + ("pullRequestId", number.to_string()), + ], + None, + )?; + Ok(list["value"] + .as_array() + .ok_or("missing Azure review threads")? + .iter() + .filter(|d| d["isDeleted"].as_bool() != Some(true)) + .filter_map(|d| { + let comments: Vec<_> = d["comments"] + .as_array()? + .iter() + .filter(|n| { + n["isDeleted"].as_bool() != Some(true) + && n["commentType"].as_str() != Some("system") + && n["commentType"].as_u64() != Some(3) + }) + .map(|n| ThreadComment { + id: ident(n, "id"), + author: text(&n["author"], "displayName"), + body: text(n, "content"), + }) + .filter(|n| !n.body.is_empty()) + .collect(); + if comments.is_empty() { + return None; + } + let status = d["status"].as_str().map(str::to_lowercase).or_else(|| { + d["status"].as_u64().map(|n| { + match n { + 1 => "active", + 2 => "fixed", + 3 => "wontfix", + 4 => "closed", + 5 => "bydesign", + 6 => "pending", + _ => "unknown", + } + .into() + }) + }); + Some(ReviewThread { + id: ident(d, "id"), + reply_id: comments.first().map(|c| c.id.clone()).unwrap_or_default(), + path: d["threadContext"]["filePath"].as_str().map(str::to_string), + line: d["threadContext"]["rightFileStart"]["line"].as_u64(), + resolved: status + .as_deref() + .map(|s| matches!(s, "fixed" | "closed" | "wontfix" | "bydesign")), + url: String::new(), + comments, + }) + }) + .collect()) + } + _ => Err("unsupported provider".into()), + } +} +fn github_threads(cwd: &Path, number: u64) -> Result, String> { + let repo = cli_json(GITHUB, cwd, args(&["repo", "view", "--json", "owner,name"]))?; + let owner = text(&repo["owner"], "login"); + let name = text(&repo, "name"); + let mut cursor = Value::Null; + let mut out = vec![]; + let query="query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{id isResolved path line comments(first:100){totalCount nodes{databaseId body url author{login}}}}}}}}"; + for _ in 0..20 { + let body = json!({"query":query,"variables":{"owner":owner,"name":name,"number":number,"cursor":cursor}}); + let v = rest(GITHUB, cwd, "graphql", Some(&body))?; + if v["errors"].is_array() { + return Err(format!( + "GitHub review threads unavailable: {}", + v["errors"] + )); + } + let connection = &v["data"]["repository"]["pullRequest"]["reviewThreads"]; + for thread in connection["nodes"] + .as_array() + .ok_or("missing GitHub review threads")? + { + let comments = thread["comments"]["nodes"] + .as_array() + .ok_or("missing thread comments")?; + if thread["comments"]["totalCount"].as_u64().unwrap_or(0) > comments.len() as u64 { + return Err("Thread exceeds 100 replies. Open the full thread on GitHub.".into()); + } + let Some(first) = comments.first() else { + continue; + }; + out.push(ReviewThread { + id: text(thread, "id"), + reply_id: ident(first, "databaseId"), + path: thread["path"].as_str().map(str::to_string), + line: thread["line"].as_u64(), + resolved: thread["isResolved"].as_bool(), + url: text(first, "url"), + comments: comments + .iter() + .map(|c| ThreadComment { + id: ident(c, "databaseId"), + author: text(&c["author"], "login"), + body: text(c, "body"), + }) + .collect(), + }); + } + if connection["pageInfo"]["hasNextPage"].as_bool() != Some(true) { + let discussion = pages( + GITHUB, + cwd, + &format!("repos/{{owner}}/{{repo}}/issues/{number}/comments"), + )?; + if !discussion.is_empty() { + out.push(ReviewThread { + id: "discussion".into(), + reply_id: String::new(), + path: None, + line: None, + resolved: None, + url: String::new(), + comments: discussion + .iter() + .map(|c| ThreadComment { + id: ident(c, "id"), + author: text(&c["user"], "login"), + body: text(c, "body"), + }) + .collect(), + }); + } + return Ok(out); + } + cursor = connection["pageInfo"]["endCursor"].clone(); + } + Err("More than 2,000 threads. Open the full review on GitHub.".into()) +} + +pub fn post_reply( + provider: &str, + cwd: &Path, + number: u64, + thread: &ReviewThread, + body: &str, +) -> Result<(), String> { + match provider { + GITHUB => { + let path = if thread.id == "discussion" { + format!("repos/{{owner}}/{{repo}}/issues/{number}/comments") + } else { + format!( + "repos/{{owner}}/{{repo}}/pulls/{number}/comments/{}/replies", + thread.reply_id + ) + }; + rest(provider, cwd, &path, Some(&json!({"body":body})))?; + } + GITLAB => { + rest( + provider, + cwd, + &format!( + "projects/:id/merge_requests/{number}/discussions/{}/notes", + thread.id + ), + Some(&json!({"body":body})), + )?; + } + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + azure( + cwd, + "git", + "pullRequestThreadComments", + &[ + ("repositoryId", repo), + ("pullRequestId", number.to_string()), + ("threadId", thread.id.clone()), + ], + Some( + &json!({"content":body,"commentType":1,"parentCommentId":thread.reply_id.parse::().map_err(|_|"Invalid reply parent")?}), + ), + )?; + } + _ => return Err("unsupported provider".into()), + } + Ok(()) +} + +pub fn log(provider: &str, cwd: &Path, id: &str) -> Result { + let p: Vec<_> = id.split(':').collect(); + if p.iter().skip(1).any(|s| s.parse::().is_err()) { + return Err("invalid log identity".into()); + } + let out = match (provider, p.as_slice()) { + (GITHUB, ["gh", job]) => checked_output( + provider, + cwd, + &args(&["run", "view", "--job", job, "--log-failed"]), + )?, + (GITLAB, ["gl", job]) => checked_output( + provider, + cwd, + &args(&["api", &format!("projects/:id/jobs/{job}/trace")]), + )?, + (AZURE, ["az", build, log]) => { + let (org, project, _) = azure_scope(cwd).map_err(|e| e.to_string())?; + checked_output( + provider, + cwd, + &args(&[ + "devops", + "invoke", + "--area", + "build", + "--resource", + "logs", + "--org", + &org, + "--api-version", + "7.1", + "--route-parameters", + &format!("project={project}"), + &format!("buildId={build}"), + &format!("logId={log}"), + "--query-parameters", + "startLine=0", + "endLine=300", + "-o", + "json", + ]), + )? + } + _ => return Err("invalid provider log".into()), + }; + let raw = String::from_utf8_lossy(&out.stdout); + let ansi = regex::Regex::new(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))") + .map_err(|e| e.to_string())?; + let raw = ansi.replace_all(&raw, ""); + let raw: String = raw + .chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')) + .collect(); + let clipped: String = raw.chars().take(32000).collect(); + Ok(if provider == AZURE || raw.chars().count() > 32000 { + format!("{clipped}\n[Excerpt truncated. Open the provider for the full log.]") + } else { + clipped + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ci_states_are_distinct() { + assert_eq!(state("CANCELLED"), "canceled"); + assert_eq!(state("ACTION_REQUIRED"), "approval"); + assert_eq!(state("notStarted"), "pending"); + assert_eq!(state("FAILURE"), "failed"); + // Azure live statuses/results land lowercased — these used to fall + // through to "unknown" and render a running build as dead. + assert_eq!(state("inProgress"), "running"); + assert_eq!(state("postponed"), "running"); + assert_eq!(state("notSet"), "pending"); + assert_eq!(state("partiallySucceeded"), "failed"); + assert_eq!(state("abandoned"), "failed"); + assert_eq!(state("something-new"), "unknown"); + assert_eq!(aggregate(&["skipped"]), "skipped"); + assert_eq!(aggregate(&["passed", "canceled"]), "canceled"); + assert_eq!(aggregate(&["passed", "approval"]), "approval"); + } +} + +fn aggregate<'a>(states: &[&'a str]) -> &'a str { + for state in [ + "failed", "running", "approval", "pending", "canceled", "unknown", + ] { + if states.contains(&state) { + return state; + } + } + if !states.is_empty() && states.iter().all(|s| *s == "skipped") { + return "skipped"; + } + if !states.is_empty() && states.iter().all(|s| matches!(*s, "passed" | "skipped")) { + return "passed"; + } + "unknown" +} + +fn parent_matches(commit: &Value, expected: &str) -> bool { + commit["parents"] + .as_array() + .or_else(|| commit["parent_ids"].as_array()) + .is_some_and(|parents| { + parents + .iter() + .any(|p| p.as_str() == Some(expected) || p["sha"].as_str() == Some(expected)) + }) +} +fn github_run_matches(run: &Value, branch: &str, revisions: &[String]) -> bool { + !branch.is_empty() + && text(run, "headBranch") == branch + && revisions.contains(&text(run, "headSha")) +} +fn merge_revision_matches( + provider: &str, + cwd: &Path, + sha: &str, + expected: &str, +) -> Result { + if sha == expected { + return Ok(true); + } + if !crate::is_commit_ish(sha) { + return Ok(false); + } + let commit = match provider { + GITHUB => rest( + provider, + cwd, + &format!("repos/{{owner}}/{{repo}}/commits/{sha}"), + None, + )?, + GITLAB => rest( + provider, + cwd, + &format!("projects/:id/repository/commits/{sha}"), + None, + )?, + AZURE => { + let (_, _, repo) = azure_scope(cwd).map_err(|e| e.to_string())?; + azure( + cwd, + "git", + "commits", + &[("repositoryId", repo), ("commitId", sha.into())], + None, + )? + } + _ => return Err("Unsupported merge revision provider".into()), + }; + Ok(parent_matches(&commit, expected)) +} +#[cfg(test)] +mod revision_tests { + use super::*; + #[test] + fn workflow_on_another_branch_is_not_pr_ci_even_with_the_same_sha() { + let revisions = vec!["source".into(), "merge".into()]; + assert!(!github_run_matches( + &json!({"headBranch":"other","headSha":"source"}), + "topic", + &revisions + )); + assert!(!github_run_matches( + &json!({"headBranch":"topic","headSha":"old"}), + "topic", + &revisions + )); + assert!(github_run_matches( + &json!({"headBranch":"topic","headSha":"merge"}), + "topic", + &revisions + )); + } + #[test] + fn merge_parents_must_include_the_current_pr_source() { + assert!(parent_matches( + &json!({"parents":[{"sha":"base"},{"sha":"current"}]}), + "current" + )); + assert!(parent_matches( + &json!({"parent_ids":["base","current"]}), + "current" + )); + assert!(parent_matches( + &json!({"parents":["base","current"]}), + "current" + )); + assert!(!parent_matches( + &json!({"parents":["base","old-source"]}), + "current" + )); + assert!(!parent_matches(&json!({}), "current")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b611c304..bf25651f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ mod shell_env; mod automation; mod cli_server; mod forge; +mod delivery; mod mcp_server; // Row shapes + OS-agnostic logic (subtree walk, cpu_ratio, label_for, // signal_from_name) shared by every `procmon` variant below. @@ -864,6 +865,9 @@ pub struct TaskMember { /// creation copied in. #[serde(default)] pub files_to_copy: Vec, + pub pr_url: Option, + pub pr_number: Option, + pub pr_provider: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -1529,7 +1533,11 @@ fn load_projects_in(id: &ProfileId) -> Vec { p.profile = id.clone(); } if dirty { - let _ = save_projects_in(id, &list); + // Write back to `f` — the file just read — rather than re-resolving + // it. `TERMIC_DATA_DIR` is process-global and a test's scratch window + // can flip it between the two resolutions, landing this write in a + // directory the records were never read from. + let _ = save_projects_at(&f, &list); } list } @@ -1760,8 +1768,12 @@ fn project_path_taken(list: &[Project], profile: &ProfileId, canon: &str) -> boo } fn save_projects_in(id: &ProfileId, list: &[Project]) -> Result<()> { + save_projects_at(&projects_file_in(id)?, list) +} + +fn save_projects_at(f: &Path, list: &[Project]) -> Result<()> { let json = serde_json::to_string_pretty(list)?; - write_atomic(&projects_file_in(id)?, json.as_bytes())?; + write_atomic(f, json.as_bytes())?; Ok(()) } @@ -2602,8 +2614,66 @@ fn fetch_ref(repo: &Path, remote_ref: &str) -> std::result::Result<(), String> { /// fetch argument — a bare refname (via the remote's configured refspec) or /// a full `src:dst` refspec; `desc` names the op in error strings. fn guarded_fetch(repo: &Path, remote: &str, spec: &str, desc: &str) -> std::result::Result<(), String> { + guarded_git(repo, &["fetch", "--no-tags", remote, spec], desc, 15) +} + +/// `git push` with the same no-prompt/deadline treatment as guarded_fetch: +/// an SSH passphrase prompt or a wedged remote would otherwise block the +/// calling thread forever. +fn guarded_push(repo: &Path, args: &[&str]) -> std::result::Result<(), String> { + guarded_git(repo, args, "push", 120) +} + +/// Strip `user:PAT@` userinfo from URLs inside arbitrary text. Git error +/// output echoes the remote URL (`fatal: unable to access +/// 'https://PAT@dev.azure.com/...'`) and it lands in persisted results and +/// toasts — `remote_for_display` covers a bare URL, this covers URLs +/// embedded in a sentence. Only the authority segment's last `@` is +/// userinfo; an `@` in the path stays. +fn scrub_url_userinfo(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(pos) = rest.find("://") { + out.push_str(&rest[..pos + 3]); + let tail = &rest[pos + 3..]; + let end = tail + .find(|c: char| matches!(c, '/' | ' ' | '\t' | '\r' | '\n' | '\'' | '"')) + .unwrap_or(tail.len()); + let (authority, tail) = tail.split_at(end); + out.push_str(authority.rsplit('@').next().unwrap_or(authority)); + rest = tail; + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod scrub_tests { + #[test] + fn userinfo_is_stripped_but_path_at_signs_stay() { + assert_eq!( + super::scrub_url_userinfo( + "fatal: unable to access 'https://user:pat123@dev.azure.com/org/proj@x/_git/r/': The requested URL returned error: 403" + ), + "fatal: unable to access 'https://dev.azure.com/org/proj@x/_git/r/': The requested URL returned error: 403" + ); + // No userinfo → unchanged; non-URL text untouched. + assert_eq!( + super::scrub_url_userinfo("fatal: unable to access 'https://github.com/o/r.git/'"), + "fatal: unable to access 'https://github.com/o/r.git/'" + ); + assert_eq!(super::scrub_url_userinfo("ssh: connect to host"), "ssh: connect to host"); + } +} + +fn guarded_git( + repo: &Path, + args: &[&str], + desc: &str, + timeout_secs: u64, +) -> std::result::Result<(), String> { let mut cmd = git_command(); - cmd.args(["fetch", "--no-tags", remote, spec]).current_dir(repo); + cmd.args(args).current_dir(repo); // Same login-shell env as git() so credential helpers / SSH config resolve // from a GUI-launched .app (bare launchd PATH otherwise). let (path, inject) = shell_env::spawn_env(); @@ -2634,13 +2704,16 @@ fn guarded_fetch(repo: &Path, remote: &str, spec: &str, desc: &str) -> std::resu }) }); let collect_err = |h: Option>| -> String { - h.and_then(|h| h.join().ok()).unwrap_or_default().trim().to_string() + h.and_then(|h| h.join().ok()) + .map(|s| scrub_url_userinfo(&s)) + .unwrap_or_default() + .trim() + .to_string() }; - // Poll to a hard deadline; SIGKILL on expiry. 15s covers a normal - // single-ref fetch on a slow link; ConnectTimeout=10 already caps the - // common dead-host case well under this. - let deadline = std::time::Instant::now() + Duration::from_secs(15); + // Poll to a hard deadline; SIGKILL on expiry. ConnectTimeout=10 already + // caps the common dead-host case well under the caller's timeout. + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); loop { match child.try_wait() { Ok(Some(status)) => { @@ -2658,12 +2731,14 @@ fn guarded_fetch(repo: &Path, remote: &str, spec: &str, desc: &str) -> std::resu if std::time::Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - let err = collect_err(stderr_reader); - return Err(if err.is_empty() { - format!("{desc} timed out") - } else { - format!("{desc} timed out: {err}") - }); + // Do NOT join the reader: a grandchild that inherited + // the pipe (ssh, credential helper) keeps it open after + // the kill, so read_to_string never ends and the + // deadline would hang forever while holding the + // delivery lock. The detached thread exits once the + // fd closes. + drop(stderr_reader); + return Err(format!("{desc} timed out")); } thread::sleep(Duration::from_millis(100)); } @@ -6025,6 +6100,9 @@ fn link_repo_mode_members(host_dir: &Path, members: &[ProjectMember], first_port let member_port = next_member_port; next_member_port = next_member_port.saturating_add(1); composition.push(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: pm.root_path.clone(), dir_name, @@ -7732,6 +7810,9 @@ fn materialize_member( // clobber a file with its own stale copy at worst. The list // is still frozen so a later mode change reads the same one. Ok(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: mp.root_path.clone(), dir_name: dir_name.to_string(), @@ -7793,6 +7874,9 @@ fn materialize_member( } } Ok(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, project_id: String::new(), repo_path: mp.root_path.clone(), dir_name: dir_name.to_string(), @@ -12089,7 +12173,7 @@ enum UpdateMode { Rebase, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct UpdateResult { branch: String, /// What we updated FROM: the branch's own upstream for Pull, the task's @@ -13442,7 +13526,11 @@ fn pr_lookup_at( let by_branch = forge::pr_status(provider, cwd, None); let resolved = match by_branch { Ok(Some(pr)) => Ok(Some(pr)), - Ok(None) if known_number.is_some() => forge::pr_status(provider, cwd, known_number), + Ok(None) if known_number.is_some() => { + let branch = git(&["branch", "--show-current"], cwd).unwrap_or_default(); + forge::pr_status(provider, cwd, known_number) + .map(|pr| pr.filter(|p| !branch.trim().is_empty() && p.head == branch.trim())) + }, other => other, }; match resolved { @@ -13518,7 +13606,8 @@ pub struct MemberPrLookup { fn member_pr_lookups(id: &str) -> Result, String> { let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; - Ok(on_disk_members(&w) + let rows: Vec = w.composition + .iter() .map(|m| { let cwd = Path::new(&m.path); // Frozen `m.branch` is the create-time value; read HEAD like @@ -13533,10 +13622,49 @@ fn member_pr_lookups(id: &str) -> Result, String> { MemberPrLookup { dir_name: m.dir_name.clone(), branch, - lookup: pr_lookup_at(cwd, None, None), + lookup: if !cwd.is_dir() { + PrLookup { + provider: None, + remote_url: String::new(), + status: "checkout-missing".into(), + message: "Repository checkout is missing".into(), + pr: None, + } + } else { + // Persisted identity as the by-number fallback — same + // contract the host card's pr_lookup_at has. + pr_lookup_at(cwd, m.pr_number, m.pr_provider.as_deref()) + }, } }) - .collect()) + .collect(); + // Persist the identity discovered by-branch so later lookups and the + // delivery panel's selectors can fall back to it. Reload the task first: + // composition may have been edited since `w` was read at the top. + let mut latest = load_tasks_all().into_iter().find(|t| t.id == id).ok_or("no task")?; + let mut changed = false; + for row in &rows { + let Some(pr) = &row.lookup.pr else { continue }; + let Some(member) = latest.composition.iter_mut().find(|m| { + m.dir_name == row.dir_name + && w.composition.iter().any(|old| old.dir_name == m.dir_name && old.path == m.path) + }) else { + continue; + }; + if member.pr_number != Some(pr.number) + || member.pr_provider.as_deref() != Some(pr.provider.as_str()) + || member.pr_url.as_deref() != Some(pr.url.as_str()) + { + member.pr_number = Some(pr.number); + member.pr_provider = Some(pr.provider.clone()); + member.pr_url = Some(pr.url.clone()); + changed = true; + } + } + if changed { + save_task(&latest).map_err(|e| e.to_string())?; + } + Ok(rows) } /// One issue-list round-trip: provider resolution + the open issues, or @@ -13955,7 +14083,7 @@ fn task_set_pr_comments_seen(id: String, iso: String) -> Result<(), String> { /// set-upstream behaviour cannot differ between "Commit and Push" and pushing /// what is already committed. fn git_push(cwd: &Path) -> Result<(), String> { - if git(&["push"], cwd).is_ok() { + if guarded_push(cwd, &["push"]).is_ok() { return Ok(()); } let remote = detect_default_remote(cwd); @@ -13966,7 +14094,7 @@ fn git_push(cwd: &Path) -> Result<(), String> { if branch.is_empty() { return Err("cannot push: detached HEAD".to_string()); } - git(&["push", "-u", &remote, &branch], cwd).map_err(|e| e.to_string())?; + guarded_push(cwd, &["push", "-u", &remote, &branch])?; Ok(()) } @@ -19856,6 +19984,21 @@ fn open_path(path: String) -> Result<(), String> { /// copies of three lines is exactly the kind of thing a later refactor edits /// one of. Now they cannot disagree. fn spawn_os_open(target: &str) -> Result<(), String> { + // A `scheme:` target goes to the OS's registered handler, which for + // file:/smb:/javascript: is something other than "the browser". Only + // schemes with a known safe handler are URL-shaped input (forge pages, + // docs, the Support mailto:); absolute paths — the other legitimate + // input — have no scheme. Two+ chars before the colon so a Windows + // drive letter (C:\) is not mistaken for a scheme. + if let Some(scheme) = target + .split_once(':') + .map(|(s, _)| s) + .filter(|s| s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) && s.starts_with(|c: char| c.is_ascii_alphabetic())) + { + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") { + return Err(format!("refusing to open '{scheme}:…' — not a web link")); + } + } let (program, args) = open_command(std::env::consts::OS, target); crate::proc_ctl::command(program).args(&args).status().map_err(|e| e.to_string())?; Ok(()) @@ -22177,7 +22320,14 @@ pub(crate) fn load_settings_in(id: &ProfileId) -> Settings { // had its settings written over the root profile on every load (the // root's accounts and paths went with them), and never received the // migration itself, so the next load did it again. - let _ = save_settings_in(id, &s); + // + // `f`, not a re-resolution through `save_settings_in`: `TERMIC_DATA_DIR` + // is process-global, and a test's scratch window can flip it between the + // read and this write — re-resolving would land the migrated copy in a + // settings.json the load never read from (clobbering that scratch's + // file — observed as an intermittent `agent_hooks` test failure — or, + // flipped the other way, in the developer's real data dir). + let _ = save_settings_at(&f, &s); } s } @@ -22667,8 +22817,12 @@ fn settings_save(app: AppHandle, window: tauri::Window, s: Settings) -> Result<( /// Settings UI does, instead of growing a second encoder that could drift. pub(crate) fn save_settings_in(id: &ProfileId, s: &Settings) -> Result<(), String> { let f = settings_file_in(id).map_err(|e| e.to_string())?; + save_settings_at(&f, s) +} + +fn save_settings_at(f: &Path, s: &Settings) -> Result<(), String> { let json = serde_json::to_string_pretty(s).map_err(|e| e.to_string())?; - write_atomic(&f, json.as_bytes()).map_err(|e| e.to_string()) + write_atomic(f, json.as_bytes()).map_err(|e| e.to_string()) } /// The ROOT profile's settings writer. Mirror of [`load_settings_inner`]. @@ -24713,6 +24867,21 @@ pub fn run() { desktop_integration_status, desktop_integration_add, desktop_integration_remove, task_list_files_for_finder, task_match_ignored_files, task_send_diff_to_main, task_changes, task_git_status, task_git_branches, project_git_branches, project_branch_context, task_git_checkout, task_git_update, task_git_update_all, task_git_update_info, task_stage, task_unstage, task_commit, task_discard, task_git_log, task_git_refs, task_git_push, task_git_commit_files, task_git_compare, task_git_blame, task_git_commit_meta, task_git_commit_offset, + delivery::task_delivery_archive_ready, + delivery::task_delivery_results, + delivery::task_delivery_repos, + delivery::task_delivery_details, + delivery::task_delivery_validate, + delivery::task_delivery_log, + delivery::task_delivery_requests, + delivery::task_delivery_request, + delivery::task_delivery_request_check, + delivery::task_delivery_request_amend, + delivery::task_delivery_request_status, + delivery::task_delivery_draft_save, + delivery::task_delivery_reply_post, + delivery::task_delivery_pr_create, + delivery::task_delivery_update, detect_forges, task_pr_status, task_member_pr_status, task_pr_create, task_pr_comments, task_set_pr_watch, task_set_pr_comments_seen, project_forge_issues, project_forge_provider, project_forge_prs, project_fetch_pr_branch, project_git_checkout, task_file_diff, task_file_diff_sides, task_file_read, file_read_external, clipboard_image_save, clipboard_image_capture, task_file_read_base64, task_file_fp, task_file_write, task_dir_list, task_path_stat, @@ -30345,6 +30514,9 @@ mod tests { let member = tempdir().unwrap(); let mut ws = task_with_member("api", host.path(), member.path()); let push = |ws: &mut Task, dir_name: &str, path: String| ws.composition.push(TaskMember { + pr_url: None, + pr_number: None, + pr_provider: None, dir_name: dir_name.into(), mode: MemberMode::RepoRoot, path, @@ -30383,7 +30555,7 @@ mod tests { branch: "stale".into(), ..Default::default() }, - // A member whose checkout vanished is skipped entirely. + // Missing checkouts must remain visible as delivery blockers. TaskMember { dir_name: "gone".into(), mode: MemberMode::RepoRoot, @@ -30393,7 +30565,8 @@ mod tests { ]; crate::save_task(&ws).unwrap(); let rows = member_pr_lookups("t1").unwrap(); - assert_eq!(rows.len(), 1); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].lookup.status, "checkout-missing"); assert_eq!(rows[0].dir_name, "api"); assert_eq!(rows[0].branch, "feat-live"); assert_eq!(rows[0].lookup.status, "no-remote"); diff --git a/src/components/task/DeliveryPanel.tsx b/src/components/task/DeliveryPanel.tsx new file mode 100644 index 00000000..90f32703 --- /dev/null +++ b/src/components/task/DeliveryPanel.tsx @@ -0,0 +1,765 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { GitPullRequest, GitBranch, GitMerge, Wrench, RefreshCw, ChevronDown, ChevronRight, MoreHorizontal, MessageSquare, ArrowUpRight, CircleCheck, CircleX, CircleHelp, Clock, CircleMinus, ShieldAlert, Sparkles, X, SquareTerminal, Copy } from "lucide-react"; +import { ChecksChip, ReviewChip, PR_STATE } from "./PrCard"; +import { Spinner } from "@/components/ui/Spinner"; +import { Tip } from "@/components/ui/Tooltip"; +import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem } from "@/components/ui/Dropdown"; +import { AppDialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { useApp } from "@/store/app"; +import { useUI } from "@/store/ui"; +import { usePr } from "@/store/pr"; +import { useDelivery } from "@/store/delivery"; +import { agentTargets, pickAgentTarget } from "@/lib/sendComments"; +import { agentDisplayName, isTerminalCli } from "@/lib/agents"; +import { forgeName, prRef } from "@/lib/forge"; +import { deliveryPrompt, repositoryLookup } from "@/lib/delivery"; +import { sendDeliveryMessage, deliveryInflight } from "@/lib/deliverySend"; +import { focusTerminalTab } from "@/lib/tabFocus"; +import { i18n } from "@/lib/i18n"; +import * as ipc from "@/lib/ipc"; +import { Input } from "@/components/ui/Input"; +import type { Task, DeliveryRepo, DeliveryDetails, DeliveryPrInput, DeliveryRequest, DeliveryDraft, QueueItem, ReviewThread, TerminalTab, UpdateMode, CiNode } from "@/lib/types"; + +const textProps = { spellCheck: false, autoCorrect: "off", autoCapitalize: "off", autoComplete: "off" }; +// Same border/focus tokens as ui/Input, sized down a step for panel density. +const field = "w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 text-[12.5px] text-[var(--color-fg)] outline-none transition-colors focus:border-[var(--color-accent)]"; +const fieldLabel = "flex flex-col gap-1.5 text-[11.5px] font-medium text-[var(--color-fg-dim)]"; +const alertCls = "break-words text-[11.5px] text-[var(--color-err)]"; +const action = "inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[11.5px] text-[var(--color-fg-dim)] hover:bg-[var(--color-hover)] hover:text-[var(--color-fg)] disabled:opacity-40"; +const noRepos: DeliveryRepo[] = []; +const KIND: Record = { + fix: { icon: Wrench, color: "var(--color-palette-orange)", label: "delivery.actions.fix" }, + replies: { icon: MessageSquare, color: "var(--color-palette-blue)", label: "delivery.actions.replies" }, + prs: { icon: GitPullRequest, color: "var(--color-palette-purple)", label: "delivery.actions.prs" }, + conflicts: { icon: GitMerge, color: "var(--color-warn)", label: "delivery.actions.conflicts" }, +}; +const STATUS_COLOR: Record = { + prepared: "var(--color-fg-faint)", queued: "var(--color-accent)", sent: "var(--color-accent)", + drafted: "var(--color-ok)", posted: "var(--color-ok)", + uncertain: "var(--color-warn)", retry_ready: "var(--color-warn)", failed: "var(--color-err)", + draft: "var(--color-fg-faint)", posting: "var(--color-accent)", +}; +type EvidencePool = { repo: DeliveryRepo; detail: DeliveryDetails; threads: ReviewThread[]; ci: CiNode[] }; +type Preview = { request: DeliveryRequest; text: string; generated: string; target: string; purpose: string; pools: Record; chosen: Set }; +const NEW_AGENT = "__new__"; +/** Spawn a fresh agent tab for a handoff when the task has none running — + * resolves once its PTY is up so the queue/send path can bind to it. + * Runs the task's configured agent (`task.cli`); terminal-kind clis + * (shell/custom) are filtered out by newAgentOk before this is reachable. */ +async function spawnAgentTab(task: Task, title: string, focus: boolean): Promise { + const s = useApp.getState(); + const cli = task.cli || task.persisted_tabs?.find(pt => pt.is_default)?.cli || "claude"; + const id = crypto.randomUUID(); + s.addTab(task.id, { id, type: "terminal", title: `${agentDisplayName(cli)} · ${title}`, cli, + unattended: true } as TerminalTab, { focus }); + // A Docker-mode first launch can spend a minute rebuilding the image — + // poll while the tab exists; a gone tab means the spawn failed or the + // user closed it. + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const tab = useApp.getState().tabs[task.id]?.find(t => t.id === id); + if (!tab) throw new Error(i18n.t("panels:delivery.agentChanged")); + if (tab.type === "terminal" && tab.ptyId) return tab; + // A spawn that already failed would otherwise sit here until the full + // deadline — TerminalPane records it on the tab. + if (tab.type === "terminal" && tab.spawnError) throw new Error(tab.spawnError); + await new Promise(r => setTimeout(r, 250)); + } + throw new Error(i18n.t("panels:delivery.agentStarting")); +} + +export function DeliveryPanel({ task }: { task: Task }) { + const { t } = useTranslation("panels"); + const entry = useDelivery(s => s.byTask[task.id]); + const pr = usePr(s => s.byTask[task.id]); + // Re-render when the fields agentTargets()/pickAgentTarget() read change — + // extend the string if those selectors start using more of the tab. + useApp(s => (s.tabs[task.id] ?? []).map(tab => tab.type === "terminal" ? [tab.id, tab.ptyId, tab.title, tab.cli, tab.runTab, tab.is_default, tab.liveTitle, tab.customTitle, tab.workState].join(":") : "").join("|")); + useApp(s => s.agents); + const repos = entry?.repos ?? noRepos; + const [expanded, setExpanded] = useState([]); + const [logs, setLogs] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + const [prInputs, setPrInputs] = useState(null); + // The update dialog's scope is fixed when it opens — repos come from the + // control that launched it (per-repo menu or the all-repos action). + const [update, setUpdate] = useState<{ mode: UpdateMode; repos: DeliveryRepo[] } | null>(null); + const targets = agentTargets(task.id); + // A task whose cli resolves to terminal-kind (shell/custom) can never host + // an agent — don't offer a spawn that sendDeliveryMessage can't address. + const newAgentOk = !isTerminalCli(task.cli || task.persisted_tabs?.find(pt => pt.is_default)?.cli || "claude", useApp.getState().agents); + const identityRepos = repos.filter(r => r.identity); + // Split a `${dir}:${kind}:${id}` evidence key: match the LONGEST repo + // prefix — dir names may contain ':' on unix, so overlapping prefixes + // ("a", "a:b") must resolve to "a:b", not the first-listed "a". + const parseKey = (k: string) => { + const repo = repos.filter(r => k.startsWith(r.dir_name + ":")).sort((a, b) => b.dir_name.length - a.dir_name.length)[0]; + return repo ? { repo, rest: k.slice(repo.dir_name.length + 1) } : null; + }; + /** Owning repo dir_name for an evidence key — use this instead of + * `k.startsWith(dir + ":")`, which mis-assigns keys when one dir_name + * prefixes another ("a" vs "a:b"). */ + const keyRepo = (k: string) => parseKey(k)?.repo.dir_name; + // Durable 'queued' requests need a live queue item to ever send; queue + // entries die with the app, so a restart leaves zombies. Two consecutive + // refreshes without the item marks it failed (send() writes the status a + // beat before the queue item, so one sighting is not proof) — and the + // fail-write re-reads the request first: a stale snapshot must not flip a + // request the drain just marked 'sent'. + const orphanQueued = useRef>(new Set()); + // Overlapping refreshes (interval + button + post-action) would each count + // an orphan miss against the same store snapshot — serialize them. + const refreshing = useRef | null>(null); + const refresh = (force = false) => refreshing.current ??= (async () => { + // IPC errors surface through the store's entry.error — don't also let + // them become unhandled rejections in every `void refresh()` caller. + try { + await Promise.all([useDelivery.getState().refresh(task.id), usePr.getState().refresh(task.id, force)]); + const reqs = useDelivery.getState().byTask[task.id]?.requests ?? []; + const live = new Set((useApp.getState().tabs[task.id] ?? []) + .flatMap(tab => tab.type === "terminal" ? (tab.queue ?? []).map(q => q.delivery?.requestId ?? "") : []).filter(Boolean)); + let stale = false; + for (const r of reqs) { + if (r.status !== "queued" || live.has(r.id)) { orphanQueued.current.delete(r.id); continue; } + // A send-now in flight holds the request at 'queued' with no queue + // item — that's a slow provider check, not an orphan. + if (deliveryInflight(r.id)) continue; + if (!orphanQueued.current.has(r.id)) { orphanQueued.current.add(r.id); continue; } + const current = (await ipc.taskDeliveryRequests(task.id)).find(x => x.id === r.id)?.status; + orphanQueued.current.delete(r.id); + if (current !== "queued") continue; + stale = true; + await ipc.taskDeliveryRequestStatus(task.id, r.id, "failed", "Queue was cleared").catch(() => {}); + } + if (stale) await useDelivery.getState().refresh(task.id); + } finally { + refreshing.current = null; + } + })().catch(() => {}); + // Poll faster while a sent request is still waiting on its report — the + // agent usually lands it within seconds and the card shouldn't sit on the + // minute cadence. 'uncertain' too: its send may have landed. + const awaitingReport = !!entry?.requests.some(r => (r.status === "sent" || r.status === "uncertain") && !r.error); + useEffect(() => { + void refresh(true); + const timer = window.setInterval(() => void refresh(), awaitingReport ? 10_000 : 60_000); + return () => window.clearInterval(timer); + // Only the mounted Delivery panel polls. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.id, awaitingReport]); + const same = (a: T[], b: T[]) => a.length === b.length && a.every((v, i) => v === b[i]); + // Details are pruned on refresh when an identity changes; log excerpts and + // expansion that reference them must go too or they'd act on data the + // store already discarded. + useEffect(() => { + const live = Object.keys(entry?.details ?? {}); + // Keys are `${dir}:${kind}:${id}`; dir names may contain ':', so the + // owning repo resolves by longest prefix, not first-prefix match. + const alive = (k: string) => { const dir = keyRepo(k); return dir !== undefined && live.includes(dir); }; + setLogs(prev => { + const next = Object.fromEntries(Object.entries(prev).filter(([k]) => alive(k))); + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + setExpanded(prev => { const next = prev.filter(dir => live.includes(dir)); return same(prev, next) ? prev : next; }); + }, [entry?.details]); + + const toggle = (values: string[], key: string) => values.includes(key) ? values.filter(v => v !== key) : [...values, key]; + const busyRef = useRef(false); + // Shared failure surface: the error paragraph lives at the top of a + // scrollable panel — actions triggered deep in the details fail invisibly + // without a toast. Dialogs render the same error inline, so skip the + // toast while one is open. + const attempt = async (work: () => Promise) => { + try { await work(); } catch (e) { + const msg = String(e); + setError(msg); + if (!preview && !prInputs && !update) useUI.getState().pushToast(msg, "error"); + } + }; + // busy is render-state: two activations before the first re-render would + // both run (double-click → two durable 'prepared' requests). The ref is + // the synchronous gate. + const run = async (work: () => Promise) => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); setError(""); + try { await attempt(work); } finally { busyRef.current = false; setBusy(false); } + }; + // Read-only probes (details expand, log excerpt, per-repo refresh) keep + // the panel usable: they disable only their own control and spin inline + // instead of holding the panel-wide busy gate for a multi-second call. + const [probing, setProbing] = useState>(new Set()); + const probe = async (key: string, work: () => Promise) => { + setProbing(s => new Set(s).add(key)); + try { await attempt(work); } finally { setProbing(s => { const n = new Set(s); n.delete(key); return n; }); } + }; + const applied = useRef>({}); + const showPrs = (scope: DeliveryRepo[]) => { + if (!scope.length) return; + applied.current = {}; + setPrInputs(scope.map(r => { + const proposed = entry?.requests.flatMap(q => q.prs).findLast(p => p.dir_name === r.dir_name); + const title = proposed?.title ?? task.name, body = proposed?.body ?? ""; + applied.current[r.dir_name] = { title, body }; + return { identity: r.identity!, title, body, base: r.base, draft: true }; + })); + }; + // A draft report that lands while the dialog is open fills only fields the + // user has not touched since the last applied draft. + useEffect(() => { + if (!prInputs) return; + const latest = new Map(); + for (const req of entry?.requests ?? []) for (const p of req.prs) latest.set(p.dir_name, { title: p.title, body: p.body }); + setPrInputs(list => { + if (!list) return list; + let dirty = false; + const next = list.map(inp => { + const p = latest.get(inp.identity.dir_name); + const ap = applied.current[inp.identity.dir_name]; + if (!p || !ap) return inp; + // Fill only fields still equal to the last applied draft — a field the + // user typed into keeps its value even when a newer report arrives. + const patch: Partial = {}; + if (inp.title === ap.title && inp.title !== p.title) patch.title = p.title; + if (inp.body === ap.body && inp.body !== p.body) patch.body = p.body; + if (patch.title === undefined && patch.body === undefined) return inp; + dirty = true; + applied.current[inp.identity.dir_name] = { title: patch.title ?? ap.title, body: patch.body ?? ap.body }; + return { ...inp, ...patch }; + }); + return dirty ? next : list; + }); + }, [entry?.requests]); // eslint-disable-line react-hooks/exhaustive-deps + const prepare = async (purpose: "fix" | "replies" | "prs" | "conflicts", scope: DeliveryRepo[], keys: string[] = []) => { + const drafts: DeliveryDraft[] = []; + const evidence: string[] = []; + const scopeBits: string[] = []; + const evidenceKeys: string[] = []; + // Evidence pools ride along to the send dialog so its item picker can + // re-scope drafts/keys/prompt without a fresh provider probe. + const pools: Record = {}; + let picked = 0; + const scoped = scope.filter(repo => repo.identity && (purpose === "prs" || purpose === "conflicts" || keys.some(k => keyRepo(k) === repo.dir_name))).map(repo => { + const detail = entry?.details[repo.dir_name]; + if (purpose === "prs" || purpose === "conflicts") scopeBits.push(repo.name); + if (purpose === "prs") return repo; + if (purpose === "conflicts") { + // A repo can hold both an update row and a pr_create row — the + // conflict evidence lives on the update one, so discriminate on + // `result`, not just the directory. + const outcome = entry?.results.find(r => r.dir_name === repo.dir_name && (r.result?.conflicted || r.result?.stash_conflicted)); + if (!outcome) throw new Error(t("delivery.noConflict")); + evidence.push(JSON.stringify(outcome)); + return repo; + } + if (!detail) throw new Error(t("delivery.loadFirst")); + const repoKeys = keys.filter(k => keyRepo(k) === repo.dir_name).map(k => parseKey(k)!.rest); + if (detail.ci_error && repoKeys.some(rest => rest.startsWith("ci:"))) throw new Error(detail.ci_error); + if (detail.threads_error && repoKeys.some(rest => rest.startsWith("review:"))) throw new Error(detail.threads_error); + // Fixable evidence is a failed CI node or an unresolved thread; replies + // may target any thread and keep selected CI as prompt context. + const threads = detail.threads.filter(thread => keys.includes(repo.dir_name + ":review:" + thread.id) && (purpose !== "fix" || thread.resolved !== true)); + const ci = detail.ci.filter(node => keys.includes(repo.dir_name + ":ci:" + node.id) && (purpose !== "fix" || node.status === "failed")); + picked += threads.length + ci.length; + pools[repo.dir_name] = { repo, detail, threads, ci }; + evidenceKeys.push(...ci.map(n => repo.dir_name + ":ci:" + n.id), ...threads.map(th => repo.dir_name + ":review:" + th.id)); + const names = [...ci.map(n => n.name), ...threads.map(th => th.path ? `${th.path}${th.line ? ":" + th.line : ""}` : t("delivery.discussion"))]; + if (names.length) scopeBits.push(`${repo.name}: ${names.join(", ")}`); + evidence.push(evidenceBlob(repo, detail, threads, ci)); + // Fix prompts ask the agent to propose replies for the thread keys it + // touched — register the drafts so import doesn't reject them as + // unrequested. + if (purpose === "replies" || purpose === "fix") for (const thread of threads) drafts.push(replyDraft(repo.dir_name, detail, thread)); + return { ...repo, identity: { ...repo.identity!, pr_number: detail.pr.number, pr_revision: detail.revision } }; + }); + if ((purpose === "fix" && !picked) || (purpose === "replies" && !drafts.length)) throw new Error(t("delivery.selectEvidence")); + const request = await ipc.taskDeliveryRequest(task.id, scoped.map(r => r.identity!), drafts, purpose, scopeBits.join(" · "), evidenceKeys); + const text = deliveryPrompt(scoped, evidence.join("\n\n") + "\nRequested draft keys: " + drafts.map(d => d.key).join(", "), + request.report, t(`delivery.purpose.${purpose}`)); + if (preview) retirePrepared(preview.request.id); // replaced preview: retire its request too + setPreview({ request, text, generated: text, purpose, target: pickAgentTarget(task.id)?.id ?? (newAgentOk ? NEW_AGENT : ""), pools, chosen: new Set(evidenceKeys) }); + await useDelivery.getState().refresh(task.id); + }; + const evidenceBlob = (repo: DeliveryRepo, detail: DeliveryDetails, threads: ReviewThread[], ci: CiNode[]) => + JSON.stringify({ repository: repo.name, provider: detail.pr.provider, pr: detail.pr.url, revision: detail.revision, threads, ci, + logs: ci.map(n => ({ id: n.id, excerpt: logs[repo.dir_name + ":ci:" + n.id] ?? null })) }); + const replyDraft = (dir: string, detail: DeliveryDetails, thread: ReviewThread): DeliveryDraft => ({ key: dir + ":review:" + thread.id, dir_name: dir, pr_number: detail.pr.number, + thread_id: thread.id, reply_id: thread.reply_id, body: "", status: "draft", error: null }); + /** Send-dialog item picker: toggle one evidence key, re-derive drafts, + * scope and prompt, and rewrite the still-prepared request to match. */ + const retune = async (key: string) => { + const p = preview; + if (!p || !Object.keys(p.pools).length) return; + const chosenKeys = new Set(p.chosen); + if (chosenKeys.has(key)) chosenKeys.delete(key); else chosenKeys.add(key); + const evidence: string[] = []; + const drafts: DeliveryDraft[] = []; + const scopeBits: string[] = []; + for (const [dir, pool] of Object.entries(p.pools)) { + const threads = pool.threads.filter(th => chosenKeys.has(dir + ":review:" + th.id)); + const ci = pool.ci.filter(n => chosenKeys.has(dir + ":ci:" + n.id)); + if (!threads.length && !ci.length) continue; + evidence.push(evidenceBlob(pool.repo, pool.detail, threads, ci)); + if (p.purpose === "replies" || p.purpose === "fix") for (const th of threads) drafts.push(replyDraft(dir, pool.detail, th)); + scopeBits.push(`${pool.repo.name}: ${[...ci.map(n => n.name), ...threads.map(th => th.path ? `${th.path}${th.line ? ":" + th.line : ""}` : t("delivery.discussion"))].join(", ")}`); + } + const amended = await ipc.taskDeliveryRequestAmend(task.id, p.request.id, drafts, [...chosenKeys], scopeBits.join(" · ")); + // A repo stripped of all its items leaves the prompt's authorized list — + // the request keeps its identities (send re-validates them) but the text + // must not claim a repository the evidence no longer covers. + const covered = new Set([...chosenKeys].map(k => parseKey(k)?.repo.dir_name).filter((d): d is string => d !== undefined)); + const scoped = p.request.identities.filter(i => covered.has(i.dir_name)).map(i => p.pools[i.dir_name]?.repo).filter(Boolean) as DeliveryRepo[]; + const fresh = deliveryPrompt(scoped, evidence.join("\n\n") + "\nRequested draft keys: " + drafts.map(d => d.key).join(", "), + amended.report, t(`delivery.purpose.${p.purpose}`)); + // Edited text wins over regeneration: toggling an item must not + // silently discard what the user typed. The amend rotated the request + // id, hence the report path — swap it inside edited text too or the + // agent's report would land at a path nothing reads. + setPreview({ ...p, request: amended, text: p.text === p.generated ? fresh : p.text.replaceAll(p.request.report, amended.report), generated: fresh, chosen: chosenKeys }); + }; + const send = async (queue: boolean) => { + if (!preview) return; + const target = preview.target === NEW_AGENT + ? await spawnAgentTab(task, t(`delivery.actions.${preview.purpose}`, { defaultValue: preview.purpose }), !queue) + : agentTargets(task.id).find(tab => tab.id === preview.target); + if (!target?.ptyId) throw new Error(t("delivery.agentChanged")); + if (preview.target === NEW_AGENT) setPreview(p => p ? { ...p, target: target.id } : p); // a retry reuses the spawned tab instead of stacking another + const item: QueueItem = { id: crypto.randomUUID(), text: preview.text, repeat: 1, remaining: 1, + delivery: { requestId: preview.request.id, identities: preview.request.identities, ptyId: target.ptyId } }; + await ipc.taskDeliveryValidate(task.id, preview.request.identities); + if (queue) { + // The mark must land on the still-prepared request: a dismiss or a + // parallel send landing in the IPC window owns it now. + const prev = await ipc.taskDeliveryRequestStatus(task.id, preview.request.id, "queued", null, target.id).then(p => p, () => null); + if (prev !== "prepared") { + // failed → queued is legal (stale-queue retry): if the mark just + // resurrected a dismissed/failed request, put it back rather than + // leaving an orphan the next refresh has to clean up. + if (prev === "failed") await ipc.taskDeliveryRequestStatus(task.id, preview.request.id, "failed").catch(() => {}); + throw new Error(t("delivery.agentChanged")); + } + // Re-read AFTER the status write — a queue drain removing a sent item + // (or a user edit) landing in the IPC window would otherwise be lost + // or resurrected by patching from the pre-await snapshot. + const current = agentTargets(task.id).find(tab => tab.id === target.id && tab.ptyId === target.ptyId); + if (!current) throw new Error(t("delivery.agentChanged")); + // A double-queue of the same request is one entry: the drain would + // otherwise deliver it, see "sent", and still burn a send pass. + const rest = (current.queue ?? []).filter(q => q.delivery?.requestId !== item.delivery!.requestId); + useApp.getState().patchTab(task.id, target.id, { queue: [...rest, item], queueActive: true, queueKick: (current.queueKick ?? 0) + 1 }); + useUI.getState().pushToast(t("delivery.queued")); + } else if (await sendDeliveryMessage(task.id, target.id, item)) { + // Surface the agent the prompt landed in so the user can follow along. + useApp.getState().setActiveTabId(task.id, target.id); + focusTerminalTab(target.id); + } else { + await useDelivery.getState().refresh(task.id); + return; + } + setPreview(null); + await useDelivery.getState().refresh(task.id); + }; + // Closing without sending retires the 'prepared' request — otherwise every + // abandoned handoff leaks a durable row in delivery.json forever. Re-read + // the status first: a request already sent/queued elsewhere must be left + // alone. + const retirePrepared = (requestId: string) => { + void ipc.taskDeliveryRequests(task.id).then(list => { + const cur = list.find(r => r.id === requestId); + if (cur?.status === "prepared") return ipc.taskDeliveryRequestStatus(task.id, cur.id, "failed"); + }).then(() => useDelivery.getState().refresh(task.id)).catch(() => {}); + orphanQueued.current.delete(requestId); + }; + // Unmount (tab/task switch, panel close) abandons the dialog without + // running cancelPreview — retire its prepared request the same way. + const previewRef = useRef(null); + previewRef.current = preview; + useEffect(() => () => { const p = previewRef.current; if (p) retirePrepared(p.request.id); }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [task.id]); + const cancelPreview = () => { + const p = preview; + setPreview(null); + if (p) retirePrepared(p.request.id); + }; + const changePr = (i: number, patch: Partial) => setPrInputs(list => list!.map((p, n) => n === i ? { ...p, ...patch } : p)); + const failed = repos.filter(repo => entry?.results.some(r => r.dir_name === repo.dir_name && (r.result?.conflicted || r.result?.stash_conflicted))); + // 'prepared' lives in the send dialog; dismissed ('failed' with no error) + // is hidden. Queued, in-flight, drafted, and wedged requests stay visible + // so a send never silently disappears. + const shown = entry?.requests.filter(r => r.status !== "prepared" && (r.status !== "failed" || r.error)) ?? []; + return
+
+ {t("delivery.panelTitle")} + + + + showPrs(identityRepos)}>{t("delivery.createPrs")} + setUpdate({ mode: "merge", repos: identityRepos })}>{t("delivery.update")} + + +
+
+ {(error || entry?.error || pr?.error) &&

{error || entry?.error || pr?.error}{(entry?.fetchedAt ?? 0) > 0 ? " " + t("delivery.stale") : ""}

} + {entry?.loading && !entry.fetchedAt &&

{t("shared.loading")}

} + {repos.map(repo => { + const detail = entry?.details[repo.dir_name]; + const sharedHost = !!task.is_main_checkout && !repo.dir_name; + const lookup = sharedHost && detail ? { status: "ok", message: "", pr: detail.pr } : repositoryLookup(pr, repo.dir_name); + const state = lookup?.pr ? PR_STATE[lookup.pr.state] : null; + // Fixable evidence on this repo — leaf CI failures plus unresolved + // threads. Feeds the card menu's "Fix all with agent" item and the + // per-section bulk buttons below. + const failedCi = detail?.ci.filter(n => n.status === "failed" && !detail.ci.some(other => other.parent === n.id)) ?? []; + const unresolved = detail?.threads.filter(th => th.resolved !== true) ?? []; + const fixableCount = failedCi.length + unresolved.length; + return
+
+
+ {repo.name} + {repo.mode === "repo_root" && {t("delivery.sharedCheckout")}} + + + showPrs([repo])}>{t("delivery.createPr")} + setUpdate({ mode: "merge", repos: [repo] })}>{t("delivery.updateBranch")} + {fixableCount > 1 && void run(() => prepare("fix", [repo], + [...failedCi.map(n => repo.dir_name + ":ci:" + n.id), ...unresolved.map(th => repo.dir_name + ":review:" + th.id)]))}> + {t("delivery.fixAllIssues", { count: fixableCount })}} + + +
+
+ {repo.identity?.branch || t("delivery.unknown")} + {repo.dirty ? t("delivery.uncommitted") : repo.changed ? t("delivery.branchChanges") : repo.changed === false ? t("delivery.clean") : t("delivery.unknown")} +
+ {lookup?.pr && state ? <> +
{t(state.labelKey)}
+ + :

{lookup?.message || t("delivery.noPr")} + {repo.identity && }

} + {repo.error &&

{repo.error}

} +
+ + {detail && expanded.includes(repo.dir_name) &&
+
{t("delivery.revision")}: {detail.revision.slice(0, 8)} + {/* Re-probing this repo drops its detail record — prune only THIS + repo's log excerpts; other repos' state stays. */} + +
+ {(entry.detailsAt[repo.dir_name] ?? 0) > 0 && Date.now() - entry.detailsAt[repo.dir_name] > 90_000 &&

{t("delivery.staleEvidence")}

} +
{t("delivery.ciStatus")}{failedCi.length > 0 && · {t("delivery.failedCount", { count: failedCi.length })}} + {detail.ci_error &&

{detail.ci_error}

} + {!detail.ci.length && !detail.ci_error &&

{t("delivery.noCi")}

} + {failedCi.length > 0 &&
} + { + const key = repo.dir_name + ":ci:" + node.id; + const failed = node.status === "failed"; + return
{node.name}{t(`delivery.ciStates.${node.status}`, { defaultValue: node.status })}{node.duration != null ? " · " + Math.round(node.duration) + "s" : ""}
+ {failed && } + {node.url && }
+ {node.log_id && failed && } + {logs[key] &&
{logs[key]}
} +
; + }} /> +
+
{t("delivery.reviewStatus")}{unresolved.length > 0 && · {t("delivery.unresolvedCount", { count: unresolved.length })}} + {detail.threads_error &&

{detail.threads_error}

} + {!detail.threads.length && !detail.threads_error &&

{t("delivery.noThreads")}

} + {unresolved.length > 0 &&
+ + +
} + {detail.threads.map(thread => { + const key = repo.dir_name + ":review:" + thread.id; + return
+

{thread.path ? `${thread.path}${thread.line ? ":" + thread.line : ""}` : t("delivery.discussion")}{thread.resolved === true ? " · " + t("delivery.resolved") : thread.resolved === false ? " · " + t("delivery.unresolved") : ""}

+
{thread.comments.map(c =>

{c.author}

{c.body}

)}
+
+ + + + {thread.resolved !== true && } + +
+
; + })} +
+
} +
; + })} + {!!entry?.results.length &&
+
{t("delivery.results")}{failed.length > 0 && }
+ {entry.results.map(result => { + const Icon = result.error ? CircleX : result.result?.conflicted || result.result?.stash_conflicted ? ShieldAlert : CircleCheck; + const color = result.error ? "var(--color-err)" : result.result?.conflicted || result.result?.stash_conflicted ? "var(--color-warn)" : "var(--color-ok)"; + return

+ + {result.name}{result.action === "pr" ? " · " + t("delivery.createPrs") : result.action === "update" ? " · " + t("delivery.update") : ""} · {result.error || (result.result?.conflicted ? t("delivery.conflict") : result.result?.stash_conflicted ? t("delivery.stashConflict") : t("delivery.success"))} + {result.result?.stashed && " · " + t("delivery.autostash")} + {result.result?.target && " · " + result.result.target} + {result.url && } + +

; + })} +
} + {entry && !entry.loading && !shown.length && !entry.results.length &&

{t("delivery.hint")}

} + {!!shown.length &&
+ {t("delivery.requests")} + {shown.map(request => { + const kind = KIND[request.kind]; + const KindIcon = kind?.icon ?? CircleHelp; + // Mirror import_report's completion rule: 'prs' waits for a PR + // draft, 'replies' waits for all reply drafts, and any other kind + // (fix/conflicts — reply drafts there are optional extras) waits + // for ANY report. + const waiting = request.status === "sent" && !request.error && + (request.kind === "prs" ? !request.prs.length + : request.kind === "replies" ? request.drafts.some(d => !d.body) + : true); + // Every visible request is dismissable; active ones confirm first — + // a dismissed request stops importing its report, so a still-running + // agent's output would be discarded. + const live = ["queued", "sent", "uncertain"].includes(request.status); + // Requests saved before `scope` existed fall back to repo names. + const scopeLabel = request.scope || request.identities.map(i => repos.find(r => r.dir_name === i.dir_name)?.name ?? i.dir_name).join(", "); + return
+
+ +
+ {kind ? t(kind.label) : request.kind} + {scopeLabel &&

{scopeLabel}

} +
+ + {(() => { + // The tab the prompt went to — jump straight to the working + // agent instead of hunting tabs. Gone tab → no button. + const tab = request.agent ? (useApp.getState().tabs[task.id] ?? []).find(tb => tb.id === request.agent) : undefined; + return tab ? : null; + })()} + +
+ {request.error &&

{request.error}

} + {waiting &&

{t("delivery.waitingReport")}

} + {request.prs.map(p => { + const repo = repos.find(r => r.dir_name === p.dir_name); + return
+
+ + {repo?.name ?? p.dir_name} · {p.title} + {repo?.identity && } +
+ {p.body &&
{t("delivery.body")}

{p.body}

} +
; + })} + {/* replies requests show every draft (empty ones are fill-in + slots); fix/conflicts drafts are optional agent extras — hide + the untouched placeholders. */} + {request.drafts.filter(d => d.body || d.error || d.status !== "draft" || (request.kind === "replies" && request.status === "drafted")).map(draft => r.dir_name === draft.dir_name)} />)} +
; + })} +
} +
+ {/* Scope is declared where each action is launched (per-item, per-section + bulk, per-repo menu) and narrowed in the send dialog — no persistent + selection state, so there's no bottom bar to get out of sync. */} + { if (!open && !busy) cancelPreview(); }} className="max-w-2xl" title={t("delivery.reviewSend")} description={t("delivery.scopeNotice")} + stickyFooter={preview && <> + {error &&

{error}

} +
+ + + +
+ }> + {preview &&
+ + {!targets.length && !newAgentOk &&

{t("reviewBar.noAgent")}

} + {preview.target === NEW_AGENT && busy &&

{t("delivery.spawning")}

} +
+

{t("delivery.scope")}

+ {preview.request.identities.map(i => { + const repo = repos.find(r => r.dir_name === i.dir_name); + return

+ {repo?.name ?? i.dir_name} + {i.branch} · {i.head.slice(0, 8)}{i.pr_number != null ? " · " + prRef(repositoryLookup(pr, i.dir_name)?.provider, i.pr_number) + (i.pr_revision ? " · " + i.pr_revision.slice(0, 8) : "") : ""} +

; + })} +
+ {!!Object.keys(preview.pools).length &&
+

{t("delivery.evidenceTitle")}

+ {Object.entries(preview.pools).map(([dir, pool]) =>
+ {Object.keys(preview.pools).length > 1 &&

{pool.repo.name}

} + {[...pool.ci.map(n => ({ key: dir + ":ci:" + n.id, label: n.name, ci: true })), ...pool.threads.map(th => ({ key: dir + ":review:" + th.id, label: th.path ? `${th.path}${th.line ? ":" + th.line : ""}` : t("delivery.discussion"), ci: false }))].map(item => + )} +
)} +
} +