Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f18f151d9 | |||
| a27589418c | |||
| 57cbab916a | |||
| a7a8bfd24c | |||
| 0c964909e0 | |||
| 6511e4ec62 | |||
| 220309db12 | |||
| c3d88c0685 | |||
| e4ecd116a2 | |||
| 8d0477e0f7 | |||
| e7e9cb20d8 | |||
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 | |||
| bc44cbc8b7 | |||
| e7060a5e42 | |||
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 |
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
@@ -36,11 +40,8 @@
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
"Bash(gh pr *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. */
|
||||
const HTML_MAX = 10 * 1024 * 1024;
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||
* (a live one's pane stopped first),
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -119,8 +122,6 @@ export function AddLaneModal({
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -242,28 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -284,8 +303,6 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -571,11 +588,7 @@ export function AddLaneModal({
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -856,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,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>
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -27,8 +30,6 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
@@ -99,6 +102,9 @@ import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1470,25 +1470,48 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||
// 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.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { paths, hunks } = await selection();
|
||||
await api.lanes.gitAction(target, "discard", {
|
||||
paths,
|
||||
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||
confirm: true,
|
||||
});
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
@@ -2067,8 +2090,7 @@ export function LaneChangesPanel({
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() =>
|
||||
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
|
||||
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
@@ -2863,13 +2885,13 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void discardTicked()}
|
||||
title={t("changes.discardTickedTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -307,30 +308,30 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const run = liveRunFor(lane, liveRuns, s.id);
|
||||
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||
// archiving only its row left a pane running that no list showed.
|
||||
if (run) {
|
||||
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||
await patchSession(s, { archived: true });
|
||||
return;
|
||||
}
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
wt
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
if (wt) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
@@ -32,7 +32,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||
// left a pane running that no list showed any more.
|
||||
const run = runFor(s);
|
||||
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -837,22 +837,37 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
// 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(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Nothing ticked: nothing to discard.
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
@@ -1673,6 +1688,25 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
|
||||
* fields unminified in its build. Undefined on any other shape. */
|
||||
type AtlasPages = { pages: { version: number }[] };
|
||||
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
|
||||
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
|
||||
?._charAtlas;
|
||||
return Array.isArray(atlas?.pages) ? atlas : undefined;
|
||||
};
|
||||
/** Last version handed out by `restampAtlas`, shared by every pane. */
|
||||
let atlasStamp = 0;
|
||||
/**
|
||||
* Gives every page of the addon's glyph atlas a version no texture slot has
|
||||
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
|
||||
* its canvas on its next draw — what addon-webgl 0.20 does with globally
|
||||
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
|
||||
* pages down a slot can hold a stale page whose counter happens to match, and
|
||||
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
|
||||
* per-glyph increments a page makes before the next restamp. False when the
|
||||
* addon is not the shape this knows.
|
||||
*/
|
||||
const restampAtlas = (addon: WebglAddon): boolean => {
|
||||
const atlas = atlasOf(addon);
|
||||
if (!atlas) return false;
|
||||
for (const page of atlas.pages) {
|
||||
atlasStamp += 2 ** 24;
|
||||
page.version = atlasStamp;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||
const readFlag = (key: string) => {
|
||||
try {
|
||||
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
const hasBox = () =>
|
||||
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
if (hasBox()) fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
|
||||
// with the same font. When it fills up it merges four pages into one
|
||||
// larger page and shifts the rest down, and a GPU texture is re-uploaded
|
||||
// only when its page's `version` differs from the uploaded one — after a
|
||||
// shift those per-page counters can coincide, so a slot keeps a stale
|
||||
// page of another size and every glyph is drawn squeezed or foreign
|
||||
// while the buffer stays correct (fixed upstream only in 0.20 betas).
|
||||
// Any change to the page list therefore restamps every page's version
|
||||
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
|
||||
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
|
||||
// but redraws only this pane, leaving its siblings slicing reused space.
|
||||
// An addon of another shape falls back to toggling the family's
|
||||
// trailing space on the next frame, which makes each renderer re-acquire
|
||||
// its atlas (as onFontLoaded does).
|
||||
let atlasFrame = 0;
|
||||
const rebuildAtlas = () => {
|
||||
if (gpu && restampAtlas(gpu)) return true;
|
||||
if (atlasFrame) return false;
|
||||
// Never inside the render that is rasterizing the merging glyph.
|
||||
atlasFrame = requestAnimationFrame(() => {
|
||||
atlasFrame = 0;
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
|
||||
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
|
||||
// The browser may also drop the textures without a context-loss event
|
||||
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
|
||||
// back, and repaint, since nothing else asks this pane to draw.
|
||||
const onPageShown = () => {
|
||||
if (document.visibilityState === "visible" && rebuildAtlas()) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onPageShown);
|
||||
window.addEventListener("focus", onPageShown);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
if (atlasFrame) cancelAnimationFrame(atlasFrame);
|
||||
atlasMerged?.dispose();
|
||||
atlasGrew?.dispose();
|
||||
document.removeEventListener("visibilitychange", onPageShown);
|
||||
window.removeEventListener("focus", onPageShown);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
|
||||
@@ -63,6 +63,9 @@ const termState = {
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const refreshMock = vi.fn();
|
||||
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||||
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
dispose: disposeMock,
|
||||
refresh: refreshMock,
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||
const gpu: {
|
||||
last: {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
lose: () => void;
|
||||
merge: () => void;
|
||||
grow: () => void;
|
||||
} | null;
|
||||
} = {
|
||||
last: null,
|
||||
};
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn().mockImplementation(() => {
|
||||
let onLoss = () => {};
|
||||
let onMerge = () => {};
|
||||
let onGrow = () => {};
|
||||
const addon = {
|
||||
__webgl: true,
|
||||
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||||
dispose: vi.fn(),
|
||||
onContextLoss: (fn: () => void) => {
|
||||
onLoss = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||||
onMerge = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||||
onGrow = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
};
|
||||
gpu.last = {
|
||||
dispose: addon.dispose,
|
||||
lose: () => onLoss(),
|
||||
merge: () => onMerge(),
|
||||
grow: () => onGrow(),
|
||||
};
|
||||
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||
return addon;
|
||||
}),
|
||||
}));
|
||||
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
|
||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||
});
|
||||
|
||||
it("restamps every atlas page at once when the page list changes", () => {
|
||||
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||||
// pages a texture slot can keep a stale page whose counter matches. Every
|
||||
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||||
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||||
nextAtlas.pages = pages;
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
gpu.last!.merge();
|
||||
const first = pages.map((p) => p.version);
|
||||
expect(new Set(first).size).toBe(3);
|
||||
expect(first.every((v) => v > 7)).toBe(true);
|
||||
gpu.last!.grow();
|
||||
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||||
true
|
||||
);
|
||||
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||||
// The page coming back re-uploads and repaints this pane.
|
||||
refreshMock.mockClear();
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||||
} finally {
|
||||
nextAtlas.pages = null;
|
||||
raf.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||||
// An addon whose atlas this cannot reach: any option change makes each
|
||||
// renderer re-acquire its atlas and re-upload every page.
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const opts = termOptions as { fontFamily?: string };
|
||||
opts.fontFamily = "JetBrains Mono";
|
||||
gpu.last!.merge();
|
||||
gpu.last!.merge(); // coalesced into one frame
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||||
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||||
raf.mockRestore();
|
||||
});
|
||||
|
||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
const r = await fn();
|
||||
toast.success(typeof success === "function" ? success(r) : success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
(r) =>
|
||||
t(
|
||||
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||
{
|
||||
name: p.label,
|
||||
tool: label,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* account — and, for an admin, whether they own the host's Linux account, so
|
||||
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), its spend quota (the
|
||||
@@ -87,6 +88,7 @@ export function UserEditDialog({
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
@@ -139,6 +141,7 @@ export function UserEditDialog({
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
@@ -364,6 +367,17 @@ export function UserEditDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{role === "admin" && (
|
||||
<div className="mt-3">
|
||||
<Checkbox
|
||||
checked={hostAccount}
|
||||
onChange={setHostAccount}
|
||||
label={t("edit.hostAccount")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"unshare": "Unshare",
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"reshare": "Share again",
|
||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||
"reshareSame": "Already the newest version — shared",
|
||||
"reshareAskTitle": "Choose the {{name}} folder again",
|
||||
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
|
||||
"resharePick": "Choose folder",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
@@ -136,6 +142,11 @@
|
||||
"installsYou": "You",
|
||||
"installsUnknown": "A removed user",
|
||||
"installsReplaced": "replaced an older copy",
|
||||
"versionAt": "Updated {{ago}}",
|
||||
"versionAtFull": "Last updated {{date}}",
|
||||
"installState.latest": "Latest",
|
||||
"installState.outdated": "Old version",
|
||||
"installState.removed": "Removed",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
@@ -179,7 +190,7 @@
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"stat.updated": "Last updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.lineElided": "… {{size}} more characters on this line",
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
@@ -214,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",
|
||||
@@ -231,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}}",
|
||||
@@ -342,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",
|
||||
@@ -505,8 +541,6 @@
|
||||
"changes.loadMore": "Load more commits",
|
||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||
"changes.splitter": "Resize the changes panel",
|
||||
"tree.archiveLiveTitle": "Archive a live session?",
|
||||
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
|
||||
"tree.state.awaiting": "waiting for you",
|
||||
"tree.state.error": "error",
|
||||
"tree.state.live": "live",
|
||||
@@ -533,9 +567,11 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"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",
|
||||
@@ -568,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",
|
||||
@@ -591,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.",
|
||||
|
||||
@@ -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",
|
||||
@@ -1396,6 +1398,7 @@
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||
"EBADPASSWORD": "Password cannot be empty.",
|
||||
"EBADEMAIL": "That email address does not look valid.",
|
||||
"ENAMETAKEN": "Another account already uses that name. Pick one that is yours.",
|
||||
"EBADFIELD": "One of the values is not valid.",
|
||||
"ENOLANE": "That workspace no longer exists.",
|
||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||
@@ -111,6 +112,8 @@
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||
"hostAccount": "Owns this server's host account",
|
||||
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
"unshare": "Bỏ chia sẻ",
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"reshare": "Share lại",
|
||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||
"reshareAskTitle": "Chọn lại thư mục {{name}}",
|
||||
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
|
||||
"resharePick": "Chọn thư mục",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
@@ -137,6 +143,11 @@
|
||||
"installsYou": "Bạn",
|
||||
"installsUnknown": "Người dùng đã xoá",
|
||||
"installsReplaced": "thay bản cũ",
|
||||
"versionAt": "Cập nhật {{ago}}",
|
||||
"versionAtFull": "Cập nhật lần cuối {{date}}",
|
||||
"installState.latest": "Bản mới nhất",
|
||||
"installState.outdated": "Bản cũ",
|
||||
"installState.removed": "Đã gỡ",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
@@ -180,7 +191,7 @@
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"stat.updated": "Cập nhật lần cuối",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
@@ -214,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",
|
||||
@@ -231,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}}",
|
||||
@@ -342,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",
|
||||
@@ -505,8 +541,6 @@
|
||||
"changes.loadMore": "Tải thêm commit",
|
||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
|
||||
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
|
||||
"tree.state.awaiting": "đang chờ bạn",
|
||||
"tree.state.error": "lỗi",
|
||||
"tree.state.live": "đang chạy",
|
||||
@@ -533,9 +567,11 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"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",
|
||||
@@ -568,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ề",
|
||||
@@ -591,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.",
|
||||
|
||||
@@ -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",
|
||||
@@ -1384,6 +1386,7 @@
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||
"ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.",
|
||||
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||
@@ -111,6 +112,8 @@
|
||||
"status": "Trạng thái",
|
||||
"active": "Đang hoạt động",
|
||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||
"hostAccount": "Sở hữu tài khoản host của máy chủ",
|
||||
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
|
||||
+137
-20
@@ -800,6 +800,8 @@ export type UserPatch = {
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
|
||||
hostAccount?: boolean;
|
||||
/** Their own spend quota (`default` = the default applies). */
|
||||
quota?: UserQuotaSetting;
|
||||
};
|
||||
@@ -983,6 +985,8 @@ export type AuthUser = {
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
|
||||
hostAccount?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
@@ -1124,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();
|
||||
@@ -1401,7 +1418,7 @@ export const api = {
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
@@ -3158,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`
|
||||
@@ -3175,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({
|
||||
@@ -3191,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. */
|
||||
@@ -3227,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) =>
|
||||
@@ -3320,16 +3353,17 @@ export const api = {
|
||||
* several skills at once (one artifact each). `path` keeps a folder's layout. */
|
||||
uploadSkills: (
|
||||
files: { file: File; path: string }[],
|
||||
fields: { name?: string; title?: string; description?: string } = {}
|
||||
/** `id`: Share again — the upload replaces that skill row's version. */
|
||||
fields: { name?: string; title?: string; description?: string; id?: string } = {}
|
||||
) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f.file, f.file.name);
|
||||
form.append("paths", JSON.stringify(files.map((f) => f.path)));
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
|
||||
"/artifacts/skill/upload",
|
||||
{ method: "POST", body: form }
|
||||
);
|
||||
},
|
||||
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
||||
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||
@@ -3368,6 +3402,12 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
|
||||
* the server becomes the new version (409 ENOSOURCE when it has none). */
|
||||
reshare: (id: number) =>
|
||||
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||
remove: (id: number) =>
|
||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
@@ -4056,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
|
||||
@@ -4476,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),
|
||||
}),
|
||||
@@ -4709,11 +4781,21 @@ export interface Artifact {
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
|
||||
versionAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||
export interface ArtifactInstalls {
|
||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
||||
items: {
|
||||
user: string | null;
|
||||
mine: boolean;
|
||||
at: string;
|
||||
replaced: boolean;
|
||||
/** Skills: where that install's copy stands against the newest version now. */
|
||||
state?: "latest" | "outdated" | "removed" | "unknown";
|
||||
paths?: string[];
|
||||
}[];
|
||||
/** Installs counted before the list was kept. */
|
||||
unrecorded: number;
|
||||
}
|
||||
@@ -4924,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`. */
|
||||
@@ -5884,6 +5969,38 @@ export interface CloneProgressView {
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/** A background clone (`GET /api/lanes/clone/jobs`), kept by the server
|
||||
* across reloads and restarts; `interrupted` = the server restarted during it. */
|
||||
export interface CloneJob {
|
||||
id: string;
|
||||
kind: "workspace" | "source";
|
||||
state: "queued" | "running" | "done" | "failed" | "interrupted" | "cancelled";
|
||||
url: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
target: string;
|
||||
/** The workspace a `source` clone is added to. */
|
||||
laneId: number | null;
|
||||
/** 1-based place in the clone queue while `queued`. */
|
||||
position: number | null;
|
||||
phase: CloneProgressView["phase"];
|
||||
label: string | null;
|
||||
percent: number | null;
|
||||
detail: string | null;
|
||||
error: { code: string; message: string; auth?: { protocol: string; host: string } } | null;
|
||||
result: { laneId: number | null; path: string; created?: boolean } | null;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
request: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name: string;
|
||||
tool?: string;
|
||||
title?: string;
|
||||
partial?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaneSourcesView {
|
||||
instructions: string;
|
||||
/** The workspace's tool can take folders and instructions (claude, codex). */
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @file cloneJobs.ts
|
||||
* @description The app-wide list of background clones (`GET /api/lanes/clone/jobs`),
|
||||
* shown by `components/BackgroundJobsDock.tsx`. The server keeps every job
|
||||
* across reloads and restarts, so this store is only a view of that list:
|
||||
* it is read on start, on window focus, after each start/retry/cancel, and
|
||||
* every 1.5 s while any job is queued or running. A job SEEN going from
|
||||
* active to finished in this page is announced once — a toast, the
|
||||
* `agenthub:clone-job` window event (open pages re-read what the clone
|
||||
* changed) and the starter's own `onDone` — and a done one then leaves the
|
||||
* dock by itself; a job that finished unseen (a closed tab, F5) stays until
|
||||
* dismissed. An older server answers a start synchronously: that answer is
|
||||
* announced the same way as a job that is already done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import i18n from "i18next";
|
||||
import { api, type CloneJob, type LaneSourcesView } from "./api";
|
||||
import type { Lane } from "./types";
|
||||
import { toast } from "./toast";
|
||||
|
||||
/** Window event carrying a finished `CloneJob` in `detail`. */
|
||||
export const CLONE_JOB_EVENT = "agenthub:clone-job";
|
||||
|
||||
const POLL_MS = 1500;
|
||||
/** How long a done job the user watched stays in the dock after its toast. */
|
||||
const DONE_LINGER_MS = 8000;
|
||||
|
||||
export const isActiveJob = (j: Pick<CloneJob, "state">) =>
|
||||
j.state === "queued" || j.state === "running";
|
||||
|
||||
type OnDone = (job: CloneJob, lane?: Lane) => void;
|
||||
type Creds = { username: string; password: string; remember?: boolean };
|
||||
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
|
||||
type SourceBody = Omit<
|
||||
Parameters<typeof api.lanes.cloneFolder>[1],
|
||||
"background" | "progressToken"
|
||||
>;
|
||||
|
||||
let jobs: CloneJob[] = [];
|
||||
/** The last state seen per job, to tell a transition from a repeat. */
|
||||
const known = new Map<string, CloneJob["state"]>();
|
||||
/** Jobs seen queued/running in this page: only their finish is announced. */
|
||||
const watched = new Set<string>();
|
||||
/** Dismissed here: a poll already in flight must not bring them back. */
|
||||
const hidden = new Set<string>();
|
||||
const callbacks = new Map<string, OnDone>();
|
||||
const listeners = new Set<() => void>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let started = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
let legacyId = 0;
|
||||
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
function publish(next: CloneJob[]) {
|
||||
jobs = next.filter((j) => !hidden.has(j.id));
|
||||
emit();
|
||||
}
|
||||
|
||||
function upsert(job: CloneJob) {
|
||||
publish([job, ...jobs.filter((j) => j.id !== job.id)]);
|
||||
}
|
||||
|
||||
/** The user-facing reason a job did not finish. */
|
||||
export function cloneJobError(job: CloneJob): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const e = job.error;
|
||||
if (job.state === "cancelled") return t("lanes:cloneJobs.cancelled");
|
||||
if (job.state === "interrupted" || e?.code === "EINTERRUPTED")
|
||||
return t("lanes:cloneJobs.interrupted");
|
||||
const host = e?.auth?.host || job.url;
|
||||
if (e?.code === "ECREDENTIALS") return t("lanes:clone.loginNeeded", { host });
|
||||
if (e?.code === "ESSHAUTH") return t("lanes:clone.sshRefused", { host });
|
||||
return e?.message || t("lanes:cloneJobs.failed");
|
||||
}
|
||||
|
||||
function announce(job: CloneJob, lane?: Lane) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const path = job.result?.path || job.target;
|
||||
if (job.state === "done") {
|
||||
toast.success(
|
||||
job.kind === "workspace"
|
||||
? t("lanes:cloneJobs.doneWorkspace", {
|
||||
name: job.name,
|
||||
path,
|
||||
workspace: lane?.title || job.request?.title || job.name,
|
||||
})
|
||||
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
|
||||
);
|
||||
} else if (job.state === "cancelled") {
|
||||
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
|
||||
} else {
|
||||
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
|
||||
if (job.state === "done") {
|
||||
const cb = callbacks.get(job.id);
|
||||
callbacks.delete(job.id);
|
||||
try {
|
||||
cb?.(job, lane);
|
||||
} catch {
|
||||
/* a page's follow-up never breaks the store */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function observe(list: CloneJob[]) {
|
||||
const finishedHere: CloneJob[] = [];
|
||||
for (const j of list) {
|
||||
const prev = known.get(j.id);
|
||||
known.set(j.id, j.state);
|
||||
if (isActiveJob(j)) {
|
||||
watched.add(j.id);
|
||||
continue;
|
||||
}
|
||||
if (!watched.has(j.id) || prev === j.state) continue;
|
||||
watched.delete(j.id);
|
||||
finishedHere.push(j);
|
||||
}
|
||||
for (const j of finishedHere) {
|
||||
announce(j);
|
||||
if (j.state === "done") setTimeout(() => void dismissCloneJob(j.id), DONE_LINGER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(ms = POLL_MS) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void refreshCloneJobs();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** Read the list now (shared while one read is in flight). */
|
||||
export function refreshCloneJobs(): Promise<void> {
|
||||
if (!inflight) {
|
||||
inflight = Promise.resolve()
|
||||
.then(() => api.lanes.cloneJobs())
|
||||
.then((r) => {
|
||||
const list = Array.isArray(r?.jobs) ? r.jobs : [];
|
||||
observe(list);
|
||||
publish(list);
|
||||
})
|
||||
// An older server (404) or a blip: keep what we had; a start polls again.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
if (jobs.some(isActiveJob)) schedule();
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const onFocus = () => void refreshCloneJobs();
|
||||
|
||||
/** Start following the list (the dock calls this once, on mount). */
|
||||
export function ensureCloneJobs(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void refreshCloneJobs();
|
||||
window.addEventListener("focus", onFocus);
|
||||
}
|
||||
|
||||
function track(job: CloneJob, onDone?: OnDone) {
|
||||
if (onDone) callbacks.set(job.id, onDone);
|
||||
if (isActiveJob(job)) watched.add(job.id);
|
||||
known.set(job.id, job.state);
|
||||
upsert(job);
|
||||
if (isActiveJob(job)) schedule();
|
||||
else observe([job]);
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` makes: the URL's last segment, no `.git`. */
|
||||
const cloneName = (url: string) =>
|
||||
url
|
||||
.replace(/\/+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop()
|
||||
?.replace(/\.git$/, "") || url;
|
||||
|
||||
/** An older server's synchronous answer, as a job that is already done. */
|
||||
function legacyJob(
|
||||
kind: CloneJob["kind"],
|
||||
body: { url: string; name?: string; parent?: string; title?: string },
|
||||
path: string | undefined,
|
||||
laneId: number | null
|
||||
): CloneJob {
|
||||
path = path || "";
|
||||
const name = body.name || path.split("/").pop() || cloneName(body.url);
|
||||
return {
|
||||
id: `legacy-${++legacyId}`,
|
||||
kind,
|
||||
state: "done",
|
||||
url: body.url,
|
||||
name,
|
||||
parent: body.parent ?? "",
|
||||
target: path,
|
||||
laneId: kind === "source" ? laneId : null,
|
||||
position: null,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId, path },
|
||||
createdAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
request: { url: body.url, parent: body.parent ?? "", name, title: body.title },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a clone in the background. Resolves with its job once the server
|
||||
* accepted it (a 4xx validation error rejects, for the form to show);
|
||||
* `onDone` runs when this page sees it finish.
|
||||
*/
|
||||
export async function startClone(
|
||||
spec:
|
||||
| { kind: "workspace"; body: WorkspaceBody }
|
||||
| { kind: "source"; laneId: number; body: SourceBody },
|
||||
opts: { onDone?: OnDone } = {}
|
||||
): Promise<CloneJob> {
|
||||
if (spec.kind === "workspace") {
|
||||
const r = await api.lanes.clone({ ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as { lane: Lane; path: string };
|
||||
const job = legacyJob("workspace", spec.body, legacy.path, legacy.lane?.id ?? null);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job, legacy.lane);
|
||||
return job;
|
||||
}
|
||||
const r = await api.lanes.cloneFolder(spec.laneId, { ...spec.body, background: true });
|
||||
if ("job" in r && r.job) {
|
||||
track(r.job, opts.onDone);
|
||||
return r.job;
|
||||
}
|
||||
const legacy = r as LaneSourcesView & { path: string };
|
||||
const job = legacyJob("source", spec.body, legacy.path, spec.laneId);
|
||||
callbacks.set(job.id, opts.onDone ?? (() => {}));
|
||||
announce(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Retry a failed / interrupted / cancelled job, optionally with a login. */
|
||||
export async function retryCloneJob(id: string, credentials?: Creds): Promise<CloneJob> {
|
||||
const { job } = await api.lanes.retryCloneJob(id, credentials);
|
||||
const cb = callbacks.get(id);
|
||||
callbacks.delete(id);
|
||||
hidden.add(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
track(job, cb);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Stop a queued or running clone. */
|
||||
export async function cancelCloneJob(id: string): Promise<void> {
|
||||
try {
|
||||
const { job } = await api.lanes.cancelCloneJob(id);
|
||||
if (job) {
|
||||
observe([job]);
|
||||
upsert(job);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; code?: string };
|
||||
if (err.code === "EDONE") {
|
||||
void refreshCloneJobs();
|
||||
return;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
toast.error(i18n.t("lanes:cloneJobs.stopUnsupported"));
|
||||
return;
|
||||
}
|
||||
toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a finished job here and on the server. */
|
||||
export async function dismissCloneJob(id: string): Promise<void> {
|
||||
hidden.add(id);
|
||||
callbacks.delete(id);
|
||||
watched.delete(id);
|
||||
publish(jobs.filter((j) => j.id !== id));
|
||||
await Promise.resolve()
|
||||
.then(() => api.lanes.dismissCloneJob(id))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
/** The jobs to show, newest first. */
|
||||
export function useCloneJobs(): CloneJob[] {
|
||||
return useSyncExternalStore(subscribe, () => jobs);
|
||||
}
|
||||
|
||||
/** Tests only: forget everything, as a fresh page load would. */
|
||||
export function resetCloneJobsForTests(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
jobs = [];
|
||||
known.clear();
|
||||
watched.clear();
|
||||
hidden.clear();
|
||||
callbacks.clear();
|
||||
inflight = null;
|
||||
started = false;
|
||||
window.removeEventListener("focus", onFocus);
|
||||
emit();
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* message. Both ask first when any pane holds an AI agent (it may be
|
||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||
* session's right-click menu (`SessionMenu`).
|
||||
* session's right-click menu (`SessionMenu`), and a live session's Archive
|
||||
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -2974,6 +2976,9 @@ export interface LaneDiffLine {
|
||||
/** Line number on the right (after) side; null on a removed line. */
|
||||
newLine: number | null;
|
||||
text: string;
|
||||
/** Characters cut off the end of a very long row (a base64 blob, a minified
|
||||
* bundle): `text` holds only its start. Absent on a whole row. */
|
||||
elided?: number;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -28,9 +28,14 @@
|
||||
* **Installed** instead of the Install button; **Uninstall** (a skill's
|
||||
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
|
||||
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
|
||||
* change there as a new snapshot when the Hub is listed); an installed copy
|
||||
* change there as a new snapshot when the Hub is listed; **Share again**
|
||||
* takes its folder on the server — or a plugin's copy — on a click, else
|
||||
* asks for the folder and uploads it onto the same row); an installed copy
|
||||
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
||||
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
|
||||
* which replaces it after a confirmation, the old copy moved aside. A skill or
|
||||
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
|
||||
* and the install count's list marks each installer's copy Latest / Old
|
||||
* version / Removed. Editing a skill
|
||||
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
||||
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
||||
* row of your own only takes it out of your library — it stays in the Hub
|
||||
@@ -79,6 +84,7 @@ import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
@@ -277,6 +283,53 @@ export function Artifacts() {
|
||||
}
|
||||
};
|
||||
|
||||
// Share again: the server's copy of the skill, else the folder picked again
|
||||
// (uploaded onto the same row). Either way installers are offered Update.
|
||||
const reshareInput = useRef<HTMLInputElement>(null);
|
||||
const reshareRow = useRef<Artifact | null>(null);
|
||||
useEffect(() => {
|
||||
reshareInput.current?.setAttribute("webkitdirectory", "");
|
||||
}, []);
|
||||
const reshared = (changed: boolean) => {
|
||||
toast.success(t(changed ? "reshareDone" : "reshareSame"));
|
||||
load();
|
||||
};
|
||||
const reshare = async (a: Artifact) => {
|
||||
try {
|
||||
const r = await api.artifacts.reshare(a.id);
|
||||
reshared(r.changed);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("reshareAskTitle", { name: a.source ?? a.title }),
|
||||
message: t("reshareAskBody", { name: a.source ?? a.title }),
|
||||
confirmLabel: t("resharePick"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
reshareRow.current = a;
|
||||
reshareInput.current?.click();
|
||||
}
|
||||
};
|
||||
const reshareUpload = async (list: FileList | null) => {
|
||||
const a = reshareRow.current;
|
||||
reshareRow.current = null;
|
||||
if (!a || !list?.length) return;
|
||||
try {
|
||||
const r = await api.artifacts.uploadSkills(
|
||||
Array.from(list).map((file) => ({
|
||||
file,
|
||||
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||
})),
|
||||
{ id: String(a.id) }
|
||||
);
|
||||
reshared(r.items.some((i) => i.changed !== false));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
||||
|
||||
const remove = async (a: Artifact) => {
|
||||
@@ -442,6 +495,7 @@ export function Artifacts() {
|
||||
const actionsFor = (a: Artifact): CardActions => ({
|
||||
onOpen: () => setDetailId(a.id),
|
||||
onShare: () => void share(a),
|
||||
onReshare: () => void reshare(a),
|
||||
onRename: () => {
|
||||
setDetailId(null);
|
||||
setRenaming(a);
|
||||
@@ -572,6 +626,17 @@ export function Artifacts() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
||||
<input
|
||||
ref={reshareInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="artifact-reshare-folder"
|
||||
onChange={(e) => {
|
||||
void reshareUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
@@ -1066,6 +1131,8 @@ function EmptyState({
|
||||
type CardActions = {
|
||||
onOpen: () => void;
|
||||
onShare: () => void;
|
||||
/** A skill of your own: its newest version, shared (installers get Update). */
|
||||
onReshare: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
onInstall: () => void;
|
||||
@@ -1208,6 +1275,7 @@ function Meta({
|
||||
server: boolean;
|
||||
date?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (server && a.owner && !a.mine)
|
||||
parts.push(
|
||||
@@ -1215,7 +1283,18 @@ function Meta({
|
||||
{a.owner}
|
||||
</span>
|
||||
);
|
||||
if (date)
|
||||
// A skill or file is a snapshot that changes: when it last did. Anything else: when added.
|
||||
if (a.kind === "skill" || a.kind === "file")
|
||||
parts.push(
|
||||
<span
|
||||
key="at"
|
||||
className="shrink-0"
|
||||
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
|
||||
>
|
||||
{t("versionAt", { ago: timeAgo(a.versionAt) })}
|
||||
</span>
|
||||
);
|
||||
else if (date)
|
||||
parts.push(
|
||||
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
||||
{fmtDate(a.createdAt)}
|
||||
@@ -1237,6 +1316,7 @@ function ArtifactMenu({
|
||||
menu,
|
||||
onOpen,
|
||||
onShare,
|
||||
onReshare,
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
@@ -1297,6 +1377,9 @@ function ArtifactMenu({
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||
{!hubOnly && (
|
||||
<MenuItem danger onSelect={pick(onDelete)}>
|
||||
@@ -1447,6 +1530,7 @@ function DetailDialog({
|
||||
canInstall,
|
||||
onClose,
|
||||
onShare,
|
||||
onReshare,
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
@@ -1468,7 +1552,10 @@ function DetailDialog({
|
||||
: []),
|
||||
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
||||
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
||||
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
|
||||
{
|
||||
label: t("stat.updated"),
|
||||
value: new Date(a.versionAt).toLocaleString(),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal title={a.title} onClose={onClose} size="md">
|
||||
@@ -1611,6 +1698,11 @@ function DetailDialog({
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
</button>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
<button type="button" className={secondary} onClick={onReshare}>
|
||||
{t("reshare")}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={secondary} onClick={onRename}>
|
||||
{t("rename")}
|
||||
</button>
|
||||
@@ -1634,6 +1726,13 @@ function DetailDialog({
|
||||
* The list floats over the page (a portal at the button, below it or above
|
||||
* when there is no room, kept inside the window): inside the card list's
|
||||
* `overflow-hidden` it was cut off at the last card. */
|
||||
/** An installer's copy against the newest version: green, amber, grey. */
|
||||
const STATE_CLASS = {
|
||||
latest: "bg-status-success/15 text-status-success",
|
||||
outdated: "bg-status-warning/15 text-status-warning",
|
||||
removed: "bg-surface-2 text-fg-muted",
|
||||
} as const;
|
||||
|
||||
function InstallsButton({ item }: { item: Artifact }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -1725,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) {
|
||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||
)}
|
||||
</span>
|
||||
{i.state && i.state !== "unknown" && (
|
||||
<span
|
||||
data-testid="install-state"
|
||||
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
|
||||
>
|
||||
{t(`installState.${i.state}`)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||
{new Date(i.at).toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
|
||||
addPlugin: vi.fn(),
|
||||
inspectMarket: vi.fn(),
|
||||
uploadSkills: vi.fn(),
|
||||
reshare: vi.fn(),
|
||||
writeSkill: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
candidates: vi.fn(),
|
||||
@@ -76,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
||||
installs: 0,
|
||||
createdAt: "2026-10-14T00:00:00Z",
|
||||
updatedAt: "2026-10-14T00:00:00Z",
|
||||
versionAt: "2026-10-14T00:00:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -348,9 +350,16 @@ describe("Artifacts", () => {
|
||||
mine: true,
|
||||
at: "2026-10-19T10:00:00Z",
|
||||
replaced: false,
|
||||
state: "latest",
|
||||
paths: ["/h/.claude/skills/deploy"],
|
||||
},
|
||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
||||
{
|
||||
user: "khoa",
|
||||
mine: false,
|
||||
at: "2026-10-18T10:00:00Z",
|
||||
replaced: true,
|
||||
state: "outdated",
|
||||
},
|
||||
],
|
||||
unrecorded: 1,
|
||||
});
|
||||
@@ -362,6 +371,12 @@ describe("Artifacts", () => {
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
// Each install says whether its copy is the newest version.
|
||||
expect(
|
||||
within(box)
|
||||
.getAllByTestId("install-state")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Latest", "Old version"]);
|
||||
// Over the page, outside the card list that clips its overflow — and a
|
||||
// click inside it keeps it open.
|
||||
expect(box.parentElement).toBe(document.body);
|
||||
@@ -394,6 +409,65 @@ describe("Artifacts", () => {
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
|
||||
vi.mocked(api.artifacts.uploadSkills).mockClear();
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: true,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
|
||||
});
|
||||
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
|
||||
item: item({ id: 2 }),
|
||||
changed: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
await menuAction(user, "Share again", card);
|
||||
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
|
||||
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
|
||||
|
||||
// No copy on the server: the folder is asked for and lands on THIS row.
|
||||
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
|
||||
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
|
||||
);
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
|
||||
items: [{ ...item({ id: 2 }), changed: true }],
|
||||
});
|
||||
await menuAction(user, "Share again", card);
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
|
||||
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
|
||||
id: "2",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("says when a skill's content last changed, not when it was renamed", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "deploy",
|
||||
source: "deploy",
|
||||
updatedAt: new Date().toISOString(),
|
||||
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.38 (rebuild client to match)
|
||||
UI build v2.8.51 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+82
-16
@@ -89,9 +89,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
|
||||
@@ -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
|
||||
@@ -817,6 +876,11 @@ that file.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- A row longer than **4,000** characters (an inlined base64 image or video, a
|
||||
minified bundle) comes back cut to its first 1,000 with `elided: <characters
|
||||
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
|
||||
the hunks after it. Each file's raw patch is read up to 16 M characters; past
|
||||
that it is still `truncated`.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
@@ -1058,7 +1122,7 @@ cannot collide on `index.lock`.
|
||||
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
@@ -1198,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)
|
||||
@@ -1732,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
|
||||
@@ -3036,7 +3100,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
|
||||
DELETE /api/tool-profiles/:tool Body: { name }
|
||||
```
|
||||
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
@@ -3142,7 +3206,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
### Artifacts hub
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`)
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
@@ -3151,7 +3215,7 @@ POST /api/artifacts/page {url, title?, description?}
|
||||
POST /api/artifacts/file {laneId, path, title?, sessionId?}
|
||||
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
|
||||
POST /api/artifacts/skill {name, laneId?, title?, description?}
|
||||
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items}
|
||||
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row
|
||||
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
|
||||
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
||||
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
||||
@@ -3161,7 +3225,8 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row
|
||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
@@ -3170,8 +3235,9 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
+23
-3
@@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
||||
workspace list's owner groups, the Users page and its audit log, the Edit user
|
||||
form — it shows their display name when one is set, else the username (the
|
||||
Users page keeps `@username` beside a display name). Logging in, the CLI and
|
||||
the Linux account always use the username.
|
||||
the Linux account always use the username. No one can pose as a colleague: a
|
||||
display name that reads as another account's display name or username — marks,
|
||||
case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters
|
||||
from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user
|
||||
sets it in Settings → Account or an admin does on the Users page. A name an
|
||||
account already has is kept on an unrelated save; rename an old clash from the
|
||||
Users page.
|
||||
|
||||
Under S3 an admin's folder browser opens on their own home too; ticking
|
||||
**Server folder** opens the host account's home (`/home/<host>`), where a
|
||||
@@ -549,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
is used only for a pane that runs as its developer (one Linux user per
|
||||
account): AgentHub writes the account's `auth.json` into that developer's
|
||||
`~/.codex` as them (0600) just before the pane starts.
|
||||
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
|
||||
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
|
||||
shared app-server daemon, which outlives every pane and keeps the login
|
||||
it started with — the new `auth.json` would not be used.
|
||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||
- A developer who signed Codex in themselves keeps their own login.
|
||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||
@@ -726,7 +736,17 @@ An admin limits what each user may spend, in dollars, on the Users page
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS through a workspace, priced by `calculateCost`. The Users table shows
|
||||
OWNS, priced by `calculateCost`. A session is owned through its workspace;
|
||||
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
|
||||
plain terminal — is owned by the user of the Linux account it ran as
|
||||
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
|
||||
else the session's folder): a member's account bills to that member, the
|
||||
host's to the admin ticked **Owns this server's host account** in Edit user
|
||||
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
|
||||
ticked to the only admin. The same rule feeds the status bar's **Today**.
|
||||
A process that drops the pane's environment also drops
|
||||
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
|
||||
spend still counts, and stops the user's next session. The Users table shows
|
||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||
in the status bar (`GET /api/me/quota`).
|
||||
|
||||
@@ -866,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;
|
||||
|
||||
+157
-2
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.30
|
||||
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ĩ
|
||||
@@ -15410,6 +15410,9 @@ paths:
|
||||
type: boolean
|
||||
enum:
|
||||
- true
|
||||
hostAccount:
|
||||
type: boolean
|
||||
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
@@ -17343,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:
|
||||
@@ -17838,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:
|
||||
@@ -17861,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.
|
||||
@@ -19202,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).
|
||||
@@ -19210,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':
|
||||
@@ -19932,6 +20034,9 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
description: Files the switch wrote.
|
||||
following:
|
||||
type: boolean
|
||||
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
|
||||
'400':
|
||||
description: '`EBADNAME` / `EBADLABEL`.'
|
||||
content:
|
||||
@@ -22868,6 +22973,56 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/artifacts/{id}/reshare:
|
||||
post:
|
||||
tags:
|
||||
- Artifacts
|
||||
summary: 'Share again: a skill''s newest version from its owner''s folder on this server'
|
||||
operationId: reshareArtifact
|
||||
description: 'The owner''s skill folder (the row''s source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Artifact id.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Taken.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
item:
|
||||
type: object
|
||||
changed:
|
||||
type: boolean
|
||||
'400':
|
||||
description: EBADKIND.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EFORBIDDEN.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOTFOUND.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ENOSOURCE.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/artifacts/{id}/claim:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.38",
|
||||
"version": "2.8.51",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.38",
|
||||
"version": "2.8.51",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.38",
|
||||
"version": "2.8.51",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Detached runner for one background `git clone`
|
||||
* (`server/lib/clone-jobs.js`). The dashboard restarts on every update, and a
|
||||
* git whose stderr was a pipe into it died of SIGPIPE on its next progress
|
||||
* line — so the clone runs here instead, in its own session/process group,
|
||||
* and outlives the server (the unit is `KillMode=process`). It reads its
|
||||
* spec (`{cmd, argv, cwd, timeoutMs}` — exactly the command `worktree.git()`
|
||||
* would run, built by `gitCommand`; never a secret, which travels in this
|
||||
* process's env as it would in git's) from the 0600 file it is given and
|
||||
* deletes it, runs the command with stderr appended straight to
|
||||
* `<job dir>/stderr.log` (a file, never a pipe: nothing can SIGPIPE it), and
|
||||
* writes `<job dir>/exit.json` `{code, signal, timedOut, finishedAt}` when it
|
||||
* ends. A cancel SIGTERMs this whole group: git dies (sudo relays it to a
|
||||
* member's git) and this runner stays to record how it ended.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const specPath = process.argv[2];
|
||||
const dir = path.dirname(specPath);
|
||||
let spec;
|
||||
try {
|
||||
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
||||
} finally {
|
||||
fs.rmSync(specPath, { force: true });
|
||||
}
|
||||
|
||||
function writeExit(fields) {
|
||||
const file = path.join(dir, "exit.json");
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify({ ...fields, finishedAt: new Date().toISOString() }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
// Stay to record git's end: a cancel signals the whole group, this included.
|
||||
process.on("SIGTERM", () => {});
|
||||
process.on("SIGHUP", () => {});
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
const log = fs.openSync(path.join(dir, "stderr.log"), "a", 0o600);
|
||||
let timedOut = false;
|
||||
let child;
|
||||
try {
|
||||
child = spawn(spec.cmd, spec.argv, {
|
||||
cwd: spec.cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "ignore", log],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
writeExit({ code: null, signal: null, timedOut: false, error: err.message });
|
||||
process.exit(0);
|
||||
}
|
||||
const timer =
|
||||
spec.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, spec.timeoutMs)
|
||||
: null;
|
||||
child.on("error", (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
writeExit({ code: null, signal: null, timedOut, error: err.message });
|
||||
process.exit(0);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
writeExit({ code, signal, timedOut });
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -665,6 +665,128 @@ describe("skills from files or written", () => {
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("updates the uploader's same-named skill instead of adding a twin", async () => {
|
||||
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
|
||||
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
|
||||
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||
const id = first.body.items[0].id;
|
||||
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
|
||||
assert.equal(again.status, 201, JSON.stringify(again.body));
|
||||
assert.equal(again.body.items[0].id, id);
|
||||
assert.equal(again.body.items[0].replaced, true);
|
||||
assert.equal(again.body.items[0].changed, true);
|
||||
assert.equal(again.body.items[0].description, "version 2");
|
||||
assert.match(snapText(again.body.items[0]), /Body 2\./);
|
||||
const rows = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
|
||||
.get();
|
||||
assert.equal(rows.n, 1);
|
||||
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
|
||||
assert.equal(same.body.items[0].changed, false);
|
||||
});
|
||||
|
||||
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
|
||||
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
|
||||
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
|
||||
const id = first.body.items[0].id;
|
||||
const at1 = first.body.items[0].versionAt;
|
||||
assert.ok(at1);
|
||||
const states = async () =>
|
||||
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
|
||||
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||
assert.deepEqual(await states(), ["latest"]);
|
||||
|
||||
// A rename is no new version; new content is.
|
||||
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
|
||||
assert.equal(renamed.body.item.versionAt, at1);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
|
||||
assert.ok(next.body.items[0].versionAt > at1);
|
||||
assert.deepEqual(await states(), ["outdated"]);
|
||||
|
||||
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
|
||||
assert.deepEqual(await states(), ["removed"]);
|
||||
});
|
||||
|
||||
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
|
||||
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
|
||||
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
|
||||
const id = first.body.items[0].id;
|
||||
const no = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||
assert.equal(no.status, 409);
|
||||
assert.equal(no.body.error.code, "ENOSOURCE");
|
||||
// The folder picked again, under any folder name: onto THIS row.
|
||||
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
|
||||
id: String(id),
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.items[0].id, id);
|
||||
assert.equal(r.body.items[0].source, "sg-up");
|
||||
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
|
||||
const two = await upload(
|
||||
"/api/artifacts/skill/upload",
|
||||
[
|
||||
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
|
||||
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
|
||||
],
|
||||
{ id: String(id) }
|
||||
);
|
||||
assert.equal(two.body.error.code, "EBADUPLOAD");
|
||||
});
|
||||
|
||||
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
|
||||
const home = process.env.CLAUDE_HOME;
|
||||
// An uploaded skill whose name a user-scope plugin also ships.
|
||||
const first = await upload("/api/artifacts/skill/upload", [
|
||||
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
|
||||
]);
|
||||
const id = first.body.items[0].id;
|
||||
const writePlugin = (ver, text) => {
|
||||
const at = path.join(home, "plugins", "cache", "m", "p", ver);
|
||||
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
|
||||
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
|
||||
return at;
|
||||
};
|
||||
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
|
||||
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
|
||||
fs.writeFileSync(
|
||||
path.join(home, "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
|
||||
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
|
||||
"proj@m": [
|
||||
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
const r = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.changed, true);
|
||||
assert.match(snapText(r.body.item), /Plugin two\./);
|
||||
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
|
||||
|
||||
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
|
||||
const own = path.join(home, "skills", "sg-plug");
|
||||
fs.mkdirSync(own, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(own, "SKILL.md"),
|
||||
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
|
||||
);
|
||||
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||
assert.equal(mine.body.changed, true);
|
||||
assert.match(snapText(mine.body.item), /Mine\./);
|
||||
// Recorded as the folder's state: the 15 s sync does not take it twice.
|
||||
assert.deepEqual(
|
||||
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
|
||||
[]
|
||||
);
|
||||
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
|
||||
fs.rmSync(own, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
||||
title: "Report",
|
||||
@@ -859,6 +981,23 @@ describe("outputs captured at a session's stop", () => {
|
||||
);
|
||||
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||
});
|
||||
|
||||
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
|
||||
const put = (rel, text = "<p>x</p>") => {
|
||||
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(ws, rel), text);
|
||||
const soon = new Date(Date.now() + 5 * 60 * 1000);
|
||||
fs.utimesSync(path.join(ws, rel), soon, soon);
|
||||
};
|
||||
put("app/index.html");
|
||||
put("app/www/page.html");
|
||||
put("widget/view.html");
|
||||
put("widget/view.js", "x()");
|
||||
put("summary-report.html");
|
||||
put("out/q3.html");
|
||||
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
|
||||
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unowned rows", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
|
||||
assert.equal(row.account, "c");
|
||||
});
|
||||
|
||||
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
|
||||
// The home's app-server daemon keeps the login it started with: a placed
|
||||
// login reaches only a pane that runs its own app server.
|
||||
const fakeCodex = (name, help) => {
|
||||
const file = path.join(TMP, name);
|
||||
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
|
||||
return file;
|
||||
};
|
||||
const prev = process.env.AGENTHUB_CODEX_BIN;
|
||||
try {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
|
||||
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
|
||||
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
|
||||
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
|
||||
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
|
||||
assert.deepEqual(pool.forSpawn(MIA).argv, []);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
|
||||
else process.env.AGENTHUB_CODEX_BIN = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||
pool.addAccount("d", { auth: AUTH("acct-d") });
|
||||
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||
assert.equal(pool.pickAccount(MIA), null);
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
|
||||
"rt-mias-seat"
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the newest auth.json back into the pool", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
|
||||
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
|
||||
|
||||
it("cuts the long rows down and still shows the hunks after them", async () => {
|
||||
const body = (mid, end) =>
|
||||
[
|
||||
"<h1>title</h1>",
|
||||
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
||||
mid,
|
||||
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
|
||||
end,
|
||||
"",
|
||||
].join("\n");
|
||||
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "deck");
|
||||
write(
|
||||
LANE,
|
||||
"deck.html",
|
||||
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
|
||||
"<h1>title</h1>",
|
||||
"<h1>new title</h1>"
|
||||
)
|
||||
);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
|
||||
const f = out.files[0];
|
||||
assert.equal(f.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
const lines = f.hunks.flatMap((h) => h.lines);
|
||||
const long = lines.filter((l) => l.elided);
|
||||
assert.equal(long.length, 2);
|
||||
for (const l of long) {
|
||||
assert.ok(l.text.length < 2000);
|
||||
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
|
||||
}
|
||||
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
|
||||
});
|
||||
|
||||
it("leaves ordinary rows whole", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\n");
|
||||
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
|
||||
const lines = out.files[0].hunks.flatMap((h) => h.lines);
|
||||
assert.ok(lines.every((l) => l.elided === undefined));
|
||||
assert.ok(lines.some((l) => l.text === "TWO"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||
|
||||
|
||||
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still returns content when a huge file has no newline to cut on", async () => {
|
||||
// A minified bundle is ONE line. Cutting the patch at the last newline
|
||||
// inside the cap would land at index -1 here and hand back an empty
|
||||
// patch — a file changed by a megabyte rendering as "no textual change".
|
||||
it("still returns content when a huge file is one line", async () => {
|
||||
// A minified bundle is ONE line. It used to be cut at the per-file cap
|
||||
// (and, cut at the last newline, could come back empty — a file changed
|
||||
// by a megabyte rendering as "no textual change"); now the row itself is
|
||||
// cut down and says how much it hides, and nothing is truncated.
|
||||
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
||||
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
||||
const entry = out.files[0];
|
||||
assert.equal(entry.truncated, true);
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
|
||||
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
|
||||
assert.equal(entry.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
|
||||
const row = entry.hunks[0].lines.find((l) => l.type === "add");
|
||||
assert.ok(row.text.startsWith("xxx"));
|
||||
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
|
||||
});
|
||||
|
||||
it("reports a binary file without trying to diff it", async () => {
|
||||
@@ -848,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
||||
});
|
||||
|
||||
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
|
||||
const edited = threeBlocks();
|
||||
write(LANE, "keep.txt", "untouched by discard\n");
|
||||
write(LANE, "drop.txt", "gone\n");
|
||||
const [hunk] = await hunkOf();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runGitAction(LANE, "discard", {
|
||||
paths: ["drop.txt"],
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||
}),
|
||||
{ code: "ECONFIRM" }
|
||||
);
|
||||
await runGitAction(LANE, "discard", {
|
||||
paths: ["drop.txt"],
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||
confirm: true,
|
||||
});
|
||||
const want = [...edited];
|
||||
want.splice(11, 0, "line 11");
|
||||
want.splice(3, 2, "line 4", "line 5");
|
||||
assert.deepEqual(read(), [...want, ""]);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
|
||||
});
|
||||
|
||||
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
|
||||
const edited = threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runGitAction(LANE, "discard", {
|
||||
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
|
||||
confirm: true,
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
assert.deepEqual(read(), [...edited, ""]);
|
||||
await runGitAction(LANE, "discard", {
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
|
||||
confirm: true,
|
||||
});
|
||||
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
|
||||
});
|
||||
|
||||
it("refuses a block the hunk does not have", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
assert.equal(bad.body.error.code, "EBADEMAIL");
|
||||
});
|
||||
|
||||
it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
|
||||
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
|
||||
assert.equal(auth.nameKey("Đào"), "dao");
|
||||
// Cyrillic о and а in place of Latin letters.
|
||||
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
// mia's display name is "Mia Tran"; boss has none, only a username.
|
||||
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
|
||||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||
assert.equal(r.status, 409, name);
|
||||
assert.equal(r.body.error.code, "ENAMETAKEN");
|
||||
}
|
||||
assert.equal(auth.findUser("leo").display_name, "Leo N");
|
||||
// Keeping one's own name, or a name of one's own, still works.
|
||||
for (const name of ["Leo N", "Leo Nguyen"]) {
|
||||
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||
assert.equal(r.status, 200, name);
|
||||
}
|
||||
});
|
||||
|
||||
it("changes its own password only with the old one, keeping this login", async () => {
|
||||
const leo = await login("leo", "member-pass-2");
|
||||
const other = await login("leo", "member-pass-2");
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file session-account.test.js
|
||||
* @description A session no workspace owns is billed to the user of the Linux
|
||||
* account it ran as (server/lib/session-account.js): a member's account to
|
||||
* that member, the host's to the admin marked as its owner (or the only
|
||||
* admin). A workspace's ownership always wins, a pushed session never gets an
|
||||
* account, and the hook peer is stamped once.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "session-account-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const sa = require("../lib/session-account");
|
||||
const scope = require("../lib/scope");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
|
||||
const own = (id) => {
|
||||
const w = scope.ownSessionWhere({ user: { id } });
|
||||
return db
|
||||
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
||||
.all(...w.params)
|
||||
.map((r) => r.id);
|
||||
};
|
||||
|
||||
before(() => {
|
||||
const user = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
user.run(1, "boss", "admin");
|
||||
user.run(2, "dev", "developer");
|
||||
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
||||
);
|
||||
s.run("host-free", "eval", null, "", "local");
|
||||
s.run("dev-free", "eval", null, "dev", "local");
|
||||
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
||||
s.run("unknown", "old", null, null, "local");
|
||||
s.run("gone-member", "x", null, "ghost", "local");
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("session-account", () => {
|
||||
it("bills a member's account to that member and the host's to the only admin", () => {
|
||||
assert.equal(sa.hostOwner(), 1);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
||||
});
|
||||
|
||||
it("lets the workspace's owner win over the account", () => {
|
||||
assert.ok(!own(1).includes("host-in-dev-lane"));
|
||||
});
|
||||
|
||||
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
||||
).run();
|
||||
assert.equal(sa.hostOwner(), null);
|
||||
assert.deepEqual(own(1), []);
|
||||
sa.setHostOwner(3);
|
||||
assert.equal(sa.hostOwner(), 3);
|
||||
assert.deepEqual(own(3), ["host-free"]);
|
||||
sa.setHostOwner(1);
|
||||
assert.deepEqual(own(3), []);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
});
|
||||
|
||||
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
||||
const rows = db.prepare(OWNED_SESSIONS).all();
|
||||
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
||||
assert.equal(by["host-free"], 1);
|
||||
assert.equal(by["dev-free"], 2);
|
||||
assert.equal(by["host-in-dev-lane"], 2);
|
||||
assert.equal(by.unknown, undefined);
|
||||
assert.equal(by["gone-member"], undefined);
|
||||
});
|
||||
|
||||
it("stamps the hook peer once and never a pushed session", () => {
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
||||
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
||||
sa.stampFromPeer("fresh", { kind: "host" });
|
||||
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
||||
sa.stampFromPeer("pushed", { kind: "refused" });
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
assert.equal(acct("fresh"), "dev");
|
||||
assert.equal(acct("pushed"), null);
|
||||
});
|
||||
|
||||
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
||||
).run();
|
||||
sa.stampFromFolders();
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
// Outside S3 every folder is the host's.
|
||||
assert.equal(acct("unknown"), "");
|
||||
assert.equal(acct("pushed"), "");
|
||||
assert.equal(acct("remote"), null);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
||||
|
||||
const log = path.join(ROOT, "codex-argv.log");
|
||||
const bin = path.join(ROOT, "fake-codex.sh");
|
||||
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
||||
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
||||
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||
fs.writeFileSync(sock, "");
|
||||
process.env.AGENTHUB_CODEX_BIN = bin;
|
||||
try {
|
||||
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
||||
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(
|
||||
fs.readFileSync(log, "utf8").trim(),
|
||||
`${process.env.CODEX_HOME} app-server daemon restart`
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_CODEX_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
|
||||
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
|
||||
assert.equal(body.tool_call_count, 2);
|
||||
assert.equal(body.distinct_tool_count, 1);
|
||||
});
|
||||
it("counts each call once in tools_used, not its Pre and Post events", async () => {
|
||||
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
|
||||
assert.ok(one.every((t) => t.count === 1));
|
||||
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
|
||||
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/events labels", () => {
|
||||
|
||||
@@ -973,11 +973,16 @@ for (const [col, ddl] of [
|
||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||
["version", "version TEXT"],
|
||||
["source_hash", "source_hash TEXT"],
|
||||
["version_at", "version_at TEXT"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||
} catch {
|
||||
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||
// `version_at`: when a row's CONTENT last changed (a new snapshot) — a
|
||||
// rename or a share toggle moves `updated_at` but not this. An existing
|
||||
// row starts from its `updated_at`, the best record there is.
|
||||
if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1445,6 +1450,16 @@ try {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add `account` to sessions — the Linux account a session ran as
|
||||
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
|
||||
// NULL = not known yet. A session no workspace owns is billed to the user of
|
||||
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
|
||||
try {
|
||||
db.prepare("SELECT account FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
|
||||
}
|
||||
|
||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||
@@ -1952,6 +1967,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
// The admin who owns the HOST's Linux account: its sessions outside every
|
||||
// workspace are billed to them (server/lib/session-account.js). At most one.
|
||||
["host_account", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
@@ -2547,10 +2565,16 @@ const stmts = {
|
||||
FROM events
|
||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||
`),
|
||||
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
|
||||
// or its PostToolUse in a session that has none (imported). Counting every
|
||||
// event with a tool_name counted each call twice.
|
||||
sessionToolUsageCounts: db.prepare(`
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
FROM events
|
||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||
AND event_type = CASE
|
||||
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
|
||||
THEN 'PreToolUse' ELSE 'PostToolUse' END
|
||||
GROUP BY tool_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 15
|
||||
|
||||
+12
-1
@@ -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();
|
||||
@@ -873,7 +881,9 @@ function startBackgroundServices() {
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop. Off with
|
||||
// flag files their hooks read, the panes no hook can stop — after giving
|
||||
// the sessions no hook made their Linux account (session-account.js), so a
|
||||
// poller's session is billed to someone too. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
@@ -883,6 +893,7 @@ function startBackgroundServices() {
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/session-account").stampFromFolders();
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
|
||||
+222
-16
@@ -173,6 +173,8 @@ function view(row, req, owners = null) {
|
||||
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
// When its content last changed (a new snapshot): a page or plugin never does.
|
||||
versionAt: row.version_at || row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -549,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||
const OUTPUT_MAX = 20;
|
||||
/** Folders that hold a site's own pages, never a report someone reads. */
|
||||
const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i;
|
||||
/** What sits next to a site's page: its scripts and styles. */
|
||||
const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i;
|
||||
|
||||
/**
|
||||
* An HTML file that is a web app's own page, not a report (`app/www/index.html`
|
||||
* of a project not yet committed): an `index.html`, one under a site folder
|
||||
* (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The
|
||||
* workspace root is exempt from that last test — a repo keeps its config
|
||||
* `.js` there next to the reports people write.
|
||||
*/
|
||||
async function isSitePage(cwd, rel) {
|
||||
const parts = rel.split("/");
|
||||
const name = parts.pop();
|
||||
if (/^index\.html?$/i.test(name)) return true;
|
||||
if (parts.some((d) => SITE_DIR_RE.test(d))) return true;
|
||||
if (!parts.length) return false;
|
||||
try {
|
||||
const names = await fs.promises.readdir(path.join(cwd, ...parts));
|
||||
return names.some((n) => SITE_ASSET_RE.test(n));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const capturing = new Map();
|
||||
|
||||
/**
|
||||
* The workspace's documents a session created or changed since it started:
|
||||
* untracked or modified files in a repo (an HTML file only when untracked, so a
|
||||
* site's own pages are not taken), every file of a plain folder. Paths are
|
||||
* workspace-relative.
|
||||
* untracked or modified files in a repo (an HTML file only when untracked and
|
||||
* not a site's own page — `isSitePage`), every file of a plain folder. Paths
|
||||
* are workspace-relative.
|
||||
*/
|
||||
async function outputCandidates(lane, since) {
|
||||
const gd = require("./git-diff");
|
||||
@@ -572,7 +599,7 @@ async function outputCandidates(lane, since) {
|
||||
const out = [];
|
||||
for (const rel of new Set(paths)) {
|
||||
if (!OUTPUT_EXT.test(rel)) continue;
|
||||
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
||||
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
|
||||
let st;
|
||||
try {
|
||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||
@@ -627,7 +654,7 @@ async function captureOutputsNow(laneId, sessionId) {
|
||||
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
||||
if (row) {
|
||||
sql(
|
||||
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||
@@ -1125,8 +1152,20 @@ function saveSkillFiles(req, { name, files, title, description }) {
|
||||
* `paths`). Answers every skill it made. Nothing is stored unless every
|
||||
* skill in the upload passes.
|
||||
*/
|
||||
function uploadSkills(req, { uploads, name, title, description }) {
|
||||
function uploadSkills(req, { uploads, name, title, description, id }) {
|
||||
// `id`: Share again for a skill the server has no copy of — the upload
|
||||
// replaces THAT row (one skill, named as the row is).
|
||||
let onto = null;
|
||||
if (id != null && id !== "") {
|
||||
onto = load(req, id);
|
||||
if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||
if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
|
||||
name = onto.source;
|
||||
}
|
||||
const found = skillsFromUpload(uploads || [], { name, description });
|
||||
if (onto && found.length !== 1) {
|
||||
throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill");
|
||||
}
|
||||
const typed = typeof name === "string" && name.trim();
|
||||
const prepared = found.map((sk) => ({
|
||||
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
|
||||
@@ -1140,16 +1179,46 @@ function uploadSkills(req, { uploads, name, title, description }) {
|
||||
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
|
||||
checkSkillFiles(p.files);
|
||||
}
|
||||
return prepared.map((p) =>
|
||||
saveSkillFiles(req, {
|
||||
return prepared.map((p) => {
|
||||
// A skill its uploader already has (same name, not a workspace's) gets
|
||||
// the new version on that row — installers see Update — never a twin.
|
||||
const own = onto || ownSkillRow(req, p.name);
|
||||
if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : "");
|
||||
return saveSkillFiles(req, {
|
||||
name: p.name,
|
||||
files: p.files,
|
||||
title: found.length === 1 ? title : "",
|
||||
description: found.length === 1 ? description : "",
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The caller's newest skill row of that name taken from no workspace, or null. */
|
||||
function ownSkillRow(req, name) {
|
||||
const owner = req?.user ? req.user.id : null;
|
||||
return (
|
||||
sql(
|
||||
`SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL
|
||||
AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1`
|
||||
).get(name, owner) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */
|
||||
function replaceUploaded(req, row, { name, files }, description) {
|
||||
const md = files.find((f) => f.path === "SKILL.md");
|
||||
const text = md.data.toString("utf8");
|
||||
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
|
||||
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
|
||||
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
|
||||
checkSkillFiles(all);
|
||||
const changed = contentHash(all) !== snapshotVersion(row);
|
||||
// The owner's folder as it is now (like `storeSkill`): only a LATER change
|
||||
// to it replaces this upload.
|
||||
if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash);
|
||||
return { ...shareNow(req, row.id, changed), replaced: true, changed };
|
||||
}
|
||||
|
||||
/** A skill written in the dialog: a name, a description and its instructions. */
|
||||
function writeSkill(req, { name, description, content, title }) {
|
||||
const n = validSkillName(name);
|
||||
@@ -1599,7 +1668,8 @@ function editSkill(req, row, { name, description, content }) {
|
||||
sql(
|
||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||
version = ?, source_hash = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(
|
||||
n,
|
||||
snap.id,
|
||||
@@ -1936,6 +2006,23 @@ function syncOne(row) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
replaceSnapshot(row, files, st.hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already
|
||||
* checked), then drop the old one — never a write into the snapshot being
|
||||
* read. Installers then see Update (`updateCheck`). `sourceHash` is the
|
||||
* source folder's state to record, so `syncOne` takes only a LATER change.
|
||||
* The description follows SKILL.md's only while it still says what the old
|
||||
* one did.
|
||||
*/
|
||||
function replaceSnapshot(row, files, sourceHash) {
|
||||
const snap = newSnapshotDir();
|
||||
let total = 0;
|
||||
const next = [];
|
||||
@@ -1947,11 +2034,10 @@ function syncOne(row) {
|
||||
total += f.data.length;
|
||||
next.push({ path: f.path, size: f.data.length });
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
return false;
|
||||
throw err;
|
||||
}
|
||||
// The description follows SKILL.md's only while it still says what the old one did.
|
||||
const md = (list) => list.find((f) => f.path === "SKILL.md");
|
||||
let oldSummary = "";
|
||||
try {
|
||||
@@ -1969,10 +2055,98 @@ function syncOne(row) {
|
||||
: row.description;
|
||||
sql(
|
||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
|
||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(
|
||||
snap.id,
|
||||
JSON.stringify(next),
|
||||
total,
|
||||
contentHash(files),
|
||||
sourceHash ?? null,
|
||||
description,
|
||||
row.id
|
||||
);
|
||||
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's skill folder on THIS server as it is now, for Share again:
|
||||
* the row's own source folder (`sourceDirOf`), else — a skill not taken from
|
||||
* a workspace — the same-named skill of a plugin the owner installed at user
|
||||
* scope (`installed_plugins.json`; the most recently updated when several
|
||||
* have it), never outside the owner's `~/.claude/plugins/`. Answers
|
||||
* `{st, fromSource}` (`folderState`) or null when the server has no copy (an
|
||||
* uploaded skill: its owner picks the folder again).
|
||||
*/
|
||||
function machineSkill(row) {
|
||||
const src = sourceDirOf(row);
|
||||
const st = src && folderState(src.dir, src.top);
|
||||
if (st) return { st, fromSource: true };
|
||||
if (!src || row.lane_id != null) return null;
|
||||
const top = path.join(src.top, "plugins");
|
||||
let installed = {};
|
||||
try {
|
||||
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
|
||||
const file = path.join(top, "installed_plugins.json");
|
||||
const text =
|
||||
user && ownAccount({ user }).acct
|
||||
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
|
||||
: fs.readFileSync(file, "utf8");
|
||||
installed = JSON.parse(text)?.plugins || {};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let best = null;
|
||||
for (const entries of Object.values(installed)) {
|
||||
for (const e of Array.isArray(entries) ? entries : []) {
|
||||
if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue;
|
||||
const st2 = folderState(path.join(e.installPath, "skills", row.source), top);
|
||||
const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0;
|
||||
if (st2 && (!best || at > best.at)) best = { st: st2, at };
|
||||
}
|
||||
}
|
||||
return best ? { st: best.st, fromSource: false } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share again: take the owner's skill folder on this server as the row's new
|
||||
* snapshot (`machineSkill`) and share it (server mode), so installers are
|
||||
* offered Update. ENOSOURCE when the server has no copy — the client then
|
||||
* asks for the folder or .zip and uploads it onto this row (`uploadSkills`
|
||||
* with `id`). Answers `{item, changed}`.
|
||||
*/
|
||||
function reshareSkill(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
|
||||
const found = machineSkill(row);
|
||||
if (!found) {
|
||||
throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip");
|
||||
}
|
||||
const files = found.st.read();
|
||||
checkSkillFiles(files);
|
||||
const changed = found.st.hash !== snapshotVersion(row);
|
||||
if (changed) {
|
||||
replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash);
|
||||
} else if (found.fromSource) {
|
||||
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id);
|
||||
}
|
||||
return { item: shareNow(req, row.id, changed), changed };
|
||||
}
|
||||
|
||||
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
|
||||
function shareNow(req, id, changed) {
|
||||
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
|
||||
if (isServer() && row.shared !== 1) {
|
||||
sql(
|
||||
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(id);
|
||||
audit(req, row, "shared");
|
||||
} else if (changed && row.shared === 1) {
|
||||
audit(req, row, "updated");
|
||||
}
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
|
||||
}
|
||||
|
||||
const SYNC_EVERY_MS = 15 * 1000;
|
||||
@@ -2163,6 +2337,36 @@ async function uninstall(req, id) {
|
||||
* the installer and an admin. `unrecorded` = installs counted before this
|
||||
* list was kept.
|
||||
*/
|
||||
/**
|
||||
* Where one recorded install of a skill stands now, from its Claude
|
||||
* `skills/<name>` copies (one per account it went into; the cross-tool
|
||||
* `~/.agents/skills` twins are the same files) —
|
||||
* `outdated` when any differs from the row's newest snapshot (its user is
|
||||
* offered Update), `removed` when none is there any more, `unknown` when one
|
||||
* is there but unreadable as a skill, else `latest`. Only hashes are
|
||||
* compared; no content leaves the server. The paths are the ones the server
|
||||
* itself recorded at install, read like `updateCheck` reads the viewer's own.
|
||||
*/
|
||||
function installState(row, paths) {
|
||||
const version = snapshotVersion(row);
|
||||
const dirs = (Array.isArray(paths) ? paths : []).filter(
|
||||
(p) =>
|
||||
typeof p === "string" &&
|
||||
path.basename(p) === row.source &&
|
||||
path.basename(path.dirname(p)) === "skills" &&
|
||||
path.basename(path.dirname(path.dirname(p))) !== ".agents"
|
||||
);
|
||||
if (!version || !dirs.length) return "unknown";
|
||||
const states = dirs.map((dir) => {
|
||||
const st = folderState(dir, path.dirname(path.dirname(dir)));
|
||||
if (st) return st.hash === version ? "latest" : "outdated";
|
||||
return exists(dir) ? "unknown" : "removed";
|
||||
});
|
||||
if (states.includes("outdated")) return "outdated";
|
||||
if (states.every((x) => x === "removed")) return "removed";
|
||||
return states.includes("unknown") ? "unknown" : "latest";
|
||||
}
|
||||
|
||||
function installsOf(req, id) {
|
||||
const row = load(req, id);
|
||||
const u = req?.user;
|
||||
@@ -2186,6 +2390,7 @@ function installsOf(req, id) {
|
||||
mine,
|
||||
at: r.installed_at,
|
||||
replaced: r.replaced === 1,
|
||||
...(row.kind === "skill" ? { state: installState(row, paths) } : {}),
|
||||
...(mine || u?.role === "admin" ? { paths } : {}),
|
||||
};
|
||||
});
|
||||
@@ -2215,6 +2420,7 @@ module.exports = {
|
||||
availableSkills,
|
||||
addSkill,
|
||||
uploadSkills,
|
||||
reshareSkill,
|
||||
writeSkill,
|
||||
uploadFile,
|
||||
skillSlug,
|
||||
|
||||
+52
-2
@@ -20,7 +20,8 @@
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||
* An account also carries an optional display name and email
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
|
||||
* ENAMETAKEN), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
@@ -212,6 +213,9 @@ const publicUser = (u) =>
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
// Owns the host's Linux account: its sessions outside every workspace
|
||||
// are billed to this admin (server/lib/session-account.js).
|
||||
hostAccount: Boolean(u.host_account),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -247,6 +251,47 @@ function validateProfile(body) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
|
||||
const CONFUSABLE = {
|
||||
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
|
||||
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
|
||||
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
|
||||
};
|
||||
|
||||
/**
|
||||
* The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case,
|
||||
* look-alike letters folded, and only letters and digits outside brackets —
|
||||
* so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name.
|
||||
*/
|
||||
function nameKey(s) {
|
||||
return String(s || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/\([^)]*\)|\[[^\]]*\]/g, "")
|
||||
.replace(/[đð]/g, "d")
|
||||
.replace(/./gu, (c) => CONFUSABLE[c] ?? c)
|
||||
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse (ENAMETAKEN) a display name that reads as another account's display
|
||||
* name or username, so no one can pose as a colleague. Keeping the name one
|
||||
* already has is always allowed — a clash from before this rule is no reason
|
||||
* to block an admin's unrelated edit.
|
||||
*/
|
||||
function assertNameFree(userId, fields) {
|
||||
if (!("display_name" in fields) || !fields.display_name) return;
|
||||
const key = nameKey(fields.display_name);
|
||||
if (!key) return;
|
||||
const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId);
|
||||
if (own && own.display_name === fields.display_name) return;
|
||||
const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId);
|
||||
if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) {
|
||||
throw err("ENAMETAKEN", "displayName: another account already uses that name");
|
||||
}
|
||||
}
|
||||
|
||||
/** Write `validateProfile`'s result for one account. */
|
||||
function setProfile(userId, fields) {
|
||||
for (const [col, value] of Object.entries(fields)) {
|
||||
@@ -373,7 +418,10 @@ function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
// Only an admin owns the host account (session-account.js): a demotion drops the mark.
|
||||
sql(
|
||||
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
|
||||
).run(role, role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
@@ -812,6 +860,8 @@ module.exports = {
|
||||
unassignWorkspace,
|
||||
ownedWorkspaces,
|
||||
validateProfile,
|
||||
nameKey,
|
||||
assertNameFree,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* @file Background clones (`POST /api/lanes/clone` with `background: true`):
|
||||
* a clone that outlives the request that started it AND the server. Each job
|
||||
* is kept in memory and in `<data dir>/runtime/clone-jobs.json` (0600, tmp +
|
||||
* rename): on every state change at once, on git's progress at most every
|
||||
* `WRITE_EVERY_MS`. Its git runs in a detached runner
|
||||
* (`scripts/clone-runner.js`, pid recorded on the job) with stderr appended
|
||||
* to `<data dir>/runtime/clone-jobs/<id>/stderr.log` — the server tails it
|
||||
* for progress and finishes the job when the runner writes `exit.json`.
|
||||
* An update restarts the server but not the runner (`KillMode=process`), so
|
||||
* the first read in a new process reconciles the file: a running job whose
|
||||
* runner is alive is ADOPTED (counted against clone-queue's limit, tailed,
|
||||
* finished when it exits), one whose `exit.json` exists is finished now, and
|
||||
* any other is `interrupted` (`EINTERRUPTED`); a queued job is queued again,
|
||||
* unless it carried a typed login (gone with the old process: interrupted,
|
||||
* so the user types it again). Finishing runs the after-step the routes
|
||||
* register (`setAfterStep`: the workspace, or the extra folder) exactly once,
|
||||
* and a cancelled job never runs it. `cancel` takes a queued job out of
|
||||
* clone-queue or SIGTERMs the runner's process group. Finished jobs are
|
||||
* dropped after 24 h (done) or 7 days (failed/interrupted/cancelled). A job
|
||||
* is answered only to its owner (`scope.ownerFor`; local mode: every job),
|
||||
* and `request` — what a retry re-sends — never holds a credential: a typed
|
||||
* login is used for its one run and never written here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const KEEP_DONE_MS = 24 * 60 * 60 * 1000;
|
||||
const KEEP_FAILED_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const WRITE_EVERY_MS = 2000;
|
||||
const POLL_MS = 500;
|
||||
const TAIL_MAX = 64 * 1024;
|
||||
const ACTIVE = new Set(["queued", "running"]);
|
||||
const REQUEST_KEYS = ["url", "parent", "name", "tool", "title", "partial"];
|
||||
const RUNNER = path.join(__dirname, "..", "..", "scripts", "clone-runner.js");
|
||||
|
||||
/** id → job record (the CloneJob fields plus `owner`, `once`). */
|
||||
let jobs = null;
|
||||
let timer = null;
|
||||
/** Live progress pollers, so a simulated restart (`_reset`) stops them. */
|
||||
const watchers = new Set();
|
||||
/** `(job) => {status, json, laneId?, created?}` — the routes' after-step. */
|
||||
let afterStep = null;
|
||||
|
||||
const runtimeDir = () => path.join(getDataDir(), "runtime");
|
||||
const filePath = () => path.join(runtimeDir(), "clone-jobs.json");
|
||||
/** A job's files; its id is server-made base64url, never from a request. */
|
||||
const jobDir = (id) => path.join(runtimeDir(), "clone-jobs", id);
|
||||
|
||||
const serverMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
const queue = () => require("./clone-queue");
|
||||
|
||||
function expired(job, now) {
|
||||
if (!job.finishedAt) return false;
|
||||
const age = now - Date.parse(job.finishedAt);
|
||||
return job.state === "done" ? age > KEEP_DONE_MS : age > KEEP_FAILED_MS;
|
||||
}
|
||||
|
||||
/** What goes to disk (`view` drops `owner` and `once`); never a callback. */
|
||||
function record(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
owner: job.owner,
|
||||
kind: job.kind,
|
||||
state: job.state,
|
||||
url: job.url,
|
||||
name: job.name,
|
||||
parent: job.parent,
|
||||
target: job.target,
|
||||
laneId: job.laneId,
|
||||
phase: job.phase,
|
||||
label: job.label,
|
||||
percent: job.percent,
|
||||
detail: job.detail,
|
||||
error: job.error,
|
||||
result: job.result,
|
||||
createdAt: job.createdAt,
|
||||
finishedAt: job.finishedAt,
|
||||
request: job.request,
|
||||
pid: job.pid ?? null,
|
||||
// Whether a login was typed for this run — never the login itself.
|
||||
once: job.once ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The in-memory-only parts of a job: cancel signal, queue hooks, mirror. */
|
||||
function arm(job) {
|
||||
const controller = new AbortController();
|
||||
Object.defineProperties(job, {
|
||||
// A progressToken's clone-queue record, kept in step (route-set).
|
||||
mirror: { enumerable: false, writable: true, value: null },
|
||||
// An adopted runner's clone-queue slot.
|
||||
release: { enumerable: false, writable: true, value: null },
|
||||
finishing: { enumerable: false, writable: true, value: false },
|
||||
signal: { enumerable: false, value: controller.signal },
|
||||
abort: { enumerable: false, value: () => controller.abort() },
|
||||
onChange: {
|
||||
enumerable: false,
|
||||
value: (what) => {
|
||||
if (job.mirror) {
|
||||
for (const k of ["state", "phase", "label", "percent", "detail"]) {
|
||||
job.mirror[k] = job[k];
|
||||
}
|
||||
}
|
||||
if (what === "state") writeNow();
|
||||
else writeSoon();
|
||||
},
|
||||
},
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
function interrupt(job) {
|
||||
Object.assign(job, {
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "AgentHub restarted while this clone ran" },
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (jobs) return jobs;
|
||||
jobs = new Map();
|
||||
let list = [];
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath(), "utf8"));
|
||||
if (Array.isArray(parsed?.jobs)) list = parsed.jobs;
|
||||
} catch {
|
||||
// No file yet, or an unreadable one: start empty.
|
||||
}
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
const later = [];
|
||||
for (const j of list) {
|
||||
if (!j || typeof j.id !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(j.id)) continue;
|
||||
if (expired(j, now)) {
|
||||
rmJobDir(j.id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
arm(j);
|
||||
jobs.set(j.id, j);
|
||||
if (j.state === "running") {
|
||||
if (readExit(j.id)) later.push(() => finishFromDisk(j));
|
||||
else if (runnerAlive(j.pid)) adopt(j);
|
||||
else {
|
||||
interrupt(j);
|
||||
rmJobDir(j.id);
|
||||
changed = true;
|
||||
}
|
||||
} else if (j.state === "queued") {
|
||||
if (j.once?.used) {
|
||||
interrupt(j);
|
||||
changed = true;
|
||||
} else later.push(() => start(j, null));
|
||||
}
|
||||
}
|
||||
if (changed) writeNow();
|
||||
for (const fn of later) setImmediate(fn);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
function writeNow() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
const file = filePath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
const body = JSON.stringify({ jobs: [...(jobs || new Map()).values()].map(record) });
|
||||
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (err) {
|
||||
console.warn(`[clone-jobs] could not save ${file}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Progress is cheap in memory; on disk at most every WRITE_EVERY_MS. */
|
||||
function writeSoon() {
|
||||
if (timer) return;
|
||||
timer = setTimeout(writeNow, WRITE_EVERY_MS);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function rmJobDir(id) {
|
||||
try {
|
||||
fs.rmSync(jobDir(id), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* AgentHub's own scratch: best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const [id, j] of load()) {
|
||||
if (expired(j, now)) {
|
||||
jobs.delete(id);
|
||||
rmJobDir(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) writeNow();
|
||||
}
|
||||
|
||||
// ── The runner ─────────────────────────────────────────────────────────────
|
||||
|
||||
function readExit(id) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(jobDir(id), "exit.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `pid` is still OUR runner (not a reused pid, not a zombie). */
|
||||
function runnerAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
if (err.code !== "EPERM") return false;
|
||||
}
|
||||
if (process.platform !== "linux") return true;
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("clone-runner.js");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SIGTERMs the runner's process group: git dies, the runner records it. */
|
||||
function killRunner(job) {
|
||||
if (!job.pid) return;
|
||||
try {
|
||||
process.kill(process.platform === "win32" ? job.pid : -job.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRunner(job, spec, env) {
|
||||
const dir = jobDir(job.id);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const specPath = path.join(dir, "spec.json");
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec), { mode: 0o600, flag: "wx" });
|
||||
const child = spawn(process.execPath, [RUNNER, specPath], {
|
||||
cwd: dir,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", () => {
|
||||
/* the poller sees no runner and fails the job */
|
||||
});
|
||||
child.unref();
|
||||
job.pid = child.pid ?? null;
|
||||
writeNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tails a runner's stderr.log into `onProgress` until `exit.json` appears
|
||||
* (or the runner is gone without one: `lost`). Resolves
|
||||
* `{ok, timedOut, lost, stderr}` — `stderr` is the last TAIL_MAX of it.
|
||||
*/
|
||||
function watch(job, onProgress) {
|
||||
return new Promise((resolve) => {
|
||||
const dir = jobDir(job.id);
|
||||
let offset = 0;
|
||||
let tail = "";
|
||||
let misses = 0;
|
||||
const read = () => {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(path.join(dir, "stderr.log"), "r");
|
||||
const size = fs.fstatSync(fd).size;
|
||||
if (size <= offset) return;
|
||||
const from = Math.max(offset, size - TAIL_MAX);
|
||||
const buf = Buffer.alloc(size - from);
|
||||
fs.readSync(fd, buf, 0, buf.length, from);
|
||||
offset = size;
|
||||
const chunk = buf.toString("utf8");
|
||||
tail = (tail + chunk).slice(-TAIL_MAX);
|
||||
try {
|
||||
onProgress?.(chunk);
|
||||
} catch {
|
||||
/* a progress reader never fails the clone */
|
||||
}
|
||||
} catch {
|
||||
/* no log yet */
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
};
|
||||
const w = {};
|
||||
const stop = (out) => {
|
||||
clearInterval(w.iv);
|
||||
watchers.delete(w);
|
||||
resolve(out);
|
||||
};
|
||||
w.iv = setInterval(() => {
|
||||
read();
|
||||
const exit = readExit(job.id);
|
||||
if (exit) {
|
||||
read();
|
||||
return stop({
|
||||
ok: exit.code === 0 && !exit.signal,
|
||||
timedOut: Boolean(exit.timedOut),
|
||||
lost: false,
|
||||
stderr: tail,
|
||||
});
|
||||
}
|
||||
if (runnerAlive(job.pid)) misses = 0;
|
||||
else if (++misses >= 2) stop({ ok: false, timedOut: false, lost: true, stderr: tail });
|
||||
}, POLL_MS);
|
||||
w.iv.unref?.();
|
||||
watchers.add(w);
|
||||
});
|
||||
}
|
||||
|
||||
const failureMessage = (out) =>
|
||||
out.lost
|
||||
? "the clone stopped unexpectedly"
|
||||
: out.timedOut
|
||||
? "git clone timed out"
|
||||
: "git clone failed";
|
||||
|
||||
/**
|
||||
* `cloneRepo`'s `launch`: the same git command, run by a detached runner
|
||||
* instead of as this process's child. Rejects like `worktree.git()`.
|
||||
*/
|
||||
function launcher(job) {
|
||||
return async (cwd, args, opts) => {
|
||||
const { cmd, argv, env } = require("./worktree").gitCommand(cwd, args, {
|
||||
env: opts.env,
|
||||
background: true,
|
||||
});
|
||||
spawnRunner(job, { cmd, argv, cwd, timeoutMs: opts.timeout || 0 }, env);
|
||||
const out = await watch(job, opts.onStderr);
|
||||
if (out.ok) return { stdout: "", stderr: out.stderr };
|
||||
throw Object.assign(new Error(failureMessage(out)), { git: { stderr: out.stderr } });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A new `queued` job. `request` is reduced to `REQUEST_KEYS` — never a
|
||||
* credential; `once` records only whether a login was typed. The record is
|
||||
* also clone-queue's job: `withCloneSlot` marks it running and `report`
|
||||
* fills its progress, each through `onChange`.
|
||||
*/
|
||||
function create({ owner, kind, url, name, parent, target, laneId = null, request, once = null }) {
|
||||
load();
|
||||
const req = {};
|
||||
for (const k of REQUEST_KEYS) if (request?.[k] !== undefined) req[k] = request[k];
|
||||
const job = arm({
|
||||
id: crypto.randomBytes(16).toString("base64url"),
|
||||
owner: owner ?? null,
|
||||
kind,
|
||||
state: "queued",
|
||||
url,
|
||||
name,
|
||||
parent,
|
||||
target,
|
||||
laneId,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
request: req,
|
||||
pid: null,
|
||||
once: once
|
||||
? {
|
||||
used: true,
|
||||
remember: once.remember === true,
|
||||
member: once.member === true,
|
||||
host: once.host ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
jobs.set(job.id, job);
|
||||
writeNow();
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a queued job: clone-queue, then the clone through a runner (`once`:
|
||||
* the typed login, this process only), then the after-step.
|
||||
*/
|
||||
function start(job, once = null) {
|
||||
void (async () => {
|
||||
let outcome;
|
||||
try {
|
||||
await require("./git-identity").cloneRepo({
|
||||
job,
|
||||
url: job.url,
|
||||
parent: job.parent,
|
||||
name: job.name,
|
||||
once,
|
||||
partial: job.request?.partial === true,
|
||||
signal: job.signal,
|
||||
launch: launcher(job),
|
||||
});
|
||||
outcome = { ok: true };
|
||||
} catch (err) {
|
||||
outcome = { ok: false, err };
|
||||
}
|
||||
finish(job, outcome);
|
||||
})();
|
||||
return job;
|
||||
}
|
||||
|
||||
/** A runner this process did not start: counted, tailed, finished on exit. */
|
||||
function adopt(job) {
|
||||
job.release = queue().occupy();
|
||||
watch(job, (chunk) => queue().report(job, chunk)).then((out) => finishFromDisk(job, out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a runner's job from its files alone (no `cloneRepo` closure: the
|
||||
* process that launched it is gone). A typed login a member's store kept for
|
||||
* this run only is taken out again, as `cloneRepo` would have.
|
||||
*/
|
||||
function finishFromDisk(job, out = null) {
|
||||
if (!out) {
|
||||
const exit = readExit(job.id) || {};
|
||||
let stderr = "";
|
||||
try {
|
||||
stderr = fs.readFileSync(path.join(jobDir(job.id), "stderr.log"), "utf8").slice(-TAIL_MAX);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
out = {
|
||||
ok: exit.code === 0 && !exit.signal,
|
||||
timedOut: Boolean(exit.timedOut),
|
||||
lost: false,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
const identity = require("./git-identity");
|
||||
if (job.once?.used && job.once.member && !(out.ok && job.once.remember) && job.once.host) {
|
||||
try {
|
||||
identity.removeHttps(identity.accountForDir(job.parent), "https", job.once.host);
|
||||
} catch {
|
||||
/* best effort: the store is the member's own */
|
||||
}
|
||||
}
|
||||
finish(
|
||||
job,
|
||||
out.ok
|
||||
? { ok: true }
|
||||
: { ok: false, err: identity.cloneFailure(job.url, out.stderr, failureMessage(out)) }
|
||||
);
|
||||
}
|
||||
|
||||
/** The error body a failed job records: the route's own code and message. */
|
||||
function errorBody(err) {
|
||||
return {
|
||||
code: err?.code || "ECLONE",
|
||||
message: err?.message || "clone failed",
|
||||
...(err?.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a job, once: the slot back, the runner's files gone, then
|
||||
* nothing (cancelled — its half-made folder dropped), the failure, or the
|
||||
* after-step and its outcome.
|
||||
*/
|
||||
function finish(job, { ok, err }) {
|
||||
if (job.finishing) return;
|
||||
job.finishing = true;
|
||||
job.release?.();
|
||||
job.release = null;
|
||||
rmJobDir(job.id);
|
||||
if (cancelled(job)) {
|
||||
dropCancelledTarget(job.target);
|
||||
return;
|
||||
}
|
||||
if (!ok) return fail(job, errorBody(err));
|
||||
let out;
|
||||
try {
|
||||
out = afterStep
|
||||
? afterStep(job)
|
||||
: { status: 500, json: { error: { code: "ECLONE", message: "no after-step registered" } } };
|
||||
} catch (e) {
|
||||
out = { status: 500, json: { error: { code: e.code || "ECLONE", message: e.message } } };
|
||||
}
|
||||
if (out.status < 300) {
|
||||
succeed(job, {
|
||||
laneId: out.laneId ?? null,
|
||||
path: job.target,
|
||||
...(out.created !== undefined ? { created: out.created } : {}),
|
||||
});
|
||||
} else fail(job, out.json?.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancelled clone of a HOST-account folder whose git left the target
|
||||
* behind (it was checked absent before the job): that exact path, never
|
||||
* through a link. A member's folder is git's own to clean (sudo relays the
|
||||
* SIGTERM) — the dashboard never deletes inside a member's home.
|
||||
*/
|
||||
function dropCancelledTarget(target) {
|
||||
try {
|
||||
if (require("./run-as").accountForPath(target)) return;
|
||||
if (!fs.lstatSync(target).isDirectory()) return;
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* nothing there, or not ours to remove */
|
||||
}
|
||||
}
|
||||
|
||||
function settle(job, fields) {
|
||||
if (job.state === "cancelled") return;
|
||||
Object.assign(job, fields, { finishedAt: new Date().toISOString() });
|
||||
if (job.mirror) queue().finish(job.mirror, job.state === "done");
|
||||
if (jobs?.get(job.id) === job) writeNow();
|
||||
}
|
||||
|
||||
/** The clone and its after-step worked: `result = {laneId, path, created?}`. */
|
||||
function succeed(job, result) {
|
||||
settle(job, { state: "done", error: null, result });
|
||||
}
|
||||
|
||||
/** It failed: `error = {code, message, auth?}` — what the route would answer. */
|
||||
function fail(job, error) {
|
||||
const e = { code: error?.code || "ECLONE", message: error?.message || "clone failed" };
|
||||
if (error?.auth) e.auth = { protocol: error.auth.protocol, host: error.auth.host };
|
||||
settle(job, { state: "failed", error: e, result: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels one of the caller's jobs: `ENOTFOUND` when not theirs, `EDONE` once
|
||||
* it finished. Queued: out of the queue, never started. Running: the
|
||||
* runner's group is SIGTERMed (and a borrowed-login probe aborted); the slot
|
||||
* frees when git exits.
|
||||
*/
|
||||
function cancel(id, owner) {
|
||||
const job = get(id, owner);
|
||||
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
|
||||
if (!ACTIVE.has(job.state)) {
|
||||
throw Object.assign(new Error("the clone already finished"), { code: "EDONE" });
|
||||
}
|
||||
settle(job, {
|
||||
state: "cancelled",
|
||||
error: { code: "ECANCELLED", message: "Clone cancelled" },
|
||||
result: null,
|
||||
});
|
||||
if (!queue().dequeue(job)) {
|
||||
job.abort();
|
||||
killRunner(job);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Whether a cancel already settled this job. */
|
||||
const cancelled = (job) => job.state === "cancelled";
|
||||
|
||||
const mine = (job, owner) => !serverMode() || job.owner === (owner ?? null);
|
||||
|
||||
/** The CloneJob the API answers. */
|
||||
function view(job) {
|
||||
const { owner: _owner, once: _once, ...out } = record(job);
|
||||
return {
|
||||
...out,
|
||||
position: job.state === "queued" ? queue().position(job) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The caller's jobs, newest first. */
|
||||
function list(owner) {
|
||||
prune();
|
||||
return [...jobs.values()]
|
||||
.filter((j) => mine(j, owner))
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
||||
}
|
||||
|
||||
/** One of the caller's jobs, or null. */
|
||||
function get(id, owner) {
|
||||
prune();
|
||||
const job = typeof id === "string" ? jobs.get(id) : null;
|
||||
return job && mine(job, owner) ? job : null;
|
||||
}
|
||||
|
||||
/** Drops a finished job: `ENOTFOUND` when not the caller's, `EBUSY` while it runs. */
|
||||
function remove(id, owner) {
|
||||
const job = get(id, owner);
|
||||
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
|
||||
if (ACTIVE.has(job.state)) {
|
||||
throw Object.assign(new Error("the clone is still running"), { code: "EBUSY" });
|
||||
}
|
||||
jobs.delete(id);
|
||||
rmJobDir(id);
|
||||
writeNow();
|
||||
}
|
||||
|
||||
/** The routes' after-step: `(job) => {status, json, laneId?, created?}`. */
|
||||
function setAfterStep(fn) {
|
||||
afterStep = fn;
|
||||
}
|
||||
|
||||
/** Server start: reconcile the file now (adopt, finish, interrupt, requeue). */
|
||||
function resume() {
|
||||
load();
|
||||
}
|
||||
|
||||
/** Tests: forget the in-memory state and stop tailing, as a restart would. */
|
||||
function _reset() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const w of watchers) clearInterval(w.iv);
|
||||
watchers.clear();
|
||||
jobs = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
start,
|
||||
view,
|
||||
list,
|
||||
get,
|
||||
remove,
|
||||
cancel,
|
||||
cancelled,
|
||||
setAfterStep,
|
||||
resume,
|
||||
filePath,
|
||||
jobDir,
|
||||
ACTIVE,
|
||||
KEEP_DONE_MS,
|
||||
KEEP_FAILED_MS,
|
||||
_reset,
|
||||
};
|
||||
@@ -9,6 +9,8 @@
|
||||
* least 1). A clone the browser named with a token (`track`) reports its
|
||||
* place in the queue, then git's own `--progress` lines (`report`), to
|
||||
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
|
||||
* A background clone (`server/lib/clone-jobs.js`) passes its own job record,
|
||||
* whose `onChange` hears each state change and progress line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -46,15 +48,18 @@ function next() {
|
||||
while (running < concurrency() && waiting.length) {
|
||||
running++;
|
||||
const { start, job } = waiting.shift();
|
||||
if (job) job.state = "running";
|
||||
if (job) {
|
||||
job.state = "running";
|
||||
job.onChange?.("state");
|
||||
}
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
|
||||
async function withCloneSlot(fn, job = null) {
|
||||
await new Promise((start) => {
|
||||
waiting.push({ start, job });
|
||||
await new Promise((start, reject) => {
|
||||
waiting.push({ start, reject, job });
|
||||
next();
|
||||
});
|
||||
try {
|
||||
@@ -136,6 +141,7 @@ function report(job, chunk) {
|
||||
.replace(/,\s*done\.?$/, "")
|
||||
.replace(/^,\s*/, "")
|
||||
.trim() || null;
|
||||
job.onChange?.("progress");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -149,10 +155,9 @@ function progress(token, owner) {
|
||||
sweep();
|
||||
const job = jobs.get(token);
|
||||
if (!job || job.owner !== owner) return null;
|
||||
const position = waiting.findIndex((w) => w.job === job);
|
||||
return {
|
||||
state: job.state,
|
||||
position: position >= 0 ? position + 1 : null,
|
||||
position: position(job),
|
||||
...cloneQueueState(),
|
||||
phase: job.phase,
|
||||
label: job.label,
|
||||
@@ -161,6 +166,40 @@ function progress(token, owner) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a clone this process did not start — a background clone's runner
|
||||
* adopted after a restart (clone-jobs.js) — against the limit, at once.
|
||||
* Returns its release.
|
||||
*/
|
||||
function occupy() {
|
||||
running++;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
running--;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a still-waiting clone out of the queue: its `withCloneSlot` rejects
|
||||
* with `ECANCELLED` and it never starts. False when it is not waiting.
|
||||
*/
|
||||
function dequeue(job) {
|
||||
const i = waiting.findIndex((w) => w.job === job);
|
||||
if (i < 0) return false;
|
||||
const [w] = waiting.splice(i, 1);
|
||||
w.reject(Object.assign(new Error("Clone cancelled"), { code: "ECANCELLED" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A queued clone's 1-based place among the waiting ones, else null. */
|
||||
function position(job) {
|
||||
const i = waiting.findIndex((w) => w.job === job);
|
||||
return i >= 0 ? i + 1 : null;
|
||||
}
|
||||
|
||||
/** A progress-free tail of git's stderr for an error message. */
|
||||
function stripProgress(stderr) {
|
||||
return String(stderr || "")
|
||||
@@ -178,5 +217,8 @@ module.exports = {
|
||||
finish,
|
||||
report,
|
||||
progress,
|
||||
position,
|
||||
dequeue,
|
||||
occupy,
|
||||
stripProgress,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
* home AgentHub placed that account in, never dated in the future, and
|
||||
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
||||
* spike showed an old refresh token still refreshes, so panes sharing an
|
||||
* account do not log each other out.
|
||||
* account do not log each other out. Such a pane runs `--no-daemon`
|
||||
* (when its Codex has the flag): the home's shared app-server daemon
|
||||
* would keep serving the login it started with.
|
||||
*
|
||||
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
||||
* 0700, file 0600); no route returns a key or a token.
|
||||
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
|
||||
});
|
||||
}
|
||||
|
||||
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
|
||||
// app-server daemon, which outlives every TUI and keeps the login it started
|
||||
// with — so a ChatGPT login placed in the home reached no pane while the
|
||||
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
|
||||
// runs its own app server instead. An older Codex has neither the daemon nor
|
||||
// the flag (it would refuse to start), so the flag goes only to a binary whose
|
||||
// --help lists it: probed once per binary (path, size, mtime).
|
||||
const noDaemonCache = new Map();
|
||||
|
||||
function noDaemonArgv(member) {
|
||||
const bin =
|
||||
process.env.AGENTHUB_CODEX_BIN ||
|
||||
(member
|
||||
? require("./shared-tools").sharedPath("codex")
|
||||
: require("./ai-tools").detect("codex").path);
|
||||
if (!bin) return [];
|
||||
let key;
|
||||
try {
|
||||
const st = fs.statSync(bin);
|
||||
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!noDaemonCache.has(key)) {
|
||||
let help = "";
|
||||
try {
|
||||
help = require("node:child_process").execFileSync(bin, ["--help"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
/* no answer: leave the flag out, as before */
|
||||
}
|
||||
noDaemonCache.set(key, /--no-daemon\b/.test(help));
|
||||
}
|
||||
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
|
||||
}
|
||||
return {
|
||||
name: a.name,
|
||||
argv: [],
|
||||
argv: noDaemonArgv(member),
|
||||
env: {},
|
||||
place: () => {
|
||||
syncHome(member.home); // a live pane may hold a newer login than ours
|
||||
|
||||
+60
-8
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
|
||||
const MAX_FILES = 300;
|
||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||
const MAX_FILE_BYTES = 200_000;
|
||||
/** Characters of one file's raw patch read before its long lines are cut down
|
||||
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
|
||||
* or a font as base64 is a few lines of megabytes each: read at
|
||||
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
|
||||
* after it was lost. Past this the patch is still truncated. */
|
||||
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
|
||||
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
|
||||
* characters, with how many it hides (`elided` on the parsed line). */
|
||||
const LONG_LINE_CHARS = 4000;
|
||||
const LONG_LINE_KEEP = 1000;
|
||||
/** Ends a cut-down row in the raw patch: a private-use character, then the
|
||||
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
|
||||
const ELIDED_MARK = "\uE000";
|
||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||
const MAX_TOTAL_BYTES = 1_000_000;
|
||||
/** An untracked file larger than this is reported without content. */
|
||||
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
|
||||
// row of its own and carries no line number.
|
||||
if (raw.startsWith("\\")) continue;
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
let text = raw.slice(1);
|
||||
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
|
||||
let elided = 0;
|
||||
const cut = text.lastIndexOf(ELIDED_MARK);
|
||||
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
|
||||
elided = Number(text.slice(cut + 1));
|
||||
text = text.slice(0, cut);
|
||||
}
|
||||
const extra = elided ? { elided } : {};
|
||||
if (marker === "+") {
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
|
||||
} else if (marker === "-") {
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
|
||||
} else if (marker === " ") {
|
||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||
// string is the tail of the final "\n", not a row.
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
|
||||
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
|
||||
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
|
||||
* re-read git's own diff and match hunks by header, which this never changes.
|
||||
*/
|
||||
function elideLongLines(patch) {
|
||||
if (patch.length <= LONG_LINE_CHARS) return patch;
|
||||
return patch
|
||||
.split("\n")
|
||||
.map((row) =>
|
||||
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
|
||||
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
|
||||
: row
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,7 +689,15 @@ async function collectChanges(
|
||||
untracked: entry.untracked,
|
||||
context: ctx,
|
||||
});
|
||||
// A read that hit its own ceiling came back partial: still cut short,
|
||||
// however small eliding its long rows makes it.
|
||||
const partial = patch.length >= MAX_PATCH_READ_CHARS;
|
||||
patch = elideLongLines(patch);
|
||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||
if (partial) {
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
if (patch.length > cap) {
|
||||
// Cut on a line boundary so the last row is a whole line — but only
|
||||
// when that boundary is near the cap. A minified file is one enormous
|
||||
@@ -1192,16 +1241,18 @@ async function diffHunks(dir, file, context) {
|
||||
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
||||
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
||||
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
||||
* git wrote it — `git apply` locates a hunk by its old side.
|
||||
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
|
||||
* it for `apply -R` off the working tree instead (the partial discard, #87),
|
||||
* where the NEW side is the one checked and every start stays git's own.
|
||||
*/
|
||||
async function selectionPatch(dir, file, context, picks) {
|
||||
async function selectionPatch(dir, file, context, picks, reverse = false) {
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
if (!diff || !picks.length) return null;
|
||||
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
||||
const bodies = [];
|
||||
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
|
||||
if (!body) return null;
|
||||
bodies.push(body);
|
||||
}
|
||||
@@ -1334,6 +1385,7 @@ async function plainTreeAll(dir) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
elideLongLines,
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
|
||||
+182
-31
@@ -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,
|
||||
|
||||
+56
-14
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
|
||||
|
||||
if (action === "discard") {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
// Ticked change blocks of partly ticked files (#87) go first: each patch
|
||||
// is built before any is applied, so a stale one refuses before the
|
||||
// irreversible whole-file part below has run.
|
||||
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
|
||||
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
|
||||
return done(action, partial);
|
||||
}
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const tracked = paths
|
||||
.filter((p) => p.code !== "??")
|
||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
const out = [];
|
||||
const out = partial ? [partial] : [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
// one state rather than half-reverted.
|
||||
@@ -825,18 +832,11 @@ function hasHunks(body) {
|
||||
const MAX_PARTIAL_FILES = 300;
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
* `body.hunks` checked for shape, and its files resolved as changed paths
|
||||
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
|
||||
* conflict, and not also named whole in `body.paths`.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
async function partialPicks(dir, body, verb) {
|
||||
const picks = Object.entries(body.hunks);
|
||||
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
||||
for (const [, list] of picks) {
|
||||
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
|
||||
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
for (const p of part) {
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
|
||||
if (whole.some((w) => w.path === p.path)) {
|
||||
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
||||
}
|
||||
}
|
||||
return { picks, whole, part };
|
||||
}
|
||||
|
||||
/**
|
||||
* The partial discard (#87): per file in `hunks`, only the ticked change
|
||||
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
|
||||
* own fresh text, never the request's — exactly what one `discard-hunk` per
|
||||
* block would do, in one step. Every patch is built before the first is
|
||||
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
|
||||
*/
|
||||
async function discardSelection(dir, body) {
|
||||
const { picks } = await partialPicks(dir, body, "discard");
|
||||
const patches = [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list, true);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
patches.push(patch);
|
||||
}
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
const out = [];
|
||||
for (const patch of patches)
|
||||
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
|
||||
return mergeResults(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
const { picks, whole, part } = await partialPicks(dir, body, "commit");
|
||||
const wholePaths = whole.flatMap((p) =>
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -5,7 +5,9 @@
|
||||
*
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
@@ -17,6 +19,7 @@
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { ACCOUNT_OWNED } = require("./session-account");
|
||||
|
||||
/** The user whose rows this request is limited to, or null for "all". */
|
||||
function memberId(req) {
|
||||
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
||||
return {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
|
||||
params: [uid, uid],
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
|
||||
|
||||
const STAFF = { staffOnly: true };
|
||||
|
||||
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
|
||||
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||||
function sessionOwners(sessionId) {
|
||||
if (!sessionId) return new Set();
|
||||
const rows = sql(
|
||||
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
||||
UNION
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
|
||||
).all(String(sessionId), String(sessionId));
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||
UNION
|
||||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file session-account.js
|
||||
* @description Which Linux account a session ran as (`sessions.account`: ""
|
||||
* = the host's, else an S3 member's name), and whose bill a session no
|
||||
* workspace owns goes on. A workspace owns a session through `lane_id` or a
|
||||
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
|
||||
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
|
||||
* neither, and without this its spend counted for nobody: not in anyone's
|
||||
* Today, the Users page or a spend quota.
|
||||
*
|
||||
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
|
||||
* loopback connection, never the body) when a hook creates the session; a
|
||||
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
|
||||
* gets it from its folder, the S3 rule `accountForPath` already applies to
|
||||
* every pane. A pushed session (another machine's) never gets one. A member
|
||||
* account bills to the user of that name; the host account to the admin an
|
||||
* admin marked as its owner (`users.host_account`), or — with no mark — the
|
||||
* only enabled admin, if there is exactly one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
|
||||
const HOST_OWNER = `COALESCE(
|
||||
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
|
||||
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||
|
||||
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||
// a run of one — exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||
|
||||
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
|
||||
const ACCOUNT_OWNED = `
|
||||
SELECT s.id AS session_id, u.id AS uid
|
||||
FROM sessions s JOIN users u
|
||||
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
|
||||
ELSE (SELECT id FROM users WHERE username = s.account) END
|
||||
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
|
||||
|
||||
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
|
||||
function accountOfPeer(peer) {
|
||||
if (!peer || peer.kind === "host") return "";
|
||||
if (peer.kind === "member" && peer.name) return peer.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
|
||||
function stampFromPeer(sessionId, peer, pushSource = null) {
|
||||
if (pushSource || !sessionId) return;
|
||||
const account = accountOfPeer(peer);
|
||||
if (account == null) return;
|
||||
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
|
||||
}
|
||||
|
||||
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
|
||||
function accountOfPath(cwd) {
|
||||
const linux = require("./linux-accounts");
|
||||
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||
const root = conf?.usersRoot;
|
||||
const path = require("path");
|
||||
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
|
||||
const rel = path.relative(root, path.resolve(cwd));
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
|
||||
// The users root itself is nobody's.
|
||||
return rel.split(path.sep)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every local session still without an account the one its folder runs
|
||||
* as (sessions the pollers made, and history from before the column). Cheap
|
||||
* once caught up: only `account IS NULL` rows are read.
|
||||
*/
|
||||
function stampFromFolders() {
|
||||
const rows = sql(
|
||||
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
|
||||
).all();
|
||||
if (!rows.length) return 0;
|
||||
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
|
||||
let n = 0;
|
||||
require("../db").db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const account = accountOfPath(r.cwd);
|
||||
if (account != null) n += set.run(account, r.id).changes;
|
||||
}
|
||||
})();
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
|
||||
function setHostOwner(userId) {
|
||||
require("../db").db.transaction(() => {
|
||||
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
|
||||
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** The user id the host account bills to right now, or null. */
|
||||
function hostOwner() {
|
||||
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_OWNED,
|
||||
accountOfPeer,
|
||||
stampFromPeer,
|
||||
accountOfPath,
|
||||
stampFromFolders,
|
||||
setHostOwner,
|
||||
hostOwner,
|
||||
};
|
||||
@@ -6,7 +6,9 @@
|
||||
* swap it back in later, touching nothing but that login.
|
||||
*
|
||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||
* key). Config, sessions, skills and history stay where they are.
|
||||
* key). Config, sessions, skills and history stay where they are. A
|
||||
* switch then restarts Codex's shared app-server daemon when one runs,
|
||||
* so running and new sessions follow the new login (`follow`).
|
||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||
@@ -85,6 +87,15 @@ const codex = {
|
||||
writeJson(codexAuthFile(), secret);
|
||||
return [codexAuthFile()];
|
||||
},
|
||||
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
||||
// outlives every TUI and holds the login it started with: a new auth.json
|
||||
// reached neither a running session nor a NEW one, which kept the old
|
||||
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
||||
// login, and every open TUI reconnects to it on its own (verified on
|
||||
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
||||
follow() {
|
||||
return restartCodexDaemon();
|
||||
},
|
||||
identify(auth) {
|
||||
if (auth.tokens && auth.tokens.id_token) {
|
||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||
@@ -107,6 +118,26 @@ const codex = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
||||
const codexDaemonSocket = () =>
|
||||
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
||||
|
||||
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
||||
function restartCodexDaemon() {
|
||||
if (!fs.existsSync(codexDaemonSocket())) return false;
|
||||
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
||||
if (!bin) return false;
|
||||
cp.execFile(
|
||||
bin,
|
||||
["app-server", "daemon", "restart"],
|
||||
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
||||
(e) => {
|
||||
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
|
||||
}
|
||||
const target = readProfile(tool, name);
|
||||
const written = a.write(target.secret, live);
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
// `following`: running sessions take the new login without a restart of their own.
|
||||
const following = typeof a.follow === "function" ? a.follow() : false;
|
||||
return { ok: true, name, savedOutgoing, written, following };
|
||||
}
|
||||
|
||||
function deleteProfile(tool, name) {
|
||||
|
||||
+42
-27
@@ -40,34 +40,12 @@ function lowPriority() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
* Treats zero exit code as success even if stderr has hints.
|
||||
*
|
||||
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
|
||||
* that leak from parent processes. Without this, git operations on a worktree (where
|
||||
* .git is a file, not a directory) fail with ".git/index: index file open failed:
|
||||
* Not a directory" when run from within a git hook or from a shell that inherited
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
* The command a git call runs — `{cmd, argv, env, gitCmd}` — exactly as
|
||||
* `git()` builds it (scrubbed env, a member's `sudo -n -u ah-<name> git`,
|
||||
* nice/ionice for `background`), without running it. A background clone's
|
||||
* detached runner (`scripts/clone-runner.js`) executes this very command.
|
||||
*/
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{
|
||||
timeout = 0,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
literal = false,
|
||||
env: extraEnv = {},
|
||||
// "buffer" for a binary read (an image at a revision).
|
||||
encoding = "utf8",
|
||||
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
||||
input = null,
|
||||
// true: run under nice/ionice (a clone — see lowPriority above).
|
||||
background = false,
|
||||
// Called with each stderr chunk as it arrives (a clone's --progress).
|
||||
onStderr = null,
|
||||
} = {}
|
||||
) {
|
||||
function gitCommand(cwd, args, { literal = false, env: extraEnv = {}, background = false } = {}) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -118,6 +96,41 @@ async function git(
|
||||
argv = [...rest, cmd, ...argv];
|
||||
cmd = first;
|
||||
}
|
||||
return { cmd, argv, env, gitCmd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||
* Treats zero exit code as success even if stderr has hints.
|
||||
*
|
||||
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
|
||||
* that leak from parent processes. Without this, git operations on a worktree (where
|
||||
* .git is a file, not a directory) fail with ".git/index: index file open failed:
|
||||
* Not a directory" when run from within a git hook or from a shell that inherited
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{
|
||||
timeout = 0,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
literal = false,
|
||||
env: extraEnv = {},
|
||||
// "buffer" for a binary read (an image at a revision).
|
||||
encoding = "utf8",
|
||||
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
||||
input = null,
|
||||
// true: run under nice/ionice (a clone — see lowPriority above).
|
||||
background = false,
|
||||
// Called with each stderr chunk as it arrives (a clone's --progress).
|
||||
onStderr = null,
|
||||
// An AbortSignal: aborting SIGTERMs the child (a cancelled clone).
|
||||
signal = null,
|
||||
} = {}
|
||||
) {
|
||||
const { cmd, argv, env, gitCmd } = gitCommand(cwd, args, { literal, env: extraEnv, background });
|
||||
|
||||
try {
|
||||
const run =
|
||||
@@ -142,6 +155,7 @@ async function git(
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
timeout,
|
||||
encoding,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
// promisify(execFile) hands back its child; the stdin runner has none.
|
||||
if (onStderr && pending.child?.stderr) {
|
||||
@@ -1006,6 +1020,7 @@ async function hasNoRemotes(dir) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gitCommand,
|
||||
LANES_ROOT,
|
||||
git,
|
||||
isGitRepo,
|
||||
|
||||
@@ -238,7 +238,7 @@ const paths = {
|
||||
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.",
|
||||
"`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: multipart(
|
||||
{
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
@@ -246,6 +246,7 @@ const paths = {
|
||||
name: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
id: { type: "string", description: "Share again: the skill row the upload replaces." },
|
||||
},
|
||||
["files"]
|
||||
),
|
||||
|
||||
@@ -790,6 +790,11 @@ const paths = {
|
||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
signOut: { type: "boolean", enum: [true] },
|
||||
hostAccount: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
|
||||
},
|
||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
}),
|
||||
responses: {
|
||||
|
||||
@@ -316,10 +316,83 @@ const paths = {
|
||||
description:
|
||||
"The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.",
|
||||
},
|
||||
background: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.",
|
||||
},
|
||||
},
|
||||
["url", "parent"]
|
||||
),
|
||||
responses: { 201: { description: "{lane, created: true, path}" } },
|
||||
responses: {
|
||||
201: { description: "{lane, created: true, path}" },
|
||||
202: {
|
||||
description: "`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs": {
|
||||
get: {
|
||||
tags: ["Git identity"],
|
||||
summary: "The caller's background clone jobs, newest first",
|
||||
description:
|
||||
"CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `<data dir>/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller's own (local mode: all).",
|
||||
operationId: "listCloneJobs",
|
||||
responses: { 200: { description: "{jobs: CloneJob[]}" } },
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}": {
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Dismiss a finished background clone job",
|
||||
operationId: "dismissCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: {
|
||||
200: { description: "{ok: true}" },
|
||||
404: { description: "ENOTFOUND — not the caller's job" },
|
||||
409: { description: "EBUSY — still queued or running" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}/cancel": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Cancel a queued or running background clone",
|
||||
description:
|
||||
"Queued: taken out of the queue, never started. Running: the runner's process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.",
|
||||
operationId: "cancelCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: {
|
||||
200: { description: "{job}" },
|
||||
404: { description: "ENOTFOUND — not the caller's job" },
|
||||
409: { description: "EDONE — it already finished" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/jobs/{id}/retry": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Retry a failed, interrupted or cancelled background clone",
|
||||
description:
|
||||
"A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.",
|
||||
operationId: "retryCloneJob",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: json({
|
||||
credentials: {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string" },
|
||||
remember: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
202: { description: "{job} — the new CloneJob" },
|
||||
404: { description: "ENOTFOUND — not the caller's job (ENOLANE: its workspace is gone)" },
|
||||
409: { description: "EBUSY — queued, running or done" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/clone/progress/{token}": {
|
||||
|
||||
@@ -660,11 +660,17 @@ const paths = {
|
||||
additionalProperties: true,
|
||||
description: "A typed HTTPS login for this one clone.",
|
||||
},
|
||||
background: {
|
||||
type: "boolean",
|
||||
description:
|
||||
'Run as a background clone job (see `/api/lanes/clone/jobs`): the checks answer at once, then 202 `{job}` with `kind: "source"`.',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
|
||||
202: { description: "`background: true`: {job} — a CloneJob." },
|
||||
400: {
|
||||
description:
|
||||
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
|
||||
|
||||
@@ -1113,6 +1113,26 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: [pathParam("id", "Artifact id.")],
|
||||
responses: {
|
||||
200: ok("Taken.", {
|
||||
item: { type: "object" },
|
||||
changed: { type: "boolean" },
|
||||
}),
|
||||
400: errs("EBADKIND."),
|
||||
403: errs("EFORBIDDEN."),
|
||||
404: errs("ENOTFOUND."),
|
||||
409: errs("ENOSOURCE."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/artifacts/{id}/claim": {
|
||||
post: {
|
||||
tags: ["Artifacts"],
|
||||
|
||||
@@ -489,6 +489,11 @@ const paths = {
|
||||
items: { type: "string" },
|
||||
description: "Files the switch wrote.",
|
||||
},
|
||||
following: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Switched."
|
||||
|
||||
@@ -39,6 +39,7 @@ const STATUS = {
|
||||
ENEEDLOGIN: 502,
|
||||
ETOOBIG: 413,
|
||||
EOWNED: 409,
|
||||
ENOSOURCE: 409,
|
||||
};
|
||||
|
||||
function sendError(res, err) {
|
||||
@@ -229,6 +230,7 @@ router.post(
|
||||
name: b.name,
|
||||
title: b.title,
|
||||
description: b.description,
|
||||
id: b.id,
|
||||
});
|
||||
for (const item of items) changed(item);
|
||||
res.status(201).json({ items });
|
||||
@@ -284,6 +286,19 @@ router.patch(
|
||||
})
|
||||
);
|
||||
|
||||
// Share again: the owner's skill folder on this server becomes the new
|
||||
// version (installers see Update); 409 ENOSOURCE when the server has none —
|
||||
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
|
||||
router.post(
|
||||
"/:id/reshare",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const out = artifacts.reshareSkill(req, req.params.id);
|
||||
changed(out.item);
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
sameOriginGuard,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
} else {
|
||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+350
-85
@@ -738,13 +738,32 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||||
*/
|
||||
/**
|
||||
* The clone itself, shared by Add workspace → Clone and a workspace's Clone
|
||||
* extra folder: every check above, then `git clone` as the folder's account.
|
||||
* Answers the new folder's path, or null once it has sent the refusal.
|
||||
* `before(target, ownerId)` runs after the checks and BEFORE the clone, so a
|
||||
* refusal there never leaves minutes of cloning behind.
|
||||
* Status + error body a failed clone answers — the same codes whether the
|
||||
* request was held open or the clone ran as a background job.
|
||||
*/
|
||||
async function cloneInto(req, res, body, before) {
|
||||
function cloneFailure(err) {
|
||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||
? 400
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
return {
|
||||
status,
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
...(err.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every check before a clone, shared by Add workspace → Clone and a
|
||||
* workspace's Clone extra folder, then `before(target, ownerId)` — so a
|
||||
* refusal there never leaves minutes of cloning behind. Answers the clone's
|
||||
* context, or null once it has sent the refusal.
|
||||
*/
|
||||
async function prepareClone(req, res, body, before) {
|
||||
const identity = require("../lib/git-identity");
|
||||
let url;
|
||||
try {
|
||||
@@ -783,6 +802,10 @@ async function cloneInto(req, res, body, before) {
|
||||
res.status(400).json({ error: { code: "EBADFIELD", message: "partial: boolean" } });
|
||||
return null;
|
||||
}
|
||||
if (body.background !== undefined && typeof body.background !== "boolean") {
|
||||
res.status(400).json({ error: { code: "EBADFIELD", message: "background: boolean" } });
|
||||
return null;
|
||||
}
|
||||
const once = body.credentials;
|
||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||
@@ -832,38 +855,141 @@ async function cloneInto(req, res, body, before) {
|
||||
}
|
||||
try {
|
||||
await before(target, ownerId);
|
||||
await identity.cloneRepo({
|
||||
job,
|
||||
url,
|
||||
parent: dir,
|
||||
name,
|
||||
once: once
|
||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||
: null,
|
||||
partial: body.partial === true,
|
||||
});
|
||||
queue.finish(job, true);
|
||||
} catch (err) {
|
||||
queue.finish(job, false);
|
||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||
? 400
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
...(err.code === "ECREDENTIALS"
|
||||
? { auth: { protocol: err.protocol, host: err.host } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const f = cloneFailure(err);
|
||||
res.status(f.status).json({ error: f.error });
|
||||
return null;
|
||||
}
|
||||
return { target, name, ownerId };
|
||||
return {
|
||||
url,
|
||||
name,
|
||||
dir,
|
||||
target,
|
||||
ownerId,
|
||||
token: job,
|
||||
once: once
|
||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||
: null,
|
||||
partial: body.partial === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** `git clone` as the folder's account, the request held open. */
|
||||
async function runClone(ctx) {
|
||||
const queue = require("../lib/clone-queue");
|
||||
try {
|
||||
await require("../lib/git-identity").cloneRepo({
|
||||
job: ctx.token,
|
||||
url: ctx.url,
|
||||
parent: ctx.dir,
|
||||
name: ctx.name,
|
||||
once: ctx.once,
|
||||
partial: ctx.partial,
|
||||
});
|
||||
queue.finish(ctx.token, true);
|
||||
} catch (err) {
|
||||
queue.finish(ctx.token, false);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The held-open clone: its context, or null once it has sent the refusal. */
|
||||
async function cloneInto(req, res, body, before) {
|
||||
const ctx = await prepareClone(req, res, body, before);
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
await runClone(ctx);
|
||||
} catch (err) {
|
||||
const f = cloneFailure(err);
|
||||
res.status(f.status).json({ error: f.error });
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers 202 `{ job }` and hands the clone to `server/lib/clone-jobs.js`:
|
||||
* a detached runner runs git, and when it ends the job's after-step
|
||||
* (`afterStepForJob` below — the workspace, or the extra folder, exactly as
|
||||
* the held-open route) runs once. A cancelled job never runs it.
|
||||
*/
|
||||
function startBackground(res, ctx, { kind, laneId, request }) {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
let account = null;
|
||||
try {
|
||||
account = require("../lib/run-as").accountForPath(ctx.target);
|
||||
} catch {
|
||||
/* checked by prepareClone already */
|
||||
}
|
||||
const job = cloneJobs.create({
|
||||
owner: ctx.ownerId,
|
||||
kind,
|
||||
url: ctx.url,
|
||||
name: ctx.name,
|
||||
parent: ctx.dir,
|
||||
target: ctx.target,
|
||||
laneId,
|
||||
request,
|
||||
once: ctx.once
|
||||
? {
|
||||
remember: ctx.once.remember,
|
||||
member: Boolean(account),
|
||||
host: (() => {
|
||||
try {
|
||||
return require("../lib/git-identity").remoteOf(ctx.url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (ctx.token) job.mirror = ctx.token;
|
||||
// Started before the answer: its first, synchronous step queues it, so the
|
||||
// 202 already carries its place in the queue.
|
||||
cloneJobs.start(job, ctx.once);
|
||||
res.status(202).json({ job: cloneJobs.view(job) });
|
||||
return job;
|
||||
}
|
||||
|
||||
/** A request-shaped owner for a job finished without its request (scope checks). */
|
||||
function reqForOwner(owner) {
|
||||
if (owner == null) return { method: "POST" };
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(owner);
|
||||
if (!row || row.disabled) return null;
|
||||
return { method: "POST", user: require("../lib/auth").publicUser(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A background clone's after-step, from the job alone — it may run in a
|
||||
* later server process than the one the request reached.
|
||||
*/
|
||||
function afterStepForJob(job) {
|
||||
const req = reqForOwner(job.owner);
|
||||
if (!req) {
|
||||
return {
|
||||
status: 409,
|
||||
json: {
|
||||
error: { code: "ENOTFOUND", message: "the account that started this clone is gone" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (job.kind === "source") {
|
||||
const lane = lanesLib.getLane(job.laneId);
|
||||
if (!lane) {
|
||||
return { status: 404, json: { error: { code: "ENOLANE", message: "workspace not found" } } };
|
||||
}
|
||||
return finishSourceClone(req, lane, job.target);
|
||||
}
|
||||
return finishWorkspaceClone(
|
||||
req,
|
||||
{ tool: job.request?.tool, title: job.request?.title },
|
||||
{ target: job.target, name: job.name, ownerId: job.owner }
|
||||
);
|
||||
}
|
||||
require("../lib/clone-jobs").setAfterStep(afterStepForJob);
|
||||
|
||||
/**
|
||||
* A clone's place in the machine-wide queue, then git's own progress
|
||||
* (`server/lib/clone-queue.js`), by the `progressToken` its POST carried —
|
||||
@@ -877,17 +1003,97 @@ router.get("/clone/progress/:token", (req, res) => {
|
||||
res.json(view);
|
||||
});
|
||||
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
const done = await cloneInto(req, res, body, (target, ownerId) => {
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
});
|
||||
if (!done) return;
|
||||
const { target, name, ownerId } = done;
|
||||
/**
|
||||
* Background clones (`background: true` on either clone POST,
|
||||
* `server/lib/clone-jobs.js`): the caller's own jobs, newest first — a job id
|
||||
* is only ever looked up among them (404 otherwise).
|
||||
*/
|
||||
router.get("/clone/jobs", (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
res.json({ jobs: cloneJobs.list(scope.ownerFor(req)).map(cloneJobs.view) });
|
||||
});
|
||||
|
||||
const cloneJobError = (res, err) => {
|
||||
const status = err.code === "ENOTFOUND" ? 404 : 409;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
};
|
||||
|
||||
router.delete("/clone/jobs/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
require("../lib/clone-jobs").remove(req.params.id, scope.ownerFor(req));
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return cloneJobError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/clone/jobs/:id/cancel", sameOriginGuard, (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
try {
|
||||
const job = cloneJobs.cancel(req.params.id, scope.ownerFor(req));
|
||||
return res.json({ job: cloneJobs.view(job) });
|
||||
} catch (err) {
|
||||
return cloneJobError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Jobs a retry is starting from, so a double click starts one clone. */
|
||||
const retrying = new Set();
|
||||
|
||||
/**
|
||||
* A failed, interrupted or cancelled job again: a NEW job from its `request`
|
||||
* (plus `credentials` typed now), through every check of a fresh POST; the
|
||||
* old one is dropped once the new one exists.
|
||||
*/
|
||||
router.post("/clone/jobs/:id/retry", sameOriginGuard, async (req, res) => {
|
||||
const cloneJobs = require("../lib/clone-jobs");
|
||||
const owner = scope.ownerFor(req);
|
||||
const old = cloneJobs.get(req.params.id, owner);
|
||||
if (!old) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such clone" } });
|
||||
}
|
||||
if (cloneJobs.ACTIVE.has(old.state) || old.state === "done" || retrying.has(old.id)) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EBUSY", message: "only a failed clone can be retried" } });
|
||||
}
|
||||
const body = { ...old.request, background: true };
|
||||
const typed = req.body?.credentials;
|
||||
if (typed !== undefined) body.credentials = typed;
|
||||
retrying.add(old.id);
|
||||
try {
|
||||
let job;
|
||||
if (old.kind === "source") {
|
||||
const lane = lanesLib.getLane(old.laneId);
|
||||
if (!lane || !scope.canSeeLane(req, lane.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
// scopeGuard's live-role check for a write to a workspace, which this
|
||||
// path (no lane id in it) does not get.
|
||||
try {
|
||||
require("../lib/run-as").assertOwnerFits(lane.cwd, lane.owner_id);
|
||||
} catch (err) {
|
||||
return res.status(409).json({ error: { code: "EOWNERFOLDER", message: err.message } });
|
||||
}
|
||||
job = await startSourceClone(req, res, lane, body);
|
||||
} else {
|
||||
job = await startWorkspaceClone(req, res, body);
|
||||
}
|
||||
if (job) {
|
||||
try {
|
||||
cloneJobs.remove(old.id, owner);
|
||||
} catch {
|
||||
/* dismissed meanwhile */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
retrying.delete(old.id);
|
||||
}
|
||||
});
|
||||
|
||||
/** After a workspace clone: the folder becomes (or re-attaches) a workspace. */
|
||||
function finishWorkspaceClone(req, body, ctx) {
|
||||
const { target, name, ownerId } = ctx;
|
||||
const cwd = lanesLib.canonicalCwd(target);
|
||||
// A workspace can outlive its folder (deleted from the File Explorer or a
|
||||
// shell): cloning into that path again re-attaches the user's own record
|
||||
@@ -896,7 +1102,12 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||
if (found && strip(found.cwd) === strip(cwd) && scope.canSeeLane(req, found.id)) {
|
||||
broadcastLane(found.id);
|
||||
return res.status(200).json({ lane: payload(found), created: false, path: target });
|
||||
return {
|
||||
status: 200,
|
||||
laneId: found.id,
|
||||
created: false,
|
||||
json: { lane: payload(found), created: false, path: target },
|
||||
};
|
||||
}
|
||||
try {
|
||||
const lane = lanesLib.createLane({
|
||||
@@ -906,16 +1117,106 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
...(body.tool ? { tool: body.tool } : {}),
|
||||
});
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({ lane: payload(lane), created: true, path: target });
|
||||
return {
|
||||
status: 201,
|
||||
laneId: lane.id,
|
||||
created: true,
|
||||
json: { lane: payload(lane), created: true, path: target },
|
||||
};
|
||||
} catch (err) {
|
||||
// The clone stays: it is the user's now, and Repo mode can adopt it.
|
||||
const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "ELANE", message: err.message }, path: target });
|
||||
return {
|
||||
status,
|
||||
json: { error: { code: err.code || "ELANE", message: err.message }, path: target },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Add workspace → Clone; the background job when `background: true`. */
|
||||
async function startWorkspaceClone(req, res, body) {
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
const before = (target, ownerId) => {
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
};
|
||||
if (body.background === true) {
|
||||
const ctx = await prepareClone(req, res, body, before);
|
||||
if (!ctx) return null;
|
||||
const request = { url: ctx.url, parent: ctx.dir, name: ctx.name };
|
||||
for (const k of ["tool", "title", "partial"]) if (body[k] !== undefined) request[k] = body[k];
|
||||
return startBackground(res, ctx, { kind: "workspace", laneId: null, request });
|
||||
}
|
||||
const done = await cloneInto(req, res, body, before);
|
||||
if (!done) return null;
|
||||
const out = finishWorkspaceClone(req, body, done);
|
||||
res.status(out.status).json(out.json);
|
||||
return null;
|
||||
}
|
||||
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
await startWorkspaceClone(req, res, req.body || {});
|
||||
});
|
||||
|
||||
/** After an extra-folder clone: `lane_sources`' own folder checks, then add it. */
|
||||
function finishSourceClone(req, lane, target) {
|
||||
const sources = require("../lib/lane-sources");
|
||||
try {
|
||||
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||
sources.addFolder(lane.id, target, { allowed });
|
||||
broadcastLane(lane.id);
|
||||
return {
|
||||
status: 201,
|
||||
laneId: lane.id,
|
||||
json: {
|
||||
path: target,
|
||||
instructions: lane.instructions || "",
|
||||
supported: true,
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(lane.id),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// The clone stays where it is; it can still be added by its path.
|
||||
return {
|
||||
status: err.code === "EFOLDER" ? 403 : 400,
|
||||
json: { error: { code: err.code || "ESOURCE", message: err.message }, path: target },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** A workspace's Clone extra folder; the background job when `background: true`. */
|
||||
async function startSourceClone(req, res, lane, body) {
|
||||
const sources = require("../lib/lane-sources");
|
||||
if (!sources.supports(lane.tool)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||
return null;
|
||||
}
|
||||
const cloneBody = {
|
||||
url: body.url,
|
||||
name: body.name,
|
||||
credentials: body.credentials,
|
||||
parent: path.dirname(lane.cwd),
|
||||
...(body.background !== undefined ? { background: body.background } : {}),
|
||||
};
|
||||
if (body.background === true) {
|
||||
const ctx = await prepareClone(req, res, cloneBody, () => {});
|
||||
if (!ctx) return null;
|
||||
const request = { url: ctx.url, parent: ctx.dir, name: ctx.name };
|
||||
return startBackground(res, ctx, { kind: "source", laneId: lane.id, request });
|
||||
}
|
||||
const done = await cloneInto(req, res, cloneBody, () => {});
|
||||
if (!done) return null;
|
||||
const out = finishSourceClone(req, lane, done.target);
|
||||
res.status(out.status).json(out.json);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a related repo as one of a workspace's extra folders: the same clone
|
||||
* as above (URL allowlist, the folder account's own identity, `ECREDENTIALS`
|
||||
@@ -928,43 +1229,7 @@ router.post("/:id/sources/clone", sameOriginGuard, async (req, res) => {
|
||||
if (!lane) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
const sources = require("../lib/lane-sources");
|
||||
if (!sources.supports(lane.tool)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||
}
|
||||
const body = req.body || {};
|
||||
const done = await cloneInto(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
url: body.url,
|
||||
name: body.name,
|
||||
credentials: body.credentials,
|
||||
parent: path.dirname(lane.cwd),
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
if (!done) return;
|
||||
try {
|
||||
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||
sources.addFolder(lane.id, done.target, { allowed });
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({
|
||||
path: done.target,
|
||||
instructions: lane.instructions || "",
|
||||
supported: true,
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(lane.id),
|
||||
});
|
||||
} catch (err) {
|
||||
// The clone stays where it is; it can still be added by its path.
|
||||
return res
|
||||
.status(err.code === "EFOLDER" ? 403 : 400)
|
||||
.json({ error: { code: err.code || "ESOURCE", message: err.message }, path: done.target });
|
||||
}
|
||||
await startSourceClone(req, res, lane, req.body || {});
|
||||
});
|
||||
|
||||
function isSymlink(p) {
|
||||
|
||||
@@ -197,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit");
|
||||
const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
ENAMETAKEN: 409,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADIMAGE: 400,
|
||||
@@ -262,6 +263,7 @@ router.patch(
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
||||
auth.assertNameFree(user.id, fields);
|
||||
auth.setProfile(user.id, fields);
|
||||
return { out: accountView(user.id), action: "own profile" };
|
||||
})
|
||||
|
||||
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
|
||||
function sessionStats(sessionId) {
|
||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
||||
const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
|
||||
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
||||
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
||||
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
||||
|
||||
+24
-3
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
// Which account each session belongs to — through its workspace, a dashboard
|
||||
// run on one, or (no workspace owns it) the Linux account it ran as
|
||||
// (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
SELECT s.id AS session_id, l.owner_id AS uid
|
||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
|
||||
UNION
|
||||
SELECT s.id, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
${require("../lib/session-account").ACCOUNT_OWNED}`;
|
||||
|
||||
/**
|
||||
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||
@@ -171,6 +174,7 @@ router.get("/tmux", (_req, res) => {
|
||||
const STATUS = {
|
||||
EBADUSER: 400,
|
||||
EBADEMAIL: 400,
|
||||
ENAMETAKEN: 409,
|
||||
ENOTOWNED: 409,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
@@ -562,6 +566,7 @@ router.patch(
|
||||
|
||||
// 1. Validate everything — nothing is written until all of it passes.
|
||||
const profile = auth.validateProfile(body);
|
||||
auth.assertNameFree(u.id, profile);
|
||||
const roleChange = body.role !== undefined && body.role !== u.role;
|
||||
if (roleChange) {
|
||||
if (self) throw ESELF("you cannot change your own role");
|
||||
@@ -578,6 +583,18 @@ router.patch(
|
||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||
}
|
||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||
if (body.hostAccount !== undefined) {
|
||||
if (typeof body.hostAccount !== "boolean") {
|
||||
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
|
||||
}
|
||||
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
|
||||
throw Object.assign(new Error("only an admin can own the host account"), {
|
||||
code: "EBADROLE",
|
||||
});
|
||||
}
|
||||
}
|
||||
const hostChange =
|
||||
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
|
||||
if (body.password !== undefined) {
|
||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||
if (self) {
|
||||
@@ -617,6 +634,10 @@ router.patch(
|
||||
auth.setRole(u.username, body.role);
|
||||
done.push(`role ${body.role}`);
|
||||
}
|
||||
if (hostChange) {
|
||||
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
|
||||
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
|
||||
}
|
||||
for (const id of add) auth.assignWorkspace(id, u.username);
|
||||
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
||||
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
||||
|
||||
Reference in New Issue
Block a user