Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f18f151d9 | |||
| a27589418c | |||
| 57cbab916a | |||
| a7a8bfd24c | |||
| 0c964909e0 | |||
| 6511e4ec62 | |||
| 220309db12 |
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @file BackgroundJobsDock.tsx
|
||||
* @description The one bottom-right stack of background work, rendered once
|
||||
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
|
||||
* server, so it is back after F5 or a server restart) and per File Explorer
|
||||
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
|
||||
* git's progress, with **Stop** (click twice) while it runs; once finished,
|
||||
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
|
||||
* stopped) — an inline login form when the host wants one, a link to
|
||||
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
|
||||
* chip shows its progress with **Stop**; one a reload cut short says how far
|
||||
* it got. Several chips collapse to one small pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
cancelCloneJob,
|
||||
cloneJobError,
|
||||
dismissCloneJob,
|
||||
ensureCloneJobs,
|
||||
isActiveJob,
|
||||
retryCloneJob,
|
||||
useCloneJobs,
|
||||
} from "../lib/cloneJobs";
|
||||
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
|
||||
import { useArmed } from "../hooks/useArmed";
|
||||
import { CloneProgress } from "./lanes/CloneProgress";
|
||||
|
||||
const chipClass =
|
||||
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
danger,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
danger?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
|
||||
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
const [login, setLogin] = useState({ username: "", token: "", remember: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = isActiveJob(job);
|
||||
const code = job.error?.code;
|
||||
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
|
||||
const title =
|
||||
job.state === "done"
|
||||
? t("cloneJobs.done", { name: job.name })
|
||||
: job.state === "cancelled"
|
||||
? t("cloneJobs.cancelledTitle", { name: job.name })
|
||||
: active
|
||||
? t("cloneJobs.cloning", { name: job.name })
|
||||
: t("cloneJobs.failedTitle", { name: job.name });
|
||||
|
||||
const retry = async (withLogin: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryCloneJob(
|
||||
job.id,
|
||||
withLogin
|
||||
? { username: login.username.trim(), password: login.token, remember: login.remember }
|
||||
: undefined
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
void cancelCloneJob(job.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
|
||||
{active ? (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
|
||||
onClick={stop}
|
||||
danger={isArmed("stop")}
|
||||
testId="clone-job-stop"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("cloneJobs.dismiss")}
|
||||
onClick={() => void dismissCloneJob(job.id)}
|
||||
testId="clone-job-dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{active && isArmed("stop") && (
|
||||
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
|
||||
)}
|
||||
{active && <CloneProgress view={job} />}
|
||||
{job.state === "done" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{job.result?.path || job.target}
|
||||
</span>
|
||||
{laneId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/workspace?lane=${laneId}`);
|
||||
void dismissCloneJob(job.id);
|
||||
}}
|
||||
>
|
||||
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!active && job.state !== "done" && (
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
<form
|
||||
data-testid="clone-job-login"
|
||||
className="space-y-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void retry(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !login.username.trim() || !login.token}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("cloneJobs.retryLogin")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
code !== "ESSHAUTH" && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(false)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("cloneJobs.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadChip({ job }: { job: UploadJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
if (job.state === "interrupted") {
|
||||
return (
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
done: job.done,
|
||||
})}
|
||||
</p>
|
||||
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stopping = job.state === "stopping";
|
||||
return (
|
||||
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
|
||||
{stopping
|
||||
? t("uploads.stopping")
|
||||
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
|
||||
</span>
|
||||
{!stopping && (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
|
||||
danger={isArmed("stop")}
|
||||
testId="upload-job-stop"
|
||||
onClick={() => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
stopUpload(job.id);
|
||||
}}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundJobsDock() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
|
||||
>
|
||||
{count > 1 && collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-pill"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t("jobs.expand")}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
|
||||
{t("jobs.count", { count })}
|
||||
{running > 0 && (
|
||||
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{count > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
|
||||
>
|
||||
{t("jobs.count", { count })}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{uploads.map((u) => (
|
||||
<UploadChip key={u.id} job={u} />
|
||||
))}
|
||||
{jobs.map((j) => (
|
||||
<CloneJobChip key={j.id} job={j} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { BackgroundJobsDock } from "./BackgroundJobsDock";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<BackgroundJobsDock />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
|
||||
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
|
||||
* announced once when the store sees it finish (toast + window event); a
|
||||
* fresh mount (F5) shows what the server kept — an interrupted clone with
|
||||
* Retry, a login failure with an inline login form that retries with it —
|
||||
* without toasting it again; × dismisses; Stop (click twice) cancels a
|
||||
* running clone, which then reads as stopped and can be retried; an older
|
||||
* server that cannot stop says so. An upload shows with Stop, and one a
|
||||
* reload cut short is shown as interrupted until dismissed. Several chips
|
||||
* collapse to one pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../i18n";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
clone: vi.fn(),
|
||||
cloneFolder: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
retryCloneJob: vi.fn(),
|
||||
dismissCloneJob: vi.fn(),
|
||||
cancelCloneJob: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api, type CloneJob } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
CLONE_JOB_EVENT,
|
||||
refreshCloneJobs,
|
||||
resetCloneJobsForTests,
|
||||
startClone,
|
||||
} from "../../lib/cloneJobs";
|
||||
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
|
||||
import { BackgroundJobsDock } from "../BackgroundJobsDock";
|
||||
|
||||
const base: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "running",
|
||||
url: "https://git.example/o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an",
|
||||
target: "/home/an/repo",
|
||||
laneId: null,
|
||||
position: null,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 40,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
|
||||
};
|
||||
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
|
||||
|
||||
const renderDock = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BackgroundJobsDock />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
reloadUploadsForTests();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
|
||||
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("cloneJobs store", () => {
|
||||
it("announces a background clone once, when it sees it finish", async () => {
|
||||
const onDone = vi.fn();
|
||||
const events: CloneJob[] = [];
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
|
||||
await refreshCloneJobs();
|
||||
await refreshCloneJobs();
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
|
||||
expect(events).toEqual([done]);
|
||||
expect(onDone).toHaveBeenCalledWith(done, undefined);
|
||||
window.removeEventListener(CLONE_JOB_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackgroundJobsDock", () => {
|
||||
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("Cloning repo");
|
||||
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an interrupted clone after F5 and retries it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "server restarted" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
|
||||
job: job({ id: "j2", state: "queued", position: 1 }),
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(within(chip).getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and retries with it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
error: {
|
||||
code: "ECREDENTIALS",
|
||||
message: "login",
|
||||
auth: { protocol: "https", host: "git.example" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const form = await screen.findByTestId("clone-job-login");
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(form).getByLabelText("Username"), "ann");
|
||||
await user.type(within(form).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
|
||||
username: "ann",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finished clone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
|
||||
await user.click(within(chip).getByTestId("clone-job-dismiss"));
|
||||
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops a running clone on a second click, then lets it be retried", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
|
||||
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const stop = await screen.findByTestId("clone-job-stop");
|
||||
await user.click(stop);
|
||||
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the server cannot stop a clone yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
|
||||
Object.assign(new Error("HTTP 404"), { status: 404 })
|
||||
);
|
||||
renderDock();
|
||||
await user.click(await screen.findByTestId("clone-job-stop"));
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an upload with Stop, which aborts it", async () => {
|
||||
renderDock();
|
||||
let up!: ReturnType<typeof beginUpload>;
|
||||
act(() => {
|
||||
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
|
||||
up.update({ name: "a.txt", done: 1, sent: 150 });
|
||||
});
|
||||
const chip = await screen.findByTestId("fx-progress");
|
||||
expect(chip).toHaveTextContent("Uploading 2/3");
|
||||
expect(chip).toHaveTextContent("one/src · a.txt");
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(false);
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(true);
|
||||
act(() => up.end());
|
||||
expect(screen.queryByTestId("fx-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an upload a reload cut short until it is dismissed", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.uploads.inflight",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "up-old",
|
||||
folder: "one/src",
|
||||
name: "b.txt",
|
||||
done: 2,
|
||||
total: 5,
|
||||
sent: 10,
|
||||
bytes: 50,
|
||||
state: "running",
|
||||
beat: Date.now() - 60_000,
|
||||
},
|
||||
])
|
||||
);
|
||||
act(() => reloadUploadsForTests());
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("upload-job");
|
||||
expect(chip).toHaveTextContent(
|
||||
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
|
||||
);
|
||||
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("upload-job")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses several chips to one pill", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job(), job({ id: "j2", name: "other" })],
|
||||
});
|
||||
renderDock();
|
||||
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
|
||||
await user.click(screen.getByTestId("jobs-dock-collapse"));
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -119,8 +122,6 @@ export function AddLaneModal({
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -242,28 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -284,8 +303,6 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -571,11 +588,7 @@ export function AddLaneModal({
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -856,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* A clone runs in the background from the moment it starts: the dialog steps
|
||||
* aside for a small progress chip (`CloneProgress`; click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open, and one asking for a login reopens the dialog with its
|
||||
* login row.
|
||||
* a workspace on the new folder. The clone is a server-side background job
|
||||
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
|
||||
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
|
||||
* outcome, across pages, F5 and server restarts. A refusal before the clone
|
||||
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
|
||||
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
|
||||
* remembered), or a pointer to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -32,16 +29,11 @@ export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -52,23 +44,10 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -77,70 +56,42 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
if (!open) return null;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
// Runs in the background by default: the dialog steps aside for the
|
||||
// progress chip, and comes back only if the clone needs a login.
|
||||
onClose();
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
// Accepted: the dock takes it from here.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -149,20 +100,11 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
message = t("clone.sshRefused", { host });
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
// A login to type: show the form again with its login row.
|
||||
if (e.code === "ECREDENTIALS") onReopen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,7 +206,6 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -275,9 +216,10 @@ export function CloneHereDialog({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -285,7 +227,7 @@ export function CloneHereDialog({
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -27,8 +30,6 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
@@ -99,6 +102,9 @@ import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
@@ -327,13 +333,6 @@ export function FileBrowser({
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
@@ -369,16 +368,21 @@ export function FileBrowser({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, initialHome, session, source, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read after every change here too: deleting a workspace's folder takes
|
||||
// it out of the places (a workspace whose folder is gone has nothing to show).
|
||||
const loadLanes = useCallback(() => {
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadLanes();
|
||||
api.files
|
||||
.homes()
|
||||
.then((r) => setHomes(r.homes))
|
||||
.catch(() => setHomes([]));
|
||||
}, []);
|
||||
}, [loadLanes]);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
@@ -439,6 +443,8 @@ export function FileBrowser({
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// Never leave the last folder's entries under another folder's error.
|
||||
if (view === "files") setListing(null);
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
setError(
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
@@ -455,8 +461,20 @@ export function FileBrowser({
|
||||
|
||||
const changed = () => {
|
||||
void load();
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
// A background clone finished (the dock announced it): its folder and
|
||||
// workspace are new here, wherever it was started from.
|
||||
const changedRef = useRef(changed);
|
||||
changedRef.current = changed;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = listing?.entries ?? [];
|
||||
@@ -467,13 +485,28 @@ export function FileBrowser({
|
||||
}, [listing, showHidden, query]);
|
||||
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
||||
|
||||
// An upload outlives this view (its progress is in the dock): once the view
|
||||
// is gone nobody can answer a name clash, so it keeps both copies.
|
||||
const mountedRef = useRef(true);
|
||||
const pendingAsk = useRef<Ask["resolve"] | null>(null);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingAsk.current?.({ choice: "keep", all: true });
|
||||
pendingAsk.current = null;
|
||||
};
|
||||
}, []);
|
||||
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
||||
const askConflict = (names: string[], many: boolean) =>
|
||||
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
||||
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
|
||||
pendingAsk.current = resolve;
|
||||
setAskAll(false);
|
||||
setAsk({ names, many, resolve });
|
||||
});
|
||||
const answer = (choice: FsConflict | null) => {
|
||||
pendingAsk.current = null;
|
||||
ask?.resolve({ choice, all: askAll });
|
||||
setAsk(null);
|
||||
};
|
||||
@@ -633,19 +666,29 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// The place is fixed now: the user may open another folder (or leave the
|
||||
// page) while this keeps sending; its progress lives in the dock.
|
||||
const place = target;
|
||||
const job = beginUpload({
|
||||
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
|
||||
total: files.length,
|
||||
bytes,
|
||||
});
|
||||
const { signal } = job;
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -656,7 +699,7 @@ export function FileBrowser({
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
@@ -676,9 +719,10 @@ export function FileBrowser({
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
place,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
@@ -689,7 +733,7 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -700,7 +744,7 @@ export function FileBrowser({
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
if (code === "ECANCEL" || code === "EABORT") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
@@ -724,9 +768,10 @@ export function FileBrowser({
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
job.end();
|
||||
setBusy(false);
|
||||
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
|
||||
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
changed();
|
||||
}
|
||||
};
|
||||
@@ -932,11 +977,18 @@ export function FileBrowser({
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
// A workspace whose folder was deleted has nothing to browse — it stays a
|
||||
// place only while it is the one open (so its error has a row to sit on).
|
||||
const placeLanes = useMemo(
|
||||
() =>
|
||||
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
|
||||
[lanes, target]
|
||||
);
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of lanes) {
|
||||
for (const l of placeLanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
@@ -959,9 +1011,9 @@ export function FileBrowser({
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, lanes, myId]);
|
||||
}, [homes, placeLanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
@@ -994,18 +1046,7 @@ export function FileBrowser({
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||
changed();
|
||||
}}
|
||||
/>
|
||||
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
@@ -1653,25 +1694,6 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
data-testid="fx-progress"
|
||||
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
|
||||
>
|
||||
<p className="truncate text-xs font-medium">
|
||||
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{
|
||||
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
@@ -1687,9 +1709,7 @@ export function FileBrowser({
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>
|
||||
{t("fx.download")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
|
||||
@@ -1476,13 +1476,21 @@ export function LaneChangesPanel({
|
||||
// that names how many untracked files it will delete.
|
||||
const discardTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
// Tracked and untracked counted apart: an untracked file is deleted, not
|
||||
// reverted, and counting it in both told "10 changed + 10 new" for 10.
|
||||
const untracked = files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length;
|
||||
const tracked = tickedPaths.length - untracked;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t("changes.discardTickedConfirm", {
|
||||
count: tickedPaths.length,
|
||||
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardTicked"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
|
||||
@@ -161,12 +161,19 @@ export function WorkspaceRow({
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
<span
|
||||
data-folder-missing={lane.folder_missing ? "true" : undefined}
|
||||
className={`truncate text-[13px] font-medium ${
|
||||
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const job = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an/code",
|
||||
target: "/home/an/code/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
|
||||
});
|
||||
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded, onClose });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
|
||||
);
|
||||
// Accepted: the modal closes at once, before the clone is done.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// The store's next read sees it done: the new workspace is selected.
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* server's clone queue (with the machine-wide counts when it has them), a
|
||||
* running one git's phase, percent and speed with a bar, and a finished one
|
||||
* nothing — all rendered from a clone job's fields, no fetching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
const base: CloneProgressFields = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
it("shows the clone's place in the queue while it waits", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #2"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
it("adds the machine-wide counts when the view carries them", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
it("shows git's phase, percent and speed with a bar while it runs", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
it("shows nothing once the clone finished", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "done" }} />);
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// A finished clone (an older server's answer, or a job the store saw end)
|
||||
// makes the form read the folder list again.
|
||||
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
const job = {
|
||||
id: "s1",
|
||||
kind: "source",
|
||||
state: "queued",
|
||||
url: "https://h/a.git",
|
||||
name: "a",
|
||||
parent: "/",
|
||||
target: "/a",
|
||||
laneId: 3,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// Another workspace's clone is not ours.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it.
|
||||
* instead of downloading it; New → Clone repository starts a background
|
||||
* clone (`background: true`), keeps a pre-start refusal in the form, and a
|
||||
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { api, type CloneJob, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
@@ -32,7 +35,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
cloneJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -75,9 +78,30 @@ const src: FsListing = {
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
const job: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@host:o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/w/one",
|
||||
target: "/w/one/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId: 9, path: "/w/one/repo" },
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
@@ -230,7 +254,8 @@ describe("FileExplorer", () => {
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -251,7 +276,8 @@ describe("FileExplorer", () => {
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
@@ -459,6 +485,33 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a workspace whose folder was deleted out of the places", async () => {
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
|
||||
await screen.findByText("one", { selector: "aside span" });
|
||||
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the last folder's entries when the next one cannot be read", async () => {
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
|
||||
if (p === "src") throw new Error("ENOENT: no such file or directory");
|
||||
return root;
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText(/ENOENT/);
|
||||
expect(screen.queryByText("a.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -586,12 +639,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
it("clones into the open folder of the workspace in the background", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
@@ -605,26 +654,28 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
background: true,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
// Accepted: the dialog steps aside, the app-wide dock takes over.
|
||||
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
it("re-reads the folder and workspaces when a background clone ends", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const lists = vi.mocked(api.files.list).mock.calls.length;
|
||||
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
|
||||
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
|
||||
});
|
||||
|
||||
it("treats an older server's synchronous answer as a finished clone", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -632,45 +683,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
// In the background straight away: the chip carries the progress.
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
expect(await within(chip).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(chip);
|
||||
const back = screen.getByTestId("fx-clone-dialog");
|
||||
expect(await within(back).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(back).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden, and reopens the form for a login", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
// A login to type reopens the form by itself, its row ready.
|
||||
const again = await screen.findByTestId("fx-clone-dialog");
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
@@ -678,26 +692,24 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
.mockResolvedValueOnce({ job } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
const reopened = await screen.findByTestId("fx-clone-dialog");
|
||||
const login = await within(reopened).findByTestId("fx-clone-login");
|
||||
// A refusal before the clone starts stays in the form, with a login row.
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(reopened).getByTestId("fx-clone-submit"));
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
background: true,
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -844,9 +844,18 @@ describe("LaneChangesPanel", () => {
|
||||
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
// The confirm button carries its count, and a tracked-only selection
|
||||
// says nothing about new files to delete.
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmLabel: "Discard (1)",
|
||||
message: expect.not.stringMatching(/new file|\{\{/),
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -41,32 +41,9 @@ const BTN =
|
||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
|
||||
// The regions AWS CodeCommit runs in.
|
||||
const AWS_REGIONS = [
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ca-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"sa-east-1",
|
||||
"me-south-1",
|
||||
"af-south-1",
|
||||
"il-central-1",
|
||||
];
|
||||
// The host a key ID is saved under when no URL names one: AWS takes the same
|
||||
// key ID in every region, so the server writes the default login for all.
|
||||
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
|
||||
|
||||
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
return (
|
||||
@@ -166,28 +143,25 @@ function KeyCard({
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
|
||||
* default key's login is saved for every region; a named key's under its own
|
||||
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
onSave: (v: { keyId: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
const [v, setV] = useState({ keyId: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -198,27 +172,6 @@ function AwsLoginForm({
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
@@ -413,28 +366,41 @@ export function GitIdentitySettings() {
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
const saveAws = (v: { keyId: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
let host = AWS_DEFAULT_HOST;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
|
||||
// region does not matter — the key ID logs in to every one.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
|
||||
}
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
k?.name
|
||||
? // A named key (another AWS account): its login goes by the key's
|
||||
// name; a clone of any CodeCommit URL tries it.
|
||||
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
|
||||
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
|
||||
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
|
||||
);
|
||||
};
|
||||
// The default key's logins, one row per key ID: "every region" once the
|
||||
// server's whole list is covered, else the regions saved (an older save) —
|
||||
// with a button that extends it to every region.
|
||||
const regionCount = id?.codecommitRegions?.length ?? 0;
|
||||
const awsDefaultGroups = (rows: typeof awsRows) => {
|
||||
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
|
||||
for (const r of rows.filter((x) => !x.alias)) {
|
||||
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
|
||||
if (g) {
|
||||
g.hosts.push(r.host);
|
||||
g.regions.push(r.region);
|
||||
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
@@ -689,14 +655,66 @@ export function GitIdentitySettings() {
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
rows.filter((r) => !!r.alias),
|
||||
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{awsDefaultGroups(rows).map((g) => {
|
||||
const all = regionCount > 0 && g.regions.length >= regionCount;
|
||||
return (
|
||||
<div
|
||||
key={`${g.user}\0${g.key ?? ""}`}
|
||||
className="mt-1 flex items-center gap-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
|
||||
{` · ${g.user} · ${keyLabel(g.key)}`}
|
||||
</span>
|
||||
{id.writable && !all && regionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host: g.hosts[0]!,
|
||||
user: g.user,
|
||||
allRegions: true,
|
||||
}),
|
||||
t("gitIdentity.awsSavedAll")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("gitIdentity.awsUseAll")}
|
||||
</button>
|
||||
)}
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
|
||||
onClick={() =>
|
||||
void run(() =>
|
||||
g.hosts.length > 1
|
||||
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
|
||||
allRegions: true,
|
||||
user: g.user,
|
||||
})
|
||||
: api.gitIdentity.removeSshUser(g.hosts[0]!)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -139,27 +139,64 @@ describe("GitIdentitySettings", () => {
|
||||
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
||||
});
|
||||
|
||||
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
|
||||
it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => {
|
||||
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||
const H = "git-codecommit.eu-west-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||
const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"];
|
||||
const host = (r: string) => `git-codecommit.${r}.amazonaws.com`;
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })),
|
||||
codecommitRegions: REGIONS,
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(aws).queryByLabelText("Region")).toBeNull();
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA1",
|
||||
allRegions: true,
|
||||
});
|
||||
// One row for the key ID, not one per region.
|
||||
expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument();
|
||||
// A CodeCommit host never shows up again under "other hosts".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
|
||||
expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" }));
|
||||
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), {
|
||||
allRegions: true,
|
||||
user: "APKA1",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers an older one-region CodeCommit login for every region", async () => {
|
||||
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||
const H = "git-codecommit.ap-northeast-1.amazonaws.com";
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA],
|
||||
sshUsers: [{ host: H, user: "APKA9" }],
|
||||
codecommitRegions: ["us-east-1", "ap-northeast-1"],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] }));
|
||||
render(<GitIdentitySettings />);
|
||||
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA9",
|
||||
allRegions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a user for any other SSH host under the advanced section", async () => {
|
||||
@@ -247,18 +284,16 @@ describe("GitIdentitySettings", () => {
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
// Its own step 3: only the key ID; the login goes by the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKA2",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
@@ -298,8 +333,6 @@ describe("GitIdentitySettings", () => {
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||
).toBeInTheDocument();
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +215,6 @@
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -232,6 +231,41 @@
|
||||
"clone.phase.resolving": "Unpacking",
|
||||
"clone.phase.checkout": "Writing files",
|
||||
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||
"cloneJobs.backgroundHint": "The clone runs in the background: follow it in the corner of the screen, on any page — even after a reload.",
|
||||
"cloneJobs.starting": "Starting…",
|
||||
"cloneJobs.started": "Started — progress is in the corner of the screen.",
|
||||
"cloneJobs.cloning": "Cloning {{name}}",
|
||||
"cloneJobs.queued": "Waiting in the clone queue — #{{position}}",
|
||||
"cloneJobs.done": "Cloned {{name}}",
|
||||
"cloneJobs.failedTitle": "Could not clone {{name}}",
|
||||
"cloneJobs.cancelledTitle": "Stopped cloning {{name}}",
|
||||
"cloneJobs.failed": "The clone failed.",
|
||||
"cloneJobs.interrupted": "AgentHub restarted while this clone was running.",
|
||||
"cloneJobs.cancelled": "You stopped this clone.",
|
||||
"cloneJobs.cancelledToast": "Stopped cloning {{name}}.",
|
||||
"cloneJobs.failedToast": "Could not clone {{name}}: {{message}}",
|
||||
"cloneJobs.doneWorkspace": "Cloned {{name}} into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"cloneJobs.doneSource": "Cloned {{name}} into {{path}} and added it to the workspace.",
|
||||
"cloneJobs.openWorkspace": "Open workspace",
|
||||
"cloneJobs.open": "Open",
|
||||
"cloneJobs.retry": "Retry",
|
||||
"cloneJobs.retryLogin": "Retry with this login",
|
||||
"cloneJobs.stop": "Stop clone",
|
||||
"cloneJobs.stopConfirm": "Click again to stop",
|
||||
"cloneJobs.stopUnsupported": "This AgentHub server cannot stop a clone yet — update it, or wait for the clone to finish.",
|
||||
"cloneJobs.dismiss": "Dismiss",
|
||||
"jobs.dock": "Background tasks",
|
||||
"jobs.count_one": "{{count}} background task",
|
||||
"jobs.count_other": "{{count}} background tasks",
|
||||
"jobs.running_one": "{{count}} running",
|
||||
"jobs.running_other": "{{count}} running",
|
||||
"jobs.expand": "Show background tasks",
|
||||
"uploads.stop": "Stop upload",
|
||||
"uploads.stopConfirm": "Click again to stop",
|
||||
"uploads.stopping": "Stopping…",
|
||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||
"uploads.interrupted": "The upload of {{total}} file(s) to {{folder}} was interrupted by a page reload — {{done}} sent.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -343,6 +377,7 @@
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file",
|
||||
"row.actions": "Workspace actions",
|
||||
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
|
||||
"row.folderMissing": "Folder deleted — remove this workspace from its ⋯ menu",
|
||||
"menu.rename": "Rename",
|
||||
"menu.newHere": "New workspace here…",
|
||||
"menu.copyPath": "Copy path",
|
||||
@@ -535,6 +570,8 @@
|
||||
"changes.discardTicked": "Discard ({{count}})",
|
||||
"changes.discardTickedTitle": "Discard the ticked changes?",
|
||||
"changes.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
|
||||
"changes.discardTickedConfirmTracked": "The ticked changes in {{count}} file(s) go back to the last commit. Unticked files and blocks are kept. This cannot be undone.",
|
||||
"changes.discardTickedConfirmNew": "{{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files are kept. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
@@ -567,7 +604,6 @@
|
||||
"fx.newFile": "New file",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Upload files",
|
||||
"fx.uploadFolder": "Upload a folder",
|
||||
"fx.download": "Download",
|
||||
|
||||
@@ -911,11 +911,13 @@
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
|
||||
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
|
||||
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here — one key ID works in every region:",
|
||||
"awsKeyId": "SSH key ID",
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Save",
|
||||
"awsSaved": "SSH key ID saved for {{region}}",
|
||||
"awsSavedAll": "SSH key ID saved for every region",
|
||||
"awsSavedNamed": "SSH key ID saved for {{name}}",
|
||||
"awsAllRegions": "Every region",
|
||||
"awsUseAll": "Use in every region",
|
||||
"otherTitle": "Other SSH hosts (advanced)",
|
||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||
"otherHost": "Host",
|
||||
@@ -929,7 +931,7 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
|
||||
@@ -215,7 +215,6 @@
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.background": "Chạy nền",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -232,6 +231,41 @@
|
||||
"clone.phase.resolving": "Đang giải nén",
|
||||
"clone.phase.checkout": "Đang ghi file",
|
||||
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||
"cloneJobs.backgroundHint": "Việc clone chạy nền: bạn theo dõi ở góc màn hình, ở trang nào cũng được — kể cả sau khi tải lại trang.",
|
||||
"cloneJobs.starting": "Đang bắt đầu…",
|
||||
"cloneJobs.started": "Đã bắt đầu — tiến độ hiện ở góc màn hình.",
|
||||
"cloneJobs.cloning": "Đang clone {{name}}",
|
||||
"cloneJobs.queued": "Đang chờ trong hàng clone — thứ {{position}}",
|
||||
"cloneJobs.done": "Đã clone {{name}}",
|
||||
"cloneJobs.failedTitle": "Không clone được {{name}}",
|
||||
"cloneJobs.cancelledTitle": "Đã ngưng clone {{name}}",
|
||||
"cloneJobs.failed": "Clone bị lỗi.",
|
||||
"cloneJobs.interrupted": "AgentHub đã khởi động lại khi đang clone.",
|
||||
"cloneJobs.cancelled": "Bạn đã ngưng clone này.",
|
||||
"cloneJobs.cancelledToast": "Đã ngưng clone {{name}}.",
|
||||
"cloneJobs.failedToast": "Không clone được {{name}}: {{message}}",
|
||||
"cloneJobs.doneWorkspace": "Đã clone {{name}} vào {{path}} — mở thành workspace “{{workspace}}”.",
|
||||
"cloneJobs.doneSource": "Đã clone {{name}} vào {{path}} và thêm vào workspace.",
|
||||
"cloneJobs.openWorkspace": "Mở workspace",
|
||||
"cloneJobs.open": "Mở",
|
||||
"cloneJobs.retry": "Thử lại",
|
||||
"cloneJobs.retryLogin": "Thử lại với tài khoản này",
|
||||
"cloneJobs.stop": "Ngưng clone",
|
||||
"cloneJobs.stopConfirm": "Bấm lần nữa để ngưng",
|
||||
"cloneJobs.stopUnsupported": "Máy chủ AgentHub này chưa ngưng clone được — hãy cập nhật, hoặc chờ clone xong.",
|
||||
"cloneJobs.dismiss": "Đóng",
|
||||
"jobs.dock": "Việc chạy nền",
|
||||
"jobs.count_one": "{{count}} việc chạy nền",
|
||||
"jobs.count_other": "{{count}} việc chạy nền",
|
||||
"jobs.running_one": "{{count}} đang chạy",
|
||||
"jobs.running_other": "{{count}} đang chạy",
|
||||
"jobs.expand": "Xem các việc chạy nền",
|
||||
"uploads.stop": "Ngưng tải lên",
|
||||
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
||||
"uploads.stopping": "Đang ngưng…",
|
||||
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||
"uploads.interrupted": "Việc tải {{total}} tệp lên {{folder}} bị ngắt do tải lại trang — đã gửi {{done}} tệp.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -343,6 +377,7 @@
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
|
||||
"row.actions": "Thao tác workspace",
|
||||
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
|
||||
"row.folderMissing": "Thư mục đã bị xóa — gỡ workspace này trong menu ⋯",
|
||||
"menu.rename": "Đổi tên",
|
||||
"menu.newHere": "Workspace mới tại đây…",
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
@@ -535,6 +570,8 @@
|
||||
"changes.discardTicked": "Bỏ ({{count}})",
|
||||
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
|
||||
"changes.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.discardTickedConfirmTracked": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.discardTickedConfirmNew": "{{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
@@ -567,7 +604,6 @@
|
||||
"fx.newFile": "File mới",
|
||||
"fx.cloneRepo": "Clone repository",
|
||||
"fx.cloneSubmit": "Clone",
|
||||
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||
"fx.uploadFiles": "Tải file lên",
|
||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||
"fx.download": "Tải về",
|
||||
|
||||
@@ -899,11 +899,13 @@
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
|
||||
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
|
||||
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây — một key ID dùng được cho mọi region:",
|
||||
"awsKeyId": "SSH key ID",
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Lưu",
|
||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||
"awsSavedAll": "Đã lưu SSH key ID cho mọi region",
|
||||
"awsSavedNamed": "Đã lưu SSH key ID cho {{name}}",
|
||||
"awsAllRegions": "Mọi region",
|
||||
"awsUseAll": "Dùng cho mọi region",
|
||||
"otherTitle": "Host SSH khác (nâng cao)",
|
||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||
"otherHost": "Host",
|
||||
@@ -917,7 +919,7 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
|
||||
+109
-13
@@ -1128,9 +1128,22 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
|
||||
const sendRaw = <T>(
|
||||
url: string,
|
||||
payload: Blob,
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const aborted = () => Object.assign(new Error("aborted"), { code: "EABORT" });
|
||||
if (signal?.aborted) {
|
||||
reject(aborted());
|
||||
return;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Stop: the request is dropped mid-stream, the caller's loop sees EABORT.
|
||||
signal?.addEventListener("abort", () => xhr.abort(), { once: true });
|
||||
xhr.onabort = () => reject(aborted());
|
||||
xhr.open("POST", `${BASE}${url}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
@@ -3162,12 +3175,14 @@ export const api = {
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress
|
||||
onProgress,
|
||||
signal
|
||||
),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
@@ -3179,7 +3194,8 @@ export const api = {
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
@@ -3195,7 +3211,8 @@ export const api = {
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
onProgress
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
},
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
@@ -3231,11 +3248,23 @@ export const api = {
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
/** `allRegions`: AWS CodeCommit's default login, for every region at once. */
|
||||
setSshUser: (body: {
|
||||
host: string;
|
||||
user: string;
|
||||
alias?: string;
|
||||
key?: string;
|
||||
allRegions?: boolean;
|
||||
}) => request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias;
|
||||
* `allRegions` (a CodeCommit host): every region's login of `user`. */
|
||||
removeSshUser: (from: string, opts?: { allRegions: true; user: string }) =>
|
||||
request<GitIdentity>(
|
||||
`/me/git/ssh-user/${encodeURIComponent(from)}${
|
||||
opts ? `?allRegions=1&user=${encodeURIComponent(opts.user)}` : ""
|
||||
}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -4067,10 +4096,40 @@ export const api = {
|
||||
partial?: boolean;
|
||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||
progressToken?: string;
|
||||
/** Run as a server-side job: 202 `{ job }` once the checks pass (an
|
||||
* older server ignores it and answers when the clone is done). */
|
||||
background?: boolean;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||
request<{ lane: Lane; created: boolean; path: string } | { job: CloneJob }>(
|
||||
"/workspaces/clone",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
/** GET /api/lanes/clone/jobs — the caller's background clones, newest
|
||||
* first, kept by the server across reloads and restarts. */
|
||||
cloneJobs: () => request<{ jobs: CloneJob[] }>("/workspaces/clone/jobs"),
|
||||
/** DELETE /api/lanes/clone/jobs/:id — forget a finished job (409 EBUSY while it runs). */
|
||||
dismissCloneJob: (id: string) =>
|
||||
request<{ ok: true }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
/** POST /api/lanes/clone/jobs/:id/retry — a failed or interrupted job
|
||||
* again, optionally with a login; answers the NEW job (the old one goes). */
|
||||
retryCloneJob: (
|
||||
id: string,
|
||||
credentials?: { username: string; password: string; remember?: boolean }
|
||||
) =>
|
||||
request<{ job: CloneJob }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}/retry`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials ? { credentials } : {}),
|
||||
}),
|
||||
/** POST /api/lanes/clone/jobs/:id/cancel — stop a queued or running clone
|
||||
* (`state: "cancelled"`); 409 EDONE once it finished, 404 on an older server. */
|
||||
cancelCloneJob: (id: string) =>
|
||||
request<{ job: CloneJob }>(`/workspaces/clone/jobs/${encodeURIComponent(id)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/clone/progress/:token — a clone's place in the
|
||||
@@ -4487,9 +4546,11 @@ export const api = {
|
||||
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
|
||||
partial?: boolean;
|
||||
progressToken?: string;
|
||||
/** A server-side job, like {@link lanes.clone}'s `background`. */
|
||||
background?: boolean;
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||
request<(LaneSourcesView & { path: string }) | { job: CloneJob }>(`/workspaces/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -4945,6 +5006,9 @@ export interface GitIdentity {
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The regions a CodeCommit key ID saved for every region covers. Absent
|
||||
* from an older server. */
|
||||
codecommitRegions?: string[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
@@ -5905,6 +5969,38 @@ export interface CloneProgressView {
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** A background clone (`GET /api/lanes/clone/jobs`), kept by the server
|
||||
* across reloads and restarts; `interrupted` = the server restarted during it. */
|
||||
export interface CloneJob {
|
||||
id: string;
|
||||
kind: "workspace" | "source";
|
||||
state: "queued" | "running" | "done" | "failed" | "interrupted" | "cancelled";
|
||||
url: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
target: string;
|
||||
/** The workspace a `source` clone is added to. */
|
||||
laneId: number | null;
|
||||
/** 1-based place in the clone queue while `queued`. */
|
||||
position: number | null;
|
||||
phase: CloneProgressView["phase"];
|
||||
label: string | null;
|
||||
percent: number | null;
|
||||
detail: string | null;
|
||||
error: { code: string; message: string; auth?: { protocol: string; host: string } } | null;
|
||||
result: { laneId: number | null; path: string; created?: boolean } | null;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
request: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name: string;
|
||||
tool?: string;
|
||||
title?: string;
|
||||
partial?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaneSourcesView {
|
||||
instructions: string;
|
||||
/** The workspace's tool can take folders and instructions (claude, codex). */
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @file cloneJobs.ts
|
||||
* @description The app-wide list of background clones (`GET /api/lanes/clone/jobs`),
|
||||
* shown by `components/BackgroundJobsDock.tsx`. The server keeps every job
|
||||
* across reloads and restarts, so this store is only a view of that list:
|
||||
* it is read on start, on window focus, after each start/retry/cancel, and
|
||||
* every 1.5 s while any job is queued or running. A job SEEN going from
|
||||
* active to finished in this page is announced once — a toast, the
|
||||
* `agenthub:clone-job` window event (open pages re-read what the clone
|
||||
* changed) and the starter's own `onDone` — and a done one then leaves the
|
||||
* dock by itself; a job that finished unseen (a closed tab, F5) stays until
|
||||
* dismissed. An older server answers a start synchronously: that answer is
|
||||
* announced the same way as a job that is already done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import i18n from "i18next";
|
||||
import { api, type CloneJob, type LaneSourcesView } from "./api";
|
||||
import type { Lane } from "./types";
|
||||
import { toast } from "./toast";
|
||||
|
||||
/** Window event carrying a finished `CloneJob` in `detail`. */
|
||||
export const CLONE_JOB_EVENT = "agenthub:clone-job";
|
||||
|
||||
const POLL_MS = 1500;
|
||||
/** How long a done job the user watched stays in the dock after its toast. */
|
||||
const DONE_LINGER_MS = 8000;
|
||||
|
||||
export const isActiveJob = (j: Pick<CloneJob, "state">) =>
|
||||
j.state === "queued" || j.state === "running";
|
||||
|
||||
type OnDone = (job: CloneJob, lane?: Lane) => void;
|
||||
type Creds = { username: string; password: string; remember?: boolean };
|
||||
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
|
||||
type SourceBody = Omit<
|
||||
Parameters<typeof api.lanes.cloneFolder>[1],
|
||||
"background" | "progressToken"
|
||||
>;
|
||||
|
||||
let jobs: CloneJob[] = [];
|
||||
/** The last state seen per job, to tell a transition from a repeat. */
|
||||
const known = new Map<string, CloneJob["state"]>();
|
||||
/** Jobs seen queued/running in this page: only their finish is announced. */
|
||||
const watched = new Set<string>();
|
||||
/** Dismissed here: a poll already in flight must not bring them back. */
|
||||
const hidden = new Set<string>();
|
||||
const callbacks = new Map<string, OnDone>();
|
||||
const listeners = new Set<() => void>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let started = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
let legacyId = 0;
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
function publish(next: CloneJob[]) {
|
||||
jobs = next.filter((j) => !hidden.has(j.id));
|
||||
emit();
|
||||
}
|
||||
|
||||
function upsert(job: CloneJob) {
|
||||
publish([job, ...jobs.filter((j) => j.id !== job.id)]);
|
||||
}
|
||||
|
||||
/** The user-facing reason a job did not finish. */
|
||||
export function cloneJobError(job: CloneJob): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const e = job.error;
|
||||
if (job.state === "cancelled") return t("lanes:cloneJobs.cancelled");
|
||||
if (job.state === "interrupted" || e?.code === "EINTERRUPTED")
|
||||
return t("lanes:cloneJobs.interrupted");
|
||||
const host = e?.auth?.host || job.url;
|
||||
if (e?.code === "ECREDENTIALS") return t("lanes:clone.loginNeeded", { host });
|
||||
if (e?.code === "ESSHAUTH") return t("lanes:clone.sshRefused", { host });
|
||||
return e?.message || t("lanes:cloneJobs.failed");
|
||||
}
|
||||
|
||||
function announce(job: CloneJob, lane?: Lane) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const path = job.result?.path || job.target;
|
||||
if (job.state === "done") {
|
||||
toast.success(
|
||||
job.kind === "workspace"
|
||||
? t("lanes:cloneJobs.doneWorkspace", {
|
||||
name: job.name,
|
||||
path,
|
||||
workspace: lane?.title || job.request?.title || job.name,
|
||||
})
|
||||
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
|
||||
);
|
||||
} else if (job.state === "cancelled") {
|
||||
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
|
||||
} else {
|
||||
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
|
||||
if (job.state === "done") {
|
||||
const cb = callbacks.get(job.id);
|
||||
callbacks.delete(job.id);
|
||||
try {
|
||||
cb?.(job, lane);
|
||||
} catch {
|
||||
/* a page's follow-up never breaks the store */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observe(list: CloneJob[]) {
|
||||
const finishedHere: CloneJob[] = [];
|
||||
for (const j of list) {
|
||||
const prev = known.get(j.id);
|
||||
known.set(j.id, j.state);
|
||||
if (isActiveJob(j)) {
|
||||
watched.add(j.id);
|
||||
continue;
|
||||
}
|
||||
if (!watched.has(j.id) || prev === j.state) continue;
|
||||
watched.delete(j.id);
|
||||
finishedHere.push(j);
|
||||
}
|
||||
for (const j of finishedHere) {
|
||||
announce(j);
|
||||
if (j.state === "done") setTimeout(() => void dismissCloneJob(j.id), DONE_LINGER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(ms = POLL_MS) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void refreshCloneJobs();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** Read the list now (shared while one read is in flight). */
|
||||
export function refreshCloneJobs(): Promise<void> {
|
||||
if (!inflight) {
|
||||
inflight = Promise.resolve()
|
||||
.then(() => api.lanes.cloneJobs())
|
||||
.then((r) => {
|
||||
const list = Array.isArray(r?.jobs) ? r.jobs : [];
|
||||
observe(list);
|
||||
publish(list);
|
||||
})
|
||||
// An older server (404) or a blip: keep what we had; a start polls again.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
if (jobs.some(isActiveJob)) schedule();
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const onFocus = () => void refreshCloneJobs();
|
||||
|
||||
/** Start following the list (the dock calls this once, on mount). */
|
||||
export function ensureCloneJobs(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void refreshCloneJobs();
|
||||
window.addEventListener("focus", onFocus);
|
||||
}
|
||||
|
||||
function track(job: CloneJob, onDone?: OnDone) {
|
||||
if (onDone) callbacks.set(job.id, onDone);
|
||||
if (isActiveJob(job)) watched.add(job.id);
|
||||
known.set(job.id, job.state);
|
||||
upsert(job);
|
||||
if (isActiveJob(job)) schedule();
|
||||
else observe([job]);
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` makes: the URL's last segment, no `.git`. */
|
||||
const cloneName = (url: string) =>
|
||||
url
|
||||
.replace(/\/+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop()
|
||||
?.replace(/\.git$/, "") || url;
|
||||
|
||||
/** An older server's synchronous answer, as a job that is already done. */
|
||||
function legacyJob(
|
||||
kind: CloneJob["kind"],
|
||||
body: { url: string; name?: string; parent?: string; title?: string },
|
||||
path: string | undefined,
|
||||
laneId: number | null
|
||||
): CloneJob {
|
||||
path = path || "";
|
||||
const name = body.name || path.split("/").pop() || cloneName(body.url);
|
||||
return {
|
||||
id: `legacy-${++legacyId}`,
|
||||
kind,
|
||||
state: "done",
|
||||
url: body.url,
|
||||
name,
|
||||
parent: body.parent ?? "",
|
||||
target: path,
|
||||
laneId: kind === "source" ? laneId : null,
|
||||
position: null,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId, path },
|
||||
createdAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
request: { url: body.url, parent: body.parent ?? "", name, title: body.title },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a clone in the background. Resolves with its job once the server
|
||||
* accepted it (a 4xx validation error rejects, for the form to show);
|
||||
* `onDone` runs when this page sees it finish.
|
||||
*/
|
||||
export async function startClone(
|
||||
spec:
|
||||
| { kind: "workspace"; body: WorkspaceBody }
|
||||
| { kind: "source"; laneId: number; body: SourceBody },
|
||||
opts: { onDone?: OnDone } = {}
|
||||
): Promise<CloneJob> {
|
||||
if (spec.kind === "workspace") {
|
||||
const r = await api.lanes.clone({ ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as { lane: Lane; path: string };
|
||||
const job = legacyJob("workspace", spec.body, legacy.path, legacy.lane?.id ?? null);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job, legacy.lane);
|
||||
return job;
|
||||
}
|
||||
const r = await api.lanes.cloneFolder(spec.laneId, { ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as LaneSourcesView & { path: string };
|
||||
const job = legacyJob("source", spec.body, legacy.path, spec.laneId);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Retry a failed / interrupted / cancelled job, optionally with a login. */
|
||||
export async function retryCloneJob(id: string, credentials?: Creds): Promise<CloneJob> {
|
||||
const { job } = await api.lanes.retryCloneJob(id, credentials);
|
||||
const cb = callbacks.get(id);
|
||||
callbacks.delete(id);
|
||||
hidden.add(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
track(job, cb);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Stop a queued or running clone. */
|
||||
export async function cancelCloneJob(id: string): Promise<void> {
|
||||
try {
|
||||
const { job } = await api.lanes.cancelCloneJob(id);
|
||||
if (job) {
|
||||
observe([job]);
|
||||
upsert(job);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; code?: string };
|
||||
if (err.code === "EDONE") {
|
||||
void refreshCloneJobs();
|
||||
return;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
toast.error(i18n.t("lanes:cloneJobs.stopUnsupported"));
|
||||
return;
|
||||
}
|
||||
toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a finished job here and on the server. */
|
||||
export async function dismissCloneJob(id: string): Promise<void> {
|
||||
hidden.add(id);
|
||||
callbacks.delete(id);
|
||||
watched.delete(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
await Promise.resolve()
|
||||
.then(() => api.lanes.dismissCloneJob(id))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
/** The jobs to show, newest first. */
|
||||
export function useCloneJobs(): CloneJob[] {
|
||||
return useSyncExternalStore(subscribe, () => jobs);
|
||||
}
|
||||
|
||||
/** Tests only: forget everything, as a fresh page load would. */
|
||||
export function resetCloneJobsForTests(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
jobs = [];
|
||||
known.clear();
|
||||
watched.clear();
|
||||
hidden.clear();
|
||||
callbacks.clear();
|
||||
inflight = null;
|
||||
started = false;
|
||||
window.removeEventListener("focus", onFocus);
|
||||
emit();
|
||||
}
|
||||
@@ -2731,6 +2731,8 @@ export interface Lane {
|
||||
stage_seconds: number | null;
|
||||
last_event_seconds: number | null;
|
||||
liveness: "active" | "idle" | "dead";
|
||||
/** The workspace's folder no longer exists (deleted from Files or a shell). */
|
||||
folder_missing?: boolean;
|
||||
/** Stage id the server's heuristic infers from ingested tool events, or null
|
||||
* if no signal was seen. Independent of `stage` (the agent's own declaration). */
|
||||
detected_stage: string | null;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file uploadJobs.ts
|
||||
* @description The app-wide list of File Explorer uploads, shown by
|
||||
* `components/BackgroundJobsDock.tsx` so an upload's progress outlives the
|
||||
* Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports
|
||||
* here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal,
|
||||
* which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an
|
||||
* upload cannot survive a reload: while one runs, a small record of it is
|
||||
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
||||
* a record that stopped being refreshed as an interrupted upload until it is
|
||||
* dismissed. A fresh record belongs to another open tab and is left alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export interface UploadJob {
|
||||
id: string;
|
||||
/** Where the files go, as the user reads it ("workspace/folder"). */
|
||||
folder: string;
|
||||
/** The file (or batch) being sent now. */
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
state: "running" | "stopping" | "interrupted";
|
||||
}
|
||||
|
||||
type Stored = UploadJob & { beat: number };
|
||||
|
||||
const KEY = "agenthub.uploads.inflight";
|
||||
const BEAT_MS = 2000;
|
||||
/** A running record older than this is from a page that is gone. */
|
||||
const STALE_MS = 6000;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
/** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */
|
||||
const mine = new Set<string>();
|
||||
let uploads: UploadJob[] = [];
|
||||
let beat: ReturnType<typeof setInterval> | undefined;
|
||||
let lastWrite = 0;
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
function readStored(): Stored[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]");
|
||||
return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
lastWrite = Date.now();
|
||||
try {
|
||||
const others = readStored().filter((r) => !mine.has(r.id));
|
||||
const own = uploads.map((u) => ({ ...u, beat: lastWrite }));
|
||||
const all = [...others, ...own];
|
||||
if (all.length) localStorage.setItem(KEY, JSON.stringify(all));
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* storage off: the dock still works for this page */
|
||||
}
|
||||
}
|
||||
|
||||
/** Load what a previous page left behind: a stale running record = interrupted. */
|
||||
function loadInterrupted() {
|
||||
const now = Date.now();
|
||||
const left = readStored().filter(
|
||||
(r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS
|
||||
);
|
||||
for (const r of left) mine.add(r.id);
|
||||
uploads = left.map((r) => ({
|
||||
id: r.id,
|
||||
folder: String(r.folder ?? ""),
|
||||
name: String(r.name ?? ""),
|
||||
done: Number(r.done) || 0,
|
||||
total: Number(r.total) || 0,
|
||||
sent: Number(r.sent) || 0,
|
||||
bytes: Number(r.bytes) || 0,
|
||||
state: "interrupted",
|
||||
}));
|
||||
if (left.length) persist();
|
||||
}
|
||||
loadInterrupted();
|
||||
|
||||
function set(id: string, patch: Partial<UploadJob>) {
|
||||
uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u));
|
||||
emit();
|
||||
if (Date.now() - lastWrite > 1000) persist();
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
const running = uploads.some((u) => u.state !== "interrupted");
|
||||
if (running && !beat) beat = setInterval(persist, BEAT_MS);
|
||||
if (!running && beat) {
|
||||
clearInterval(beat);
|
||||
beat = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an upload; the loop reports through the handle and checks `signal`. */
|
||||
export function beginUpload(init: { folder: string; total: number; bytes: number }) {
|
||||
const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const ctrl = new AbortController();
|
||||
controllers.set(id, ctrl);
|
||||
mine.add(id);
|
||||
uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }];
|
||||
emit();
|
||||
persist();
|
||||
heartbeat();
|
||||
return {
|
||||
id,
|
||||
signal: ctrl.signal,
|
||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
|
||||
end: () => {
|
||||
controllers.delete(id);
|
||||
uploads = uploads.filter((u) => u.id !== id);
|
||||
emit();
|
||||
persist();
|
||||
mine.delete(id);
|
||||
heartbeat();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop a running upload: the request in flight is dropped, the loop ends. */
|
||||
export function stopUpload(id: string): void {
|
||||
const ctrl = controllers.get(id);
|
||||
if (!ctrl) return;
|
||||
set(id, { state: "stopping" });
|
||||
ctrl.abort();
|
||||
}
|
||||
|
||||
/** Forget an interrupted upload's record. */
|
||||
export function dismissUpload(id: string): void {
|
||||
uploads = uploads.filter((u) => u.id !== id);
|
||||
emit();
|
||||
persist();
|
||||
mine.delete(id);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useUploadJobs(): UploadJob[] {
|
||||
return useSyncExternalStore(subscribe, () => uploads);
|
||||
}
|
||||
|
||||
/** Tests only: re-read storage as a fresh page load would. */
|
||||
export function reloadUploadsForTests(): void {
|
||||
for (const c of controllers.values()) c.abort();
|
||||
controllers.clear();
|
||||
mine.clear();
|
||||
if (beat) clearInterval(beat);
|
||||
beat = undefined;
|
||||
lastWrite = 0;
|
||||
loadInterrupted();
|
||||
emit();
|
||||
}
|
||||
@@ -50,7 +50,10 @@ export function FilesPage() {
|
||||
// The workspace last shown first: Files reached from a workspace opens
|
||||
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
|
||||
// but for hidden files); the home is the fallback.
|
||||
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
||||
// A workspace whose folder was deleted has nothing to open.
|
||||
const lanes = (await api.lanes.list().catch(() => ({ lanes: [] }))).lanes.filter(
|
||||
(l) => !l.folder_missing
|
||||
);
|
||||
const last = lastWorkspace();
|
||||
const shown = lanes.find((l) => l.id === last);
|
||||
if (shown) return setStart({ lane: shown.id });
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.49 (rebuild client to match)
|
||||
UI build v2.8.51 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+66
-7
@@ -398,10 +398,17 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
||||
into the folder it has open — of the user's own home or of a workspace's own
|
||||
folder, never a session worktree, an extra folder or another user's home —
|
||||
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||
check of that route applies, and opens a workspace on the clone. It sends a
|
||||
`progressToken` and shows the queue place / git progress while the clone runs;
|
||||
closing the dialog then only hides it behind a progress chip (click to reopen)
|
||||
— the request keeps going and its outcome arrives as a toast.
|
||||
check of that route applies, and opens a workspace on the clone. It sends
|
||||
`background: true` and closes as soon as the server accepted the clone; from
|
||||
then on the app-wide dock (`client/src/components/BackgroundJobsDock.tsx`,
|
||||
rendered once by `Layout`, store `client/src/lib/cloneJobs.ts` polling
|
||||
`GET /api/lanes/clone/jobs`) shows the queue place, git's progress, **Stop**
|
||||
(click twice) and the outcome — on every page, after F5 and after a server
|
||||
restart (an `interrupted` job offers **Retry**, a login failure an inline login
|
||||
form, a refused SSH key a link to Settings → Git). Add workspace → **Clone**
|
||||
and a workspace's **Or clone repositories** box start their clones the same
|
||||
way. File Explorer uploads show in the same dock (`client/src/lib/uploadJobs.ts`)
|
||||
with **Stop**; an upload cut short by a reload is shown as interrupted.
|
||||
|
||||
#### Clone a repository into a new workspace
|
||||
|
||||
@@ -431,7 +438,8 @@ else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is
|
||||
held open, `GET /api/lanes/clone/progress/:token` answers its 1-based
|
||||
`position` among the waiting clones, then git's `--progress` line parsed into
|
||||
`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the
|
||||
form polls it every second (`CloneProgress.tsx`). Only the user who started
|
||||
dashboard's own forms no longer use it (they start background jobs, whose
|
||||
list carries the same fields, rendered by `CloneProgress.tsx`). Only the user who started
|
||||
the clone sees it (404 otherwise), and it is dropped a minute after the clone
|
||||
ends. `url` passes the same allowlist as the git bar's
|
||||
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
|
||||
@@ -455,6 +463,57 @@ own config (`core.sshCommand` naming the key, or a member's
|
||||
`credential.helper=store --file=…`), so later fetch/pull/push — the dashboard's
|
||||
and an agent's — use it too; the user's global git config is never touched.
|
||||
|
||||
#### Background clone jobs
|
||||
|
||||
```
|
||||
POST /api/lanes/clone Body: { …as above, background: true } → 202 { job }
|
||||
POST /api/lanes/:id/sources/clone Body: { …, background: true } → 202 { job }
|
||||
GET /api/lanes/clone/jobs → 200 { jobs: CloneJob[] }
|
||||
DELETE /api/lanes/clone/jobs/:id → 200 { ok: true } (404 ENOTFOUND, 409 EBUSY while queued/running)
|
||||
POST /api/lanes/clone/jobs/:id/cancel → 200 { job } (404 ENOTFOUND, 409 EDONE once finished)
|
||||
POST /api/lanes/clone/jobs/:id/retry Body: { credentials? } → 202 { job } (404 ENOTFOUND, 409 EBUSY unless failed/interrupted/cancelled)
|
||||
|
||||
CloneJob = { id, kind: "workspace"|"source", state: "queued"|"running"|"done"|"failed"|"interrupted"|"cancelled",
|
||||
url, name, parent, target, laneId (source: its workspace), position (1-based while queued),
|
||||
phase, label, percent, detail, error: { code, message, auth? } | null,
|
||||
result: { laneId, path, created? } | null, createdAt, finishedAt,
|
||||
request: { url, parent, name, tool?, title?, partial? }, pid }
|
||||
```
|
||||
|
||||
`background: true` (`400 EBADFIELD` when not a boolean) runs every check of
|
||||
the clone POST synchronously — a refusal still answers 4xx at once — then
|
||||
answers `202 { job }` and clones in the background
|
||||
(`server/lib/clone-jobs.js`); without it both routes hold the request open as
|
||||
above. git runs in a **detached runner** (`scripts/clone-runner.js`: its own
|
||||
session and process group, the exact command `worktree.git()` would run —
|
||||
`sudo -n -u ah-<name> git …` for a member — with stderr appended to
|
||||
`<data dir>/runtime/clone-jobs/<id>/stderr.log` and `exit.json` written when
|
||||
git ends), so a clone survives a browser reload AND a server restart or
|
||||
update (the unit is `KillMode=process`; a git whose stderr was a pipe into the
|
||||
server died of SIGPIPE when it restarted). The server tails the log for
|
||||
`phase`/`percent`/`detail`, and when the runner ends it creates the workspace
|
||||
(or adds the extra folder) and broadcasts it exactly as the held-open route,
|
||||
once; a failure records that route's own `error.code`/`message`
|
||||
(`ECREDENTIALS` with `auth`, `ESSHAUTH`, `ENOREPO`, `EEXISTS`, `ECLONE`…). Jobs
|
||||
live in `<data dir>/runtime/clone-jobs.json` (0600, tmp + rename; progress at
|
||||
most every 2 s, every state change at once). On start the server **adopts** a
|
||||
job whose runner is still alive (counted against `AGENTHUB_CLONE_CONCURRENCY`,
|
||||
tailed, finished when it exits), finishes one whose `exit.json` is already
|
||||
there, requeues a queued one, and marks the rest `interrupted`
|
||||
(`EINTERRUPTED`, "AgentHub restarted while this clone ran") — a queued job
|
||||
that carried a typed login is interrupted too, so the login is typed again.
|
||||
`cancel` takes a queued job out of the queue or SIGTERMs the runner's process
|
||||
group (git removes its half-made folder; sudo relays the signal to a member's
|
||||
git); the job ends `cancelled` (`ECANCELLED`) and never makes its workspace or
|
||||
extra folder. `retry` starts a NEW job from the old one's `request` through
|
||||
every check of a fresh POST, then drops the old one. `request` never holds a
|
||||
credential: a typed `credentials` login is used for that one run (the host's
|
||||
travels in the runner's environment, as it would in git's) — a host
|
||||
`remember` is saved only when the same server process sees the clone succeed.
|
||||
Done jobs are kept 24 h, failed/interrupted/cancelled ones 7 days. A job is
|
||||
only ever looked up among the caller's own (`scope.ownerFor`; local mode:
|
||||
every job), 404 otherwise. The browser polls; there is no websocket message.
|
||||
|
||||
#### Update a workspace
|
||||
|
||||
```http
|
||||
@@ -1203,7 +1262,7 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
|
||||
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
|
||||
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
|
||||
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` whose region has no login of its own borrows the ones saved for other CodeCommit regions (each with its own key): the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. 400 EBADHOST / EBADSSHUSER
|
||||
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
|
||||
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
|
||||
POST /api/me/git/https Save an https login — Body: { host, username, password }
|
||||
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
|
||||
@@ -1737,7 +1796,7 @@ POST /api/locks/:name/release
|
||||
GET /api/lanes/:id/sources
|
||||
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
|
||||
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
|
||||
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}}
|
||||
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}, "background"?: true}
|
||||
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
|
||||
DELETE /api/lanes/:id/sources/:sourceId
|
||||
GET /api/lanes/:id/memory
|
||||
|
||||
@@ -1629,6 +1629,8 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
|
||||
|
||||
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a workspace's record. It deletes that record only — never the worktree, the branch or the database.
|
||||
|
||||
**A workspace whose folder was deleted** (from Files, or a shell) is not removed for you — its record still holds its sessions' history. Every workspace payload carries `folder_missing` (true only when its `cwd` is gone — `ENOENT`/`ENOTDIR`, never a folder the server merely cannot read): the Files page leaves such a workspace out of its places, and the Workspace sidebar shows its name struck through with a hint to remove it from its ⋯ menu.
|
||||
|
||||
## Proof gallery
|
||||
|
||||
A workspace's proof gallery is a collection of QC screenshots captured during active work, grouped by feature (the same slug from **Per-feature state and archive** above) and phase (e.g. desktop, mobile, e2e, visual). A QC agent running an MCP server like `playwright-mcp` captures screenshots to disk at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and generates an optional ticket report at `<slug>/ticket/REPORT.html`. The Workspace gallery panel displays these files grouped by feature, using the same feature picker as the per-feature state section.
|
||||
|
||||
+1
-1
@@ -886,7 +886,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone also lends a region with no key ID of its own the key IDs saved for other regions (an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
+151
-2
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.40
|
||||
version: 2.8.50
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -17346,9 +17346,100 @@ paths:
|
||||
type: string
|
||||
pattern: ^[A-Za-z0-9_-]{16,64}$
|
||||
description: The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.
|
||||
background:
|
||||
type: boolean
|
||||
description: 'Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.'
|
||||
responses:
|
||||
'201':
|
||||
description: '{lane, created: true, path}'
|
||||
'202':
|
||||
description: '`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).'
|
||||
/api/lanes/clone/jobs:
|
||||
get:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: The caller's background clone jobs, newest first
|
||||
description: 'CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `<data dir>/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller''s own (local mode: all).'
|
||||
operationId: listCloneJobs
|
||||
responses:
|
||||
'200':
|
||||
description: '{jobs: CloneJob[]}'
|
||||
/api/lanes/clone/jobs/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Dismiss a finished background clone job
|
||||
operationId: dismissCloneJob
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{ok: true}'
|
||||
'404':
|
||||
description: ENOTFOUND — not the caller's job
|
||||
'409':
|
||||
description: EBUSY — still queued or running
|
||||
/api/lanes/clone/jobs/{id}/cancel:
|
||||
post:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Cancel a queued or running background clone
|
||||
description: 'Queued: taken out of the queue, never started. Running: the runner''s process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.'
|
||||
operationId: cancelCloneJob
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{job}'
|
||||
'404':
|
||||
description: ENOTFOUND — not the caller's job
|
||||
'409':
|
||||
description: EDONE — it already finished
|
||||
/api/lanes/clone/jobs/{id}/retry:
|
||||
post:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Retry a failed, interrupted or cancelled background clone
|
||||
description: A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.
|
||||
operationId: retryCloneJob
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: []
|
||||
properties:
|
||||
credentials:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
remember:
|
||||
type: boolean
|
||||
responses:
|
||||
'202':
|
||||
description: '{job} — the new CloneJob'
|
||||
'404':
|
||||
description: 'ENOTFOUND — not the caller''s job (ENOLANE: its workspace is gone)'
|
||||
'409':
|
||||
description: EBUSY — queued, running or done
|
||||
/api/lanes/clone/progress/{token}:
|
||||
get:
|
||||
tags:
|
||||
@@ -17841,7 +17932,7 @@ paths:
|
||||
- Artifacts
|
||||
summary: 'Skills from files: a file, a folder, a .zip/.skill or several skills'
|
||||
operationId: uploadArtifactSkills
|
||||
description: '`paths` is a JSON array of each file''s relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.'
|
||||
description: '`paths` is a JSON array of each file''s relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace''s) becomes that row''s new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row''s name.'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -17864,6 +17955,9 @@ paths:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
description: 'Share again: the skill row the upload replaces.'
|
||||
responses:
|
||||
'201':
|
||||
description: Created.
|
||||
@@ -19205,6 +19299,9 @@ paths:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: A typed HTTPS login for this one clone.
|
||||
background:
|
||||
type: boolean
|
||||
description: 'Run as a background clone job (see `/api/lanes/clone/jobs`): the checks answer at once, then 202 `{job}` with `kind: "source"`.'
|
||||
responses:
|
||||
'201':
|
||||
description: The context view plus `path` (the clone).
|
||||
@@ -19213,6 +19310,8 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'202':
|
||||
description: '`background: true`: {job} — a CloneJob.'
|
||||
'400':
|
||||
description: EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).
|
||||
'403':
|
||||
@@ -22874,6 +22973,56 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/artifacts/{id}/reshare:
|
||||
post:
|
||||
tags:
|
||||
- Artifacts
|
||||
summary: 'Share again: a skill''s newest version from its owner''s folder on this server'
|
||||
operationId: reshareArtifact
|
||||
description: 'The owner''s skill folder (the row''s source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Artifact id.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Taken.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
item:
|
||||
type: object
|
||||
changed:
|
||||
type: boolean
|
||||
'400':
|
||||
description: EBADKIND.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EFORBIDDEN.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOTFOUND.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ENOSOURCE.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/artifacts/{id}/claim:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.49",
|
||||
"version": "2.8.51",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.49",
|
||||
"version": "2.8.51",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.49",
|
||||
"version": "2.8.51",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Detached runner for one background `git clone`
|
||||
* (`server/lib/clone-jobs.js`). The dashboard restarts on every update, and a
|
||||
* git whose stderr was a pipe into it died of SIGPIPE on its next progress
|
||||
* line — so the clone runs here instead, in its own session/process group,
|
||||
* and outlives the server (the unit is `KillMode=process`). It reads its
|
||||
* spec (`{cmd, argv, cwd, timeoutMs}` — exactly the command `worktree.git()`
|
||||
* would run, built by `gitCommand`; never a secret, which travels in this
|
||||
* process's env as it would in git's) from the 0600 file it is given and
|
||||
* deletes it, runs the command with stderr appended straight to
|
||||
* `<job dir>/stderr.log` (a file, never a pipe: nothing can SIGPIPE it), and
|
||||
* writes `<job dir>/exit.json` `{code, signal, timedOut, finishedAt}` when it
|
||||
* ends. A cancel SIGTERMs this whole group: git dies (sudo relays it to a
|
||||
* member's git) and this runner stays to record how it ended.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const specPath = process.argv[2];
|
||||
const dir = path.dirname(specPath);
|
||||
let spec;
|
||||
try {
|
||||
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
||||
} finally {
|
||||
fs.rmSync(specPath, { force: true });
|
||||
}
|
||||
|
||||
function writeExit(fields) {
|
||||
const file = path.join(dir, "exit.json");
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ ...fields, finishedAt: new Date().toISOString() }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
// Stay to record git's end: a cancel signals the whole group, this included.
|
||||
process.on("SIGTERM", () => {});
|
||||
process.on("SIGHUP", () => {});
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
const log = fs.openSync(path.join(dir, "stderr.log"), "a", 0o600);
|
||||
let timedOut = false;
|
||||
let child;
|
||||
try {
|
||||
child = spawn(spec.cmd, spec.argv, {
|
||||
cwd: spec.cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "ignore", log],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
writeExit({ code: null, signal: null, timedOut: false, error: err.message });
|
||||
process.exit(0);
|
||||
}
|
||||
const timer =
|
||||
spec.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, spec.timeoutMs)
|
||||
: null;
|
||||
child.on("error", (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
writeExit({ code: null, signal: null, timedOut, error: err.message });
|
||||
process.exit(0);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
writeExit({ code, signal, timedOut });
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* @file Tests for background clones (`server/lib/clone-jobs.js` and the
|
||||
* `background: true` path of `POST /api/lanes/clone` and
|
||||
* `POST /api/lanes/:id/sources/clone`): checks still answer synchronously,
|
||||
* a job is answered 202 and ends `done` with the workspace / extra folder
|
||||
* made, a failure keeps the route's own code (ENOREPO, ECREDENTIALS with
|
||||
* auth), a typed login never reaches the job file, retry and dismiss, cancel
|
||||
* of a queued clone (never starts) and of a running one (git is killed, no
|
||||
* workspace made), and a restart: a live detached runner adopted (and still
|
||||
* cancellable), one that ended finished with its after-step run once, a dead
|
||||
* one `interrupted`, a queued job requeued (or interrupted when it carried a
|
||||
* typed login), retention; and that in server mode a job is only ever its
|
||||
* owner's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "clone-jobs-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
const queue = require("../lib/clone-queue");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
const WORK = path.join(ROOT, "work");
|
||||
|
||||
function request(method, p, body) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
async function waitFor(fn, ms = 20_000) {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
if (Date.now() > end) throw new Error("timed out");
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function jobById(id) {
|
||||
const r = await request("GET", "/api/lanes/clone/jobs");
|
||||
return r.body.jobs.find((j) => j.id === id);
|
||||
}
|
||||
|
||||
const finished = (id) =>
|
||||
waitFor(async () => {
|
||||
const j = await jobById(id);
|
||||
return j && !["queued", "running"].includes(j.state) ? j : null;
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
const src = path.join(ROOT, "src");
|
||||
const bare = path.join(ROOT, "bare.git");
|
||||
execFileSync("git", ["init", "-q", "-b", "main", src]);
|
||||
fs.writeFileSync(path.join(src, "README.md"), "hi\n");
|
||||
execFileSync("git", ["-C", src, "add", "."]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
src,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"i",
|
||||
]);
|
||||
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
|
||||
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT, ".gitconfig"),
|
||||
[
|
||||
`[url "file://${bare}"]`,
|
||||
"\tinsteadOf = https://forge.test/o/r.git",
|
||||
`[url "file://${path.join(ROOT, "missing.git")}"]`,
|
||||
"\tinsteadOf = git@forge.test:o/missing.git",
|
||||
`[url "http://127.0.0.1:${remote.address().port}/"]`,
|
||||
"\tinsteadOf = https://private.test/",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
fs.mkdirSync(WORK);
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
await new Promise((r) => remote.close(r));
|
||||
});
|
||||
|
||||
describe("background clone", () => {
|
||||
it("still refuses bad input synchronously", async () => {
|
||||
const bad = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
background: "yes",
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
const url = await request("POST", "/api/lanes/clone", {
|
||||
url: "file:///etc",
|
||||
parent: WORK,
|
||||
background: true,
|
||||
});
|
||||
assert.equal(url.body.error.code, "EBADURL");
|
||||
fs.mkdirSync(path.join(WORK, "taken"));
|
||||
const taken = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "taken",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(taken.status, 409);
|
||||
assert.equal(taken.body.error.code, "EEXISTS");
|
||||
const tool = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "t2",
|
||||
tool: "nope",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(tool.body.error.code, "EBADTOOL");
|
||||
const jobs = await request("GET", "/api/lanes/clone/jobs");
|
||||
assert.deepEqual(jobs.body.jobs, []);
|
||||
});
|
||||
|
||||
it("answers 202 with a job that ends done with its workspace", async () => {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "bg",
|
||||
title: "Bg",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(r.status, 202, JSON.stringify(r.body));
|
||||
const { job } = r.body;
|
||||
assert.match(job.id, /^[A-Za-z0-9_-]{16,}$/);
|
||||
assert.equal(job.kind, "workspace");
|
||||
assert.ok(["queued", "running"].includes(job.state));
|
||||
assert.equal(job.target, path.join(WORK, "bg"));
|
||||
assert.equal(job.laneId, null);
|
||||
assert.equal(job.finishedAt, null);
|
||||
assert.ok("phase" in job && "position" in job);
|
||||
assert.equal(job.owner, undefined);
|
||||
assert.deepEqual(job.request, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "bg",
|
||||
title: "Bg",
|
||||
});
|
||||
const done = await finished(job.id);
|
||||
assert.equal(done.state, "done", JSON.stringify(done));
|
||||
assert.equal(done.result.created, true);
|
||||
assert.equal(done.result.path, path.join(WORK, "bg"));
|
||||
assert.ok(done.finishedAt);
|
||||
const lane = await request("GET", `/api/lanes/${done.result.laneId}`);
|
||||
assert.equal(lane.body.lane?.title ?? lane.body.title, "Bg");
|
||||
assert.equal(fs.readFileSync(path.join(WORK, "bg", "README.md"), "utf8"), "hi\n");
|
||||
|
||||
const file = cloneJobs.filePath();
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
const disk = JSON.parse(fs.readFileSync(file, "utf8")).jobs.find((j) => j.id === job.id);
|
||||
assert.equal(disk.state, "done");
|
||||
|
||||
const busy = await request("POST", `/api/lanes/clone/jobs/${job.id}/retry`, {});
|
||||
assert.equal(busy.status, 409);
|
||||
assert.equal(busy.body.error.code, "EBUSY");
|
||||
const dismissed = await request("DELETE", `/api/lanes/clone/jobs/${job.id}`);
|
||||
assert.deepEqual(dismissed.body, { ok: true });
|
||||
assert.equal(await jobById(job.id), undefined);
|
||||
const again = await request("DELETE", `/api/lanes/clone/jobs/${job.id}`);
|
||||
assert.equal(again.status, 404);
|
||||
assert.equal(again.body.error.code, "ENOTFOUND");
|
||||
});
|
||||
|
||||
it("records the route's own error codes, and never a typed login", async () => {
|
||||
const missing = await request("POST", "/api/lanes/clone", {
|
||||
url: "git@forge.test:o/missing.git",
|
||||
parent: WORK,
|
||||
name: "missing",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(missing.status, 202);
|
||||
const m = await finished(missing.body.job.id);
|
||||
assert.equal(m.state, "failed");
|
||||
assert.equal(m.error.code, "ENOREPO");
|
||||
assert.equal(m.result, null);
|
||||
|
||||
const login = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://private.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "private",
|
||||
credentials: { username: "u", password: "s3cret-typed" },
|
||||
background: true,
|
||||
});
|
||||
assert.equal(login.status, 202);
|
||||
assert.equal(login.body.job.request.credentials, undefined);
|
||||
const l = await finished(login.body.job.id);
|
||||
assert.equal(l.error.code, "ECREDENTIALS");
|
||||
assert.deepEqual(l.error.auth, { protocol: "https", host: "private.test" });
|
||||
assert.ok(!fs.readFileSync(cloneJobs.filePath(), "utf8").includes("s3cret-typed"));
|
||||
|
||||
// Retry: a new job from the old request (+ a login typed now); the old goes.
|
||||
const retry = await request("POST", `/api/lanes/clone/jobs/${l.id}/retry`, {
|
||||
credentials: { username: "u", password: "again-typed" },
|
||||
});
|
||||
assert.equal(retry.status, 202, JSON.stringify(retry.body));
|
||||
assert.notEqual(retry.body.job.id, l.id);
|
||||
assert.equal(retry.body.job.name, "private");
|
||||
assert.equal(await jobById(l.id), undefined);
|
||||
const r2 = await finished(retry.body.job.id);
|
||||
assert.equal(r2.error.code, "ECREDENTIALS");
|
||||
assert.ok(!fs.readFileSync(cloneJobs.filePath(), "utf8").includes("again-typed"));
|
||||
const bad = await request("POST", `/api/lanes/clone/jobs/${r2.id}/retry`, {
|
||||
credentials: "nope",
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EBADCRED");
|
||||
assert.ok(await jobById(r2.id), "a refused retry keeps the old job");
|
||||
const none = await request("POST", "/api/lanes/clone/jobs/nope-nope-nope-nope/retry", {});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("clones an extra folder beside the workspace", async () => {
|
||||
const home = path.join(WORK, "app");
|
||||
fs.mkdirSync(home);
|
||||
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
|
||||
const id = made.body.lane.id;
|
||||
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: "lib",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(r.status, 202, JSON.stringify(r.body));
|
||||
assert.equal(r.body.job.kind, "source");
|
||||
assert.equal(r.body.job.laneId, id);
|
||||
const done = await finished(r.body.job.id);
|
||||
assert.equal(done.state, "done", JSON.stringify(done));
|
||||
assert.equal(done.result.laneId, id);
|
||||
const ctx = await request("GET", `/api/lanes/${id}/sources`);
|
||||
const folders = (ctx.body.sources || []).filter((s) => s.kind === "folder");
|
||||
assert.deepEqual(
|
||||
folders.map((f) => fs.realpathSync(f.path)),
|
||||
[fs.realpathSync(path.join(WORK, "lib"))]
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels a queued clone before it starts", async () => {
|
||||
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
||||
let open;
|
||||
const blocker = queue.withCloneSlot(() => new Promise((r) => (open = r)));
|
||||
try {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "queued",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(r.status, 202);
|
||||
assert.equal(r.body.job.state, "queued");
|
||||
assert.equal(r.body.job.position, 1);
|
||||
const c = await request("POST", `/api/lanes/clone/jobs/${r.body.job.id}/cancel`);
|
||||
assert.equal(c.status, 200, JSON.stringify(c.body));
|
||||
assert.equal(c.body.job.state, "cancelled");
|
||||
assert.equal(c.body.job.error.code, "ECANCELLED");
|
||||
assert.equal(queue.cloneQueueState().waiting, 0);
|
||||
const twice = await request("POST", `/api/lanes/clone/jobs/${r.body.job.id}/cancel`);
|
||||
assert.equal(twice.status, 409);
|
||||
assert.equal(twice.body.error.code, "EDONE");
|
||||
open();
|
||||
await blocker;
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
assert.equal((await jobById(r.body.job.id)).state, "cancelled");
|
||||
assert.ok(!fs.existsSync(path.join(WORK, "queued")));
|
||||
} finally {
|
||||
open?.();
|
||||
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
|
||||
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels a running clone: git is killed, the slot frees, no workspace", async () => {
|
||||
// An ssh "transport" that never answers keeps git clone running (a real
|
||||
// ssh exits once git is gone; this stand-in lingers for its 15 s).
|
||||
process.env.GIT_SSH_COMMAND = "exec sleep 15 2>/dev/null #";
|
||||
try {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "ssh://slow.test/o/slow.git",
|
||||
parent: WORK,
|
||||
name: "slow",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(r.status, 202);
|
||||
const id = r.body.job.id;
|
||||
await waitFor(async () => (await jobById(id))?.state === "running");
|
||||
await new Promise((res) => setTimeout(res, 300));
|
||||
const c = await request("POST", `/api/lanes/clone/jobs/${id}/cancel`);
|
||||
assert.equal(c.body.job?.state, "cancelled", JSON.stringify([c.body, await jobById(id)]));
|
||||
await waitFor(() => queue.cloneQueueState().running === 0, 10_000);
|
||||
const j = await jobById(id);
|
||||
assert.equal(j.state, "cancelled");
|
||||
assert.equal(j.result, null);
|
||||
assert.ok(!fs.existsSync(path.join(WORK, "slow")));
|
||||
const lanes = await request("GET", "/api/lanes");
|
||||
const list = lanes.body.lanes || lanes.body;
|
||||
assert.ok(!list.some((l) => l.cwd.endsWith(`${path.sep}slow`)));
|
||||
const retry = await request("POST", `/api/lanes/clone/jobs/${id}/retry`, {});
|
||||
assert.equal(retry.status, 202, "a cancelled clone can be retried");
|
||||
const c2 = await request("POST", `/api/lanes/clone/jobs/${retry.body.job.id}/cancel`);
|
||||
assert.equal(c2.body.job.state, "cancelled");
|
||||
await waitFor(() => queue.cloneQueueState().running === 0, 10_000);
|
||||
} finally {
|
||||
delete process.env.GIT_SSH_COMMAND;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("clone-jobs store", () => {
|
||||
it("answers a job only to its owner in server mode", () => {
|
||||
const savedMode = process.env.AGENTHUB_MODE;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
try {
|
||||
const mk = (owner) =>
|
||||
cloneJobs.create({
|
||||
owner,
|
||||
kind: "workspace",
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: `o${owner}`,
|
||||
parent: WORK,
|
||||
target: path.join(WORK, `o${owner}`),
|
||||
request: { url: "u", parent: WORK, name: "n", credentials: { password: "x" } },
|
||||
});
|
||||
const a = mk(101);
|
||||
const b = mk(102);
|
||||
assert.equal(a.request.credentials, undefined);
|
||||
assert.deepEqual(
|
||||
cloneJobs.list(101).map((j) => j.id),
|
||||
[a.id]
|
||||
);
|
||||
assert.equal(cloneJobs.get(b.id, 101), null);
|
||||
assert.throws(() => cloneJobs.remove(b.id, 101), { code: "ENOTFOUND" });
|
||||
assert.throws(() => cloneJobs.cancel(b.id, 101), { code: "ENOTFOUND" });
|
||||
assert.throws(() => cloneJobs.remove(a.id, 101), { code: "EBUSY" });
|
||||
cloneJobs.cancel(a.id, 101);
|
||||
cloneJobs.cancel(b.id, 102);
|
||||
cloneJobs.remove(a.id, 101);
|
||||
cloneJobs.remove(b.id, 102);
|
||||
} finally {
|
||||
if (savedMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = savedMode;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a restart", () => {
|
||||
const H = 60 * 60 * 1000;
|
||||
const ago = (ms) => new Date(Date.now() - ms).toISOString();
|
||||
const row = (id, state, extra = {}) => ({
|
||||
id,
|
||||
owner: null,
|
||||
kind: "workspace",
|
||||
state,
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: id,
|
||||
parent: WORK,
|
||||
target: path.join(WORK, id),
|
||||
laneId: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: ago(H),
|
||||
finishedAt: null,
|
||||
request: { url: "https://forge.test/o/r.git", parent: WORK, name: id },
|
||||
pid: null,
|
||||
once: null,
|
||||
...extra,
|
||||
});
|
||||
/** Writes the job file and reloads it, as a new server process would. */
|
||||
const restart = (rows) => {
|
||||
cloneJobs._reset();
|
||||
fs.mkdirSync(path.dirname(cloneJobs.filePath()), { recursive: true });
|
||||
fs.writeFileSync(cloneJobs.filePath(), JSON.stringify({ jobs: rows }));
|
||||
cloneJobs.resume();
|
||||
};
|
||||
const lanesAt = async (dir) => {
|
||||
const r = await request("GET", "/api/lanes");
|
||||
const list = r.body.lanes || r.body;
|
||||
return list.filter((l) => fs.realpathSync(l.cwd) === fs.realpathSync(dir));
|
||||
};
|
||||
|
||||
it("interrupts a dead runner, requeues a queued clone, drops expired jobs", async () => {
|
||||
const dead = 2 ** 22 + 12345; // past pid_max on a default kernel: never alive
|
||||
restart([
|
||||
row("was-running-aaaaaaaa", "running", { pid: dead }),
|
||||
row("queued-login-aaaaaaa", "queued", {
|
||||
once: { used: true, remember: false, member: false, host: "forge.test" },
|
||||
}),
|
||||
row("requeued-aaaaaaaaaaa", "queued"),
|
||||
row("done-fresh-aaaaaaaaa", "done", { finishedAt: ago(2 * H) }),
|
||||
row("done-stale-aaaaaaaaa", "done", { finishedAt: ago(25 * H) }),
|
||||
row("failed-fresh-aaaaaaa", "failed", { finishedAt: ago(6 * 24 * H) }),
|
||||
row("failed-stale-aaaaaaa", "failed", { finishedAt: ago(8 * 24 * H) }),
|
||||
row("cancel-stale-aaaaaaa", "cancelled", { finishedAt: ago(8 * 24 * H) }),
|
||||
]);
|
||||
const ids = cloneJobs.list(null).map((j) => j.id);
|
||||
assert.deepEqual([...ids].sort(), [
|
||||
"done-fresh-aaaaaaaaa",
|
||||
"failed-fresh-aaaaaaa",
|
||||
"queued-login-aaaaaaa",
|
||||
"requeued-aaaaaaaaaaa",
|
||||
"was-running-aaaaaaaa",
|
||||
]);
|
||||
for (const id of ["was-running-aaaaaaaa", "queued-login-aaaaaaa"]) {
|
||||
const j = cloneJobs.get(id, null);
|
||||
assert.equal(j.state, "interrupted", id);
|
||||
assert.deepEqual(j.error, {
|
||||
code: "EINTERRUPTED",
|
||||
message: "AgentHub restarted while this clone ran",
|
||||
});
|
||||
assert.ok(j.finishedAt);
|
||||
}
|
||||
const disk = JSON.parse(fs.readFileSync(cloneJobs.filePath(), "utf8")).jobs;
|
||||
assert.equal(disk.find((j) => j.id === "was-running-aaaaaaaa").state, "interrupted");
|
||||
// The queued one (no typed login) simply runs now, workspace included.
|
||||
const done = await finished("requeued-aaaaaaaaaaa");
|
||||
assert.equal(done.state, "done", JSON.stringify(done));
|
||||
assert.equal((await lanesAt(path.join(WORK, "requeued-aaaaaaaaaaa"))).length, 1);
|
||||
});
|
||||
|
||||
it("finishes a runner that ended while no server ran: the after-step runs once", async () => {
|
||||
const id = "ended-ok-aaaaaaaaaaa";
|
||||
const target = path.join(WORK, id);
|
||||
execFileSync("git", ["clone", "-q", path.join(ROOT, "bare.git"), target]);
|
||||
fs.mkdirSync(cloneJobs.jobDir(id), { recursive: true });
|
||||
fs.writeFileSync(path.join(cloneJobs.jobDir(id), "exit.json"), '{"code":0,"signal":null}');
|
||||
const bad = "ended-bad-aaaaaaaaaa";
|
||||
fs.mkdirSync(cloneJobs.jobDir(bad), { recursive: true });
|
||||
fs.writeFileSync(path.join(cloneJobs.jobDir(bad), "exit.json"), '{"code":128,"signal":null}');
|
||||
fs.writeFileSync(
|
||||
path.join(cloneJobs.jobDir(bad), "stderr.log"),
|
||||
"Cloning into 'x'...\nfatal: repository 'https://forge.test/o/nope.git/' not found\n"
|
||||
);
|
||||
restart([
|
||||
row(id, "running", { pid: 2 ** 22 + 1 }),
|
||||
row(bad, "running", { pid: 2 ** 22 + 2, url: "https://forge.test/o/nope.git" }),
|
||||
]);
|
||||
const ok = await finished(id);
|
||||
assert.equal(ok.state, "done", JSON.stringify(ok));
|
||||
assert.equal(ok.result.created, true);
|
||||
const failed = await finished(bad);
|
||||
assert.equal(failed.error.code, "ENOREPO");
|
||||
assert.ok(!fs.existsSync(cloneJobs.jobDir(id)), "the runner's files are cleaned up");
|
||||
// A second reload finds nothing left to do: still one workspace.
|
||||
restart(JSON.parse(fs.readFileSync(cloneJobs.filePath(), "utf8")).jobs);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.equal((await lanesAt(target)).length, 1);
|
||||
assert.equal(cloneJobs.get(id, null).result.laneId, ok.result.laneId);
|
||||
});
|
||||
|
||||
it("adopts a runner that outlived the server, and can still cancel it", async () => {
|
||||
process.env.GIT_SSH_COMMAND = "exec sleep 15 2>/dev/null #";
|
||||
try {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "ssh://slow.test/o/slow.git",
|
||||
parent: WORK,
|
||||
name: "adopted",
|
||||
background: true,
|
||||
});
|
||||
assert.equal(r.status, 202);
|
||||
const id = r.body.job.id;
|
||||
const pid = await waitFor(async () => (await jobById(id))?.pid);
|
||||
// Its own session and group: a server restart leaves it running.
|
||||
const pgid = Number(
|
||||
execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" })
|
||||
);
|
||||
assert.equal(pgid, pid);
|
||||
const before = queue.cloneQueueState().running;
|
||||
// A new server process: the old one's tail stops, the file is re-read.
|
||||
cloneJobs._reset();
|
||||
cloneJobs.resume();
|
||||
const adopted = cloneJobs.get(id, null);
|
||||
assert.equal(adopted.state, "running");
|
||||
assert.equal(queue.cloneQueueState().running, before + 1, "counted against the limit");
|
||||
const c = await request("POST", `/api/lanes/clone/jobs/${id}/cancel`);
|
||||
assert.equal(c.body.job.state, "cancelled");
|
||||
await waitFor(() => queue.cloneQueueState().running === before, 10_000);
|
||||
await waitFor(() => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, 10_000);
|
||||
assert.equal((await jobById(id)).state, "cancelled");
|
||||
assert.ok(!fs.existsSync(path.join(WORK, "adopted")));
|
||||
} finally {
|
||||
delete process.env.GIT_SSH_COMMAND;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@
|
||||
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
|
||||
* only the public half returned, 0600 on disk; named keys beside the default
|
||||
* ones, SSH logins per alias tied to one key, which a clone then offers
|
||||
* alone; a CodeCommit region with no login borrowing another region's), and the member (S3) path of
|
||||
* alone; a CodeCommit region with no login borrowing another region's; one key ID
|
||||
* saved for every region; a second account's login tried when the region's own is
|
||||
* refused), and the member (S3) path of
|
||||
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
|
||||
* git's argv, and that a login typed without "remember" leaves the member's
|
||||
* store as it was.
|
||||
@@ -688,6 +690,91 @@ describe("CodeCommit: a region with no login borrows another region's", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CodeCommit: one key ID for every region", () => {
|
||||
const getUrl = (url) =>
|
||||
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
||||
const { CODECOMMIT_REGIONS } = require("../lib/git-identity");
|
||||
|
||||
it("saves the default login for every region, and removes it in one go", async () => {
|
||||
const r = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKAALL",
|
||||
allRegions: true,
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.equal(r.body.sshUsers.length, CODECOMMIT_REGIONS.length);
|
||||
assert.deepEqual(r.body.codecommitRegions, CODECOMMIT_REGIONS);
|
||||
for (const region of ["ap-northeast-1", "eu-west-1"]) {
|
||||
const h = `git-codecommit.${region}.amazonaws.com`;
|
||||
assert.equal(getUrl(`ssh://${h}/v1/repos/x`), `ssh://APKAALL@${h}/v1/repos/x`);
|
||||
}
|
||||
// A second every-region save replaces each region's rule, never stacks.
|
||||
const again = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKAALL2",
|
||||
allRegions: true,
|
||||
});
|
||||
assert.equal(again.body.sshUsers.length, CODECOMMIT_REGIONS.length);
|
||||
assert.ok(again.body.sshUsers.every((u) => u.user === "APKAALL2"));
|
||||
// An alias is never every region's.
|
||||
const bad = await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKAX",
|
||||
alias: "prod",
|
||||
allRegions: true,
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const del = await request(
|
||||
"DELETE",
|
||||
"/api/me/git/ssh-user/git-codecommit.us-east-1.amazonaws.com?allRegions=1&user=APKAALL2"
|
||||
);
|
||||
assert.equal(del.status, 200);
|
||||
assert.deepEqual(del.body.sshUsers, []);
|
||||
});
|
||||
|
||||
it("tries a second account's login when the region's own is refused", async () => {
|
||||
const bare = path.join(ROOT, "bare.git");
|
||||
const R = "git-codecommit.ap-northeast-1.amazonaws.com";
|
||||
assert.equal(
|
||||
(await request("PUT", "/api/me/git/ssh-user", { host: R, user: "APKAMINE" })).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request("PUT", "/api/me/git/ssh-user", {
|
||||
host: "git-codecommit.us-east-1.amazonaws.com",
|
||||
user: "APKAOTHER",
|
||||
alias: "other-acct",
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
const rules = [
|
||||
[`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAMINE@${R}/v1/repos/r`],
|
||||
[`url.file://${bare}.insteadOf`, `ssh://APKAOTHER@${R}/v1/repos/r`],
|
||||
];
|
||||
for (const [k, v] of rules) execFileSync("git", ["config", "--global", "--add", k, v]);
|
||||
try {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: `ssh://${R}/v1/repos/r`,
|
||||
parent: WORK,
|
||||
name: "second-account",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const origin = execFileSync(
|
||||
"git",
|
||||
["-C", path.join(WORK, "second-account"), "config", "remote.origin.url"],
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
assert.equal(origin, `ssh://APKAOTHER@${R}/v1/repos/r`);
|
||||
} finally {
|
||||
for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]);
|
||||
await request("DELETE", `/api/me/git/ssh-user/${R}`);
|
||||
await request("DELETE", "/api/me/git/ssh-user/other-acct");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
|
||||
@@ -337,6 +337,16 @@ describe("lanes lib", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("payload says folder_missing only when the folder is gone", () => {
|
||||
const fs = require("node:fs");
|
||||
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-gone-"));
|
||||
const l = lanes.createLane({ cwd: dir });
|
||||
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, false);
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, true);
|
||||
lanes.deleteLane(l.id);
|
||||
});
|
||||
|
||||
it("payload carries node states and progress", () => {
|
||||
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
|
||||
lanes.setStage(l.id, { stage: "review" });
|
||||
|
||||
@@ -500,6 +500,14 @@ function startBackgroundServices() {
|
||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||
autoImportLegacySessions();
|
||||
|
||||
// Background clones a previous process started: adopt a runner that is
|
||||
// still cloning, finish one that ended, requeue or interrupt the rest.
|
||||
try {
|
||||
require("./lib/clone-jobs").resume();
|
||||
} catch (err) {
|
||||
console.warn(`[clone-jobs] resume failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Before any tmux call: without a UTF-8 locale every attach prints `_` for
|
||||
// non-ASCII text (a macOS LaunchAgent sets none).
|
||||
require("./lib/shell-path").ensureUtf8Locale();
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* @file Background clones (`POST /api/lanes/clone` with `background: true`):
|
||||
* a clone that outlives the request that started it AND the server. Each job
|
||||
* is kept in memory and in `<data dir>/runtime/clone-jobs.json` (0600, tmp +
|
||||
* rename): on every state change at once, on git's progress at most every
|
||||
* `WRITE_EVERY_MS`. Its git runs in a detached runner
|
||||
* (`scripts/clone-runner.js`, pid recorded on the job) with stderr appended
|
||||
* to `<data dir>/runtime/clone-jobs/<id>/stderr.log` — the server tails it
|
||||
* for progress and finishes the job when the runner writes `exit.json`.
|
||||
* An update restarts the server but not the runner (`KillMode=process`), so
|
||||
* the first read in a new process reconciles the file: a running job whose
|
||||
* runner is alive is ADOPTED (counted against clone-queue's limit, tailed,
|
||||
* finished when it exits), one whose `exit.json` exists is finished now, and
|
||||
* any other is `interrupted` (`EINTERRUPTED`); a queued job is queued again,
|
||||
* unless it carried a typed login (gone with the old process: interrupted,
|
||||
* so the user types it again). Finishing runs the after-step the routes
|
||||
* register (`setAfterStep`: the workspace, or the extra folder) exactly once,
|
||||
* and a cancelled job never runs it. `cancel` takes a queued job out of
|
||||
* clone-queue or SIGTERMs the runner's process group. Finished jobs are
|
||||
* dropped after 24 h (done) or 7 days (failed/interrupted/cancelled). A job
|
||||
* is answered only to its owner (`scope.ownerFor`; local mode: every job),
|
||||
* and `request` — what a retry re-sends — never holds a credential: a typed
|
||||
* login is used for its one run and never written here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const KEEP_DONE_MS = 24 * 60 * 60 * 1000;
|
||||
const KEEP_FAILED_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const WRITE_EVERY_MS = 2000;
|
||||
const POLL_MS = 500;
|
||||
const TAIL_MAX = 64 * 1024;
|
||||
const ACTIVE = new Set(["queued", "running"]);
|
||||
const REQUEST_KEYS = ["url", "parent", "name", "tool", "title", "partial"];
|
||||
const RUNNER = path.join(__dirname, "..", "..", "scripts", "clone-runner.js");
|
||||
|
||||
/** id → job record (the CloneJob fields plus `owner`, `once`). */
|
||||
let jobs = null;
|
||||
let timer = null;
|
||||
/** Live progress pollers, so a simulated restart (`_reset`) stops them. */
|
||||
const watchers = new Set();
|
||||
/** `(job) => {status, json, laneId?, created?}` — the routes' after-step. */
|
||||
let afterStep = null;
|
||||
|
||||
const runtimeDir = () => path.join(getDataDir(), "runtime");
|
||||
const filePath = () => path.join(runtimeDir(), "clone-jobs.json");
|
||||
/** A job's files; its id is server-made base64url, never from a request. */
|
||||
const jobDir = (id) => path.join(runtimeDir(), "clone-jobs", id);
|
||||
|
||||
const serverMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
const queue = () => require("./clone-queue");
|
||||
|
||||
function expired(job, now) {
|
||||
if (!job.finishedAt) return false;
|
||||
const age = now - Date.parse(job.finishedAt);
|
||||
return job.state === "done" ? age > KEEP_DONE_MS : age > KEEP_FAILED_MS;
|
||||
}
|
||||
|
||||
/** What goes to disk (`view` drops `owner` and `once`); never a callback. */
|
||||
function record(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
owner: job.owner,
|
||||
kind: job.kind,
|
||||
state: job.state,
|
||||
url: job.url,
|
||||
name: job.name,
|
||||
parent: job.parent,
|
||||
target: job.target,
|
||||
laneId: job.laneId,
|
||||
phase: job.phase,
|
||||
label: job.label,
|
||||
percent: job.percent,
|
||||
detail: job.detail,
|
||||
error: job.error,
|
||||
result: job.result,
|
||||
createdAt: job.createdAt,
|
||||
finishedAt: job.finishedAt,
|
||||
request: job.request,
|
||||
pid: job.pid ?? null,
|
||||
// Whether a login was typed for this run — never the login itself.
|
||||
once: job.once ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The in-memory-only parts of a job: cancel signal, queue hooks, mirror. */
|
||||
function arm(job) {
|
||||
const controller = new AbortController();
|
||||
Object.defineProperties(job, {
|
||||
// A progressToken's clone-queue record, kept in step (route-set).
|
||||
mirror: { enumerable: false, writable: true, value: null },
|
||||
// An adopted runner's clone-queue slot.
|
||||
release: { enumerable: false, writable: true, value: null },
|
||||
finishing: { enumerable: false, writable: true, value: false },
|
||||
signal: { enumerable: false, value: controller.signal },
|
||||
abort: { enumerable: false, value: () => controller.abort() },
|
||||
onChange: {
|
||||
enumerable: false,
|
||||
value: (what) => {
|
||||
if (job.mirror) {
|
||||
for (const k of ["state", "phase", "label", "percent", "detail"]) {
|
||||
job.mirror[k] = job[k];
|
||||
}
|
||||
}
|
||||
if (what === "state") writeNow();
|
||||
else writeSoon();
|
||||
},
|
||||
},
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
function interrupt(job) {
|
||||
Object.assign(job, {
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "AgentHub restarted while this clone ran" },
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (jobs) return jobs;
|
||||
jobs = new Map();
|
||||
let list = [];
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath(), "utf8"));
|
||||
if (Array.isArray(parsed?.jobs)) list = parsed.jobs;
|
||||
} catch {
|
||||
// No file yet, or an unreadable one: start empty.
|
||||
}
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
const later = [];
|
||||
for (const j of list) {
|
||||
if (!j || typeof j.id !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(j.id)) continue;
|
||||
if (expired(j, now)) {
|
||||
rmJobDir(j.id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
arm(j);
|
||||
jobs.set(j.id, j);
|
||||
if (j.state === "running") {
|
||||
if (readExit(j.id)) later.push(() => finishFromDisk(j));
|
||||
else if (runnerAlive(j.pid)) adopt(j);
|
||||
else {
|
||||
interrupt(j);
|
||||
rmJobDir(j.id);
|
||||
changed = true;
|
||||
}
|
||||
} else if (j.state === "queued") {
|
||||
if (j.once?.used) {
|
||||
interrupt(j);
|
||||
changed = true;
|
||||
} else later.push(() => start(j, null));
|
||||
}
|
||||
}
|
||||
if (changed) writeNow();
|
||||
for (const fn of later) setImmediate(fn);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
function writeNow() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
const file = filePath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
const body = JSON.stringify({ jobs: [...(jobs || new Map()).values()].map(record) });
|
||||
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (err) {
|
||||
console.warn(`[clone-jobs] could not save ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Progress is cheap in memory; on disk at most every WRITE_EVERY_MS. */
|
||||
function writeSoon() {
|
||||
if (timer) return;
|
||||
timer = setTimeout(writeNow, WRITE_EVERY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function rmJobDir(id) {
|
||||
try {
|
||||
fs.rmSync(jobDir(id), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* AgentHub's own scratch: best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const [id, j] of load()) {
|
||||
if (expired(j, now)) {
|
||||
jobs.delete(id);
|
||||
rmJobDir(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) writeNow();
|
||||
}
|
||||
|
||||
// ── The runner ─────────────────────────────────────────────────────────────
|
||||
|
||||
function readExit(id) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(jobDir(id), "exit.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `pid` is still OUR runner (not a reused pid, not a zombie). */
|
||||
function runnerAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
if (err.code !== "EPERM") return false;
|
||||
}
|
||||
if (process.platform !== "linux") return true;
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("clone-runner.js");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SIGTERMs the runner's process group: git dies, the runner records it. */
|
||||
function killRunner(job) {
|
||||
if (!job.pid) return;
|
||||
try {
|
||||
process.kill(process.platform === "win32" ? job.pid : -job.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRunner(job, spec, env) {
|
||||
const dir = jobDir(job.id);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const specPath = path.join(dir, "spec.json");
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec), { mode: 0o600, flag: "wx" });
|
||||
const child = spawn(process.execPath, [RUNNER, specPath], {
|
||||
cwd: dir,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", () => {
|
||||
/* the poller sees no runner and fails the job */
|
||||
});
|
||||
child.unref();
|
||||
job.pid = child.pid ?? null;
|
||||
writeNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tails a runner's stderr.log into `onProgress` until `exit.json` appears
|
||||
* (or the runner is gone without one: `lost`). Resolves
|
||||
* `{ok, timedOut, lost, stderr}` — `stderr` is the last TAIL_MAX of it.
|
||||
*/
|
||||
function watch(job, onProgress) {
|
||||
return new Promise((resolve) => {
|
||||
const dir = jobDir(job.id);
|
||||
let offset = 0;
|
||||
let tail = "";
|
||||
let misses = 0;
|
||||
const read = () => {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(path.join(dir, "stderr.log"), "r");
|
||||
const size = fs.fstatSync(fd).size;
|
||||
if (size <= offset) return;
|
||||
const from = Math.max(offset, size - TAIL_MAX);
|
||||
const buf = Buffer.alloc(size - from);
|
||||
fs.readSync(fd, buf, 0, buf.length, from);
|
||||
offset = size;
|
||||
const chunk = buf.toString("utf8");
|
||||
tail = (tail + chunk).slice(-TAIL_MAX);
|
||||
try {
|
||||
onProgress?.(chunk);
|
||||
} catch {
|
||||
/* a progress reader never fails the clone */
|
||||
}
|
||||
} catch {
|
||||
/* no log yet */
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
};
|
||||
const w = {};
|
||||
const stop = (out) => {
|
||||
clearInterval(w.iv);
|
||||
watchers.delete(w);
|
||||
resolve(out);
|
||||
};
|
||||
w.iv = setInterval(() => {
|
||||
read();
|
||||
const exit = readExit(job.id);
|
||||
if (exit) {
|
||||
read();
|
||||
return stop({
|
||||
ok: exit.code === 0 && !exit.signal,
|
||||
timedOut: Boolean(exit.timedOut),
|
||||
lost: false,
|
||||
stderr: tail,
|
||||
});
|
||||
}
|
||||
if (runnerAlive(job.pid)) misses = 0;
|
||||
else if (++misses >= 2) stop({ ok: false, timedOut: false, lost: true, stderr: tail });
|
||||
}, POLL_MS);
|
||||
w.iv.unref?.();
|
||||
watchers.add(w);
|
||||
});
|
||||
}
|
||||
|
||||
const failureMessage = (out) =>
|
||||
out.lost
|
||||
? "the clone stopped unexpectedly"
|
||||
: out.timedOut
|
||||
? "git clone timed out"
|
||||
: "git clone failed";
|
||||
|
||||
/**
|
||||
* `cloneRepo`'s `launch`: the same git command, run by a detached runner
|
||||
* instead of as this process's child. Rejects like `worktree.git()`.
|
||||
*/
|
||||
function launcher(job) {
|
||||
return async (cwd, args, opts) => {
|
||||
const { cmd, argv, env } = require("./worktree").gitCommand(cwd, args, {
|
||||
env: opts.env,
|
||||
background: true,
|
||||
});
|
||||
spawnRunner(job, { cmd, argv, cwd, timeoutMs: opts.timeout || 0 }, env);
|
||||
const out = await watch(job, opts.onStderr);
|
||||
if (out.ok) return { stdout: "", stderr: out.stderr };
|
||||
throw Object.assign(new Error(failureMessage(out)), { git: { stderr: out.stderr } });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A new `queued` job. `request` is reduced to `REQUEST_KEYS` — never a
|
||||
* credential; `once` records only whether a login was typed. The record is
|
||||
* also clone-queue's job: `withCloneSlot` marks it running and `report`
|
||||
* fills its progress, each through `onChange`.
|
||||
*/
|
||||
function create({ owner, kind, url, name, parent, target, laneId = null, request, once = null }) {
|
||||
load();
|
||||
const req = {};
|
||||
for (const k of REQUEST_KEYS) if (request?.[k] !== undefined) req[k] = request[k];
|
||||
const job = arm({
|
||||
id: crypto.randomBytes(16).toString("base64url"),
|
||||
owner: owner ?? null,
|
||||
kind,
|
||||
state: "queued",
|
||||
url,
|
||||
name,
|
||||
parent,
|
||||
target,
|
||||
laneId,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
request: req,
|
||||
pid: null,
|
||||
once: once
|
||||
? {
|
||||
used: true,
|
||||
remember: once.remember === true,
|
||||
member: once.member === true,
|
||||
host: once.host ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
jobs.set(job.id, job);
|
||||
writeNow();
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a queued job: clone-queue, then the clone through a runner (`once`:
|
||||
* the typed login, this process only), then the after-step.
|
||||
*/
|
||||
function start(job, once = null) {
|
||||
void (async () => {
|
||||
let outcome;
|
||||
try {
|
||||
await require("./git-identity").cloneRepo({
|
||||
job,
|
||||
url: job.url,
|
||||
parent: job.parent,
|
||||
name: job.name,
|
||||
once,
|
||||
partial: job.request?.partial === true,
|
||||
signal: job.signal,
|
||||
launch: launcher(job),
|
||||
});
|
||||
outcome = { ok: true };
|
||||
} catch (err) {
|
||||
outcome = { ok: false, err };
|
||||
}
|
||||
finish(job, outcome);
|
||||
})();
|
||||
return job;
|
||||
}
|
||||
|
||||
/** A runner this process did not start: counted, tailed, finished on exit. */
|
||||
function adopt(job) {
|
||||
job.release = queue().occupy();
|
||||
watch(job, (chunk) => queue().report(job, chunk)).then((out) => finishFromDisk(job, out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a runner's job from its files alone (no `cloneRepo` closure: the
|
||||
* process that launched it is gone). A typed login a member's store kept for
|
||||
* this run only is taken out again, as `cloneRepo` would have.
|
||||
*/
|
||||
function finishFromDisk(job, out = null) {
|
||||
if (!out) {
|
||||
const exit = readExit(job.id) || {};
|
||||
let stderr = "";
|
||||
try {
|
||||
stderr = fs.readFileSync(path.join(jobDir(job.id), "stderr.log"), "utf8").slice(-TAIL_MAX);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
out = {
|
||||
ok: exit.code === 0 && !exit.signal,
|
||||
timedOut: Boolean(exit.timedOut),
|
||||
lost: false,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
const identity = require("./git-identity");
|
||||
if (job.once?.used && job.once.member && !(out.ok && job.once.remember) && job.once.host) {
|
||||
try {
|
||||
identity.removeHttps(identity.accountForDir(job.parent), "https", job.once.host);
|
||||
} catch {
|
||||
/* best effort: the store is the member's own */
|
||||
}
|
||||
}
|
||||
finish(
|
||||
job,
|
||||
out.ok
|
||||
? { ok: true }
|
||||
: { ok: false, err: identity.cloneFailure(job.url, out.stderr, failureMessage(out)) }
|
||||
);
|
||||
}
|
||||
|
||||
/** The error body a failed job records: the route's own code and message. */
|
||||
function errorBody(err) {
|
||||
return {
|
||||
code: err?.code || "ECLONE",
|
||||
message: err?.message || "clone failed",
|
||||
...(err?.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a job, once: the slot back, the runner's files gone, then
|
||||
* nothing (cancelled — its half-made folder dropped), the failure, or the
|
||||
* after-step and its outcome.
|
||||
*/
|
||||
function finish(job, { ok, err }) {
|
||||
if (job.finishing) return;
|
||||
job.finishing = true;
|
||||
job.release?.();
|
||||
job.release = null;
|
||||
rmJobDir(job.id);
|
||||
if (cancelled(job)) {
|
||||
dropCancelledTarget(job.target);
|
||||
return;
|
||||
}
|
||||
if (!ok) return fail(job, errorBody(err));
|
||||
let out;
|
||||
try {
|
||||
out = afterStep
|
||||
? afterStep(job)
|
||||
: { status: 500, json: { error: { code: "ECLONE", message: "no after-step registered" } } };
|
||||
} catch (e) {
|
||||
out = { status: 500, json: { error: { code: e.code || "ECLONE", message: e.message } } };
|
||||
}
|
||||
if (out.status < 300) {
|
||||
succeed(job, {
|
||||
laneId: out.laneId ?? null,
|
||||
path: job.target,
|
||||
...(out.created !== undefined ? { created: out.created } : {}),
|
||||
});
|
||||
} else fail(job, out.json?.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancelled clone of a HOST-account folder whose git left the target
|
||||
* behind (it was checked absent before the job): that exact path, never
|
||||
* through a link. A member's folder is git's own to clean (sudo relays the
|
||||
* SIGTERM) — the dashboard never deletes inside a member's home.
|
||||
*/
|
||||
function dropCancelledTarget(target) {
|
||||
try {
|
||||
if (require("./run-as").accountForPath(target)) return;
|
||||
if (!fs.lstatSync(target).isDirectory()) return;
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* nothing there, or not ours to remove */
|
||||
}
|
||||
}
|
||||
|
||||
function settle(job, fields) {
|
||||
if (job.state === "cancelled") return;
|
||||
Object.assign(job, fields, { finishedAt: new Date().toISOString() });
|
||||
if (job.mirror) queue().finish(job.mirror, job.state === "done");
|
||||
if (jobs?.get(job.id) === job) writeNow();
|
||||
}
|
||||
|
||||
/** The clone and its after-step worked: `result = {laneId, path, created?}`. */
|
||||
function succeed(job, result) {
|
||||
settle(job, { state: "done", error: null, result });
|
||||
}
|
||||
|
||||
/** It failed: `error = {code, message, auth?}` — what the route would answer. */
|
||||
function fail(job, error) {
|
||||
const e = { code: error?.code || "ECLONE", message: error?.message || "clone failed" };
|
||||
if (error?.auth) e.auth = { protocol: error.auth.protocol, host: error.auth.host };
|
||||
settle(job, { state: "failed", error: e, result: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels one of the caller's jobs: `ENOTFOUND` when not theirs, `EDONE` once
|
||||
* it finished. Queued: out of the queue, never started. Running: the
|
||||
* runner's group is SIGTERMed (and a borrowed-login probe aborted); the slot
|
||||
* frees when git exits.
|
||||
*/
|
||||
function cancel(id, owner) {
|
||||
const job = get(id, owner);
|
||||
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
|
||||
if (!ACTIVE.has(job.state)) {
|
||||
throw Object.assign(new Error("the clone already finished"), { code: "EDONE" });
|
||||
}
|
||||
settle(job, {
|
||||
state: "cancelled",
|
||||
error: { code: "ECANCELLED", message: "Clone cancelled" },
|
||||
result: null,
|
||||
});
|
||||
if (!queue().dequeue(job)) {
|
||||
job.abort();
|
||||
killRunner(job);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Whether a cancel already settled this job. */
|
||||
const cancelled = (job) => job.state === "cancelled";
|
||||
|
||||
const mine = (job, owner) => !serverMode() || job.owner === (owner ?? null);
|
||||
|
||||
/** The CloneJob the API answers. */
|
||||
function view(job) {
|
||||
const { owner: _owner, once: _once, ...out } = record(job);
|
||||
return {
|
||||
...out,
|
||||
position: job.state === "queued" ? queue().position(job) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The caller's jobs, newest first. */
|
||||
function list(owner) {
|
||||
prune();
|
||||
return [...jobs.values()]
|
||||
.filter((j) => mine(j, owner))
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
||||
}
|
||||
|
||||
/** One of the caller's jobs, or null. */
|
||||
function get(id, owner) {
|
||||
prune();
|
||||
const job = typeof id === "string" ? jobs.get(id) : null;
|
||||
return job && mine(job, owner) ? job : null;
|
||||
}
|
||||
|
||||
/** Drops a finished job: `ENOTFOUND` when not the caller's, `EBUSY` while it runs. */
|
||||
function remove(id, owner) {
|
||||
const job = get(id, owner);
|
||||
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
|
||||
if (ACTIVE.has(job.state)) {
|
||||
throw Object.assign(new Error("the clone is still running"), { code: "EBUSY" });
|
||||
}
|
||||
jobs.delete(id);
|
||||
rmJobDir(id);
|
||||
writeNow();
|
||||
}
|
||||
|
||||
/** The routes' after-step: `(job) => {status, json, laneId?, created?}`. */
|
||||
function setAfterStep(fn) {
|
||||
afterStep = fn;
|
||||
}
|
||||
|
||||
/** Server start: reconcile the file now (adopt, finish, interrupt, requeue). */
|
||||
function resume() {
|
||||
load();
|
||||
}
|
||||
|
||||
/** Tests: forget the in-memory state and stop tailing, as a restart would. */
|
||||
function _reset() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const w of watchers) clearInterval(w.iv);
|
||||
watchers.clear();
|
||||
jobs = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
start,
|
||||
view,
|
||||
list,
|
||||
get,
|
||||
remove,
|
||||
cancel,
|
||||
cancelled,
|
||||
setAfterStep,
|
||||
resume,
|
||||
filePath,
|
||||
jobDir,
|
||||
ACTIVE,
|
||||
KEEP_DONE_MS,
|
||||
KEEP_FAILED_MS,
|
||||
_reset,
|
||||
};
|
||||
@@ -9,6 +9,8 @@
|
||||
* least 1). A clone the browser named with a token (`track`) reports its
|
||||
* place in the queue, then git's own `--progress` lines (`report`), to
|
||||
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
|
||||
* A background clone (`server/lib/clone-jobs.js`) passes its own job record,
|
||||
* whose `onChange` hears each state change and progress line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -46,15 +48,18 @@ function next() {
|
||||
while (running < concurrency() && waiting.length) {
|
||||
running++;
|
||||
const { start, job } = waiting.shift();
|
||||
if (job) job.state = "running";
|
||||
if (job) {
|
||||
job.state = "running";
|
||||
job.onChange?.("state");
|
||||
}
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
|
||||
async function withCloneSlot(fn, job = null) {
|
||||
await new Promise((start) => {
|
||||
waiting.push({ start, job });
|
||||
await new Promise((start, reject) => {
|
||||
waiting.push({ start, reject, job });
|
||||
next();
|
||||
});
|
||||
try {
|
||||
@@ -136,6 +141,7 @@ function report(job, chunk) {
|
||||
.replace(/,\s*done\.?$/, "")
|
||||
.replace(/^,\s*/, "")
|
||||
.trim() || null;
|
||||
job.onChange?.("progress");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -149,10 +155,9 @@ function progress(token, owner) {
|
||||
sweep();
|
||||
const job = jobs.get(token);
|
||||
if (!job || job.owner !== owner) return null;
|
||||
const position = waiting.findIndex((w) => w.job === job);
|
||||
return {
|
||||
state: job.state,
|
||||
position: position >= 0 ? position + 1 : null,
|
||||
position: position(job),
|
||||
...cloneQueueState(),
|
||||
phase: job.phase,
|
||||
label: job.label,
|
||||
@@ -161,6 +166,40 @@ function progress(token, owner) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a clone this process did not start — a background clone's runner
|
||||
* adopted after a restart (clone-jobs.js) — against the limit, at once.
|
||||
* Returns its release.
|
||||
*/
|
||||
function occupy() {
|
||||
running++;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
running--;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a still-waiting clone out of the queue: its `withCloneSlot` rejects
|
||||
* with `ECANCELLED` and it never starts. False when it is not waiting.
|
||||
*/
|
||||
function dequeue(job) {
|
||||
const i = waiting.findIndex((w) => w.job === job);
|
||||
if (i < 0) return false;
|
||||
const [w] = waiting.splice(i, 1);
|
||||
w.reject(Object.assign(new Error("Clone cancelled"), { code: "ECANCELLED" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A queued clone's 1-based place among the waiting ones, else null. */
|
||||
function position(job) {
|
||||
const i = waiting.findIndex((w) => w.job === job);
|
||||
return i >= 0 ? i + 1 : null;
|
||||
}
|
||||
|
||||
/** A progress-free tail of git's stderr for an error message. */
|
||||
function stripProgress(stderr) {
|
||||
return String(stderr || "")
|
||||
@@ -178,5 +217,8 @@ module.exports = {
|
||||
finish,
|
||||
report,
|
||||
progress,
|
||||
position,
|
||||
dequeue,
|
||||
occupy,
|
||||
stripProgress,
|
||||
};
|
||||
|
||||
+127
-35
@@ -569,6 +569,38 @@ const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY
|
||||
// region of that AWS account. Saved with `allRegions`, the default login is
|
||||
// written for each region here, so a plain `ssh://git-codecommit.<any>…/` URL
|
||||
// logs in — in a pane's terminal too — and the user never picks a region.
|
||||
const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
const CODECOMMIT_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2",
|
||||
"ca-central-1",
|
||||
"sa-east-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"il-central-1",
|
||||
"me-south-1",
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
];
|
||||
const codecommitHost = (region) => `git-codecommit.${region}.amazonaws.com`;
|
||||
|
||||
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
|
||||
async function keyLinks(account) {
|
||||
const { git } = require("./worktree");
|
||||
@@ -642,10 +674,16 @@ async function listSshUsers(account) {
|
||||
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
|
||||
* it names; false when there was none.
|
||||
*/
|
||||
async function removeSshUser(account, from) {
|
||||
async function removeSshUser(account, from, { allRegions = false, user = null } = {}) {
|
||||
const { git } = require("./worktree");
|
||||
const f = String(from || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
|
||||
// `allRegions` (from a CodeCommit host): every region's own rule — of
|
||||
// `user` only when given — never an alias (a named key's login).
|
||||
const rows = (await listSshUsers(account)).filter((r) =>
|
||||
allRegions && CODECOMMIT_RE.test(f)
|
||||
? r.alias === null && CODECOMMIT_RE.test(r.host) && (!user || r.user === user)
|
||||
: (r.alias ?? r.host) === f
|
||||
);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
@@ -653,10 +691,10 @@ async function removeSshUser(account, from) {
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${f}/`,
|
||||
`ssh://${r.alias ?? r.host}/`,
|
||||
]);
|
||||
await unlinkKey(account, r.alias ?? r.host);
|
||||
}
|
||||
if (rows.length) await unlinkKey(account, f);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
@@ -666,7 +704,10 @@ async function removeSshUser(account, from) {
|
||||
* the key `key` only, or every key when it is null. Replaces our earlier rule
|
||||
* for that host or alias.
|
||||
*/
|
||||
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
|
||||
async function setSshUser(
|
||||
account,
|
||||
{ host, user, alias = null, key = null, allRegions = false } = {}
|
||||
) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -682,9 +723,28 @@ async function setSshUser(account, { host, user, alias = null, key = null } = {}
|
||||
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
|
||||
throw err("ENOKEY", "no such SSH key");
|
||||
}
|
||||
const { git } = require("./worktree");
|
||||
if (allRegions === true) {
|
||||
if (a !== null || !CODECOMMIT_RE.test(h)) {
|
||||
throw err("EBADHOST", "every region is for AWS CodeCommit's own login, without an alias");
|
||||
}
|
||||
// One login for the whole AWS account: every region's rule is replaced.
|
||||
await removeSshUser(account, h, { allRegions: true });
|
||||
for (const region of CODECOMMIT_REGIONS) {
|
||||
const rh = codecommitHost(region);
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(rh, u)}.insteadOf`,
|
||||
`ssh://${rh}/`,
|
||||
]);
|
||||
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${rh}.sshKey`, key]);
|
||||
}
|
||||
return listSshUsers(account);
|
||||
}
|
||||
const from = a ?? h;
|
||||
await removeSshUser(account, from);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
@@ -715,12 +775,12 @@ async function linkedKeyFile(account, url) {
|
||||
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
|
||||
}
|
||||
|
||||
// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY
|
||||
// region of that AWS account — but our login rules are per host, and a repo
|
||||
// in a region the user never saved stayed refused. Such a clone borrows the
|
||||
// logins saved for the other CodeCommit regions (not aliases' hosts only: an
|
||||
// alias row's user is as valid), each with its own key when it names one.
|
||||
const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
// A CodeCommit URL with no user is tried with every CodeCommit login saved
|
||||
// (the region's own first, then other regions' and every named key's — an
|
||||
// alias row's user is as valid), each with its own key when it names one:
|
||||
// a login saved for one region, or a second AWS account's key, clones from
|
||||
// any region without the user rewriting the URL. The first the remote
|
||||
// accepts wins (`cloneNow`).
|
||||
|
||||
/** `[{url, keyFile}]`: `url` with each borrowable CodeCommit user in it. */
|
||||
async function borrowedLogins(account, url) {
|
||||
@@ -733,9 +793,12 @@ async function borrowedLogins(account, url) {
|
||||
}
|
||||
const host = u.host.toLowerCase();
|
||||
if (u.username || !CODECOMMIT_RE.test(host)) return [];
|
||||
const rows = await listSshUsers(account);
|
||||
// The region has a login of its own: git's insteadOf already applies it.
|
||||
if (rows.some((r) => (r.alias ?? r.host) === host)) return [];
|
||||
const all = await listSshUsers(account);
|
||||
// The region's own login first: it is the likeliest to be the right one.
|
||||
const rows = [
|
||||
...all.filter((r) => r.alias === null && r.host === host),
|
||||
...all.filter((r) => !(r.alias === null && r.host === host)),
|
||||
];
|
||||
const keys = currentKeys(account);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
@@ -748,6 +811,8 @@ async function borrowedLogins(account, url) {
|
||||
seen.add(id);
|
||||
out.push({ url: `ssh://${r.user}@${host}${u.pathname}`, keyFile });
|
||||
}
|
||||
// One login in all, and it is the region's own: git's insteadOf applies it.
|
||||
if (out.length === 1 && rows[0]?.alias === null && rows[0]?.host === host) return [];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -873,6 +938,32 @@ function sshFailure(stderr) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a failed `git clone` of `url` answers, from git's stderr:
|
||||
* `ECREDENTIALS` (with `{protocol, host}`), `ENOREPO`, `ESSHAUTH`, else
|
||||
* `ECLONE` with git's own words. Shared by the held-open clone and a
|
||||
* background one finished by a later server (clone-jobs.js).
|
||||
*/
|
||||
function cloneFailure(url, stderr, fallback = "git clone failed") {
|
||||
const remote = remoteOf(url);
|
||||
if (remote.scheme === "https" && httpsFailure(stderr)) {
|
||||
return Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
|
||||
protocol: "https",
|
||||
host: remote.host,
|
||||
});
|
||||
}
|
||||
if (notFound(stderr)) {
|
||||
return err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
|
||||
}
|
||||
if (remote.scheme === "ssh" && sshFailure(stderr)) {
|
||||
return Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
|
||||
}
|
||||
return err(
|
||||
"ECLONE",
|
||||
(require("./clone-queue").stripProgress(stderr) || fallback).trim().slice(-800)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
|
||||
* that account's own identity. `once` is a login typed for this clone
|
||||
@@ -897,7 +988,13 @@ function cloneRepo({ job = null, ...opts }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) {
|
||||
// `signal` (a background clone's cancel, clone-jobs.js) SIGTERMs whatever git
|
||||
// call is running: a borrowed-login probe or the clone itself.
|
||||
async function cloneNow(
|
||||
{ url, parent, name, once = null, partial = false, signal = null, launch = null },
|
||||
threads,
|
||||
onProgress
|
||||
) {
|
||||
const { git } = require("./worktree");
|
||||
const account = accountForDir(parent);
|
||||
const remote = remoteOf(url);
|
||||
@@ -931,6 +1028,7 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
|
||||
borrowed.length === 1 ||
|
||||
(await git(parent, ["-c", sshFor(b.keyFile), "ls-remote", "--", b.url, "HEAD"], {
|
||||
timeout: 60_000,
|
||||
signal,
|
||||
}).then(
|
||||
() => true,
|
||||
() => false
|
||||
@@ -990,33 +1088,25 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled");
|
||||
const filter = partial ? ["--filter=blob:none"] : [];
|
||||
// --progress: git prints it to a pipe too, for the UI's progress line.
|
||||
await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], {
|
||||
// `launch` (a background clone, clone-jobs.js) runs this same command in
|
||||
// a detached runner instead, and rejects like git() does.
|
||||
const args = [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name];
|
||||
const gitOpts = {
|
||||
timeout: CLONE_TIMEOUT_MS,
|
||||
env,
|
||||
background: true,
|
||||
onStderr: onProgress,
|
||||
});
|
||||
signal,
|
||||
};
|
||||
await (launch ? launch(parent, args, gitOpts) : git(parent, args, gitOpts));
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
const stderr = e.git?.stderr;
|
||||
if (remote.scheme === "https" && httpsFailure(stderr)) {
|
||||
throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
|
||||
protocol: "https",
|
||||
host: remote.host,
|
||||
});
|
||||
}
|
||||
if (notFound(stderr)) {
|
||||
throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
|
||||
}
|
||||
if (remote.scheme === "ssh" && sshFailure(stderr)) {
|
||||
throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
|
||||
}
|
||||
throw err(
|
||||
"ECLONE",
|
||||
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
|
||||
);
|
||||
// SIGTERMed by a cancel: git (sudo forwards it) removes its own half-made folder.
|
||||
if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled");
|
||||
throw cloneFailure(url, e.git?.stderr, e.message);
|
||||
} finally {
|
||||
// A login typed without "remember" — or one that did not work — leaves
|
||||
// the member's store as it was.
|
||||
@@ -1050,12 +1140,14 @@ module.exports = {
|
||||
listSshUsers,
|
||||
setSshUser,
|
||||
removeSshUser,
|
||||
CODECOMMIT_REGIONS,
|
||||
listHttps,
|
||||
saveHttps,
|
||||
removeHttps,
|
||||
remoteOf,
|
||||
defaultName,
|
||||
cloneRepo,
|
||||
cloneFailure,
|
||||
getAuthor,
|
||||
defaultEmail,
|
||||
setAuthor,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -749,6 +750,17 @@ function ownerNames(id) {
|
||||
};
|
||||
}
|
||||
|
||||
/** True only when the folder is GONE (ENOENT/ENOTDIR) — a folder this
|
||||
* process may not read is still there, so it never counts as deleted. */
|
||||
function folderMissing(cwd) {
|
||||
try {
|
||||
fs.statSync(cwd);
|
||||
return false;
|
||||
} catch (err) {
|
||||
return err.code === "ENOENT" || err.code === "ENOTDIR";
|
||||
}
|
||||
}
|
||||
|
||||
function lanePayload(lane, ageSec = null) {
|
||||
const pipeline = getPipeline(lane.pipeline);
|
||||
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
|
||||
@@ -775,6 +787,9 @@ function lanePayload(lane, ageSec = null) {
|
||||
: Math.max(0, Math.round((Date.now() - since) / 1000)),
|
||||
last_event_seconds: ageSec,
|
||||
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
|
||||
// The folder was deleted under the workspace (from Files, a shell): the
|
||||
// record stays — its sessions are history — but nothing can open in it.
|
||||
folder_missing: folderMissing(lane.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+42
-27
@@ -40,34 +40,12 @@ function lowPriority() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
* Treats zero exit code as success even if stderr has hints.
|
||||
*
|
||||
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
|
||||
* that leak from parent processes. Without this, git operations on a worktree (where
|
||||
* .git is a file, not a directory) fail with ".git/index: index file open failed:
|
||||
* Not a directory" when run from within a git hook or from a shell that inherited
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
* The command a git call runs — `{cmd, argv, env, gitCmd}` — exactly as
|
||||
* `git()` builds it (scrubbed env, a member's `sudo -n -u ah-<name> git`,
|
||||
* nice/ionice for `background`), without running it. A background clone's
|
||||
* detached runner (`scripts/clone-runner.js`) executes this very command.
|
||||
*/
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{
|
||||
timeout = 0,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
literal = false,
|
||||
env: extraEnv = {},
|
||||
// "buffer" for a binary read (an image at a revision).
|
||||
encoding = "utf8",
|
||||
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
||||
input = null,
|
||||
// true: run under nice/ionice (a clone — see lowPriority above).
|
||||
background = false,
|
||||
// Called with each stderr chunk as it arrives (a clone's --progress).
|
||||
onStderr = null,
|
||||
} = {}
|
||||
) {
|
||||
function gitCommand(cwd, args, { literal = false, env: extraEnv = {}, background = false } = {}) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -118,6 +96,41 @@ async function git(
|
||||
argv = [...rest, cmd, ...argv];
|
||||
cmd = first;
|
||||
}
|
||||
return { cmd, argv, env, gitCmd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
* Treats zero exit code as success even if stderr has hints.
|
||||
*
|
||||
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
|
||||
* that leak from parent processes. Without this, git operations on a worktree (where
|
||||
* .git is a file, not a directory) fail with ".git/index: index file open failed:
|
||||
* Not a directory" when run from within a git hook or from a shell that inherited
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{
|
||||
timeout = 0,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
literal = false,
|
||||
env: extraEnv = {},
|
||||
// "buffer" for a binary read (an image at a revision).
|
||||
encoding = "utf8",
|
||||
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
||||
input = null,
|
||||
// true: run under nice/ionice (a clone — see lowPriority above).
|
||||
background = false,
|
||||
// Called with each stderr chunk as it arrives (a clone's --progress).
|
||||
onStderr = null,
|
||||
// An AbortSignal: aborting SIGTERMs the child (a cancelled clone).
|
||||
signal = null,
|
||||
} = {}
|
||||
) {
|
||||
const { cmd, argv, env, gitCmd } = gitCommand(cwd, args, { literal, env: extraEnv, background });
|
||||
|
||||
try {
|
||||
const run =
|
||||
@@ -142,6 +155,7 @@ async function git(
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
timeout,
|
||||
encoding,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
// promisify(execFile) hands back its child; the stdin runner has none.
|
||||
if (onStderr && pending.child?.stderr) {
|
||||
@@ -1006,6 +1020,7 @@ async function hasNoRemotes(dir) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gitCommand,
|
||||
LANES_ROOT,
|
||||
git,
|
||||
isGitRepo,
|
||||
|
||||
@@ -316,10 +316,83 @@ const paths = {
|
||||
description:
|
||||
"The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.",
|
||||
},
|
||||
background: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.",
|
||||
},
|
||||
},
|
||||
["url", "parent"]
|
||||
),
|
||||
responses: { 201: { description: "{lane, created: true, path}" } },
|
||||
responses: {
|
||||
201: { description: "{lane, created: true, path}" },
|
||||
202: {
|
||||
description: "`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs": {
|
||||
get: {
|
||||
tags: ["Git identity"],
|
||||
summary: "The caller's background clone jobs, newest first",
|
||||
description:
|
||||
"CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `<data dir>/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller's own (local mode: all).",
|
||||
operationId: "listCloneJobs",
|
||||
responses: { 200: { description: "{jobs: CloneJob[]}" } },
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}": {
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Dismiss a finished background clone job",
|
||||
operationId: "dismissCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: {
|
||||
200: { description: "{ok: true}" },
|
||||
404: { description: "ENOTFOUND — not the caller's job" },
|
||||
409: { description: "EBUSY — still queued or running" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}/cancel": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Cancel a queued or running background clone",
|
||||
description:
|
||||
"Queued: taken out of the queue, never started. Running: the runner's process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.",
|
||||
operationId: "cancelCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: {
|
||||
200: { description: "{job}" },
|
||||
404: { description: "ENOTFOUND — not the caller's job" },
|
||||
409: { description: "EDONE — it already finished" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}/retry": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Retry a failed, interrupted or cancelled background clone",
|
||||
description:
|
||||
"A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.",
|
||||
operationId: "retryCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: json({
|
||||
credentials: {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string" },
|
||||
remember: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
202: { description: "{job} — the new CloneJob" },
|
||||
404: { description: "ENOTFOUND — not the caller's job (ENOLANE: its workspace is gone)" },
|
||||
409: { description: "EBUSY — queued, running or done" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/progress/{token}": {
|
||||
|
||||
@@ -660,11 +660,17 @@ const paths = {
|
||||
additionalProperties: true,
|
||||
description: "A typed HTTPS login for this one clone.",
|
||||
},
|
||||
background: {
|
||||
type: "boolean",
|
||||
description:
|
||||
'Run as a background clone job (see `/api/lanes/clone/jobs`): the checks answer at once, then 202 `{job}` with `kind: "source"`.',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
|
||||
202: { description: "`background: true`: {job} — a CloneJob." },
|
||||
400: {
|
||||
description:
|
||||
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
|
||||
|
||||
@@ -58,6 +58,8 @@ async function view(account, username) {
|
||||
sshKeys: keys,
|
||||
sshTerminal: terminal ?? null,
|
||||
sshUsers: await identity.listSshUsers(account),
|
||||
// The regions a CodeCommit key ID saved for "every region" covers.
|
||||
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
@@ -136,10 +138,12 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key
|
||||
* ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more
|
||||
* An SSH login (`{host, user, alias?, key?, allRegions?}`) — e.g. AWS
|
||||
* CodeCommit's SSH key ID, so a plain `ssh://git-codecommit…/` URL logs in
|
||||
* (`allRegions`: for every CodeCommit region at once); with `alias`, one more
|
||||
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
|
||||
* DELETE `/ssh-user/<host or alias>` drops it.
|
||||
* DELETE `/ssh-user/<host or alias>` drops it (`?allRegions=1&user=` every
|
||||
* region's).
|
||||
*/
|
||||
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -154,7 +158,16 @@ router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!(await identity.removeSshUser(account, req.params.host))) {
|
||||
// `?allRegions=1[&user=APKA…]` from a CodeCommit host: every region's
|
||||
// own login (of that key ID) at once.
|
||||
const opts =
|
||||
req.query.allRegions === "1"
|
||||
? {
|
||||
allRegions: true,
|
||||
user: typeof req.query.user === "string" && req.query.user ? req.query.user : null,
|
||||
}
|
||||
: {};
|
||||
if (!(await identity.removeSshUser(account, req.params.host, opts))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
|
||||
}
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
|
||||
+350
-85
@@ -738,13 +738,32 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||||
*/
|
||||
/**
|
||||
* The clone itself, shared by Add workspace → Clone and a workspace's Clone
|
||||
* extra folder: every check above, then `git clone` as the folder's account.
|
||||
* Answers the new folder's path, or null once it has sent the refusal.
|
||||
* `before(target, ownerId)` runs after the checks and BEFORE the clone, so a
|
||||
* refusal there never leaves minutes of cloning behind.
|
||||
* Status + error body a failed clone answers — the same codes whether the
|
||||
* request was held open or the clone ran as a background job.
|
||||
*/
|
||||
async function cloneInto(req, res, body, before) {
|
||||
function cloneFailure(err) {
|
||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||
? 400
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
return {
|
||||
status,
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
...(err.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every check before a clone, shared by Add workspace → Clone and a
|
||||
* workspace's Clone extra folder, then `before(target, ownerId)` — so a
|
||||
* refusal there never leaves minutes of cloning behind. Answers the clone's
|
||||
* context, or null once it has sent the refusal.
|
||||
*/
|
||||
async function prepareClone(req, res, body, before) {
|
||||
const identity = require("../lib/git-identity");
|
||||
let url;
|
||||
try {
|
||||
@@ -783,6 +802,10 @@ async function cloneInto(req, res, body, before) {
|
||||
res.status(400).json({ error: { code: "EBADFIELD", message: "partial: boolean" } });
|
||||
return null;
|
||||
}
|
||||
if (body.background !== undefined && typeof body.background !== "boolean") {
|
||||
res.status(400).json({ error: { code: "EBADFIELD", message: "background: boolean" } });
|
||||
return null;
|
||||
}
|
||||
const once = body.credentials;
|
||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||
@@ -832,38 +855,141 @@ async function cloneInto(req, res, body, before) {
|
||||
}
|
||||
try {
|
||||
await before(target, ownerId);
|
||||
await identity.cloneRepo({
|
||||
job,
|
||||
url,
|
||||
parent: dir,
|
||||
name,
|
||||
once: once
|
||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||
: null,
|
||||
partial: body.partial === true,
|
||||
});
|
||||
queue.finish(job, true);
|
||||
} catch (err) {
|
||||
queue.finish(job, false);
|
||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||
? 400
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
...(err.code === "ECREDENTIALS"
|
||||
? { auth: { protocol: err.protocol, host: err.host } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const f = cloneFailure(err);
|
||||
res.status(f.status).json({ error: f.error });
|
||||
return null;
|
||||
}
|
||||
return { target, name, ownerId };
|
||||
return {
|
||||
url,
|
||||
name,
|
||||
dir,
|
||||
target,
|
||||
ownerId,
|
||||
token: job,
|
||||
once: once
|
||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||
: null,
|
||||
partial: body.partial === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** `git clone` as the folder's account, the request held open. */
|
||||
async function runClone(ctx) {
|
||||
const queue = require("../lib/clone-queue");
|
||||
try {
|
||||
await require("../lib/git-identity").cloneRepo({
|
||||
job: ctx.token,
|
||||
url: ctx.url,
|
||||
parent: ctx.dir,
|
||||
name: ctx.name,
|
||||
once: ctx.once,
|
||||
partial: ctx.partial,
|
||||
});
|
||||
queue.finish(ctx.token, true);
|
||||
} catch (err) {
|
||||
queue.finish(ctx.token, false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The held-open clone: its context, or null once it has sent the refusal. */
|
||||
async function cloneInto(req, res, body, before) {
|
||||
const ctx = await prepareClone(req, res, body, before);
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
await runClone(ctx);
|
||||
} catch (err) {
|
||||
const f = cloneFailure(err);
|
||||
res.status(f.status).json({ error: f.error });
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers 202 `{ job }` and hands the clone to `server/lib/clone-jobs.js`:
|
||||
* a detached runner runs git, and when it ends the job's after-step
|
||||
* (`afterStepForJob` below — the workspace, or the extra folder, exactly as
|
||||
* the held-open route) runs once. A cancelled job never runs it.
|
||||
*/
|
||||
function startBackground(res, ctx, { kind, laneId, request }) {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
let account = null;
|
||||
try {
|
||||
account = require("../lib/run-as").accountForPath(ctx.target);
|
||||
} catch {
|
||||
/* checked by prepareClone already */
|
||||
}
|
||||
const job = cloneJobs.create({
|
||||
owner: ctx.ownerId,
|
||||
kind,
|
||||
url: ctx.url,
|
||||
name: ctx.name,
|
||||
parent: ctx.dir,
|
||||
target: ctx.target,
|
||||
laneId,
|
||||
request,
|
||||
once: ctx.once
|
||||
? {
|
||||
remember: ctx.once.remember,
|
||||
member: Boolean(account),
|
||||
host: (() => {
|
||||
try {
|
||||
return require("../lib/git-identity").remoteOf(ctx.url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (ctx.token) job.mirror = ctx.token;
|
||||
// Started before the answer: its first, synchronous step queues it, so the
|
||||
// 202 already carries its place in the queue.
|
||||
cloneJobs.start(job, ctx.once);
|
||||
res.status(202).json({ job: cloneJobs.view(job) });
|
||||
return job;
|
||||
}
|
||||
|
||||
/** A request-shaped owner for a job finished without its request (scope checks). */
|
||||
function reqForOwner(owner) {
|
||||
if (owner == null) return { method: "POST" };
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(owner);
|
||||
if (!row || row.disabled) return null;
|
||||
return { method: "POST", user: require("../lib/auth").publicUser(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A background clone's after-step, from the job alone — it may run in a
|
||||
* later server process than the one the request reached.
|
||||
*/
|
||||
function afterStepForJob(job) {
|
||||
const req = reqForOwner(job.owner);
|
||||
if (!req) {
|
||||
return {
|
||||
status: 409,
|
||||
json: {
|
||||
error: { code: "ENOTFOUND", message: "the account that started this clone is gone" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (job.kind === "source") {
|
||||
const lane = lanesLib.getLane(job.laneId);
|
||||
if (!lane) {
|
||||
return { status: 404, json: { error: { code: "ENOLANE", message: "workspace not found" } } };
|
||||
}
|
||||
return finishSourceClone(req, lane, job.target);
|
||||
}
|
||||
return finishWorkspaceClone(
|
||||
req,
|
||||
{ tool: job.request?.tool, title: job.request?.title },
|
||||
{ target: job.target, name: job.name, ownerId: job.owner }
|
||||
);
|
||||
}
|
||||
require("../lib/clone-jobs").setAfterStep(afterStepForJob);
|
||||
|
||||
/**
|
||||
* A clone's place in the machine-wide queue, then git's own progress
|
||||
* (`server/lib/clone-queue.js`), by the `progressToken` its POST carried —
|
||||
@@ -877,17 +1003,97 @@ router.get("/clone/progress/:token", (req, res) => {
|
||||
res.json(view);
|
||||
});
|
||||
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
const done = await cloneInto(req, res, body, (target, ownerId) => {
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
});
|
||||
if (!done) return;
|
||||
const { target, name, ownerId } = done;
|
||||
/**
|
||||
* Background clones (`background: true` on either clone POST,
|
||||
* `server/lib/clone-jobs.js`): the caller's own jobs, newest first — a job id
|
||||
* is only ever looked up among them (404 otherwise).
|
||||
*/
|
||||
router.get("/clone/jobs", (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
res.json({ jobs: cloneJobs.list(scope.ownerFor(req)).map(cloneJobs.view) });
|
||||
});
|
||||
|
||||
const cloneJobError = (res, err) => {
|
||||
const status = err.code === "ENOTFOUND" ? 404 : 409;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
};
|
||||
|
||||
router.delete("/clone/jobs/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
require("../lib/clone-jobs").remove(req.params.id, scope.ownerFor(req));
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return cloneJobError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/clone/jobs/:id/cancel", sameOriginGuard, (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
try {
|
||||
const job = cloneJobs.cancel(req.params.id, scope.ownerFor(req));
|
||||
return res.json({ job: cloneJobs.view(job) });
|
||||
} catch (err) {
|
||||
return cloneJobError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Jobs a retry is starting from, so a double click starts one clone. */
|
||||
const retrying = new Set();
|
||||
|
||||
/**
|
||||
* A failed, interrupted or cancelled job again: a NEW job from its `request`
|
||||
* (plus `credentials` typed now), through every check of a fresh POST; the
|
||||
* old one is dropped once the new one exists.
|
||||
*/
|
||||
router.post("/clone/jobs/:id/retry", sameOriginGuard, async (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
const owner = scope.ownerFor(req);
|
||||
const old = cloneJobs.get(req.params.id, owner);
|
||||
if (!old) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such clone" } });
|
||||
}
|
||||
if (cloneJobs.ACTIVE.has(old.state) || old.state === "done" || retrying.has(old.id)) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EBUSY", message: "only a failed clone can be retried" } });
|
||||
}
|
||||
const body = { ...old.request, background: true };
|
||||
const typed = req.body?.credentials;
|
||||
if (typed !== undefined) body.credentials = typed;
|
||||
retrying.add(old.id);
|
||||
try {
|
||||
let job;
|
||||
if (old.kind === "source") {
|
||||
const lane = lanesLib.getLane(old.laneId);
|
||||
if (!lane || !scope.canSeeLane(req, lane.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
// scopeGuard's live-role check for a write to a workspace, which this
|
||||
// path (no lane id in it) does not get.
|
||||
try {
|
||||
require("../lib/run-as").assertOwnerFits(lane.cwd, lane.owner_id);
|
||||
} catch (err) {
|
||||
return res.status(409).json({ error: { code: "EOWNERFOLDER", message: err.message } });
|
||||
}
|
||||
job = await startSourceClone(req, res, lane, body);
|
||||
} else {
|
||||
job = await startWorkspaceClone(req, res, body);
|
||||
}
|
||||
if (job) {
|
||||
try {
|
||||
cloneJobs.remove(old.id, owner);
|
||||
} catch {
|
||||
/* dismissed meanwhile */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
retrying.delete(old.id);
|
||||
}
|
||||
});
|
||||
|
||||
/** After a workspace clone: the folder becomes (or re-attaches) a workspace. */
|
||||
function finishWorkspaceClone(req, body, ctx) {
|
||||
const { target, name, ownerId } = ctx;
|
||||
const cwd = lanesLib.canonicalCwd(target);
|
||||
// A workspace can outlive its folder (deleted from the File Explorer or a
|
||||
// shell): cloning into that path again re-attaches the user's own record
|
||||
@@ -896,7 +1102,12 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||
if (found && strip(found.cwd) === strip(cwd) && scope.canSeeLane(req, found.id)) {
|
||||
broadcastLane(found.id);
|
||||
return res.status(200).json({ lane: payload(found), created: false, path: target });
|
||||
return {
|
||||
status: 200,
|
||||
laneId: found.id,
|
||||
created: false,
|
||||
json: { lane: payload(found), created: false, path: target },
|
||||
};
|
||||
}
|
||||
try {
|
||||
const lane = lanesLib.createLane({
|
||||
@@ -906,16 +1117,106 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
...(body.tool ? { tool: body.tool } : {}),
|
||||
});
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({ lane: payload(lane), created: true, path: target });
|
||||
return {
|
||||
status: 201,
|
||||
laneId: lane.id,
|
||||
created: true,
|
||||
json: { lane: payload(lane), created: true, path: target },
|
||||
};
|
||||
} catch (err) {
|
||||
// The clone stays: it is the user's now, and Repo mode can adopt it.
|
||||
const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "ELANE", message: err.message }, path: target });
|
||||
return {
|
||||
status,
|
||||
json: { error: { code: err.code || "ELANE", message: err.message }, path: target },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Add workspace → Clone; the background job when `background: true`. */
|
||||
async function startWorkspaceClone(req, res, body) {
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
const before = (target, ownerId) => {
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
};
|
||||
if (body.background === true) {
|
||||
const ctx = await prepareClone(req, res, body, before);
|
||||
if (!ctx) return null;
|
||||
const request = { url: ctx.url, parent: ctx.dir, name: ctx.name };
|
||||
for (const k of ["tool", "title", "partial"]) if (body[k] !== undefined) request[k] = body[k];
|
||||
return startBackground(res, ctx, { kind: "workspace", laneId: null, request });
|
||||
}
|
||||
const done = await cloneInto(req, res, body, before);
|
||||
if (!done) return null;
|
||||
const out = finishWorkspaceClone(req, body, done);
|
||||
res.status(out.status).json(out.json);
|
||||
return null;
|
||||
}
|
||||
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
await startWorkspaceClone(req, res, req.body || {});
|
||||
});
|
||||
|
||||
/** After an extra-folder clone: `lane_sources`' own folder checks, then add it. */
|
||||
function finishSourceClone(req, lane, target) {
|
||||
const sources = require("../lib/lane-sources");
|
||||
try {
|
||||
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||
sources.addFolder(lane.id, target, { allowed });
|
||||
broadcastLane(lane.id);
|
||||
return {
|
||||
status: 201,
|
||||
laneId: lane.id,
|
||||
json: {
|
||||
path: target,
|
||||
instructions: lane.instructions || "",
|
||||
supported: true,
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(lane.id),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// The clone stays where it is; it can still be added by its path.
|
||||
return {
|
||||
status: err.code === "EFOLDER" ? 403 : 400,
|
||||
json: { error: { code: err.code || "ESOURCE", message: err.message }, path: target },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** A workspace's Clone extra folder; the background job when `background: true`. */
|
||||
async function startSourceClone(req, res, lane, body) {
|
||||
const sources = require("../lib/lane-sources");
|
||||
if (!sources.supports(lane.tool)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||
return null;
|
||||
}
|
||||
const cloneBody = {
|
||||
url: body.url,
|
||||
name: body.name,
|
||||
credentials: body.credentials,
|
||||
parent: path.dirname(lane.cwd),
|
||||
...(body.background !== undefined ? { background: body.background } : {}),
|
||||
};
|
||||
if (body.background === true) {
|
||||
const ctx = await prepareClone(req, res, cloneBody, () => {});
|
||||
if (!ctx) return null;
|
||||
const request = { url: ctx.url, parent: ctx.dir, name: ctx.name };
|
||||
return startBackground(res, ctx, { kind: "source", laneId: lane.id, request });
|
||||
}
|
||||
const done = await cloneInto(req, res, cloneBody, () => {});
|
||||
if (!done) return null;
|
||||
const out = finishSourceClone(req, lane, done.target);
|
||||
res.status(out.status).json(out.json);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a related repo as one of a workspace's extra folders: the same clone
|
||||
* as above (URL allowlist, the folder account's own identity, `ECREDENTIALS`
|
||||
@@ -928,43 +1229,7 @@ router.post("/:id/sources/clone", sameOriginGuard, async (req, res) => {
|
||||
if (!lane) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
const sources = require("../lib/lane-sources");
|
||||
if (!sources.supports(lane.tool)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||
}
|
||||
const body = req.body || {};
|
||||
const done = await cloneInto(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
url: body.url,
|
||||
name: body.name,
|
||||
credentials: body.credentials,
|
||||
parent: path.dirname(lane.cwd),
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
if (!done) return;
|
||||
try {
|
||||
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||
sources.addFolder(lane.id, done.target, { allowed });
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({
|
||||
path: done.target,
|
||||
instructions: lane.instructions || "",
|
||||
supported: true,
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(lane.id),
|
||||
});
|
||||
} catch (err) {
|
||||
// The clone stays where it is; it can still be added by its path.
|
||||
return res
|
||||
.status(err.code === "EFOLDER" ? 403 : 400)
|
||||
.json({ error: { code: err.code || "ESOURCE", message: err.message }, path: done.target });
|
||||
}
|
||||
await startSourceClone(req, res, lane, req.body || {});
|
||||
});
|
||||
|
||||
function isSymlink(p) {
|
||||
|
||||
Reference in New Issue
Block a user