Compare commits

...

11 Commits

Author SHA1 Message Date
nntrivi2001 1f18f151d9 chore: v2.8.51 — clones and uploads in one global dock; a clone survives an update; CodeCommit needs only the key ID 2026-11-02 10:49:01 +07:00
nntrivi2001 a27589418c feat(clone): background clone jobs that survive a reload and a server restart
Both clone POSTs take background: true: every check answers synchronously,
then 202 {job}. git runs in a detached runner (scripts/clone-runner.js, its
own process group, stderr to a file — no SIGPIPE when the server restarts);
the server tails it for progress and runs the after-step (workspace / extra
folder) once when it exits. Jobs persist in <data dir>/runtime/clone-jobs.json
(0600, atomic, throttled progress writes); on start a live runner is adopted
(counted against the clone limit), an ended one finished, a queued one
requeued (interrupted if it carried a typed login), the rest interrupted.
GET /clone/jobs, DELETE /clone/jobs/:id, POST /clone/jobs/:id/cancel (queued:
dequeued; running: SIGTERM to the runner's group -> cancelled, no after-step)
and /retry (new job from the stored request, never a credential). The
held-open path is unchanged; worktree.gitCommand builds the runner's argv
and git-identity.cloneFailure the shared error classification.
2026-11-02 10:47:51 +07:00
nntrivi2001 57cbab916a feat(clone): background clones and uploads in one global dock that survives F5 and restarts
Clone repository (Files), Add workspace -> Clone and a workspace's extra-folder
clones now start server-side jobs (background: true) and close at once; the
app-wide BackgroundJobsDock (Layout) shows each job's queue place, git progress,
Stop (click twice), and outcome: Open workspace, Retry, an inline login form on
ECREDENTIALS, a Settings -> Git link on ESSHAUTH, dismiss. lib/cloneJobs.ts
polls GET /workspaces/clone/jobs while any job is active, announces a finish it
saw once (toast + agenthub:clone-job event + the starter's onDone), and treats
an older server's synchronous answer as a finished clone. File Explorer uploads
report to lib/uploadJobs.ts (Stop aborts the request in flight; a reload-cut
upload shows as interrupted from localStorage). CloneProgress renders a job's
fields instead of polling a token.
2026-11-02 10:44:23 +07:00
nntrivi2001 a7a8bfd24c fix(changes): Discard (n) dialog shows its count and does not count a new file twice
The confirm button was translated without count, so it read Discard ({{count}}).
The message counted ticked untracked files both as changed files and as new
files to delete; tracked and untracked are now counted apart, with a wording
for each mix.
2026-11-02 10:34:34 +07:00
nntrivi2001 0c964909e0 feat(git): a CodeCommit SSH key ID is saved for every region; a clone tries each saved account
Settings → Git → AWS CodeCommit asks for the key ID only: the default key's
login is written for every CodeCommit region at once (allRegions), shown as
one row, with Use in every region for an older one-region save. A clone of
a CodeCommit URL with no user tries every saved CodeCommit login — the
region's own first, then other regions' and each named key's — so a second
AWS account clones from the plain AWS URL with no ssh://<alias>/ rewrite.
2026-11-02 10:32:20 +07:00
nntrivi2001 6511e4ec62 chore: v2.8.50 — a workspace whose folder was deleted leaves the Files places 2026-11-02 10:11:53 +07:00
nntrivi2001 220309db12 fix(files): a workspace whose folder was deleted leaves the Files places
The lane payload carries folder_missing (ENOENT/ENOTDIR only). Files hides
such a workspace and never reopens it as the default place; the Workspace
sidebar strikes its name through with a hint to remove it from its menu.
A folder that cannot be read no longer keeps the previous folder's entries
under its error, and a change in Files re-reads the workspace list.
2026-11-02 10:11:47 +07:00
nntrivi2001 c3d88c0685 chore: v2.8.49 — CodeCommit clone works across regions; Clone runs in the background 2026-11-02 10:00:09 +07:00
nntrivi2001 e4ecd116a2 fix(clone): a CodeCommit region borrows saved key IDs; Clone runs in the background
An IAM SSH key ID logs in to every region of its AWS account, but a login
was saved per host, so a repo in a region with no key ID of its own was
refused. Clone now tries the key IDs saved for the other CodeCommit regions
(each with its own key; the first ls-remote accepts, or the only one) and
keeps that user in the clone's origin.

Files → New → Clone repository steps aside for its progress chip as soon
as the clone starts; a failure asking for a login reopens the form.
2026-11-02 10:00:09 +07:00
nntrivi2001 8d0477e0f7 chore: v2.8.48 — a folder upload skips .git with one notice instead of an error per file 2026-11-02 09:53:03 +07:00
nntrivi2001 e7e9cb20d8 fix(files): a folder upload leaves .git out with one notice
Uploading a cloned repo from the desktop sent every .git object, and the
server refused each one (nothing is uploaded into .git) — one error toast
per object, thousands of them. The drop walker no longer reads a .git
folder, and uploadAll drops any path inside .git before sending, saying
once how many were skipped.
2026-11-02 09:53:03 +07:00
48 changed files with 4349 additions and 668 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
@@ -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>
);
}
+3
View File
@@ -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);
});
});
+47 -34
View File
@@ -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>
+36 -88
View File
@@ -5,12 +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 running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* 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>
*/
@@ -18,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 };
@@ -31,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);
@@ -51,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);
@@ -76,67 +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);
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 ?? "",
@@ -145,18 +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);
}
}
};
@@ -258,7 +206,6 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -269,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"
@@ -279,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>
+20 -42
View File
@@ -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">
+85 -57
View File
@@ -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";
@@ -229,6 +235,8 @@ async function walkEntry(
out.push({ file, rel: `${prefix}${entry.name}` });
return;
}
// The server takes nothing into .git: never read one off the disk at all.
if (entry.name === ".git") return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in pages until it answers [].
for (;;) {
@@ -325,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;
@@ -367,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
@@ -437,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)
@@ -453,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 ?? [];
@@ -465,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);
};
@@ -614,8 +649,14 @@ export function FileBrowser({
};
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
setMenu(null);
// A dropped/picked repo carries its .git, which the server refuses file by
// file — skip it here with ONE notice instead of a toast per object.
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
if (files.length < picked.length) {
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
}
if (!files.length) return;
const bytes = files.reduce((n, f) => n + f.file.size, 0);
if (bytes > FX_BATCH_MAX_BYTES) {
@@ -625,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;
@@ -648,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;
@@ -668,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)
);
@@ -681,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;
@@ -692,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;
@@ -716,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();
}
};
@@ -924,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"
@@ -951,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) ||
@@ -986,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">
@@ -1645,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 && (
@@ -1679,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;
+9 -2
View File
@@ -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")
);
});
});
@@ -3,22 +3,26 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, restores from the user's own
* batch over 500 MB before sending anything, leaves a repo's .git out of a
* folder upload with one notice, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* 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) }));
@@ -31,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: {
@@ -74,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" },
@@ -229,7 +254,8 @@ describe("FileExplorer", () => {
"a.txt",
file,
"keep",
expect.any(Function)
expect.any(Function),
expect.any(AbortSignal)
)
);
});
@@ -250,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"]);
@@ -269,6 +296,23 @@ describe("FileExplorer", () => {
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
const f = new File(["hi"], rel.split("/").pop()!);
Object.defineProperty(f, "webkitRelativePath", { value: rel });
return f;
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
@@ -441,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();
@@ -568,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"));
@@ -587,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();
@@ -614,43 +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"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
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 keeps it for the next open", 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"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
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 () => {
@@ -658,18 +692,16 @@ 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"));
// 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(dialog).getByTestId("fx-clone-submit"));
@@ -677,6 +709,7 @@ describe("FileExplorer — New → Clone repository", () => {
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();
});
});
+39 -2
View File
@@ -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",
@@ -590,6 +626,7 @@
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
"fx.uploading": "Uploading {{done}}/{{total}}…",
"fx.uploaded": "Uploaded {{count}} file(s).",
"fx.gitSkipped": "Skipped {{count}} file(s) inside .git — nothing is uploaded into .git.",
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
+6 -4
View File
@@ -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",
+39 -2
View File
@@ -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ề",
@@ -590,6 +626,7 @@
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
"fx.uploaded": "Đã tải lên {{count}} file.",
"fx.gitSkipped": "Đã bỏ qua {{count}} file trong .git — không tải gì vào .git.",
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
+6 -4
View File
@@ -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
View File
@@ -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). */
+318
View File
@@ -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();
}
+2
View File
@@ -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;
+167
View File
@@ -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();
}
+4 -1
View File
@@ -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.47 (rebuild client to match)
UI build v2.8.51 (rebuild client to match)
</span>
</p>
</div>
+66 -7
View File
@@ -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. 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
+2
View File
@@ -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
View File
@@ -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. 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
View File
@@ -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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.47",
"version": "2.8.51",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.47",
"version": "2.8.51",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.47",
"version": "2.8.51",
"bin": {
"agenthub": "bin/agenthub.js"
},
+77
View File
@@ -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);
});
+527
View File
@@ -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;
}
});
});
+126 -1
View File
@@ -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), 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.
@@ -650,6 +652,129 @@ describe("several accounts: named keys and SSH logins per alias", () => {
});
});
describe("CodeCommit: a region with no login borrows another region's", () => {
it("clones as the first saved CodeCommit user the remote accepts, and keeps that user in origin", async () => {
const bare = path.join(ROOT, "bare.git");
const R = "git-codecommit.ap-southeast-2.amazonaws.com";
const saved = [
["git-codecommit.ap-southeast-1.amazonaws.com", "APKAONE"],
["git-codecommit.us-east-1.amazonaws.com", "APKATWO"],
];
for (const [host, user] of saved) {
assert.equal((await request("PUT", "/api/me/git/ssh-user", { host, user })).status, 200);
}
// The test forge stands in for the region: APKAONE is refused (no such
// repo behind it), APKATWO is the account that owns the repo.
const rules = [
[`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAONE@${R}/v1/repos/r`],
[`url.file://${bare}.insteadOf`, `ssh://APKATWO@${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: "borrowed-login",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const origin = execFileSync(
"git",
["-C", path.join(WORK, "borrowed-login"), "config", "remote.origin.url"],
{ encoding: "utf8" }
).trim();
assert.equal(origin, `ssh://APKATWO@${R}/v1/repos/r`);
} finally {
for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]);
for (const [host] of saved) await request("DELETE", `/api/me/git/ssh-user/${host}`);
}
});
});
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");
+10
View File
@@ -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" });
+8
View File
@@ -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();
+651
View File
@@ -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,
};
+47 -5
View File
@@ -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,
};
+182 -31
View File
@@ -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,6 +775,47 @@ async function linkedKeyFile(account, url) {
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
}
// 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) {
if (!url.startsWith("ssh://")) return [];
let u;
try {
u = new URL(url);
} catch {
return [];
}
const host = u.host.toLowerCase();
if (u.username || !CODECOMMIT_RE.test(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 = [];
for (const r of rows) {
if (!CODECOMMIT_RE.test(r.host)) continue;
const keyFile = r.key ? (keys.find((k) => k.id === r.key)?.file ?? null) : null;
if (r.key && !keyFile) continue;
const id = `${r.user}\0${keyFile ?? ""}`;
if (seen.has(id)) continue;
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;
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
@@ -837,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
@@ -861,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);
@@ -878,12 +1011,36 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
// A login naming its key (another AWS account, a second GitHub user)
// offers that key ONLY — another key could log in as the wrong account.
const linked = await linkedKeyFile(account, url);
let linked = await linkedKeyFile(account, url);
const sshFor = (file) => {
const k = file ? [file] : keyFiles(account);
const b = k.length ? `ssh${identityArgs(k)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
return `core.sshCommand=${b} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
};
// A CodeCommit region with no login of its own: the first borrowed login
// the remote accepts (one is taken as is). The clone's URL then names
// that user, so the repo's `origin` keeps logging in the same way.
const borrowed = await borrowedLogins(account, url);
for (const b of borrowed) {
const ok =
borrowed.length === 1 ||
(await git(parent, ["-c", sshFor(b.keyFile), "ls-remote", "--", b.url, "HEAD"], {
timeout: 60_000,
signal,
}).then(
() => true,
() => false
));
if (ok) {
url = b.url;
linked = b.keyFile;
break;
}
}
const keys = linked ? [linked] : keyFiles(account);
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
pre.push("-c", sshFor(linked));
if (keys.length) {
// Persisted by FILE name — both default types plus every named key:
// a default key made later is offered too (ssh skips a missing
@@ -931,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.
@@ -991,12 +1140,14 @@ module.exports = {
listSshUsers,
setSshUser,
removeSshUser,
CODECOMMIT_REGIONS,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
cloneFailure,
getAuthor,
defaultEmail,
setAuthor,
+15
View File
@@ -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
View File
@@ -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,
+74 -1
View File
@@ -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}": {
+6
View File
@@ -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).",
+17 -4
View File
@@ -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
View File
@@ -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) {