Compare commits

..

44 Commits

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

Files → New → Clone repository steps aside for its progress chip as soon
as the clone starts; a failure asking for a login reopens the form.
2026-11-02 10:00:09 +07:00
nntrivi2001 8d0477e0f7 chore: v2.8.48 — a folder upload skips .git with one notice instead of an error per file 2026-11-02 09:53:03 +07:00
nntrivi2001 e7e9cb20d8 fix(files): a folder upload leaves .git out with one notice
Uploading a cloned repo from the desktop sent every .git object, and the
server refused each one (nothing is uploaded into .git) — one error toast
per object, thousands of them. The drop walker no longer reads a .git
folder, and uploadAll drops any path inside .git before sending, saying
once how many were skipped.
2026-11-02 09:53:03 +07:00
nntrivi2001 ebfae9b891 chore: v2.8.47 — a Codex ChatGPT pool account reaches its pane even while an older login's daemon runs 2026-10-30 16:06:33 +07:00
nntrivi2001 3ab484ddc0 fix(pool): a Codex ChatGPT pool pane runs on the login placed for it
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. A ChatGPT pool account written into a member's auth.json
therefore reached no pane while their daemon still ran on the previous
login (reproduced on 0.164.0: the daemon kept sending the old key, a
--no-daemon TUI sent the new one). Such a pane now runs `codex
--no-daemon`, only when that binary's --help lists the flag (probed once
per path/size/mtime), so an older Codex still starts.
2026-10-30 16:06:20 +07:00
nntrivi2001 6bcef26446 chore: v2.8.46 — last updated time and installers' versions in Artifacts; a web app's pages are no longer auto-captured 2026-10-30 15:23:03 +07:00
nntrivi2001 240444eafd fix(artifacts): auto-capture leaves a web app's own HTML pages alone
At a session's Stop every untracked .html was snapshotted as a report, so a
web project not yet committed had its index.html taken into its owner's
Artifacts. An index.html, a page under a site folder (www, public, dist,
build, src, static, templates, assets, site, _site) or one beside scripts or
stylesheets (outside the workspace root) is now a site's own page and left
alone; a standalone HTML report is still captured.
2026-10-30 15:22:19 +07:00
nntrivi2001 d09c9d6633 feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list
could not tell who still runs an old version. `artifacts.version_at` (new
column, existing rows from `updated_at`) is stamped only when a snapshot is
replaced — a rename or share toggle leaves it — and served as `versionAt`:
skill and file rows read "Updated 3h ago", the detail sheet "Last updated".
`GET /api/artifacts/:id/installs` gives each skill install a `state` —
latest / outdated / removed / unknown — from its Claude skill copies' hash
against the newest snapshot, shown as a badge.
2026-10-30 15:21:31 +07:00
nntrivi2001 bc44cbc8b7 chore: v2.8.45 — Share again updates a shared skill; a same-named upload updates it too 2026-10-30 14:20:51 +07:00
nntrivi2001 e7060a5e42 feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one
uploaded from a browser (or living in a plugin) stayed at its first snapshot
for good. Share again (a skill's ⋯ menu and detail sheet) now takes the
owner's copy on the server — its source folder, else the same-named skill of
a plugin they installed at user scope — and shares it; with no copy it asks
for the folder and uploads it onto that row (`id`). An upload whose skill is
named like one the uploader already has replaces that row's version instead
of adding a twin. Installers are then offered Update, as before.
2026-10-30 14:20:18 +07:00
nntrivi2001 4b8f59695b chore: v2.8.44 — Discard in the Changes panel takes only the ticked files and blocks 2026-10-30 13:05:48 +07:00
nntrivi2001 da7b86d5c8 fix(changes): Discard takes only the ticked files and blocks (#87)
The Changes panel's "Discard all" threw away every changed file, ticked or
not. It is now "Discard (n)": the same selection Commit sends — whole ticked
files as `paths`, a partly ticked file's ticked blocks as `hunks` — and is
disabled with nothing ticked. The server's `discard` accepts `hunks`
(`selectionPatch` in reverse, `git apply -R`), building every patch before
applying any so a stale selection undoes nothing.

Closes #87
2026-10-30 13:05:13 +07:00
nntrivi2001 efe21c79e9 chore: v2.8.43 — terminal glyphs stay sharp in every pane; a huge diff line no longer hides the rest 2026-10-30 10:34:51 +07:00
nntrivi2001 ee119fe437 fix(terminal): restamp every glyph-atlas page so no pane keeps a stale texture
v2.8.42 reacted to a page merge by toggling the font family on the next
frame, so each renderer re-acquired an atlas. Panes still showed squeezed,
overlapping glyphs: the toggle lands a frame late, swaps every pane onto a
fresh atlas (or onto one another pane already toggled to), and a page added
without a merge shifts nothing yet still raced it.

addon-webgl 0.20 fixes the root cause with globally monotonic page versions.
Do the same on 0.19: whenever the shared atlas adds, removes or merges a page,
give every page a version no texture slot has uploaded, synchronously, so
every renderer on that atlas re-uploads all pages before it draws again.
When the page comes back from the background, restamp and repaint. An addon
of another shape keeps the old font-toggle fallback.
2026-10-30 10:33:57 +07:00
nntrivi2001 d97c403aab fix(changes): a huge line no longer cuts the diff short; preview pages up to 64 MB
A page that inlines a video as base64 has rows of megabytes. Its patch was read
at the 200 KB per-file cap, so the first such row used the whole budget and
every hunk after it was lost ("Diff cut short"). The patch is now read up to
16 M characters, rows over 4,000 characters are cut to 1,000 with an `elided`
count the diff shows, and only then is the cap applied. Hunk actions re-read
git's own diff by header and are unchanged.

The HTML preview refused a page over 10 MB and showed only the file icon; a
self-contained 18 MB slide deck now opens (limit 64 MB).
2026-10-30 10:28:53 +07:00
nntrivi2001 c41114faf8 chore: v2.8.42 — terminal glyphs stay sharp after the WebGL atlas fills up 2026-10-30 10:10:33 +07:00
nntrivi2001 c850f7603a fix(terminal): re-upload the shared WebGL atlas after a page merge
@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
2026-10-30 10:09:28 +07:00
nntrivi2001 5463e354fa chore: v2.8.41 — a session outside every workspace counts toward its user's spend 2026-10-30 08:50:58 +07:00
nntrivi2001 783b7f4101 fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the
pane's env) had no workspace, so its spend reached nobody's Today, the Users
page or a spend quota — ~$57 of a $74 day was missing from the status bar.

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
A member kept renaming themselves to a colleague's display name, plus
"(you)", so the CPU/RAM card and every owner list showed two of that person.
A display name is now refused (409 ENAMETAKEN) when, with marks, case,
spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters
ignored, it equals another account's display name or username — in
Settings → Account and on the Users page alike. Keeping the name one already
has stays allowed, so an older clash does not block an admin's other edits.
2026-10-29 13:47:20 +07:00
nntrivi2001 9f8dc9082e chore: v2.8.38 — every user row shows its avatar 2026-10-29 13:40:39 +07:00
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00
nntrivi2001 db5a7776cc chore: v2.8.37 — the Artifacts installs list is never cut off 2026-10-29 11:25:10 +07:00
nntrivi2001 6c2c094f64 fix(artifacts): the installs list floats over the page, never cut off
It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
2026-10-29 11:25:10 +07:00
nntrivi2001 609a35f251 chore: v2.8.36 — an HTML page opens in a modal over the screen (Changes Preview, Chat) 2026-10-29 11:20:54 +07:00
nntrivi2001 97c39d5e15 feat(files): an HTML page opens in a modal over the screen
- Changes panel: Preview beside Whole file opens the page in a modal
  (HtmlViewerModal, shared with the File Explorer) instead of inside the
  panel; Changes and Whole file stay as they were.
- Chat: an HTML file's card, Outputs row or a path in a reply (in the
  workspace or not) opens FilePreview as a modal — Preview | Code, versions,
  Save to Artifacts kept — rather than the side panel or the Changes panel.
- Esc closes both modals: they stop every key from reaching the view below,
  which also kept useDialog's document listener from seeing it.
2026-10-29 11:20:54 +07:00
nntrivi2001 4a82790734 chore: v2.8.35 — an HTML file's Preview sits beside Whole file 2026-10-29 10:47:39 +07:00
nntrivi2001 80000793f4 feat(files): an HTML file's Preview sits beside Whole file
The Changes panel's view toggle becomes Changes | Whole file | Preview for an
HTML file; the Preview/Raw button on the file's name row stays for markdown.
2026-10-29 10:47:39 +07:00
nntrivi2001 39bea78a9c chore: v2.8.34 — HTML page on Preview in the Changes panel (also from Changes) 2026-10-29 10:38:15 +07:00
nntrivi2001 364fe4a286 fix(files): an HTML file in the Changes panel shows as the page only on Preview
Changes stays the default view and Whole file shows the source, as for any
file. A Preview button — offered on the Changes diff too, where it was
missing — opens the page; Raw, Changes or Whole file go back to the source.
2026-10-29 10:38:15 +07:00
107 changed files with 9813 additions and 1017 deletions
+6 -5
View File
@@ -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": ""
}
}
+7 -7
View File
File diff suppressed because one or more lines are too long
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>
);
}
+4 -2
View File
@@ -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,
+109
View File
@@ -0,0 +1,109 @@
/**
* @file HtmlViewerModal.tsx
* @description An HTML file shown as the page in a modal over the whole
* screen — the one viewer the File Explorer and the Changes panel's Preview
* open (the Chat's file preview opens its own panel as a modal instead, for
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
* artifact's sandbox, its relative files read through the caller's guarded
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
* underneath (the explorer's Delete) never fires from inside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Download, X } from "lucide-react";
import { useDialog } from "../hooks/useDialog";
import { HtmlFrame } from "./HtmlFrame";
import type { FetchFile } from "../lib/htmlInline";
export function HtmlViewerModal({
path,
html,
fetchFile,
onDownload,
onClose,
testId = "html-viewer",
}: {
/** The file's path relative to the place `fetchFile` reads from; also its title. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
fetchFile?: FetchFile | null;
onDownload?: () => void;
onClose: () => void;
testId?: string;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const name = path.slice(path.lastIndexOf("/") + 1);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={name}
data-testid={testId}
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{path}
</span>
{onDownload && (
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
)}
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
</div>
</div>,
document.body
);
}
function HeadButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
>
{children}
</button>
);
}
+3
View File
@@ -51,6 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { BackgroundJobsDock } from "./BackgroundJobsDock";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<BackgroundJobsDock />
<InterruptedSessionsPrompt />
<SagiMount />
{onboarding.loaded && !onboarding.onboardedAt && (
@@ -0,0 +1,290 @@
/**
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
* announced once when the store sees it finish (toast + window event); a
* fresh mount (F5) shows what the server kept — an interrupted clone with
* Retry, a login failure with an inline login form that retries with it —
* without toasting it again; × dismisses; Stop (click twice) cancels a
* running clone, which then reads as stopped and can be retried; an older
* server that cannot stop says so. An upload shows with Stop, and one a
* reload cut short is shown as interrupted until dismissed. Several chips
* collapse to one pill.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../i18n";
vi.mock("../../lib/api", () => ({
api: {
lanes: {
clone: vi.fn(),
cloneFolder: vi.fn(),
cloneJobs: vi.fn(),
retryCloneJob: vi.fn(),
dismissCloneJob: vi.fn(),
cancelCloneJob: vi.fn(),
},
},
}));
vi.mock("../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
import { api, type CloneJob } from "../../lib/api";
import { toast } from "../../lib/toast";
import {
CLONE_JOB_EVENT,
refreshCloneJobs,
resetCloneJobsForTests,
startClone,
} from "../../lib/cloneJobs";
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
import { BackgroundJobsDock } from "../BackgroundJobsDock";
const base: CloneJob = {
id: "j1",
kind: "workspace",
state: "running",
url: "https://git.example/o/repo.git",
name: "repo",
parent: "/home/an",
target: "/home/an/repo",
laneId: null,
position: null,
phase: "receiving",
label: null,
percent: 40,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
};
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
const renderDock = () =>
render(
<MemoryRouter>
<BackgroundJobsDock />
</MemoryRouter>
);
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
try {
localStorage.clear();
} catch {
/* none */
}
reloadUploadsForTests();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
});
describe("cloneJobs store", () => {
it("announces a background clone once, when it sees it finish", async () => {
const onDone = vi.fn();
const events: CloneJob[] = [];
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
window.addEventListener(CLONE_JOB_EVENT, listen);
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
expect(toast.success).not.toHaveBeenCalled();
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
await refreshCloneJobs();
await refreshCloneJobs();
expect(toast.success).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
expect(events).toEqual([done]);
expect(onDone).toHaveBeenCalledWith(done, undefined);
window.removeEventListener(CLONE_JOB_EVENT, listen);
});
});
describe("BackgroundJobsDock", () => {
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("Cloning repo");
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("shows an interrupted clone after F5 and retries it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "interrupted",
error: { code: "EINTERRUPTED", message: "server restarted" },
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
job: job({ id: "j2", state: "queued", position: 1 }),
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
expect(toast.error).not.toHaveBeenCalled();
await user.click(within(chip).getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
);
});
it("asks for a login on ECREDENTIALS and retries with it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
error: {
code: "ECREDENTIALS",
message: "login",
auth: { protocol: "https", host: "git.example" },
},
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const form = await screen.findByTestId("clone-job-login");
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
await user.type(within(form).getByLabelText("Username"), "ann");
await user.type(within(form).getByLabelText("Password or access token"), "tok");
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
username: "ann",
password: "tok",
remember: true,
});
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("dismisses a finished clone", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
await user.click(within(chip).getByTestId("clone-job-dismiss"));
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
expect(screen.queryByTestId("clone-job")).toBeNull();
});
it("stops a running clone on a second click, then lets it be retried", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const stop = await screen.findByTestId("clone-job-stop");
await user.click(stop);
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
await user.click(screen.getByTestId("clone-job-stop"));
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
const chip = await screen.findByText("You stopped this clone.");
expect(chip).toBeInTheDocument();
expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("says so when the server cannot stop a clone yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
Object.assign(new Error("HTTP 404"), { status: 404 })
);
renderDock();
await user.click(await screen.findByTestId("clone-job-stop"));
await user.click(screen.getByTestId("clone-job-stop"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
);
});
it("shows an upload with Stop, which aborts it", async () => {
renderDock();
let up!: ReturnType<typeof beginUpload>;
act(() => {
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
up.update({ name: "a.txt", done: 1, sent: 150 });
});
const chip = await screen.findByTestId("fx-progress");
expect(chip).toHaveTextContent("Uploading 2/3");
expect(chip).toHaveTextContent("one/src · a.txt");
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(false);
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(true);
act(() => up.end());
expect(screen.queryByTestId("fx-progress")).toBeNull();
});
it("shows an upload a reload cut short until it is dismissed", async () => {
localStorage.setItem(
"agenthub.uploads.inflight",
JSON.stringify([
{
id: "up-old",
folder: "one/src",
name: "b.txt",
done: 2,
total: 5,
sent: 10,
bytes: 50,
state: "running",
beat: Date.now() - 60_000,
},
])
);
act(() => reloadUploadsForTests());
renderDock();
const chip = await screen.findByTestId("upload-job");
expect(chip).toHaveTextContent(
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
);
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("upload-job")).toBeNull();
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
});
it("collapses several chips to one pill", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job(), job({ id: "j2", name: "other" })],
});
renderDock();
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
await user.click(screen.getByTestId("jobs-dock-collapse"));
expect(screen.queryByTestId("clone-job")).toBeNull();
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
});
@@ -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 });
});
+6 -1
View File
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
@@ -69,6 +69,7 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
+47 -34
View File
@@ -9,8 +9,11 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless). The clone is a
* background job (`lib/cloneJobs.ts`): the modal closes once the server
* accepted it, the app-wide dock shows its queue place, git's progress and
* outcome, and the new workspace is selected when it ends while the user is
* still on that page. There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { startClone } from "../../lib/cloneJobs";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -119,8 +122,6 @@ export function AddLaneModal({
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -242,28 +243,46 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
// The new workspace is selected when the clone ends only while the user
// is still on the page they started it from; elsewhere the dock's
// "Open workspace" does it.
const from = window.location.pathname;
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
await startClone(
{
kind: "workspace",
body: {
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
},
{
onDone: (job, lane) => {
if (window.location.pathname !== from) return;
if (lane) return onAdded(lane);
const id = job.result?.laneId;
if (id == null) return;
api.lanes
.get(id)
.then((r) => onAdded(r.lane))
.catch(() => undefined);
},
}
);
// Accepted: the dock carries the clone from here.
reset();
onClose();
} catch (err) {
@@ -284,8 +303,6 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -571,11 +588,7 @@ export function AddLaneModal({
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
</div>
)}
@@ -856,7 +869,7 @@ export function AddLaneModal({
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
? t("cloneJobs.starting")
: t("add")}
</button>
</div>
+36 -88
View File
@@ -5,12 +5,13 @@
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* a workspace on the new folder. The clone is a server-side background job
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
* outcome, across pages, F5 and server restarts. A refusal before the clone
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
* remembered), or a pointer to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
import { startClone } from "../../lib/cloneJobs";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -31,16 +29,11 @@ export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -51,23 +44,10 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
if (!open) return;
setUrl("");
setName("");
setLogin(null);
@@ -76,67 +56,42 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
const folder = name.trim() || cloneDirName(url);
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) return null;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "workspace",
body: {
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
// Accepted: the dock takes it from here.
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -145,18 +100,11 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
message = t("clone.sshRefused", { host });
setError(t("clone.sshRefused", { host }));
} else {
message = err instanceof Error ? err.message : String(err);
setError(err instanceof Error ? err.message : String(err));
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
@@ -258,7 +206,6 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -269,9 +216,10 @@ export function CloneHereDialog({
)}
</p>
)}
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
{t("fx.cancel")}
</button>
<button
type="submit"
@@ -279,7 +227,7 @@ export function CloneHereDialog({
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
</button>
</div>
</form>
+20 -42
View File
@@ -2,59 +2,35 @@
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* with a bar. It renders the progress fields of a background clone job
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
import type { CloneJob } from "../../lib/api";
const POLL_MS = 1000;
export type CloneProgressFields = Pick<
CloneJob,
"state" | "position" | "phase" | "label" | "percent" | "detail"
> & { running?: number; waiting?: number; limit?: number };
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
export function CloneProgress({ view }: { view: CloneProgressFields }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state !== "queued" && view.state !== "running") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
? t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})
: t("cloneJobs.queued", { position: view.position ?? 1 })}
</p>
);
}
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
{view.detail}
</span>
)}
</p>
{view.phase && (
@@ -10,16 +10,19 @@
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
* the server accepted it the app-wide dock shows its queue place, git's
* progress and outcome (it survives leaving the page, F5 and a server
* restart), and the folder list is re-read when it ends; **Partial clone**
* sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
type Row = {
url: string;
@@ -27,8 +30,6 @@ type Row = {
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
.catch(() => {}),
[]
);
// A background clone into THIS workspace ended: its folder is now in the
// list (the server added it), so read the list again.
const onAddedRef = useRef(onAdded);
onAddedRef.current = onAdded;
useEffect(() => {
const onJob = (e: Event) => {
const job = (e as CustomEvent<CloneJob>).detail;
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
Promise.resolve()
.then(() => api.lanes.sources(laneId))
.then((view) => onAddedRef.current(view))
.catch(() => undefined);
};
window.addEventListener(CLONE_JOB_EVENT, onJob);
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
}, [laneId]);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "source",
laneId,
body: {
url: row.url,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
// Accepted: the dock shows its progress; the folder list is re-read
// when it ends (the CLONE_JOB_EVENT listener above).
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
{r.state === "done" && (
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
+97 -99
View File
@@ -15,7 +15,10 @@
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
* (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
* upload keeps going when the user opens Trash or another page, and can be
* stopped there; New → Clone repository runs as a background clone job;
* drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
@@ -91,13 +94,17 @@ import {
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
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";
@@ -228,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 (;;) {
@@ -324,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;
@@ -366,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
@@ -436,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)
@@ -452,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 ?? [];
@@ -464,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);
};
@@ -613,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) {
@@ -624,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;
@@ -647,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;
@@ -667,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)
);
@@ -680,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;
@@ -691,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;
@@ -715,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();
}
};
@@ -923,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"
@@ -950,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) ||
@@ -985,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">
@@ -1644,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 && (
@@ -1678,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 && (
@@ -1935,10 +1964,8 @@ export function FileExplorer({
);
}
/** An HTML file shown as the page, over the explorer: its relative CSS,
* scripts and images read through the same download route (`HtmlFrame`).
* Keys and clicks stop here — through the portal they would otherwise reach
* the explorer's own shortcuts (Delete would trash the selection). */
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
* its relative CSS, scripts and images read through the same download route. */
function HtmlViewer({
entry,
place,
@@ -1950,49 +1977,20 @@ function HtmlViewer({
onDownload: () => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={entry.name}
data-testid="fx-html-viewer"
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{entry.path}
</span>
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
</div>
</div>,
document.body
return (
<HtmlViewerModal
path={entry.path}
fetchFile={fetchFile}
onDownload={onDownload}
onClose={onClose}
testId="fx-html-viewer"
/>
);
}
@@ -14,8 +14,9 @@
* is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS,
* scripts and images read like downloads), and an
* file's Preview beside Whole file opens the page in a modal
* (`HtmlViewerModal`, its relative CSS, scripts and images read
* like downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
@@ -138,7 +139,8 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
@@ -197,7 +199,7 @@ function Delta({
);
}
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
@@ -889,11 +891,13 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false);
// A whole markdown or HTML file shown rendered instead of as source; per
// file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page always as the page.
// A whole markdown file shown rendered instead of as source; per file,
// opening as Settings → Appearance says (`lib/markdownView.ts`).
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
const [htmlOpen, setHtmlOpen] = useState(false);
useEffect(() => setHtmlOpen(false), [openFile, mode]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
@@ -1466,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.
@@ -2058,13 +2085,12 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown or HTML file.
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
// Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile);
// 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)]
);
@@ -2584,6 +2610,31 @@ export function LaneChangesPanel({
))}
</div>
)}
{/* An HTML file opens as the page in a modal over the screen;
a commit's page once its text is read (Whole file). */}
{mode === "file" && isHtml(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
disabled={dirty || (range === "commit" && !text)}
onClick={() => setHtmlOpen(true)}
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Eye className="h-3 w-3" />
{t("changes.preview")}
</button>
)}
{htmlOpen && openFile && (
<HtmlViewerModal
path={openFile}
// A commit's page is its text at that commit (no sibling
// files); a working one is read whole from the disk.
html={range === "commit" ? text?.lines.join("\n") : undefined}
fetchFile={htmlFetch}
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
onClose={() => setHtmlOpen(false)}
/>
)}
</div>
)}
</div>
@@ -2834,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>
@@ -2993,7 +3044,8 @@ export function LaneChangesPanel({
{t("changes.annotate")}
</button>
)}
{whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
{/* An HTML file's Preview sits beside Whole file instead. */}
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
@@ -3137,17 +3189,7 @@ export function LaneChangesPanel({
</Suspense>
)
) : whole ? (
text && mdShown && isHtml(openFile) ? (
<HtmlFrame
key={openFile}
path={text.path}
// A cut-off page is read whole through the download.
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
fetchFile={htmlFetch}
title={text.path}
className="rounded border border-border-light"
/>
) : text && mdShown ? (
text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</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>
+17 -16
View File
@@ -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;
+9 -2
View File
@@ -161,12 +161,19 @@ export function WorkspaceRow({
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
<span
data-folder-missing={lane.folder_missing ? "true" : undefined}
className={`truncate text-[13px] font-medium ${
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
}`}
>
{name}
</span>
</button>
)}
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
@@ -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 },
})
)
@@ -544,7 +544,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("opens a whole HTML file as the page, its source after Raw", async () => {
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -560,16 +560,53 @@ describe("LaneChangesPanel", () => {
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
const frame = await within(modal).findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
// The source stayed where it was, under the modal.
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
} finally {
fetchSpy.mockRestore();
}
});
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
],
})
);
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await user.click(await screen.findByTitle("deck.html"));
await screen.findByRole("button", { name: "Changes", pressed: true });
await user.click(await screen.findByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
} finally {
fetchSpy.mockRestore();
}
});
it("shows an image file as the picture, with no edit control", async () => {
@@ -800,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 () => {
@@ -1636,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);
});
});
+48 -11
View File
@@ -12,6 +12,8 @@
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
* a modal over the whole screen.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only.
@@ -357,6 +359,7 @@ export function FilePreview({
onClose,
className = "",
messages,
modal = false,
}: {
path: string;
scope: PreviewScope;
@@ -364,12 +367,16 @@ export function FilePreview({
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
modal?: boolean;
}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useDialog(modal, modalRef, onClose);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
@@ -424,7 +431,9 @@ export function FilePreview({
<section
data-testid="chat-file-preview"
className={
full
modal
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
: full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
@@ -522,16 +531,18 @@ export function FilePreview({
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
{!modal && (
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
)}
<button
type="button"
onClick={onClose}
@@ -546,6 +557,32 @@ export function FilePreview({
<Body path={path} content={content} view={view} scope={scope} />
</section>
);
if (modal) {
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={displayName(path)}
onClick={(e) => e.stopPropagation()}
>
{panel}
</div>
</div>,
document.body
);
}
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
+23 -5
View File
@@ -35,7 +35,9 @@
* of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
* an Outputs row, a path in a reply — opens instead as the page, the same
* preview in a modal over the whole screen. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
// An HTML file shown as the page, in a modal over the screen.
const [htmlView, setHtmlView] = useState<string | null>(null);
const show = useCallback(
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
[]
);
// The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide);
@@ -757,20 +765,21 @@ export const PaneChat = memo(function PaneChat({
// not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback(
(named: string) => {
setPreview(resolveIn(cwd, named));
show(resolveIn(cwd, named));
},
[cwd]
[cwd, show]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named);
if (/\.html?$/i.test(path)) return show(path);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
},
[laneId, cwd]
[laneId, cwd, show]
);
// Before the session starts the CLI may be asking in the terminal (trust
@@ -984,7 +993,7 @@ export const PaneChat = memo(function PaneChat({
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenFile={show}
onOpenList={setList}
/>
)
@@ -1006,6 +1015,15 @@ export const PaneChat = memo(function PaneChat({
/>
</div>
)}
{htmlView && (
<FilePreview
path={htmlView}
scope={scope}
messages={convMessages}
onClose={() => setHtmlView(null)}
modal
/>
)}
{list === "skills" && (
<SkillListModal
title={t("chat.outline.skills")}
+75 -1
View File
@@ -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();
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
cleanup();
});
it("shows a page as a modal over the screen, closed by Esc", async () => {
const onClose = vi.fn();
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
const dialog = await screen.findByRole("dialog", { name: "page.html" });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-expand")).toBeNull();
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
@@ -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();
});
});
+9 -1
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */
@@ -168,7 +169,14 @@ export function MemberPicker({
checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)}
label={
<span>
<span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)}
+8 -6
View File
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker";
import { UserAvatar } from "./UserAvatar";
/** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
@@ -363,12 +364,13 @@ export function PoolGroups({
} ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member"
>
<span
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
aria-hidden
>
{name.charAt(0)}
</span>
<UserAvatar
name={name}
userId={id}
avatarAt={u?.avatarAt}
letters={1}
className="h-4 w-4 text-[9px]"
/>
{name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && (
+3 -1
View File
@@ -4,7 +4,9 @@
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+22 -7
View File
@@ -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
@@ -33,6 +34,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
@@ -86,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);
@@ -138,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];
@@ -243,12 +247,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span
aria-hidden
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<UserAvatar
name={shown}
userId={user.id}
avatarAt={user.avatarAt}
className="h-10 w-10 text-sm"
/>
<div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })}
@@ -363,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")}>
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({
list: vi.fn(),
ompSharing: vi.fn(),
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(),
update: vi.fn(),
get: vi.fn(),
avatarUrl: () => null,
}));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
+12 -1
View File
@@ -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}}",
+43 -7
View File
@@ -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.",
+7 -4
View File
@@ -911,11 +911,13 @@
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here — one key ID works in every region:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Save",
"awsSaved": "SSH key ID saved for {{region}}",
"awsSavedAll": "SSH key ID saved for every region",
"awsSavedNamed": "SSH key ID saved for {{name}}",
"awsAllRegions": "Every region",
"awsUseAll": "Use in every region",
"otherTitle": "Other SSH hosts (advanced)",
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
"otherHost": "Host",
@@ -929,7 +931,7 @@
"alias": "Account name",
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
"tabGit": "GitHub · GitLab",
"tabOther": "Other hosts",
"addKey": "Add another key",
@@ -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": {
+3
View File
@@ -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",
+12 -1
View File
@@ -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}}",
+43 -7
View File
@@ -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.",
+7 -4
View File
@@ -899,11 +899,13 @@
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây — một key ID dùng được cho mọi region:",
"awsKeyId": "SSH key ID",
"awsRegion": "Region",
"awsSave": "Lưu",
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
"awsSavedAll": "Đã lưu SSH key ID cho mọi region",
"awsSavedNamed": "Đã lưu SSH key ID cho {{name}}",
"awsAllRegions": "Mọi region",
"awsUseAll": "Dùng cho mọi region",
"otherTitle": "Host SSH khác (nâng cao)",
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
"otherHost": "Host",
@@ -917,7 +919,7 @@
"alias": "Tên tài khoản",
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
"tabGit": "GitHub · GitLab",
"tabOther": "Host khác",
"addKey": "Thêm key khác",
@@ -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": {
+3
View File
@@ -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",
+139 -20
View File
@@ -684,6 +684,8 @@ export interface HostUsageGroup {
username?: string;
/** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null;
/** When the user's avatar was last set (null = none; absent from an older server). */
avatarAt?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
@@ -798,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;
};
@@ -981,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). */
@@ -1122,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();
@@ -1399,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 }) }
),
@@ -3156,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`
@@ -3173,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({
@@ -3189,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. */
@@ -3225,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) =>
@@ -3318,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 } = {}) => {
@@ -3366,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" }),
@@ -4054,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
@@ -4474,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),
}),
@@ -4707,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;
}
@@ -4922,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`. */
@@ -5882,6 +5969,38 @@ export interface CloneProgressView {
detail: string | null;
}
/** A background clone (`GET /api/lanes/clone/jobs`), kept by the server
* across reloads and restarts; `interrupted` = the server restarted during it. */
export interface CloneJob {
id: string;
kind: "workspace" | "source";
state: "queued" | "running" | "done" | "failed" | "interrupted" | "cancelled";
url: string;
name: string;
parent: string;
target: string;
/** The workspace a `source` clone is added to. */
laneId: number | null;
/** 1-based place in the clone queue while `queued`. */
position: number | null;
phase: CloneProgressView["phase"];
label: string | null;
percent: number | null;
detail: string | null;
error: { code: string; message: string; auth?: { protocol: string; host: string } } | null;
result: { laneId: number | null; path: string; created?: boolean } | null;
createdAt: string;
finishedAt: string | null;
request: {
url: string;
parent: string;
name: string;
tool?: string;
title?: string;
partial?: boolean;
};
}
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
+318
View File
@@ -0,0 +1,318 @@
/**
* @file cloneJobs.ts
* @description The app-wide list of background clones (`GET /api/lanes/clone/jobs`),
* shown by `components/BackgroundJobsDock.tsx`. The server keeps every job
* across reloads and restarts, so this store is only a view of that list:
* it is read on start, on window focus, after each start/retry/cancel, and
* every 1.5 s while any job is queued or running. A job SEEN going from
* active to finished in this page is announced once — a toast, the
* `agenthub:clone-job` window event (open pages re-read what the clone
* changed) and the starter's own `onDone` — and a done one then leaves the
* dock by itself; a job that finished unseen (a closed tab, F5) stays until
* dismissed. An older server answers a start synchronously: that answer is
* announced the same way as a job that is already done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import i18n from "i18next";
import { api, type CloneJob, type LaneSourcesView } from "./api";
import type { Lane } from "./types";
import { toast } from "./toast";
/** Window event carrying a finished `CloneJob` in `detail`. */
export const CLONE_JOB_EVENT = "agenthub:clone-job";
const POLL_MS = 1500;
/** How long a done job the user watched stays in the dock after its toast. */
const DONE_LINGER_MS = 8000;
export const isActiveJob = (j: Pick<CloneJob, "state">) =>
j.state === "queued" || j.state === "running";
type OnDone = (job: CloneJob, lane?: Lane) => void;
type Creds = { username: string; password: string; remember?: boolean };
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
type SourceBody = Omit<
Parameters<typeof api.lanes.cloneFolder>[1],
"background" | "progressToken"
>;
let jobs: CloneJob[] = [];
/** The last state seen per job, to tell a transition from a repeat. */
const known = new Map<string, CloneJob["state"]>();
/** Jobs seen queued/running in this page: only their finish is announced. */
const watched = new Set<string>();
/** Dismissed here: a poll already in flight must not bring them back. */
const hidden = new Set<string>();
const callbacks = new Map<string, OnDone>();
const listeners = new Set<() => void>();
let timer: ReturnType<typeof setTimeout> | undefined;
let started = false;
let inflight: Promise<void> | null = null;
let legacyId = 0;
const emit = () => listeners.forEach((l) => l());
function publish(next: CloneJob[]) {
jobs = next.filter((j) => !hidden.has(j.id));
emit();
}
function upsert(job: CloneJob) {
publish([job, ...jobs.filter((j) => j.id !== job.id)]);
}
/** The user-facing reason a job did not finish. */
export function cloneJobError(job: CloneJob): string {
const t = i18n.t.bind(i18n);
const e = job.error;
if (job.state === "cancelled") return t("lanes:cloneJobs.cancelled");
if (job.state === "interrupted" || e?.code === "EINTERRUPTED")
return t("lanes:cloneJobs.interrupted");
const host = e?.auth?.host || job.url;
if (e?.code === "ECREDENTIALS") return t("lanes:clone.loginNeeded", { host });
if (e?.code === "ESSHAUTH") return t("lanes:clone.sshRefused", { host });
return e?.message || t("lanes:cloneJobs.failed");
}
function announce(job: CloneJob, lane?: Lane) {
const t = i18n.t.bind(i18n);
const path = job.result?.path || job.target;
if (job.state === "done") {
toast.success(
job.kind === "workspace"
? t("lanes:cloneJobs.doneWorkspace", {
name: job.name,
path,
workspace: lane?.title || job.request?.title || job.name,
})
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
);
} else if (job.state === "cancelled") {
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
} else {
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
}
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
if (job.state === "done") {
const cb = callbacks.get(job.id);
callbacks.delete(job.id);
try {
cb?.(job, lane);
} catch {
/* a page's follow-up never breaks the store */
}
}
}
function observe(list: CloneJob[]) {
const finishedHere: CloneJob[] = [];
for (const j of list) {
const prev = known.get(j.id);
known.set(j.id, j.state);
if (isActiveJob(j)) {
watched.add(j.id);
continue;
}
if (!watched.has(j.id) || prev === j.state) continue;
watched.delete(j.id);
finishedHere.push(j);
}
for (const j of finishedHere) {
announce(j);
if (j.state === "done") setTimeout(() => void dismissCloneJob(j.id), DONE_LINGER_MS);
}
}
function schedule(ms = POLL_MS) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void refreshCloneJobs();
}, ms);
}
/** Read the list now (shared while one read is in flight). */
export function refreshCloneJobs(): Promise<void> {
if (!inflight) {
inflight = Promise.resolve()
.then(() => api.lanes.cloneJobs())
.then((r) => {
const list = Array.isArray(r?.jobs) ? r.jobs : [];
observe(list);
publish(list);
})
// An older server (404) or a blip: keep what we had; a start polls again.
.catch(() => undefined)
.finally(() => {
inflight = null;
if (jobs.some(isActiveJob)) schedule();
});
}
return inflight;
}
const onFocus = () => void refreshCloneJobs();
/** Start following the list (the dock calls this once, on mount). */
export function ensureCloneJobs(): void {
if (started) return;
started = true;
void refreshCloneJobs();
window.addEventListener("focus", onFocus);
}
function track(job: CloneJob, onDone?: OnDone) {
if (onDone) callbacks.set(job.id, onDone);
if (isActiveJob(job)) watched.add(job.id);
known.set(job.id, job.state);
upsert(job);
if (isActiveJob(job)) schedule();
else observe([job]);
}
/** The folder `git clone <url>` makes: the URL's last segment, no `.git`. */
const cloneName = (url: string) =>
url
.replace(/\/+$/, "")
.split(/[/:]/)
.pop()
?.replace(/\.git$/, "") || url;
/** An older server's synchronous answer, as a job that is already done. */
function legacyJob(
kind: CloneJob["kind"],
body: { url: string; name?: string; parent?: string; title?: string },
path: string | undefined,
laneId: number | null
): CloneJob {
path = path || "";
const name = body.name || path.split("/").pop() || cloneName(body.url);
return {
id: `legacy-${++legacyId}`,
kind,
state: "done",
url: body.url,
name,
parent: body.parent ?? "",
target: path,
laneId: kind === "source" ? laneId : null,
position: null,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: { laneId, path },
createdAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
request: { url: body.url, parent: body.parent ?? "", name, title: body.title },
};
}
/**
* Start a clone in the background. Resolves with its job once the server
* accepted it (a 4xx validation error rejects, for the form to show);
* `onDone` runs when this page sees it finish.
*/
export async function startClone(
spec:
| { kind: "workspace"; body: WorkspaceBody }
| { kind: "source"; laneId: number; body: SourceBody },
opts: { onDone?: OnDone } = {}
): Promise<CloneJob> {
if (spec.kind === "workspace") {
const r = await api.lanes.clone({ ...spec.body, background: true });
if ("job" in r && r.job) {
track(r.job, opts.onDone);
return r.job;
}
const legacy = r as { lane: Lane; path: string };
const job = legacyJob("workspace", spec.body, legacy.path, legacy.lane?.id ?? null);
callbacks.set(job.id, opts.onDone ?? (() => {}));
announce(job, legacy.lane);
return job;
}
const r = await api.lanes.cloneFolder(spec.laneId, { ...spec.body, background: true });
if ("job" in r && r.job) {
track(r.job, opts.onDone);
return r.job;
}
const legacy = r as LaneSourcesView & { path: string };
const job = legacyJob("source", spec.body, legacy.path, spec.laneId);
callbacks.set(job.id, opts.onDone ?? (() => {}));
announce(job);
return job;
}
/** Retry a failed / interrupted / cancelled job, optionally with a login. */
export async function retryCloneJob(id: string, credentials?: Creds): Promise<CloneJob> {
const { job } = await api.lanes.retryCloneJob(id, credentials);
const cb = callbacks.get(id);
callbacks.delete(id);
hidden.add(id);
publish(jobs.filter((j) => j.id !== id));
track(job, cb);
return job;
}
/** Stop a queued or running clone. */
export async function cancelCloneJob(id: string): Promise<void> {
try {
const { job } = await api.lanes.cancelCloneJob(id);
if (job) {
observe([job]);
upsert(job);
}
} catch (e) {
const err = e as { status?: number; code?: string };
if (err.code === "EDONE") {
void refreshCloneJobs();
return;
}
if (err.status === 404) {
toast.error(i18n.t("lanes:cloneJobs.stopUnsupported"));
return;
}
toast.error(e);
}
}
/** Forget a finished job here and on the server. */
export async function dismissCloneJob(id: string): Promise<void> {
hidden.add(id);
callbacks.delete(id);
watched.delete(id);
publish(jobs.filter((j) => j.id !== id));
await Promise.resolve()
.then(() => api.lanes.dismissCloneJob(id))
.catch(() => undefined);
}
function subscribe(l: () => void) {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
/** The jobs to show, newest first. */
export function useCloneJobs(): CloneJob[] {
return useSyncExternalStore(subscribe, () => jobs);
}
/** Tests only: forget everything, as a fresh page load would. */
export function resetCloneJobsForTests(): void {
if (timer) clearTimeout(timer);
timer = undefined;
jobs = [];
known.clear();
watched.clear();
hidden.clear();
callbacks.clear();
inflight = null;
started = false;
window.removeEventListener("focus", onFocus);
emit();
}
+2 -1
View File
@@ -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>
*/
+5
View File
@@ -2731,6 +2731,8 @@ export interface Lane {
stage_seconds: number | null;
last_event_seconds: number | null;
liveness: "active" | "idle" | "dead";
/** The workspace's folder no longer exists (deleted from Files or a shell). */
folder_missing?: boolean;
/** Stage id the server's heuristic infers from ingested tool events, or null
* if no signal was seen. Independent of `stage` (the agent's own declaration). */
detected_stage: string | null;
@@ -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
+167
View File
@@ -0,0 +1,167 @@
/**
* @file uploadJobs.ts
* @description The app-wide list of File Explorer uploads, shown by
* `components/BackgroundJobsDock.tsx` so an upload's progress outlives the
* Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports
* here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal,
* which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an
* upload cannot survive a reload: while one runs, a small record of it is
* kept in localStorage (refreshed every ~2 s), and the next page load shows
* a record that stopped being refreshed as an interrupted upload until it is
* dismissed. A fresh record belongs to another open tab and is left alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
export interface UploadJob {
id: string;
/** Where the files go, as the user reads it ("workspace/folder"). */
folder: string;
/** The file (or batch) being sent now. */
name: string;
done: number;
total: number;
sent: number;
bytes: number;
state: "running" | "stopping" | "interrupted";
}
type Stored = UploadJob & { beat: number };
const KEY = "agenthub.uploads.inflight";
const BEAT_MS = 2000;
/** A running record older than this is from a page that is gone. */
const STALE_MS = 6000;
const listeners = new Set<() => void>();
const controllers = new Map<string, AbortController>();
/** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */
const mine = new Set<string>();
let uploads: UploadJob[] = [];
let beat: ReturnType<typeof setInterval> | undefined;
let lastWrite = 0;
const emit = () => listeners.forEach((l) => l());
function readStored(): Stored[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]");
return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : [];
} catch {
return [];
}
}
function persist() {
lastWrite = Date.now();
try {
const others = readStored().filter((r) => !mine.has(r.id));
const own = uploads.map((u) => ({ ...u, beat: lastWrite }));
const all = [...others, ...own];
if (all.length) localStorage.setItem(KEY, JSON.stringify(all));
else localStorage.removeItem(KEY);
} catch {
/* storage off: the dock still works for this page */
}
}
/** Load what a previous page left behind: a stale running record = interrupted. */
function loadInterrupted() {
const now = Date.now();
const left = readStored().filter(
(r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS
);
for (const r of left) mine.add(r.id);
uploads = left.map((r) => ({
id: r.id,
folder: String(r.folder ?? ""),
name: String(r.name ?? ""),
done: Number(r.done) || 0,
total: Number(r.total) || 0,
sent: Number(r.sent) || 0,
bytes: Number(r.bytes) || 0,
state: "interrupted",
}));
if (left.length) persist();
}
loadInterrupted();
function set(id: string, patch: Partial<UploadJob>) {
uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u));
emit();
if (Date.now() - lastWrite > 1000) persist();
}
function heartbeat() {
const running = uploads.some((u) => u.state !== "interrupted");
if (running && !beat) beat = setInterval(persist, BEAT_MS);
if (!running && beat) {
clearInterval(beat);
beat = undefined;
}
}
/** Register an upload; the loop reports through the handle and checks `signal`. */
export function beginUpload(init: { folder: string; total: number; bytes: number }) {
const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const ctrl = new AbortController();
controllers.set(id, ctrl);
mine.add(id);
uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }];
emit();
persist();
heartbeat();
return {
id,
signal: ctrl.signal,
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
end: () => {
controllers.delete(id);
uploads = uploads.filter((u) => u.id !== id);
emit();
persist();
mine.delete(id);
heartbeat();
},
};
}
/** Stop a running upload: the request in flight is dropped, the loop ends. */
export function stopUpload(id: string): void {
const ctrl = controllers.get(id);
if (!ctrl) return;
set(id, { state: "stopping" });
ctrl.abort();
}
/** Forget an interrupted upload's record. */
export function dismissUpload(id: string): void {
uploads = uploads.filter((u) => u.id !== id);
emit();
persist();
mine.delete(id);
}
function subscribe(l: () => void) {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
export function useUploadJobs(): UploadJob[] {
return useSyncExternalStore(subscribe, () => uploads);
}
/** Tests only: re-read storage as a fresh page load would. */
export function reloadUploadsForTests(): void {
for (const c of controllers.values()) c.abort();
controllers.clear();
mine.clear();
if (beat) clearInterval(beat);
beat = undefined;
lastWrite = 0;
loadInterrupted();
emit();
}
+207 -65
View File
@@ -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
@@ -48,7 +53,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
@@ -78,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,
@@ -276,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) => {
@@ -441,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);
@@ -571,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>
@@ -1065,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;
@@ -1207,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(
@@ -1214,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)}
@@ -1236,6 +1316,7 @@ function ArtifactMenu({
menu,
onOpen,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1296,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)}>
@@ -1446,6 +1530,7 @@ function DetailDialog({
canInstall,
onClose,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1467,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">
@@ -1610,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>
@@ -1629,12 +1722,48 @@ function DetailDialog({
);
}
/** "N installs" on a card; a click lists who installed it, when, and where. */
/** "N installs" on a card; a click lists who installed it, when, and where.
* 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);
const [data, setData] = useState<ArtifactInstalls | null>(null);
const box = useRef<HTMLSpanElement>(null);
const pop = useRef<HTMLDivElement>(null);
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
if (!open) return setAt(null);
const place = () => {
const b = box.current?.getBoundingClientRect();
const p = pop.current;
if (!b || !p) return;
const w = p.offsetWidth;
const h = p.offsetHeight;
const gap = 4;
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
setAt({
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
top: below ? b.bottom + gap : b.top - gap - h,
});
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open, data]);
useEffect(() => {
if (!open) return;
@@ -1647,7 +1776,8 @@ function InstallsButton({ item }: { item: Artifact }) {
toast.error(err);
});
const onDown = (e: MouseEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false);
const n = e.target as Node;
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
@@ -1670,66 +1800,78 @@ function InstallsButton({ item }: { item: Artifact }) {
>
{t("installs", { count: item.installs })}
</button>
{open && (
<div
role="dialog"
aria-label={t("installsTitle")}
data-testid="artifact-installs"
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
>
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
{data === null ? (
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
) : (
<ul className="max-h-64 space-y-1 overflow-auto">
{data.items.map((i, n) => (
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-fg-primary">
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
{i.replaced && (
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
)}
</span>
<span className="shrink-0 text-[11px] text-fg-muted">
{new Date(i.at).toLocaleString()}
</span>
</div>
{i.paths && (
<div className="mt-0.5 space-y-0.5">
{i.paths.length ? (
i.paths.map((p) => (
<p
key={p}
className="truncate font-mono text-[10px] text-fg-muted"
title={p}
>
{p}
</p>
))
) : (
<p className="font-mono text-[10px] text-fg-muted">
{t("installsPluginWhere")}
</p>
{open &&
createPortal(
<div
ref={pop}
role="dialog"
aria-label={t("installsTitle")}
data-testid="artifact-installs"
style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
>
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
{data === null ? (
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
) : (
<ul className="max-h-64 space-y-1 overflow-auto">
{data.items.map((i, n) => (
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-fg-primary">
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
{i.replaced && (
<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>
</div>
)}
</li>
))}
{data.unrecorded > 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsUnrecorded", { count: data.unrecorded })}
</li>
)}
{data.items.length === 0 && data.unrecorded === 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsNoneOfYours")}
</li>
)}
</ul>
)}
</div>
)}
{i.paths && (
<div className="mt-0.5 space-y-0.5">
{i.paths.length ? (
i.paths.map((p) => (
<p
key={p}
className="truncate font-mono text-[10px] text-fg-muted"
title={p}
>
{p}
</p>
))
) : (
<p className="font-mono text-[10px] text-fg-muted">
{t("installsPluginWhere")}
</p>
)}
</div>
)}
</li>
))}
{data.unrecorded > 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsUnrecorded", { count: data.unrecorded })}
</li>
)}
{data.items.length === 0 && data.unrecorded === 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsNoneOfYours")}
</li>
)}
</ul>
)}
</div>,
document.body
)}
</span>
);
}
+4 -1
View File
@@ -50,7 +50,10 @@ export function FilesPage() {
// The workspace last shown first: Files reached from a workspace opens
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
// but for hidden files); the home is the fallback.
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
// A workspace whose folder was deleted has nothing to open.
const lanes = (await api.lanes.list().catch(() => ({ lanes: [] }))).lanes.filter(
(l) => !l.folder_missing
);
const last = lastWorkspace();
const shown = lanes.find((l) => l.id === last);
if (shown) return setStart({ lane: shown.id });
+80 -1
View File
@@ -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,17 @@ 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);
await user.click(within(box).getByText(/khoa/));
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByTestId("artifact-installs")).toBeNull();
});
@@ -389,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.33 (rebuild client to match)
UI build v2.8.51 (rebuild client to match)
</span>
</p>
</div>
+82 -16
View File
@@ -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.
+11 -6
View File
File diff suppressed because one or more lines are too long
+23 -3
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.33",
"version": "2.8.51",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.33",
"version": "2.8.51",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.33",
"version": "2.8.51",
"bin": {
"agenthub": "bin/agenthub.js"
},
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* @file Detached runner for one background `git clone`
* (`server/lib/clone-jobs.js`). The dashboard restarts on every update, and a
* git whose stderr was a pipe into it died of SIGPIPE on its next progress
* line — so the clone runs here instead, in its own session/process group,
* and outlives the server (the unit is `KillMode=process`). It reads its
* spec (`{cmd, argv, cwd, timeoutMs}` — exactly the command `worktree.git()`
* would run, built by `gitCommand`; never a secret, which travels in this
* process's env as it would in git's) from the 0600 file it is given and
* deletes it, runs the command with stderr appended straight to
* `<job dir>/stderr.log` (a file, never a pipe: nothing can SIGPIPE it), and
* writes `<job dir>/exit.json` `{code, signal, timedOut, finishedAt}` when it
* ends. A cancel SIGTERMs this whole group: git dies (sudo relays it to a
* member's git) and this runner stays to record how it ended.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const specPath = process.argv[2];
const dir = path.dirname(specPath);
let spec;
try {
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
} finally {
fs.rmSync(specPath, { force: true });
}
function writeExit(fields) {
const file = path.join(dir, "exit.json");
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ ...fields, finishedAt: new Date().toISOString() }), {
mode: 0o600,
});
fs.renameSync(tmp, file);
}
// Stay to record git's end: a cancel signals the whole group, this included.
process.on("SIGTERM", () => {});
process.on("SIGHUP", () => {});
process.on("SIGINT", () => {});
const log = fs.openSync(path.join(dir, "stderr.log"), "a", 0o600);
let timedOut = false;
let child;
try {
child = spawn(spec.cmd, spec.argv, {
cwd: spec.cwd,
env: process.env,
stdio: ["ignore", "ignore", log],
windowsHide: true,
});
} catch (err) {
writeExit({ code: null, signal: null, timedOut: false, error: err.message });
process.exit(0);
}
const timer =
spec.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, spec.timeoutMs)
: null;
child.on("error", (err) => {
if (timer) clearTimeout(timer);
writeExit({ code: null, signal: null, timedOut, error: err.message });
process.exit(0);
});
child.on("exit", (code, signal) => {
if (timer) clearTimeout(timer);
writeExit({ code, signal, timedOut });
process.exit(0);
});
+139
View File
@@ -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", () => {
+527
View File
@@ -0,0 +1,527 @@
/**
* @file Tests for background clones (`server/lib/clone-jobs.js` and the
* `background: true` path of `POST /api/lanes/clone` and
* `POST /api/lanes/:id/sources/clone`): checks still answer synchronously,
* a job is answered 202 and ends `done` with the workspace / extra folder
* made, a failure keeps the route's own code (ENOREPO, ECREDENTIALS with
* auth), a typed login never reaches the job file, retry and dismiss, cancel
* of a queued clone (never starts) and of a running one (git is killed, no
* workspace made), and a restart: a live detached runner adopted (and still
* cancellable), one that ended finished with its after-step run once, a dead
* one `interrupted`, a queued job requeued (or interrupted when it carried a
* typed login), retention; and that in server mode a job is only ever its
* owner's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "clone-jobs-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { createApp } = require("../index");
const cloneJobs = require("../lib/clone-jobs");
const queue = require("../lib/clone-queue");
let app;
let remote;
let base;
const WORK = path.join(ROOT, "work");
function request(method, p, body) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
}
async function waitFor(fn, ms = 20_000) {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
async function jobById(id) {
const r = await request("GET", "/api/lanes/clone/jobs");
return r.body.jobs.find((j) => j.id === id);
}
const finished = (id) =>
waitFor(async () => {
const j = await jobById(id);
return j && !["queued", "running"].includes(j.state) ? j : null;
});
before(async () => {
const src = path.join(ROOT, "src");
const bare = path.join(ROOT, "bare.git");
execFileSync("git", ["init", "-q", "-b", "main", src]);
fs.writeFileSync(path.join(src, "README.md"), "hi\n");
execFileSync("git", ["-C", src, "add", "."]);
execFileSync("git", [
"-C",
src,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"i",
]);
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
res.end();
});
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
fs.writeFileSync(
path.join(ROOT, ".gitconfig"),
[
`[url "file://${bare}"]`,
"\tinsteadOf = https://forge.test/o/r.git",
`[url "file://${path.join(ROOT, "missing.git")}"]`,
"\tinsteadOf = git@forge.test:o/missing.git",
`[url "http://127.0.0.1:${remote.address().port}/"]`,
"\tinsteadOf = https://private.test/",
"",
].join("\n")
);
fs.mkdirSync(WORK);
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
await new Promise((r) => remote.close(r));
});
describe("background clone", () => {
it("still refuses bad input synchronously", async () => {
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
background: "yes",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
const url = await request("POST", "/api/lanes/clone", {
url: "file:///etc",
parent: WORK,
background: true,
});
assert.equal(url.body.error.code, "EBADURL");
fs.mkdirSync(path.join(WORK, "taken"));
const taken = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "taken",
background: true,
});
assert.equal(taken.status, 409);
assert.equal(taken.body.error.code, "EEXISTS");
const tool = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "t2",
tool: "nope",
background: true,
});
assert.equal(tool.body.error.code, "EBADTOOL");
const jobs = await request("GET", "/api/lanes/clone/jobs");
assert.deepEqual(jobs.body.jobs, []);
});
it("answers 202 with a job that ends done with its workspace", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "bg",
title: "Bg",
background: true,
});
assert.equal(r.status, 202, JSON.stringify(r.body));
const { job } = r.body;
assert.match(job.id, /^[A-Za-z0-9_-]{16,}$/);
assert.equal(job.kind, "workspace");
assert.ok(["queued", "running"].includes(job.state));
assert.equal(job.target, path.join(WORK, "bg"));
assert.equal(job.laneId, null);
assert.equal(job.finishedAt, null);
assert.ok("phase" in job && "position" in job);
assert.equal(job.owner, undefined);
assert.deepEqual(job.request, {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "bg",
title: "Bg",
});
const done = await finished(job.id);
assert.equal(done.state, "done", JSON.stringify(done));
assert.equal(done.result.created, true);
assert.equal(done.result.path, path.join(WORK, "bg"));
assert.ok(done.finishedAt);
const lane = await request("GET", `/api/lanes/${done.result.laneId}`);
assert.equal(lane.body.lane?.title ?? lane.body.title, "Bg");
assert.equal(fs.readFileSync(path.join(WORK, "bg", "README.md"), "utf8"), "hi\n");
const file = cloneJobs.filePath();
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
const disk = JSON.parse(fs.readFileSync(file, "utf8")).jobs.find((j) => j.id === job.id);
assert.equal(disk.state, "done");
const busy = await request("POST", `/api/lanes/clone/jobs/${job.id}/retry`, {});
assert.equal(busy.status, 409);
assert.equal(busy.body.error.code, "EBUSY");
const dismissed = await request("DELETE", `/api/lanes/clone/jobs/${job.id}`);
assert.deepEqual(dismissed.body, { ok: true });
assert.equal(await jobById(job.id), undefined);
const again = await request("DELETE", `/api/lanes/clone/jobs/${job.id}`);
assert.equal(again.status, 404);
assert.equal(again.body.error.code, "ENOTFOUND");
});
it("records the route's own error codes, and never a typed login", async () => {
const missing = await request("POST", "/api/lanes/clone", {
url: "git@forge.test:o/missing.git",
parent: WORK,
name: "missing",
background: true,
});
assert.equal(missing.status, 202);
const m = await finished(missing.body.job.id);
assert.equal(m.state, "failed");
assert.equal(m.error.code, "ENOREPO");
assert.equal(m.result, null);
const login = await request("POST", "/api/lanes/clone", {
url: "https://private.test/o/r.git",
parent: WORK,
name: "private",
credentials: { username: "u", password: "s3cret-typed" },
background: true,
});
assert.equal(login.status, 202);
assert.equal(login.body.job.request.credentials, undefined);
const l = await finished(login.body.job.id);
assert.equal(l.error.code, "ECREDENTIALS");
assert.deepEqual(l.error.auth, { protocol: "https", host: "private.test" });
assert.ok(!fs.readFileSync(cloneJobs.filePath(), "utf8").includes("s3cret-typed"));
// Retry: a new job from the old request (+ a login typed now); the old goes.
const retry = await request("POST", `/api/lanes/clone/jobs/${l.id}/retry`, {
credentials: { username: "u", password: "again-typed" },
});
assert.equal(retry.status, 202, JSON.stringify(retry.body));
assert.notEqual(retry.body.job.id, l.id);
assert.equal(retry.body.job.name, "private");
assert.equal(await jobById(l.id), undefined);
const r2 = await finished(retry.body.job.id);
assert.equal(r2.error.code, "ECREDENTIALS");
assert.ok(!fs.readFileSync(cloneJobs.filePath(), "utf8").includes("again-typed"));
const bad = await request("POST", `/api/lanes/clone/jobs/${r2.id}/retry`, {
credentials: "nope",
});
assert.equal(bad.body.error.code, "EBADCRED");
assert.ok(await jobById(r2.id), "a refused retry keeps the old job");
const none = await request("POST", "/api/lanes/clone/jobs/nope-nope-nope-nope/retry", {});
assert.equal(none.status, 404);
});
it("clones an extra folder beside the workspace", async () => {
const home = path.join(WORK, "app");
fs.mkdirSync(home);
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
const id = made.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://forge.test/o/r.git",
name: "lib",
background: true,
});
assert.equal(r.status, 202, JSON.stringify(r.body));
assert.equal(r.body.job.kind, "source");
assert.equal(r.body.job.laneId, id);
const done = await finished(r.body.job.id);
assert.equal(done.state, "done", JSON.stringify(done));
assert.equal(done.result.laneId, id);
const ctx = await request("GET", `/api/lanes/${id}/sources`);
const folders = (ctx.body.sources || []).filter((s) => s.kind === "folder");
assert.deepEqual(
folders.map((f) => fs.realpathSync(f.path)),
[fs.realpathSync(path.join(WORK, "lib"))]
);
});
it("cancels a queued clone before it starts", async () => {
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
let open;
const blocker = queue.withCloneSlot(() => new Promise((r) => (open = r)));
try {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "queued",
background: true,
});
assert.equal(r.status, 202);
assert.equal(r.body.job.state, "queued");
assert.equal(r.body.job.position, 1);
const c = await request("POST", `/api/lanes/clone/jobs/${r.body.job.id}/cancel`);
assert.equal(c.status, 200, JSON.stringify(c.body));
assert.equal(c.body.job.state, "cancelled");
assert.equal(c.body.job.error.code, "ECANCELLED");
assert.equal(queue.cloneQueueState().waiting, 0);
const twice = await request("POST", `/api/lanes/clone/jobs/${r.body.job.id}/cancel`);
assert.equal(twice.status, 409);
assert.equal(twice.body.error.code, "EDONE");
open();
await blocker;
await new Promise((res) => setTimeout(res, 200));
assert.equal((await jobById(r.body.job.id)).state, "cancelled");
assert.ok(!fs.existsSync(path.join(WORK, "queued")));
} finally {
open?.();
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
}
});
it("cancels a running clone: git is killed, the slot frees, no workspace", async () => {
// An ssh "transport" that never answers keeps git clone running (a real
// ssh exits once git is gone; this stand-in lingers for its 15 s).
process.env.GIT_SSH_COMMAND = "exec sleep 15 2>/dev/null #";
try {
const r = await request("POST", "/api/lanes/clone", {
url: "ssh://slow.test/o/slow.git",
parent: WORK,
name: "slow",
background: true,
});
assert.equal(r.status, 202);
const id = r.body.job.id;
await waitFor(async () => (await jobById(id))?.state === "running");
await new Promise((res) => setTimeout(res, 300));
const c = await request("POST", `/api/lanes/clone/jobs/${id}/cancel`);
assert.equal(c.body.job?.state, "cancelled", JSON.stringify([c.body, await jobById(id)]));
await waitFor(() => queue.cloneQueueState().running === 0, 10_000);
const j = await jobById(id);
assert.equal(j.state, "cancelled");
assert.equal(j.result, null);
assert.ok(!fs.existsSync(path.join(WORK, "slow")));
const lanes = await request("GET", "/api/lanes");
const list = lanes.body.lanes || lanes.body;
assert.ok(!list.some((l) => l.cwd.endsWith(`${path.sep}slow`)));
const retry = await request("POST", `/api/lanes/clone/jobs/${id}/retry`, {});
assert.equal(retry.status, 202, "a cancelled clone can be retried");
const c2 = await request("POST", `/api/lanes/clone/jobs/${retry.body.job.id}/cancel`);
assert.equal(c2.body.job.state, "cancelled");
await waitFor(() => queue.cloneQueueState().running === 0, 10_000);
} finally {
delete process.env.GIT_SSH_COMMAND;
}
});
});
describe("clone-jobs store", () => {
it("answers a job only to its owner in server mode", () => {
const savedMode = process.env.AGENTHUB_MODE;
process.env.AGENTHUB_MODE = "server";
try {
const mk = (owner) =>
cloneJobs.create({
owner,
kind: "workspace",
url: "https://forge.test/o/r.git",
name: `o${owner}`,
parent: WORK,
target: path.join(WORK, `o${owner}`),
request: { url: "u", parent: WORK, name: "n", credentials: { password: "x" } },
});
const a = mk(101);
const b = mk(102);
assert.equal(a.request.credentials, undefined);
assert.deepEqual(
cloneJobs.list(101).map((j) => j.id),
[a.id]
);
assert.equal(cloneJobs.get(b.id, 101), null);
assert.throws(() => cloneJobs.remove(b.id, 101), { code: "ENOTFOUND" });
assert.throws(() => cloneJobs.cancel(b.id, 101), { code: "ENOTFOUND" });
assert.throws(() => cloneJobs.remove(a.id, 101), { code: "EBUSY" });
cloneJobs.cancel(a.id, 101);
cloneJobs.cancel(b.id, 102);
cloneJobs.remove(a.id, 101);
cloneJobs.remove(b.id, 102);
} finally {
if (savedMode === undefined) delete process.env.AGENTHUB_MODE;
else process.env.AGENTHUB_MODE = savedMode;
}
});
});
describe("a restart", () => {
const H = 60 * 60 * 1000;
const ago = (ms) => new Date(Date.now() - ms).toISOString();
const row = (id, state, extra = {}) => ({
id,
owner: null,
kind: "workspace",
state,
url: "https://forge.test/o/r.git",
name: id,
parent: WORK,
target: path.join(WORK, id),
laneId: null,
error: null,
result: null,
createdAt: ago(H),
finishedAt: null,
request: { url: "https://forge.test/o/r.git", parent: WORK, name: id },
pid: null,
once: null,
...extra,
});
/** Writes the job file and reloads it, as a new server process would. */
const restart = (rows) => {
cloneJobs._reset();
fs.mkdirSync(path.dirname(cloneJobs.filePath()), { recursive: true });
fs.writeFileSync(cloneJobs.filePath(), JSON.stringify({ jobs: rows }));
cloneJobs.resume();
};
const lanesAt = async (dir) => {
const r = await request("GET", "/api/lanes");
const list = r.body.lanes || r.body;
return list.filter((l) => fs.realpathSync(l.cwd) === fs.realpathSync(dir));
};
it("interrupts a dead runner, requeues a queued clone, drops expired jobs", async () => {
const dead = 2 ** 22 + 12345; // past pid_max on a default kernel: never alive
restart([
row("was-running-aaaaaaaa", "running", { pid: dead }),
row("queued-login-aaaaaaa", "queued", {
once: { used: true, remember: false, member: false, host: "forge.test" },
}),
row("requeued-aaaaaaaaaaa", "queued"),
row("done-fresh-aaaaaaaaa", "done", { finishedAt: ago(2 * H) }),
row("done-stale-aaaaaaaaa", "done", { finishedAt: ago(25 * H) }),
row("failed-fresh-aaaaaaa", "failed", { finishedAt: ago(6 * 24 * H) }),
row("failed-stale-aaaaaaa", "failed", { finishedAt: ago(8 * 24 * H) }),
row("cancel-stale-aaaaaaa", "cancelled", { finishedAt: ago(8 * 24 * H) }),
]);
const ids = cloneJobs.list(null).map((j) => j.id);
assert.deepEqual([...ids].sort(), [
"done-fresh-aaaaaaaaa",
"failed-fresh-aaaaaaa",
"queued-login-aaaaaaa",
"requeued-aaaaaaaaaaa",
"was-running-aaaaaaaa",
]);
for (const id of ["was-running-aaaaaaaa", "queued-login-aaaaaaa"]) {
const j = cloneJobs.get(id, null);
assert.equal(j.state, "interrupted", id);
assert.deepEqual(j.error, {
code: "EINTERRUPTED",
message: "AgentHub restarted while this clone ran",
});
assert.ok(j.finishedAt);
}
const disk = JSON.parse(fs.readFileSync(cloneJobs.filePath(), "utf8")).jobs;
assert.equal(disk.find((j) => j.id === "was-running-aaaaaaaa").state, "interrupted");
// The queued one (no typed login) simply runs now, workspace included.
const done = await finished("requeued-aaaaaaaaaaa");
assert.equal(done.state, "done", JSON.stringify(done));
assert.equal((await lanesAt(path.join(WORK, "requeued-aaaaaaaaaaa"))).length, 1);
});
it("finishes a runner that ended while no server ran: the after-step runs once", async () => {
const id = "ended-ok-aaaaaaaaaaa";
const target = path.join(WORK, id);
execFileSync("git", ["clone", "-q", path.join(ROOT, "bare.git"), target]);
fs.mkdirSync(cloneJobs.jobDir(id), { recursive: true });
fs.writeFileSync(path.join(cloneJobs.jobDir(id), "exit.json"), '{"code":0,"signal":null}');
const bad = "ended-bad-aaaaaaaaaa";
fs.mkdirSync(cloneJobs.jobDir(bad), { recursive: true });
fs.writeFileSync(path.join(cloneJobs.jobDir(bad), "exit.json"), '{"code":128,"signal":null}');
fs.writeFileSync(
path.join(cloneJobs.jobDir(bad), "stderr.log"),
"Cloning into 'x'...\nfatal: repository 'https://forge.test/o/nope.git/' not found\n"
);
restart([
row(id, "running", { pid: 2 ** 22 + 1 }),
row(bad, "running", { pid: 2 ** 22 + 2, url: "https://forge.test/o/nope.git" }),
]);
const ok = await finished(id);
assert.equal(ok.state, "done", JSON.stringify(ok));
assert.equal(ok.result.created, true);
const failed = await finished(bad);
assert.equal(failed.error.code, "ENOREPO");
assert.ok(!fs.existsSync(cloneJobs.jobDir(id)), "the runner's files are cleaned up");
// A second reload finds nothing left to do: still one workspace.
restart(JSON.parse(fs.readFileSync(cloneJobs.filePath(), "utf8")).jobs);
await new Promise((r) => setTimeout(r, 100));
assert.equal((await lanesAt(target)).length, 1);
assert.equal(cloneJobs.get(id, null).result.laneId, ok.result.laneId);
});
it("adopts a runner that outlived the server, and can still cancel it", async () => {
process.env.GIT_SSH_COMMAND = "exec sleep 15 2>/dev/null #";
try {
const r = await request("POST", "/api/lanes/clone", {
url: "ssh://slow.test/o/slow.git",
parent: WORK,
name: "adopted",
background: true,
});
assert.equal(r.status, 202);
const id = r.body.job.id;
const pid = await waitFor(async () => (await jobById(id))?.pid);
// Its own session and group: a server restart leaves it running.
const pgid = Number(
execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" })
);
assert.equal(pgid, pid);
const before = queue.cloneQueueState().running;
// A new server process: the old one's tail stops, the file is re-read.
cloneJobs._reset();
cloneJobs.resume();
const adopted = cloneJobs.get(id, null);
assert.equal(adopted.state, "running");
assert.equal(queue.cloneQueueState().running, before + 1, "counted against the limit");
const c = await request("POST", `/api/lanes/clone/jobs/${id}/cancel`);
assert.equal(c.body.job.state, "cancelled");
await waitFor(() => queue.cloneQueueState().running === before, 10_000);
await waitFor(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}, 10_000);
assert.equal((await jobById(id)).state, "cancelled");
assert.ok(!fs.existsSync(path.join(WORK, "adopted")));
} finally {
delete process.env.GIT_SSH_COMMAND;
}
});
});
+27 -1
View File
@@ -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", () => {
+126 -1
View File
@@ -5,7 +5,9 @@
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk; named keys beside the default
* ones, SSH logins per alias tied to one key, which a clone then offers
* alone), and the member (S3) path of
* alone; a CodeCommit region with no login borrowing another region's; one key ID
* saved for every region; a second account's login tried when the region's own is
* refused), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's
* store as it was.
@@ -650,6 +652,129 @@ describe("several accounts: named keys and SSH logins per alias", () => {
});
});
describe("CodeCommit: a region with no login borrows another region's", () => {
it("clones as the first saved CodeCommit user the remote accepts, and keeps that user in origin", async () => {
const bare = path.join(ROOT, "bare.git");
const R = "git-codecommit.ap-southeast-2.amazonaws.com";
const saved = [
["git-codecommit.ap-southeast-1.amazonaws.com", "APKAONE"],
["git-codecommit.us-east-1.amazonaws.com", "APKATWO"],
];
for (const [host, user] of saved) {
assert.equal((await request("PUT", "/api/me/git/ssh-user", { host, user })).status, 200);
}
// The test forge stands in for the region: APKAONE is refused (no such
// repo behind it), APKATWO is the account that owns the repo.
const rules = [
[`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAONE@${R}/v1/repos/r`],
[`url.file://${bare}.insteadOf`, `ssh://APKATWO@${R}/v1/repos/r`],
];
for (const [k, v] of rules) execFileSync("git", ["config", "--global", "--add", k, v]);
try {
const r = await request("POST", "/api/lanes/clone", {
url: `ssh://${R}/v1/repos/r`,
parent: WORK,
name: "borrowed-login",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const origin = execFileSync(
"git",
["-C", path.join(WORK, "borrowed-login"), "config", "remote.origin.url"],
{ encoding: "utf8" }
).trim();
assert.equal(origin, `ssh://APKATWO@${R}/v1/repos/r`);
} finally {
for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]);
for (const [host] of saved) await request("DELETE", `/api/me/git/ssh-user/${host}`);
}
});
});
describe("CodeCommit: one key ID for every region", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const { CODECOMMIT_REGIONS } = require("../lib/git-identity");
it("saves the default login for every region, and removes it in one go", async () => {
const r = await request("PUT", "/api/me/git/ssh-user", {
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKAALL",
allRegions: true,
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.sshUsers.length, CODECOMMIT_REGIONS.length);
assert.deepEqual(r.body.codecommitRegions, CODECOMMIT_REGIONS);
for (const region of ["ap-northeast-1", "eu-west-1"]) {
const h = `git-codecommit.${region}.amazonaws.com`;
assert.equal(getUrl(`ssh://${h}/v1/repos/x`), `ssh://APKAALL@${h}/v1/repos/x`);
}
// A second every-region save replaces each region's rule, never stacks.
const again = await request("PUT", "/api/me/git/ssh-user", {
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKAALL2",
allRegions: true,
});
assert.equal(again.body.sshUsers.length, CODECOMMIT_REGIONS.length);
assert.ok(again.body.sshUsers.every((u) => u.user === "APKAALL2"));
// An alias is never every region's.
const bad = await request("PUT", "/api/me/git/ssh-user", {
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKAX",
alias: "prod",
allRegions: true,
});
assert.equal(bad.status, 400);
const del = await request(
"DELETE",
"/api/me/git/ssh-user/git-codecommit.us-east-1.amazonaws.com?allRegions=1&user=APKAALL2"
);
assert.equal(del.status, 200);
assert.deepEqual(del.body.sshUsers, []);
});
it("tries a second account's login when the region's own is refused", async () => {
const bare = path.join(ROOT, "bare.git");
const R = "git-codecommit.ap-northeast-1.amazonaws.com";
assert.equal(
(await request("PUT", "/api/me/git/ssh-user", { host: R, user: "APKAMINE" })).status,
200
);
assert.equal(
(
await request("PUT", "/api/me/git/ssh-user", {
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKAOTHER",
alias: "other-acct",
})
).status,
200
);
const rules = [
[`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAMINE@${R}/v1/repos/r`],
[`url.file://${bare}.insteadOf`, `ssh://APKAOTHER@${R}/v1/repos/r`],
];
for (const [k, v] of rules) execFileSync("git", ["config", "--global", "--add", k, v]);
try {
const r = await request("POST", "/api/lanes/clone", {
url: `ssh://${R}/v1/repos/r`,
parent: WORK,
name: "second-account",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const origin = execFileSync(
"git",
["-C", path.join(WORK, "second-account"), "config", "remote.origin.url"],
{ encoding: "utf8" }
).trim();
assert.equal(origin, `ssh://APKAOTHER@${R}/v1/repos/r`);
} finally {
for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]);
await request("DELETE", `/api/me/git/ssh-user/${R}`);
await request("DELETE", "/api/me/git/ssh-user/other-acct");
}
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
@@ -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"}}';
+56 -8
View File
@@ -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();
+10
View File
@@ -337,6 +337,16 @@ describe("lanes lib", () => {
);
});
it("payload says folder_missing only when the folder is gone", () => {
const fs = require("node:fs");
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-gone-"));
const l = lanes.createLane({ cwd: dir });
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, false);
fs.rmSync(dir, { recursive: true });
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, true);
lanes.deleteLane(l.id);
});
it("payload carries node states and progress", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
lanes.setStage(l.id, { stage: "review" });
@@ -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");
+110
View File
@@ -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);
});
});
+27
View File
@@ -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");
+6
View File
@@ -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", () => {
+24
View File
@@ -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
View File
@@ -500,6 +500,14 @@ function startBackgroundServices() {
// One-time legacy-session backfill (a no-op once its marker file exists).
autoImportLegacySessions();
// Background clones a previous process started: adopt a runner that is
// still cloning, finish one that ended, requeue or interrupt the rest.
try {
require("./lib/clone-jobs").resume();
} catch (err) {
console.warn(`[clone-jobs] resume failed: ${err.message}`);
}
// Before any tmux call: without a UTF-8 locale every attach prints `_` for
// non-ASCII text (a macOS LaunchAgent sets none).
require("./lib/shell-path").ensureUtf8Locale();
@@ -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
View File
@@ -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
View File
@@ -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,
+651
View File
@@ -0,0 +1,651 @@
/**
* @file Background clones (`POST /api/lanes/clone` with `background: true`):
* a clone that outlives the request that started it AND the server. Each job
* is kept in memory and in `<data dir>/runtime/clone-jobs.json` (0600, tmp +
* rename): on every state change at once, on git's progress at most every
* `WRITE_EVERY_MS`. Its git runs in a detached runner
* (`scripts/clone-runner.js`, pid recorded on the job) with stderr appended
* to `<data dir>/runtime/clone-jobs/<id>/stderr.log` — the server tails it
* for progress and finishes the job when the runner writes `exit.json`.
* An update restarts the server but not the runner (`KillMode=process`), so
* the first read in a new process reconciles the file: a running job whose
* runner is alive is ADOPTED (counted against clone-queue's limit, tailed,
* finished when it exits), one whose `exit.json` exists is finished now, and
* any other is `interrupted` (`EINTERRUPTED`); a queued job is queued again,
* unless it carried a typed login (gone with the old process: interrupted,
* so the user types it again). Finishing runs the after-step the routes
* register (`setAfterStep`: the workspace, or the extra folder) exactly once,
* and a cancelled job never runs it. `cancel` takes a queued job out of
* clone-queue or SIGTERMs the runner's process group. Finished jobs are
* dropped after 24 h (done) or 7 days (failed/interrupted/cancelled). A job
* is answered only to its owner (`scope.ownerFor`; local mode: every job),
* and `request` — what a retry re-sends — never holds a credential: a typed
* login is used for its one run and never written here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const { getDataDir } = require("./claude-home");
const KEEP_DONE_MS = 24 * 60 * 60 * 1000;
const KEEP_FAILED_MS = 7 * 24 * 60 * 60 * 1000;
const WRITE_EVERY_MS = 2000;
const POLL_MS = 500;
const TAIL_MAX = 64 * 1024;
const ACTIVE = new Set(["queued", "running"]);
const REQUEST_KEYS = ["url", "parent", "name", "tool", "title", "partial"];
const RUNNER = path.join(__dirname, "..", "..", "scripts", "clone-runner.js");
/** id → job record (the CloneJob fields plus `owner`, `once`). */
let jobs = null;
let timer = null;
/** Live progress pollers, so a simulated restart (`_reset`) stops them. */
const watchers = new Set();
/** `(job) => {status, json, laneId?, created?}` — the routes' after-step. */
let afterStep = null;
const runtimeDir = () => path.join(getDataDir(), "runtime");
const filePath = () => path.join(runtimeDir(), "clone-jobs.json");
/** A job's files; its id is server-made base64url, never from a request. */
const jobDir = (id) => path.join(runtimeDir(), "clone-jobs", id);
const serverMode = () => process.env.AGENTHUB_MODE === "server";
const queue = () => require("./clone-queue");
function expired(job, now) {
if (!job.finishedAt) return false;
const age = now - Date.parse(job.finishedAt);
return job.state === "done" ? age > KEEP_DONE_MS : age > KEEP_FAILED_MS;
}
/** What goes to disk (`view` drops `owner` and `once`); never a callback. */
function record(job) {
return {
id: job.id,
owner: job.owner,
kind: job.kind,
state: job.state,
url: job.url,
name: job.name,
parent: job.parent,
target: job.target,
laneId: job.laneId,
phase: job.phase,
label: job.label,
percent: job.percent,
detail: job.detail,
error: job.error,
result: job.result,
createdAt: job.createdAt,
finishedAt: job.finishedAt,
request: job.request,
pid: job.pid ?? null,
// Whether a login was typed for this run — never the login itself.
once: job.once ?? null,
};
}
/** The in-memory-only parts of a job: cancel signal, queue hooks, mirror. */
function arm(job) {
const controller = new AbortController();
Object.defineProperties(job, {
// A progressToken's clone-queue record, kept in step (route-set).
mirror: { enumerable: false, writable: true, value: null },
// An adopted runner's clone-queue slot.
release: { enumerable: false, writable: true, value: null },
finishing: { enumerable: false, writable: true, value: false },
signal: { enumerable: false, value: controller.signal },
abort: { enumerable: false, value: () => controller.abort() },
onChange: {
enumerable: false,
value: (what) => {
if (job.mirror) {
for (const k of ["state", "phase", "label", "percent", "detail"]) {
job.mirror[k] = job[k];
}
}
if (what === "state") writeNow();
else writeSoon();
},
},
});
return job;
}
function interrupt(job) {
Object.assign(job, {
state: "interrupted",
error: { code: "EINTERRUPTED", message: "AgentHub restarted while this clone ran" },
finishedAt: new Date().toISOString(),
});
}
function load() {
if (jobs) return jobs;
jobs = new Map();
let list = [];
try {
const parsed = JSON.parse(fs.readFileSync(filePath(), "utf8"));
if (Array.isArray(parsed?.jobs)) list = parsed.jobs;
} catch {
// No file yet, or an unreadable one: start empty.
}
const now = Date.now();
let changed = false;
const later = [];
for (const j of list) {
if (!j || typeof j.id !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(j.id)) continue;
if (expired(j, now)) {
rmJobDir(j.id);
changed = true;
continue;
}
arm(j);
jobs.set(j.id, j);
if (j.state === "running") {
if (readExit(j.id)) later.push(() => finishFromDisk(j));
else if (runnerAlive(j.pid)) adopt(j);
else {
interrupt(j);
rmJobDir(j.id);
changed = true;
}
} else if (j.state === "queued") {
if (j.once?.used) {
interrupt(j);
changed = true;
} else later.push(() => start(j, null));
}
}
if (changed) writeNow();
for (const fn of later) setImmediate(fn);
return jobs;
}
function writeNow() {
if (timer) {
clearTimeout(timer);
timer = null;
}
const file = filePath();
try {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
const body = JSON.stringify({ jobs: [...(jobs || new Map()).values()].map(record) });
fs.writeFileSync(tmp, body, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, file);
} catch (err) {
console.warn(`[clone-jobs] could not save ${file}: ${err.message}`);
}
}
/** Progress is cheap in memory; on disk at most every WRITE_EVERY_MS. */
function writeSoon() {
if (timer) return;
timer = setTimeout(writeNow, WRITE_EVERY_MS);
timer.unref?.();
}
function rmJobDir(id) {
try {
fs.rmSync(jobDir(id), { recursive: true, force: true });
} catch {
/* AgentHub's own scratch: best effort */
}
}
function prune() {
const now = Date.now();
let changed = false;
for (const [id, j] of load()) {
if (expired(j, now)) {
jobs.delete(id);
rmJobDir(id);
changed = true;
}
}
if (changed) writeNow();
}
// ── The runner ─────────────────────────────────────────────────────────────
function readExit(id) {
try {
return JSON.parse(fs.readFileSync(path.join(jobDir(id), "exit.json"), "utf8"));
} catch {
return null;
}
}
/** Whether `pid` is still OUR runner (not a reused pid, not a zombie). */
function runnerAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
} catch (err) {
if (err.code !== "EPERM") return false;
}
if (process.platform !== "linux") return true;
try {
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes("clone-runner.js");
} catch {
return false;
}
}
/** SIGTERMs the runner's process group: git dies, the runner records it. */
function killRunner(job) {
if (!job.pid) return;
try {
process.kill(process.platform === "win32" ? job.pid : -job.pid, "SIGTERM");
} catch {
/* already gone */
}
}
function spawnRunner(job, spec, env) {
const dir = jobDir(job.id);
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const specPath = path.join(dir, "spec.json");
fs.writeFileSync(specPath, JSON.stringify(spec), { mode: 0o600, flag: "wx" });
const child = spawn(process.execPath, [RUNNER, specPath], {
cwd: dir,
env,
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.on("error", () => {
/* the poller sees no runner and fails the job */
});
child.unref();
job.pid = child.pid ?? null;
writeNow();
}
/**
* Tails a runner's stderr.log into `onProgress` until `exit.json` appears
* (or the runner is gone without one: `lost`). Resolves
* `{ok, timedOut, lost, stderr}` — `stderr` is the last TAIL_MAX of it.
*/
function watch(job, onProgress) {
return new Promise((resolve) => {
const dir = jobDir(job.id);
let offset = 0;
let tail = "";
let misses = 0;
const read = () => {
let fd;
try {
fd = fs.openSync(path.join(dir, "stderr.log"), "r");
const size = fs.fstatSync(fd).size;
if (size <= offset) return;
const from = Math.max(offset, size - TAIL_MAX);
const buf = Buffer.alloc(size - from);
fs.readSync(fd, buf, 0, buf.length, from);
offset = size;
const chunk = buf.toString("utf8");
tail = (tail + chunk).slice(-TAIL_MAX);
try {
onProgress?.(chunk);
} catch {
/* a progress reader never fails the clone */
}
} catch {
/* no log yet */
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
};
const w = {};
const stop = (out) => {
clearInterval(w.iv);
watchers.delete(w);
resolve(out);
};
w.iv = setInterval(() => {
read();
const exit = readExit(job.id);
if (exit) {
read();
return stop({
ok: exit.code === 0 && !exit.signal,
timedOut: Boolean(exit.timedOut),
lost: false,
stderr: tail,
});
}
if (runnerAlive(job.pid)) misses = 0;
else if (++misses >= 2) stop({ ok: false, timedOut: false, lost: true, stderr: tail });
}, POLL_MS);
w.iv.unref?.();
watchers.add(w);
});
}
const failureMessage = (out) =>
out.lost
? "the clone stopped unexpectedly"
: out.timedOut
? "git clone timed out"
: "git clone failed";
/**
* `cloneRepo`'s `launch`: the same git command, run by a detached runner
* instead of as this process's child. Rejects like `worktree.git()`.
*/
function launcher(job) {
return async (cwd, args, opts) => {
const { cmd, argv, env } = require("./worktree").gitCommand(cwd, args, {
env: opts.env,
background: true,
});
spawnRunner(job, { cmd, argv, cwd, timeoutMs: opts.timeout || 0 }, env);
const out = await watch(job, opts.onStderr);
if (out.ok) return { stdout: "", stderr: out.stderr };
throw Object.assign(new Error(failureMessage(out)), { git: { stderr: out.stderr } });
};
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
/**
* A new `queued` job. `request` is reduced to `REQUEST_KEYS` — never a
* credential; `once` records only whether a login was typed. The record is
* also clone-queue's job: `withCloneSlot` marks it running and `report`
* fills its progress, each through `onChange`.
*/
function create({ owner, kind, url, name, parent, target, laneId = null, request, once = null }) {
load();
const req = {};
for (const k of REQUEST_KEYS) if (request?.[k] !== undefined) req[k] = request[k];
const job = arm({
id: crypto.randomBytes(16).toString("base64url"),
owner: owner ?? null,
kind,
state: "queued",
url,
name,
parent,
target,
laneId,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: null,
createdAt: new Date().toISOString(),
finishedAt: null,
request: req,
pid: null,
once: once
? {
used: true,
remember: once.remember === true,
member: once.member === true,
host: once.host ?? null,
}
: null,
});
jobs.set(job.id, job);
writeNow();
return job;
}
/**
* Runs a queued job: clone-queue, then the clone through a runner (`once`:
* the typed login, this process only), then the after-step.
*/
function start(job, once = null) {
void (async () => {
let outcome;
try {
await require("./git-identity").cloneRepo({
job,
url: job.url,
parent: job.parent,
name: job.name,
once,
partial: job.request?.partial === true,
signal: job.signal,
launch: launcher(job),
});
outcome = { ok: true };
} catch (err) {
outcome = { ok: false, err };
}
finish(job, outcome);
})();
return job;
}
/** A runner this process did not start: counted, tailed, finished on exit. */
function adopt(job) {
job.release = queue().occupy();
watch(job, (chunk) => queue().report(job, chunk)).then((out) => finishFromDisk(job, out));
}
/**
* Finishes a runner's job from its files alone (no `cloneRepo` closure: the
* process that launched it is gone). A typed login a member's store kept for
* this run only is taken out again, as `cloneRepo` would have.
*/
function finishFromDisk(job, out = null) {
if (!out) {
const exit = readExit(job.id) || {};
let stderr = "";
try {
stderr = fs.readFileSync(path.join(jobDir(job.id), "stderr.log"), "utf8").slice(-TAIL_MAX);
} catch {
/* none */
}
out = {
ok: exit.code === 0 && !exit.signal,
timedOut: Boolean(exit.timedOut),
lost: false,
stderr,
};
}
const identity = require("./git-identity");
if (job.once?.used && job.once.member && !(out.ok && job.once.remember) && job.once.host) {
try {
identity.removeHttps(identity.accountForDir(job.parent), "https", job.once.host);
} catch {
/* best effort: the store is the member's own */
}
}
finish(
job,
out.ok
? { ok: true }
: { ok: false, err: identity.cloneFailure(job.url, out.stderr, failureMessage(out)) }
);
}
/** The error body a failed job records: the route's own code and message. */
function errorBody(err) {
return {
code: err?.code || "ECLONE",
message: err?.message || "clone failed",
...(err?.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}),
};
}
/**
* The end of a job, once: the slot back, the runner's files gone, then
* nothing (cancelled — its half-made folder dropped), the failure, or the
* after-step and its outcome.
*/
function finish(job, { ok, err }) {
if (job.finishing) return;
job.finishing = true;
job.release?.();
job.release = null;
rmJobDir(job.id);
if (cancelled(job)) {
dropCancelledTarget(job.target);
return;
}
if (!ok) return fail(job, errorBody(err));
let out;
try {
out = afterStep
? afterStep(job)
: { status: 500, json: { error: { code: "ECLONE", message: "no after-step registered" } } };
} catch (e) {
out = { status: 500, json: { error: { code: e.code || "ECLONE", message: e.message } } };
}
if (out.status < 300) {
succeed(job, {
laneId: out.laneId ?? null,
path: job.target,
...(out.created !== undefined ? { created: out.created } : {}),
});
} else fail(job, out.json?.error);
}
/**
* A cancelled clone of a HOST-account folder whose git left the target
* behind (it was checked absent before the job): that exact path, never
* through a link. A member's folder is git's own to clean (sudo relays the
* SIGTERM) — the dashboard never deletes inside a member's home.
*/
function dropCancelledTarget(target) {
try {
if (require("./run-as").accountForPath(target)) return;
if (!fs.lstatSync(target).isDirectory()) return;
fs.rmSync(target, { recursive: true, force: true });
} catch {
/* nothing there, or not ours to remove */
}
}
function settle(job, fields) {
if (job.state === "cancelled") return;
Object.assign(job, fields, { finishedAt: new Date().toISOString() });
if (job.mirror) queue().finish(job.mirror, job.state === "done");
if (jobs?.get(job.id) === job) writeNow();
}
/** The clone and its after-step worked: `result = {laneId, path, created?}`. */
function succeed(job, result) {
settle(job, { state: "done", error: null, result });
}
/** It failed: `error = {code, message, auth?}` — what the route would answer. */
function fail(job, error) {
const e = { code: error?.code || "ECLONE", message: error?.message || "clone failed" };
if (error?.auth) e.auth = { protocol: error.auth.protocol, host: error.auth.host };
settle(job, { state: "failed", error: e, result: null });
}
/**
* Cancels one of the caller's jobs: `ENOTFOUND` when not theirs, `EDONE` once
* it finished. Queued: out of the queue, never started. Running: the
* runner's group is SIGTERMed (and a borrowed-login probe aborted); the slot
* frees when git exits.
*/
function cancel(id, owner) {
const job = get(id, owner);
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
if (!ACTIVE.has(job.state)) {
throw Object.assign(new Error("the clone already finished"), { code: "EDONE" });
}
settle(job, {
state: "cancelled",
error: { code: "ECANCELLED", message: "Clone cancelled" },
result: null,
});
if (!queue().dequeue(job)) {
job.abort();
killRunner(job);
}
return job;
}
/** Whether a cancel already settled this job. */
const cancelled = (job) => job.state === "cancelled";
const mine = (job, owner) => !serverMode() || job.owner === (owner ?? null);
/** The CloneJob the API answers. */
function view(job) {
const { owner: _owner, once: _once, ...out } = record(job);
return {
...out,
position: job.state === "queued" ? queue().position(job) : null,
};
}
/** The caller's jobs, newest first. */
function list(owner) {
prune();
return [...jobs.values()]
.filter((j) => mine(j, owner))
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
}
/** One of the caller's jobs, or null. */
function get(id, owner) {
prune();
const job = typeof id === "string" ? jobs.get(id) : null;
return job && mine(job, owner) ? job : null;
}
/** Drops a finished job: `ENOTFOUND` when not the caller's, `EBUSY` while it runs. */
function remove(id, owner) {
const job = get(id, owner);
if (!job) throw Object.assign(new Error("no such clone"), { code: "ENOTFOUND" });
if (ACTIVE.has(job.state)) {
throw Object.assign(new Error("the clone is still running"), { code: "EBUSY" });
}
jobs.delete(id);
rmJobDir(id);
writeNow();
}
/** The routes' after-step: `(job) => {status, json, laneId?, created?}`. */
function setAfterStep(fn) {
afterStep = fn;
}
/** Server start: reconcile the file now (adopt, finish, interrupt, requeue). */
function resume() {
load();
}
/** Tests: forget the in-memory state and stop tailing, as a restart would. */
function _reset() {
if (timer) clearTimeout(timer);
timer = null;
for (const w of watchers) clearInterval(w.iv);
watchers.clear();
jobs = null;
}
module.exports = {
create,
start,
view,
list,
get,
remove,
cancel,
cancelled,
setAfterStep,
resume,
filePath,
jobDir,
ACTIVE,
KEEP_DONE_MS,
KEEP_FAILED_MS,
_reset,
};
+47 -5
View File
@@ -9,6 +9,8 @@
* least 1). A clone the browser named with a token (`track`) reports its
* place in the queue, then git's own `--progress` lines (`report`), to
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
* A background clone (`server/lib/clone-jobs.js`) passes its own job record,
* whose `onChange` hears each state change and progress line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -46,15 +48,18 @@ function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
if (job) {
job.state = "running";
job.onChange?.("state");
}
start();
}
}
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
async function withCloneSlot(fn, job = null) {
await new Promise((start) => {
waiting.push({ start, job });
await new Promise((start, reject) => {
waiting.push({ start, reject, job });
next();
});
try {
@@ -136,6 +141,7 @@ function report(job, chunk) {
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
job.onChange?.("progress");
return;
}
}
@@ -149,10 +155,9 @@ function progress(token, owner) {
sweep();
const job = jobs.get(token);
if (!job || job.owner !== owner) return null;
const position = waiting.findIndex((w) => w.job === job);
return {
state: job.state,
position: position >= 0 ? position + 1 : null,
position: position(job),
...cloneQueueState(),
phase: job.phase,
label: job.label,
@@ -161,6 +166,40 @@ function progress(token, owner) {
};
}
/**
* Counts a clone this process did not start — a background clone's runner
* adopted after a restart (clone-jobs.js) — against the limit, at once.
* Returns its release.
*/
function occupy() {
running++;
let released = false;
return () => {
if (released) return;
released = true;
running--;
next();
};
}
/**
* Takes a still-waiting clone out of the queue: its `withCloneSlot` rejects
* with `ECANCELLED` and it never starts. False when it is not waiting.
*/
function dequeue(job) {
const i = waiting.findIndex((w) => w.job === job);
if (i < 0) return false;
const [w] = waiting.splice(i, 1);
w.reject(Object.assign(new Error("Clone cancelled"), { code: "ECANCELLED" }));
return true;
}
/** A queued clone's 1-based place among the waiting ones, else null. */
function position(job) {
const i = waiting.findIndex((w) => w.job === job);
return i >= 0 ? i + 1 : null;
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
@@ -178,5 +217,8 @@ module.exports = {
finish,
report,
progress,
position,
dequeue,
occupy,
stripProgress,
};
+44 -2
View File
@@ -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
View File
@@ -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
View File
@@ -569,6 +569,38 @@ const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY
// region of that AWS account. Saved with `allRegions`, the default login is
// written for each region here, so a plain `ssh://git-codecommit.<any>…/` URL
// logs in — in a pane's terminal too — and the user never picks a region.
const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/;
const CODECOMMIT_REGIONS = [
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"sa-east-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"il-central-1",
"me-south-1",
"af-south-1",
"ap-east-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
];
const codecommitHost = (region) => `git-codecommit.${region}.amazonaws.com`;
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
async function keyLinks(account) {
const { git } = require("./worktree");
@@ -642,10 +674,16 @@ async function listSshUsers(account) {
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
* it names; false when there was none.
*/
async function removeSshUser(account, from) {
async function removeSshUser(account, from, { allRegions = false, user = null } = {}) {
const { git } = require("./worktree");
const f = String(from || "").toLowerCase();
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
// `allRegions` (from a CodeCommit host): every region's own rule — of
// `user` only when given — never an alias (a named key's login).
const rows = (await listSshUsers(account)).filter((r) =>
allRegions && CODECOMMIT_RE.test(f)
? r.alias === null && CODECOMMIT_RE.test(r.host) && (!user || r.user === user)
: (r.alias ?? r.host) === f
);
for (const r of rows) {
await git(homeOf(account), [
"config",
@@ -653,10 +691,10 @@ async function removeSshUser(account, from) {
"--fixed-value",
"--unset-all",
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
`ssh://${f}/`,
`ssh://${r.alias ?? r.host}/`,
]);
await unlinkKey(account, r.alias ?? r.host);
}
if (rows.length) await unlinkKey(account, f);
return rows.length > 0;
}
@@ -666,7 +704,10 @@ async function removeSshUser(account, from) {
* the key `key` only, or every key when it is null. Replaces our earlier rule
* for that host or alias.
*/
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
async function setSshUser(
account,
{ host, user, alias = null, key = null, allRegions = false } = {}
) {
const h = String(host || "")
.trim()
.toLowerCase();
@@ -682,9 +723,28 @@ async function setSshUser(account, { host, user, alias = null, key = null } = {}
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
throw err("ENOKEY", "no such SSH key");
}
const { git } = require("./worktree");
if (allRegions === true) {
if (a !== null || !CODECOMMIT_RE.test(h)) {
throw err("EBADHOST", "every region is for AWS CodeCommit's own login, without an alias");
}
// One login for the whole AWS account: every region's rule is replaced.
await removeSshUser(account, h, { allRegions: true });
for (const region of CODECOMMIT_REGIONS) {
const rh = codecommitHost(region);
await git(homeOf(account), [
"config",
"--global",
"--add",
`url.${insteadOfBase(rh, u)}.insteadOf`,
`ssh://${rh}/`,
]);
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${rh}.sshKey`, key]);
}
return listSshUsers(account);
}
const from = a ?? h;
await removeSshUser(account, from);
const { git } = require("./worktree");
await git(homeOf(account), [
"config",
"--global",
@@ -715,6 +775,47 @@ async function linkedKeyFile(account, url) {
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
}
// A CodeCommit URL with no user is tried with every CodeCommit login saved
// (the region's own first, then other regions' and every named key's — an
// alias row's user is as valid), each with its own key when it names one:
// a login saved for one region, or a second AWS account's key, clones from
// any region without the user rewriting the URL. The first the remote
// accepts wins (`cloneNow`).
/** `[{url, keyFile}]`: `url` with each borrowable CodeCommit user in it. */
async function borrowedLogins(account, url) {
if (!url.startsWith("ssh://")) return [];
let u;
try {
u = new URL(url);
} catch {
return [];
}
const host = u.host.toLowerCase();
if (u.username || !CODECOMMIT_RE.test(host)) return [];
const all = await listSshUsers(account);
// The region's own login first: it is the likeliest to be the right one.
const rows = [
...all.filter((r) => r.alias === null && r.host === host),
...all.filter((r) => !(r.alias === null && r.host === host)),
];
const keys = currentKeys(account);
const seen = new Set();
const out = [];
for (const r of rows) {
if (!CODECOMMIT_RE.test(r.host)) continue;
const keyFile = r.key ? (keys.find((k) => k.id === r.key)?.file ?? null) : null;
if (r.key && !keyFile) continue;
const id = `${r.user}\0${keyFile ?? ""}`;
if (seen.has(id)) continue;
seen.add(id);
out.push({ url: `ssh://${r.user}@${host}${u.pathname}`, keyFile });
}
// One login in all, and it is the region's own: git's insteadOf applies it.
if (out.length === 1 && rows[0]?.alias === null && rows[0]?.host === host) return [];
return out;
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
@@ -837,6 +938,32 @@ function sshFailure(stderr) {
);
}
/**
* The error a failed `git clone` of `url` answers, from git's stderr:
* `ECREDENTIALS` (with `{protocol, host}`), `ENOREPO`, `ESSHAUTH`, else
* `ECLONE` with git's own words. Shared by the held-open clone and a
* background one finished by a later server (clone-jobs.js).
*/
function cloneFailure(url, stderr, fallback = "git clone failed") {
const remote = remoteOf(url);
if (remote.scheme === "https" && httpsFailure(stderr)) {
return Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
protocol: "https",
host: remote.host,
});
}
if (notFound(stderr)) {
return err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
}
if (remote.scheme === "ssh" && sshFailure(stderr)) {
return Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
}
return err(
"ECLONE",
(require("./clone-queue").stripProgress(stderr) || fallback).trim().slice(-800)
);
}
/**
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
@@ -861,7 +988,13 @@ function cloneRepo({ job = null, ...opts }) {
);
}
async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) {
// `signal` (a background clone's cancel, clone-jobs.js) SIGTERMs whatever git
// call is running: a borrowed-login probe or the clone itself.
async function cloneNow(
{ url, parent, name, once = null, partial = false, signal = null, launch = null },
threads,
onProgress
) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
@@ -878,12 +1011,36 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
// A login naming its key (another AWS account, a second GitHub user)
// offers that key ONLY — another key could log in as the wrong account.
const linked = await linkedKeyFile(account, url);
let linked = await linkedKeyFile(account, url);
const sshFor = (file) => {
const k = file ? [file] : keyFiles(account);
const b = k.length ? `ssh${identityArgs(k)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
return `core.sshCommand=${b} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
};
// A CodeCommit region with no login of its own: the first borrowed login
// the remote accepts (one is taken as is). The clone's URL then names
// that user, so the repo's `origin` keeps logging in the same way.
const borrowed = await borrowedLogins(account, url);
for (const b of borrowed) {
const ok =
borrowed.length === 1 ||
(await git(parent, ["-c", sshFor(b.keyFile), "ls-remote", "--", b.url, "HEAD"], {
timeout: 60_000,
signal,
}).then(
() => true,
() => false
));
if (ok) {
url = b.url;
linked = b.keyFile;
break;
}
}
const keys = linked ? [linked] : keyFiles(account);
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
pre.push("-c", sshFor(linked));
if (keys.length) {
// Persisted by FILE name — both default types plus every named key:
// a default key made later is offered too (ssh skips a missing
@@ -931,33 +1088,25 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
let ok = false;
try {
if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled");
const filter = partial ? ["--filter=blob:none"] : [];
// --progress: git prints it to a pipe too, for the UI's progress line.
await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], {
// `launch` (a background clone, clone-jobs.js) runs this same command in
// a detached runner instead, and rejects like git() does.
const args = [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name];
const gitOpts = {
timeout: CLONE_TIMEOUT_MS,
env,
background: true,
onStderr: onProgress,
});
signal,
};
await (launch ? launch(parent, args, gitOpts) : git(parent, args, gitOpts));
ok = true;
} catch (e) {
const stderr = e.git?.stderr;
if (remote.scheme === "https" && httpsFailure(stderr)) {
throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
protocol: "https",
host: remote.host,
});
}
if (notFound(stderr)) {
throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
}
if (remote.scheme === "ssh" && sshFailure(stderr)) {
throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
}
throw err(
"ECLONE",
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
);
// SIGTERMed by a cancel: git (sudo forwards it) removes its own half-made folder.
if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled");
throw cloneFailure(url, e.git?.stderr, e.message);
} finally {
// A login typed without "remember" — or one that did not work — leaves
// the member's store as it was.
@@ -991,12 +1140,14 @@ module.exports = {
listSshUsers,
setSshUser,
removeSshUser,
CODECOMMIT_REGIONS,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
cloneFailure,
getAuthor,
defaultEmail,
setAuthor,
+56 -14
View File
@@ -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]
);
+2 -1
View File
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */
/** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
const userExtra = (u) => ({
kind: "user",
userId: u.id,
username: u.username,
displayName: u.displayName ?? null,
avatarAt: u.avatarAt ?? null,
});
async function collect() {
+15
View File
@@ -9,6 +9,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { db } = require("../db");
const aiTools = require("./ai-tools");
@@ -749,6 +750,17 @@ function ownerNames(id) {
};
}
/** True only when the folder is GONE (ENOENT/ENOTDIR) — a folder this
* process may not read is still there, so it never counts as deleted. */
function folderMissing(cwd) {
try {
fs.statSync(cwd);
return false;
} catch (err) {
return err.code === "ENOENT" || err.code === "ENOTDIR";
}
}
function lanePayload(lane, ageSec = null) {
const pipeline = getPipeline(lane.pipeline);
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
@@ -775,6 +787,9 @@ function lanePayload(lane, ageSec = null) {
: Math.max(0, Math.round((Date.now() - since) / 1000)),
last_event_seconds: ageSec,
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
// The folder was deleted under the workspace (from Files, a shell): the
// record stays — its sessions are history — but nothing can open in it.
folder_missing: folderMissing(lane.cwd),
};
}
+12 -6
View File
@@ -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));
}
+113
View File
@@ -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,
};
+35 -2
View File
@@ -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
View File
@@ -40,34 +40,12 @@ function lowPriority() {
}
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
* The command a git call runs — `{cmd, argv, env, gitCmd}` — exactly as
* `git()` builds it (scrubbed env, a member's `sudo -n -u ah-<name> git`,
* nice/ionice for `background`), without running it. A background clone's
* detached runner (`scripts/clone-runner.js`) executes this very command.
*/
async function git(
cwd,
args,
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
// true: run under nice/ionice (a clone — see lowPriority above).
background = false,
// Called with each stderr chunk as it arrives (a clone's --progress).
onStderr = null,
} = {}
) {
function gitCommand(cwd, args, { literal = false, env: extraEnv = {}, background = false } = {}) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -118,6 +96,41 @@ async function git(
argv = [...rest, cmd, ...argv];
cmd = first;
}
return { cmd, argv, env, gitCmd };
}
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
*
* CRITICAL: Scrubs git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.)
* that leak from parent processes. Without this, git operations on a worktree (where
* .git is a file, not a directory) fail with ".git/index: index file open failed:
* Not a directory" when run from within a git hook or from a shell that inherited
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(
cwd,
args,
{
timeout = 0,
maxBuffer = 8 * 1024 * 1024,
literal = false,
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
// true: run under nice/ionice (a clone — see lowPriority above).
background = false,
// Called with each stderr chunk as it arrives (a clone's --progress).
onStderr = null,
// An AbortSignal: aborting SIGTERMs the child (a cancelled clone).
signal = null,
} = {}
) {
const { cmd, argv, env, gitCmd } = gitCommand(cwd, args, { literal, env: extraEnv, background });
try {
const run =
@@ -142,6 +155,7 @@ async function git(
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
timeout,
encoding,
...(signal ? { signal } : {}),
});
// promisify(execFile) hands back its child; the stdin runner has none.
if (onStderr && pending.child?.stderr) {
@@ -1006,6 +1020,7 @@ async function hasNoRemotes(dir) {
}
module.exports = {
gitCommand,
LANES_ROOT,
git,
isGitRepo,
+2 -1
View File
@@ -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"]
),
+5
View File
@@ -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: {
+74 -1
View File
@@ -316,10 +316,83 @@ const paths = {
description:
"The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.",
},
background: {
type: "boolean",
description:
"Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.",
},
},
["url", "parent"]
),
responses: { 201: { description: "{lane, created: true, path}" } },
responses: {
201: { description: "{lane, created: true, path}" },
202: {
description: "`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).",
},
},
},
},
"/api/lanes/clone/jobs": {
get: {
tags: ["Git identity"],
summary: "The caller's background clone jobs, newest first",
description:
"CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `<data dir>/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller's own (local mode: all).",
operationId: "listCloneJobs",
responses: { 200: { description: "{jobs: CloneJob[]}" } },
},
},
"/api/lanes/clone/jobs/{id}": {
delete: {
tags: ["Git identity"],
summary: "Dismiss a finished background clone job",
operationId: "dismissCloneJob",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
responses: {
200: { description: "{ok: true}" },
404: { description: "ENOTFOUND — not the caller's job" },
409: { description: "EBUSY — still queued or running" },
},
},
},
"/api/lanes/clone/jobs/{id}/cancel": {
post: {
tags: ["Git identity"],
summary: "Cancel a queued or running background clone",
description:
"Queued: taken out of the queue, never started. Running: the runner's process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.",
operationId: "cancelCloneJob",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
responses: {
200: { description: "{job}" },
404: { description: "ENOTFOUND — not the caller's job" },
409: { description: "EDONE — it already finished" },
},
},
},
"/api/lanes/clone/jobs/{id}/retry": {
post: {
tags: ["Git identity"],
summary: "Retry a failed, interrupted or cancelled background clone",
description:
"A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.",
operationId: "retryCloneJob",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
requestBody: json({
credentials: {
type: "object",
properties: {
username: { type: "string" },
password: { type: "string" },
remember: { type: "boolean" },
},
},
}),
responses: {
202: { description: "{job} — the new CloneJob" },
404: { description: "ENOTFOUND — not the caller's job (ENOLANE: its workspace is gone)" },
409: { description: "EBUSY — queued, running or done" },
},
},
},
"/api/lanes/clone/progress/{token}": {
+6
View File
@@ -660,11 +660,17 @@ const paths = {
additionalProperties: true,
description: "A typed HTTPS login for this one clone.",
},
background: {
type: "boolean",
description:
'Run as a background clone job (see `/api/lanes/clone/jobs`): the checks answer at once, then 202 `{job}` with `kind: "source"`.',
},
},
}),
},
responses: {
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
202: { description: "`background: true`: {job} — a CloneJob." },
400: {
description:
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
+20
View File
@@ -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"],
+5
View File
@@ -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."

Some files were not shown because too many files have changed in this diff Show More