Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 |
+41
-8
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"accounts",
|
"accounts",
|
||||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||||
],
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
@@ -4328,8 +4328,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||||
|
async function cmdCursorAccounts(sub, name) {
|
||||||
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||||
|
if (sub === "add") {
|
||||||
|
console.log(
|
||||||
|
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||||
|
);
|
||||||
|
const added = pool.addAccount(name, {
|
||||||
|
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||||
|
});
|
||||||
|
console.log(`${c.green("✔")} ${added.name} added`);
|
||||||
|
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "remove") {
|
||||||
|
pool.removeAccount(name);
|
||||||
|
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub !== undefined && sub !== "list") {
|
||||||
|
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||||
|
}
|
||||||
|
table(
|
||||||
|
["cursor account", "live", "pinned"],
|
||||||
|
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||||
*/
|
*/
|
||||||
async function cmdAccountPin(sub, positional, tool) {
|
async function cmdAccountPin(sub, positional, tool) {
|
||||||
@@ -4337,7 +4365,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
|||||||
const [first, second] = positional;
|
const [first, second] = positional;
|
||||||
const username = sub === "pin" ? second : first;
|
const username = sub === "pin" ? second : first;
|
||||||
if (!username || (sub === "pin" && !first)) {
|
if (!username || (sub === "pin" && !first)) {
|
||||||
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
throw new Error(
|
||||||
|
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||||
@@ -4387,14 +4417,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||||
const name = positional[0];
|
const name = positional[0];
|
||||||
if (sub === "add" || sub === "remove") {
|
if (sub === "add" || sub === "remove") {
|
||||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||||
}
|
}
|
||||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||||
throw new Error("--tool is claude (the default) or codex");
|
throw new Error("--tool is claude (the default), codex or cursor");
|
||||||
}
|
}
|
||||||
if (sub === "pin" || sub === "unpin")
|
if (sub === "pin" || sub === "unpin")
|
||||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||||
|
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||||
if (sub === "add") {
|
if (sub === "add") {
|
||||||
console.log(
|
console.log(
|
||||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||||
@@ -4450,11 +4481,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
);
|
);
|
||||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||||
|
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||||
|
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
c.red(
|
c.red(
|
||||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -17,8 +17,15 @@
|
|||||||
* keeps answering while the chain installs. New skills/slash commands still
|
* keeps answering while the chain installs. New skills/slash commands still
|
||||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||||
* background process can do for it. Escape, the backdrop and × close it for
|
* background process can do for it. Escape, the backdrop and × close it for
|
||||||
* this page load only (never while applying); "Skip this version" is the one
|
* this page load only — also while an update applies: it keeps running in the
|
||||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
* background (the page still reloads itself once the new server is up), and a
|
||||||
|
* failure re-opens the modal with its error. While hidden — or once the status
|
||||||
|
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||||
|
* it, with the plugin chain's current step from `plugin_update_progress`, and a
|
||||||
|
* click on it shows the modal again. A chain the server reports as running
|
||||||
|
* (started before a reload or from another tab) is adopted the same way;
|
||||||
|
* "Skip this version" is the one
|
||||||
|
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||||
* and starts a Claude Code session there with the error as its prompt — the
|
* and starts a Claude Code session there with the error as its prompt — the
|
||||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||||
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
|
|||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [fixing, setFixing] = useState(false);
|
const [fixing, setFixing] = useState(false);
|
||||||
|
/** The running plugin chain's step, from the polls while it runs. */
|
||||||
|
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
@@ -236,20 +245,24 @@ export function UpdateNotifier() {
|
|||||||
status?.latest_release ||
|
status?.latest_release ||
|
||||||
status?.remote_sha ||
|
status?.remote_sha ||
|
||||||
(installed ? `installed:${installed.root}` : null);
|
(installed ? `installed:${installed.root}` : null);
|
||||||
// Updating replaces the host's install: only an admin is offered it.
|
// Updating replaces the host's install: only an admin is offered it. A
|
||||||
|
// running update stays shown (unless closed) even once the status stops
|
||||||
|
// offering it — the new version may already be on disk before it restarts.
|
||||||
const show = Boolean(
|
const show = Boolean(
|
||||||
isAdmin &&
|
isAdmin &&
|
||||||
status?.update_available &&
|
|
||||||
dismissKey &&
|
dismissKey &&
|
||||||
dismissedSha !== dismissKey &&
|
closedKey !== dismissKey &&
|
||||||
closedKey !== dismissKey
|
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||||
);
|
);
|
||||||
const busy = applying || restarting || fixing;
|
const busy = applying || restarting || fixing;
|
||||||
|
|
||||||
|
// Closing while an update applies only hides it: the apply request and the
|
||||||
|
// poll that reloads the page keep running, and a failure re-opens it.
|
||||||
const closeForNow = useCallback(() => {
|
const closeForNow = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (!dismissKey) return;
|
||||||
|
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||||
setClosedKey(dismissKey);
|
setClosedKey(dismissKey);
|
||||||
}, [busy, dismissKey]);
|
}, [applying, restarting, dismissKey, t]);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (busy || !dismissKey) return;
|
||||||
@@ -261,8 +274,8 @@ export function UpdateNotifier() {
|
|||||||
setDismissedSha(dismissKey);
|
setDismissedSha(dismissKey);
|
||||||
}, [busy, dismissKey]);
|
}, [busy, dismissKey]);
|
||||||
|
|
||||||
// Escape closes for now — never while an update is applying.
|
// Escape closes for now — an applying update carries on in the background.
|
||||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
useDialog(show, panelRef, closeForNow);
|
||||||
|
|
||||||
const copyCmd = async () => {
|
const copyCmd = async () => {
|
||||||
if (!status?.manual_command) return;
|
if (!status?.manual_command) return;
|
||||||
@@ -307,6 +320,7 @@ export function UpdateNotifier() {
|
|||||||
api.updates
|
api.updates
|
||||||
.status()
|
.status()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
|
setProgress(s.plugin_update_progress ?? null);
|
||||||
const v = s.last_plugin_update;
|
const v = s.last_plugin_update;
|
||||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||||
@@ -315,6 +329,7 @@ export function UpdateNotifier() {
|
|||||||
syncFromPayload(s);
|
syncFromPayload(s);
|
||||||
setRestarting(false);
|
setRestarting(false);
|
||||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||||
|
else setClosedKey(null); // closed while it ran: show why it failed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A new version answering is back too, even if a poll never
|
// A new version answering is back too, even if a poll never
|
||||||
@@ -333,9 +348,23 @@ export function UpdateNotifier() {
|
|||||||
[syncFromPayload, t]
|
[syncFromPayload, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A chain already running on the server (started before a reload, in another
|
||||||
|
// tab, or from the old bundle) is adopted: this tab shows its steps, then
|
||||||
|
// reloads onto the new server like the tab that started it.
|
||||||
|
const running = status?.plugin_update_progress ?? null;
|
||||||
|
const adoptedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
|
||||||
|
adoptedRef.current = true;
|
||||||
|
setProgress(running);
|
||||||
|
setRestarting(true);
|
||||||
|
pollUntilBack(true, status?.local_version ?? null);
|
||||||
|
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
|
||||||
|
|
||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgress(null);
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.updates.apply();
|
const result = await api.updates.apply();
|
||||||
@@ -359,6 +388,8 @@ export function UpdateNotifier() {
|
|||||||
} finally {
|
} finally {
|
||||||
setApplying(false);
|
setApplying(false);
|
||||||
}
|
}
|
||||||
|
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||||
|
setClosedKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||||
@@ -384,6 +415,54 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepText = progress?.step
|
||||||
|
? t(`pluginStep.${progress.step}`, {
|
||||||
|
index: progress.index,
|
||||||
|
total: progress.total,
|
||||||
|
defaultValue: progress.step,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hidden while it runs — closed, or the status no longer offers the update
|
||||||
|
// (the new version is already on disk): a chip keeps it in sight.
|
||||||
|
if (!show && isAdmin && (applying || restarting)) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="update-chip"
|
||||||
|
onClick={() => setClosedKey(null)}
|
||||||
|
title={t("chipHint")}
|
||||||
|
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 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">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||||
|
<span className="truncate">
|
||||||
|
{status?.latest_release
|
||||||
|
? t("chipTitle", { version: status.latest_release })
|
||||||
|
: t("updating")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||||
|
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||||
|
</span>
|
||||||
|
{progress && progress.total > 0 ? (
|
||||||
|
<span
|
||||||
|
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progress.index}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={progress.total}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent-solid transition-all"
|
||||||
|
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -436,7 +515,6 @@ export function UpdateNotifier() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeForNow}
|
onClick={closeForNow}
|
||||||
disabled={busy}
|
|
||||||
aria-label={t("later")}
|
aria-label={t("later")}
|
||||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
@@ -513,7 +591,14 @@ export function UpdateNotifier() {
|
|||||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||||
dependencies before it restarts, so it takes minutes, not
|
dependencies before it restarts, so it takes minutes, not
|
||||||
seconds — saying "restarting" there would look hung. */}
|
seconds — saying "restarting" there would look hung. */}
|
||||||
|
<span>
|
||||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||||
|
{stepText ? (
|
||||||
|
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||||
|
{stepText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||||
|
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||||
|
* server like the other preferences) arrange the list under foldable headers —
|
||||||
|
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||||
|
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||||
|
* on the group's header, by a drag that lands it below a row of that
|
||||||
|
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||||
|
* another to reorder the groups, and its menu renames, moves, folds every
|
||||||
|
* group, adds a new workspace straight into it or deletes it (its
|
||||||
|
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||||
|
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
@@ -46,7 +56,31 @@ import {
|
|||||||
writeLaneOrder,
|
writeLaneOrder,
|
||||||
} from "../lib/laneOrderStorage";
|
} from "../lib/laneOrderStorage";
|
||||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
import {
|
||||||
|
arrangeByGroup,
|
||||||
|
assignGroup,
|
||||||
|
createGroup,
|
||||||
|
deleteGroup,
|
||||||
|
groupOf,
|
||||||
|
moveGroup,
|
||||||
|
moveGroupBefore,
|
||||||
|
pruneWorkspaceGroups,
|
||||||
|
renameGroup,
|
||||||
|
setAllFolded,
|
||||||
|
setGroupFolded,
|
||||||
|
useWorkspaceGroups,
|
||||||
|
type WorkspaceGroup,
|
||||||
|
} from "../lib/workspaceGroups";
|
||||||
|
import { confirmDialog } from "../lib/confirm";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||||
|
import {
|
||||||
|
LANE_DRAG_TYPE,
|
||||||
|
WorkspaceRow,
|
||||||
|
isLaneDrag,
|
||||||
|
workspaceDot,
|
||||||
|
workspaceDotLabel,
|
||||||
|
workspaceName,
|
||||||
|
} from "./lanes/WorkspaceRow";
|
||||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||||
import { UserAvatar } from "./users/UserAvatar";
|
import { UserAvatar } from "./users/UserAvatar";
|
||||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||||
|
// "New workspace in this group": the group the added workspace joins.
|
||||||
|
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||||
const [orderTick, setOrderTick] = useState(0);
|
const [orderTick, setOrderTick] = useState(0);
|
||||||
|
const groups = useWorkspaceGroups();
|
||||||
|
const hasGroups = groups.groups.length > 0;
|
||||||
|
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||||
|
const [groupMenu, setGroupMenu] = useState<{
|
||||||
|
group: WorkspaceGroup;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
// A removed workspace leaves the stored groups (they hold ids only).
|
||||||
|
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||||
|
useEffect(() => {
|
||||||
|
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||||
|
}, [laneIdsKey]);
|
||||||
|
|
||||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||||
// status or session moves, so that is when the live-pane list is re-read;
|
// status or session moves, so that is when the live-pane list is re-read;
|
||||||
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
|
|||||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAdd = (sourceRepo?: string) => {
|
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||||
|
setAddToGroup(groupId);
|
||||||
setAddOpen(true);
|
setAddOpen(true);
|
||||||
api.run
|
api.run
|
||||||
.cwds()
|
.cwds()
|
||||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
|||||||
const toolFilter =
|
const toolFilter =
|
||||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const list = sortByStoredOrder(lanes, order).filter(
|
const list = arrangeByGroup(
|
||||||
(l) => !toolFilter || l.tool === toolFilter
|
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||||
|
groups
|
||||||
);
|
);
|
||||||
if (!grouped) return list;
|
if (!grouped) return list;
|
||||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
|||||||
return [...list].sort(
|
return [...list].sort(
|
||||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||||
);
|
);
|
||||||
}, [lanes, order, toolFilter, grouped, meId]);
|
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const nameHit = (l: Lane) =>
|
const nameHit = (l: Lane) =>
|
||||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
|||||||
setOrderTick((n) => n + 1);
|
setOrderTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||||
|
// screen and joins the next group instead (the arrangement would otherwise
|
||||||
|
// put it straight back): last of the group above, first of the one below —
|
||||||
|
// opened if folded, so the focused row stays shown. The shown order is
|
||||||
|
// written as it stands, so the store agrees. Never across an owner's section.
|
||||||
|
const nudge = (from: number, to: number) => {
|
||||||
|
const lane = sorted[from];
|
||||||
|
const next = sorted[to];
|
||||||
|
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||||
|
const target = groupOf(groups, next.id);
|
||||||
|
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||||
|
assignGroup(lane.id, target?.id ?? null);
|
||||||
|
if (target?.folded) setGroupFolded(target.id, false);
|
||||||
|
reorder((ids) => [...ids]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reorder((ids) => moveLane(ids, from, to));
|
||||||
|
};
|
||||||
|
|
||||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||||
// workspace's session list, the space above or below a row — lands too.
|
// workspace's session list, the space above or below a row — lands too.
|
||||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||||
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
|
|||||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||||
// Number("") is 0: an empty payload must not move the first row.
|
// Number("") is 0: an empty payload must not move the first row.
|
||||||
const from = raw === "" ? NaN : Number(raw);
|
const from = raw === "" ? NaN : Number(raw);
|
||||||
|
if (!Number.isInteger(from)) return;
|
||||||
|
const lane = sorted[from];
|
||||||
|
// Dropped on a group's header: into that group, at its top.
|
||||||
|
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||||
|
if (header && lane) {
|
||||||
|
const gid = header.dataset.groupDrop || null;
|
||||||
|
assignGroup(lane.id, gid);
|
||||||
|
const first = sorted.findIndex(
|
||||||
|
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||||
|
);
|
||||||
|
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const before = dropIndexAt(e.clientY);
|
const before = dropIndexAt(e.clientY);
|
||||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
// Elsewhere it joins the group of the row it now sits under (the line
|
||||||
|
// drawn above a header closes the section before it).
|
||||||
|
if (lane && hasGroups) {
|
||||||
|
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||||
|
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||||
|
const neighbour = rest[at - 1] ?? rest[0];
|
||||||
|
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||||
|
}
|
||||||
|
reorder((ids) => moveBefore(ids, from, before));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||||
|
// just above the ungrouped ones; a search hides it.
|
||||||
|
const emptyGroups = q
|
||||||
|
? []
|
||||||
|
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||||
|
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||||
|
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||||
|
<GroupHeader
|
||||||
|
key={g?.id ?? "none"}
|
||||||
|
group={g}
|
||||||
|
count={members.length}
|
||||||
|
// What a folded group hides: the most urgent of its workspaces, and
|
||||||
|
// whether the open one is among them.
|
||||||
|
worst={worstOf(members)}
|
||||||
|
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||||
|
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||||
|
folded={!!g?.folded && !q}
|
||||||
|
renaming={!!g && renamingGroup === g.id}
|
||||||
|
first={first}
|
||||||
|
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||||
|
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||||
|
onRenameEnd={(name) => {
|
||||||
|
setRenamingGroup(null);
|
||||||
|
if (g && name !== null) renameGroup(g.id, name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newGroup = (laneIds: number[] = []) => {
|
||||||
|
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||||
|
setRenamingGroup(id);
|
||||||
|
};
|
||||||
|
const removeGroup = async (g: WorkspaceGroup) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("tree.groupDelete"),
|
||||||
|
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||||
|
confirmLabel: t("tree.groupDelete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
deleteGroup(g.id);
|
||||||
|
};
|
||||||
|
|
||||||
const rename = async (lane: Lane, title: string | null) => {
|
const rename = async (lane: Lane, title: string | null) => {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
if (title === null || title === (lane.title || "")) return;
|
if (title === null || title === (lane.title || "")) return;
|
||||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
|||||||
>
|
>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="workspace-tree-new-group"
|
||||||
|
onClick={() => newGroup()}
|
||||||
|
aria-label={t("tree.newGroup")}
|
||||||
|
title={t("tree.newGroup")}
|
||||||
|
className={headBtn}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-tree-add"
|
data-testid="workspace-tree-add"
|
||||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
|||||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
|
// The user's own group, inside the owner's.
|
||||||
|
const ug = groupOf(groups, lane.id);
|
||||||
|
const ugKey = ug?.id ?? "";
|
||||||
|
const sameSection = (l: Lane) =>
|
||||||
|
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||||
|
const prev = sorted[i - 1];
|
||||||
|
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||||
|
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||||
|
const sectionFolded = !!ug?.folded && !q;
|
||||||
|
const hiddenRow = folded || sectionFolded;
|
||||||
return (
|
return (
|
||||||
<div key={lane.id} className="relative">
|
<div key={lane.id} className="relative">
|
||||||
{groupStart && groupShown && (
|
{groupStart && groupShown && (
|
||||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||||
|
{sectionStart &&
|
||||||
|
sectionShown &&
|
||||||
|
!folded &&
|
||||||
|
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||||
{dropAt === i && <DropLine />}
|
{dropAt === i && <DropLine />}
|
||||||
{visible(lane) && !folded && (
|
{visible(lane) && !hiddenRow && (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
lane={lane}
|
lane={lane}
|
||||||
index={i}
|
index={i}
|
||||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
|||||||
}}
|
}}
|
||||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||||
onNewSession={() => select(lane.id)}
|
onNewSession={() => select(lane.id)}
|
||||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
onReorder={nudge}
|
||||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||||
onRenameStart={() => setRenamingId(lane.id)}
|
onRenameStart={() => setRenamingId(lane.id)}
|
||||||
onRenameEnd={(title) => void rename(lane, title)}
|
onRenameEnd={(title) => void rename(lane, title)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{expanded(lane.id) && !folded && (
|
{expanded(lane.id) && !hiddenRow && (
|
||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
lane={lane}
|
lane={lane}
|
||||||
liveRuns={liveRuns}
|
liveRuns={liveRuns}
|
||||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{firstUngrouped === -1 &&
|
||||||
|
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||||
{dropAt === sorted.length && (
|
{dropAt === sorted.length && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DropLine />
|
<DropLine />
|
||||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
|||||||
y={menu.y}
|
y={menu.y}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||||
|
groups={groups.groups}
|
||||||
|
groupId={groups.of[menu.lane.id] ?? null}
|
||||||
|
onGroup={(target) => {
|
||||||
|
const id = menu.lane.id;
|
||||||
|
setMenu(null);
|
||||||
|
if (target === "new") newGroup([id]);
|
||||||
|
else assignGroup(id, target);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{groupMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={groupMenu.x}
|
||||||
|
y={groupMenu.y}
|
||||||
|
onClose={() => setGroupMenu(null)}
|
||||||
|
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setRenamingGroup(groupMenu.group.id);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupRename")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, -1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveUp")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||||
|
onSelect={() => {
|
||||||
|
moveGroup(groupMenu.group.id, 1);
|
||||||
|
setGroupMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupMoveDown")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-add"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
openAdd(undefined, g.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupAddWorkspace")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-fold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupFoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
testId="workspace-group-unfold-all"
|
||||||
|
onSelect={() => {
|
||||||
|
setGroupMenu(null);
|
||||||
|
setAllFolded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupUnfoldAll")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
testId="workspace-group-delete"
|
||||||
|
onSelect={() => {
|
||||||
|
const g = groupMenu.group;
|
||||||
|
setGroupMenu(null);
|
||||||
|
void removeGroup(g);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tree.groupDelete")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
setAddPrefill(null);
|
setAddPrefill(null);
|
||||||
|
setAddToGroup(null);
|
||||||
}}
|
}}
|
||||||
onAdded={(lane) => {
|
onAdded={(lane) => {
|
||||||
|
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||||
void refreshLanes();
|
void refreshLanes();
|
||||||
select(lane.id);
|
select(lane.id);
|
||||||
}}
|
}}
|
||||||
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a group header's drag carries: the group's id. */
|
||||||
|
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||||
|
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||||
|
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||||
|
|
||||||
|
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||||
|
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||||
|
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||||
|
function worstOf(lanes: Lane[]): Lane | null {
|
||||||
|
let best: Lane | null = null;
|
||||||
|
let top = 0;
|
||||||
|
for (const l of lanes) {
|
||||||
|
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||||
|
if (u > top) [best, top] = [l, u];
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** A workspace's owner group: its user id, or "none". */
|
/** A workspace's owner group: its user id, or "none". */
|
||||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||||
|
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||||
|
*/
|
||||||
|
function GroupHeader({
|
||||||
|
group,
|
||||||
|
count,
|
||||||
|
worst,
|
||||||
|
holdsSelected,
|
||||||
|
folded,
|
||||||
|
renaming,
|
||||||
|
first,
|
||||||
|
onToggle,
|
||||||
|
onMenu,
|
||||||
|
onRenameEnd,
|
||||||
|
onGroupDrop,
|
||||||
|
}: {
|
||||||
|
group: WorkspaceGroup | null;
|
||||||
|
count: number;
|
||||||
|
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||||
|
worst: Lane | null;
|
||||||
|
/** The open workspace is in this group (marked while folded). */
|
||||||
|
holdsSelected: boolean;
|
||||||
|
folded: boolean;
|
||||||
|
renaming: boolean;
|
||||||
|
first: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onMenu: (x: number, y: number) => void;
|
||||||
|
/** The typed name, or null when the edit was cancelled. */
|
||||||
|
onRenameEnd: (name: string | null) => void;
|
||||||
|
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||||
|
onGroupDrop: (draggedId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [draft, setDraft] = useState(group?.name ?? "");
|
||||||
|
const [dropOver, setDropOver] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) setDraft(group?.name ?? "");
|
||||||
|
}, [renaming, group?.name]);
|
||||||
|
const label = group ? group.name : t("tree.ungrouped");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-group-drop={group?.id ?? ""}
|
||||||
|
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||||
|
draggable={!!group && !renaming}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (!group) return;
|
||||||
|
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDropOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setDropOver(false);
|
||||||
|
if (!isGroupDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||||
|
if (id) onGroupDrop(id);
|
||||||
|
}}
|
||||||
|
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||||
|
>
|
||||||
|
{renaming && group ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
maxLength={60}
|
||||||
|
aria-label={t("tree.groupNameLabel")}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => onRenameEnd(draft)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onRenameEnd(draft);
|
||||||
|
else if (e.key === "Escape") onRenameEnd(null);
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={group ? !folded : undefined}
|
||||||
|
disabled={!group}
|
||||||
|
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||||
|
>
|
||||||
|
{group ? (
|
||||||
|
folded ? (
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||||
|
{folded && worst && (
|
||||||
|
<span
|
||||||
|
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||||
|
className="ml-1 flex shrink-0 items-center"
|
||||||
|
>
|
||||||
|
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||||
|
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{group && !renaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("tree.groupMenu")}
|
||||||
|
title={t("tree.groupMenu")}
|
||||||
|
onClick={(e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
onMenu(r.left, r.bottom);
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||||
function DropLine() {
|
function DropLine() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -298,12 +298,102 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores Escape while an update is applying", async () => {
|
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||||
applyMock.mockReturnValue(new Promise(() => {}));
|
let fail: (e: Error) => void = () => {};
|
||||||
|
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||||
render(<UpdateNotifier />);
|
render(<UpdateNotifier />);
|
||||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
await userEvent.keyboard("{Escape}");
|
await userEvent.keyboard("{Escape}");
|
||||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
fail(new Error("Command failed: npm run build"));
|
||||||
|
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — hidden while it runs", () => {
|
||||||
|
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||||
|
const PLUGIN: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(PLUGIN);
|
||||||
|
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
|
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...PLUGIN,
|
||||||
|
plugin_update_progress: {
|
||||||
|
step: "plugin_update",
|
||||||
|
index: 2,
|
||||||
|
total: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||||
|
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
const chip = await screen.findByTestId("update-chip");
|
||||||
|
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||||
|
expect(chip).toHaveTextContent(step);
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(
|
||||||
|
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
|
||||||
|
const running: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
// The new version is already installed: the status no longer offers it.
|
||||||
|
update_available: false,
|
||||||
|
plugin_update_progress: {
|
||||||
|
step: "bootstrap",
|
||||||
|
index: 3,
|
||||||
|
total: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(running);
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
|
||||||
|
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||||
|
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
const chip = await screen.findByTestId("update-chip");
|
||||||
|
expect(chip).toHaveTextContent(step);
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no chip when nothing is running", async () => {
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* 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,
|
||||||
* selection
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, and the status dot.
|
* search, the row menu's items by kind and tool, the status dot, and the
|
||||||
|
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||||
|
* state dot, dragging a header to reorder, fold all).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||||
|
vi.mock("../lanes/AddLaneModal", () => ({
|
||||||
|
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="stub-add-lane"
|
||||||
|
onClick={() => {
|
||||||
|
onAdded(lane(2, "/r/sub"));
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { WorkspaceTree } from "../WorkspaceTree";
|
import { WorkspaceTree } from "../WorkspaceTree";
|
||||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||||
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
|
|||||||
menu = screen.getByTestId("workspace-row-menu-3");
|
menu = screen.getByTestId("workspace-row-menu-3");
|
||||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
// No group yet: no headers at all.
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
|
||||||
|
// A new group from the row menu holds that workspace and asks for a name.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||||
|
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Backend{Enter}");
|
||||||
|
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||||
|
const gid = stored.groups[0].id as string;
|
||||||
|
expect(stored.of).toEqual({ 3: gid });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||||
|
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||||
|
|
||||||
|
// Another workspace joins it from its menu.
|
||||||
|
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||||
|
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Folding hides its rows; the ungrouped one stays.
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||||
|
expanded: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||||
|
|
||||||
|
// Deleting the group only ungroups: every workspace is still listed.
|
||||||
|
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||||
|
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||||
|
await user.clear(name);
|
||||||
|
await user.type(name, "Later{Enter}");
|
||||||
|
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||||
|
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||||
|
expect(header).toHaveTextContent("Later0");
|
||||||
|
expect(
|
||||||
|
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(rowIds()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceTree groups", () => {
|
||||||
|
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||||
|
const seed = (extra: { folded?: boolean } = {}) =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({
|
||||||
|
groups: [
|
||||||
|
{ id: "ga", name: "Alpha", ...extra },
|
||||||
|
{ id: "gb", name: "Beta" },
|
||||||
|
],
|
||||||
|
of: { 1: "ga", 3: "gb" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||||
|
|
||||||
|
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
// The last of Alpha, Alt+↓: the first of Beta.
|
||||||
|
screen.getByTestId("workspace-open-1").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||||
|
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||||
|
screen.getByTestId("workspace-open-3").focus();
|
||||||
|
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||||
|
expect(stored().of).toEqual({ 1: "gb" });
|
||||||
|
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||||
|
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||||
|
seed({ folded: true });
|
||||||
|
renderTree("/workspace?lane=1");
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||||
|
const state = screen.getByTestId("workspace-group-state-ga");
|
||||||
|
expect(state).toHaveTextContent("running");
|
||||||
|
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||||
|
"text-accent"
|
||||||
|
);
|
||||||
|
// An open group shows its rows instead.
|
||||||
|
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||||
|
seed();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
const dataTransfer = {
|
||||||
|
get types() {
|
||||||
|
return [...data.keys()];
|
||||||
|
},
|
||||||
|
setData: (k: string, v: string) => data.set(k, v),
|
||||||
|
getData: (k: string) => data.get(k) ?? "",
|
||||||
|
dropEffect: "move",
|
||||||
|
effectAllowed: "move",
|
||||||
|
};
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||||
|
);
|
||||||
|
data.clear();
|
||||||
|
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||||
|
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||||
|
// A header drag never moves a workspace.
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a workspace added from a group's menu into that group", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-gb");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||||
|
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||||
|
act(() =>
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||||
|
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||||
|
expect(stored().of).toEqual({ 1: "ga" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds and unfolds every group from a header's menu", async () => {
|
||||||
|
seed();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-group-ga");
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||||
|
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||||
|
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||||
|
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspaceDot", () => {
|
describe("workspaceDot", () => {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
* 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
|
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
* 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.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { cloneDirName } from "./AddLaneModal";
|
import { cloneDirName } from "./AddLaneModal";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
|||||||
parent,
|
parent,
|
||||||
onClose,
|
onClose,
|
||||||
onCloned,
|
onCloned,
|
||||||
|
onReopen,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** The absolute folder the clone goes into. */
|
/** The absolute folder the clone goes into. */
|
||||||
parent: string;
|
parent: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCloned: (lane: Lane, path: string) => void;
|
onCloned: (lane: Lane, path: string) => void;
|
||||||
|
/** Show the dialog again from the background chip. */
|
||||||
|
onReopen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
|||||||
const [sshRefused, setSshRefused] = useState(false);
|
const [sshRefused, setSshRefused] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || busyRef.current) return;
|
||||||
|
if (keepRef.current) {
|
||||||
|
keepRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUrl("");
|
setUrl("");
|
||||||
setName("");
|
setName("");
|
||||||
setLogin(null);
|
setLogin(null);
|
||||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, [open, parent]);
|
}, [open, parent]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
const folder = name.trim() || cloneDirName(url);
|
const folder = name.trim() || cloneDirName(url);
|
||||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||||
|
|
||||||
|
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 submit = async () => {
|
const submit = async () => {
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!u || busy) return;
|
if (!u || busy) return;
|
||||||
|
const progressToken = newCloneToken();
|
||||||
|
busyRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setToken(progressToken);
|
||||||
|
setTarget(into);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
try {
|
try {
|
||||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
|||||||
url: u,
|
url: u,
|
||||||
parent,
|
parent,
|
||||||
name: name.trim() || undefined,
|
name: name.trim() || undefined,
|
||||||
|
progressToken,
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
onCloned(result.lane, result.path);
|
onCloned(result.lane, result.path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
const host = e.auth?.host || u;
|
const host = e.auth?.host || u;
|
||||||
|
let message: string;
|
||||||
if (e.code === "ECREDENTIALS") {
|
if (e.code === "ECREDENTIALS") {
|
||||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||||
setLogin((l) => ({
|
setLogin((l) => ({
|
||||||
host,
|
host,
|
||||||
username: l?.username ?? "",
|
username: l?.username ?? "",
|
||||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
|||||||
}));
|
}));
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
setSshRefused(true);
|
setSshRefused(true);
|
||||||
setError(t("clone.sshRefused", { host }));
|
message = t("clone.sshRefused", { host });
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
message = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
setError(message);
|
||||||
|
busyRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
|
if (!openRef.current) {
|
||||||
|
keepRef.current = true;
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
onClick={busy ? undefined : onClose}
|
onClick={onClose}
|
||||||
// A portal still bubbles through React: keep keys away from the File
|
// A portal still bubbles through React: keep keys away from the File
|
||||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
|||||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
<FolderGit2 className="h-4 w-4" />
|
<FolderGit2 className="h-4 w-4" />
|
||||||
{t("fx.cloneRepo")}
|
{t("fx.cloneRepo")}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{login && (
|
{login && (
|
||||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
<div
|
||||||
|
data-testid="fx-clone-login"
|
||||||
|
className="space-y-2 rounded-md border border-border p-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={t("clone.username")}
|
aria-label={t("clone.username")}
|
||||||
placeholder={t("clone.username")}
|
placeholder={t("clone.username")}
|
||||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{busy && token && <CloneProgress token={token} />}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}{" "}
|
{error}{" "}
|
||||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||||
{t("fx.cancel")}
|
{busy ? t("clone.background") : t("fx.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
type FsTrashItem,
|
type FsTrashItem,
|
||||||
type FsHome,
|
type FsHome,
|
||||||
type FsPlace,
|
type FsPlace,
|
||||||
|
type GitSource,
|
||||||
isHomePlace,
|
isHomePlace,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
@@ -283,7 +284,7 @@ export function FileBrowser({
|
|||||||
/** A home to open on (`me`, or another user's for an admin). */
|
/** A home to open on (`me`, or another user's for an admin). */
|
||||||
initialHome?: string | null;
|
initialHome?: string | null;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
@@ -974,6 +975,7 @@ export function FileBrowser({
|
|||||||
open={cloning}
|
open={cloning}
|
||||||
parent={cloneParent}
|
parent={cloneParent}
|
||||||
onClose={() => setCloning(false)}
|
onClose={() => setCloning(false)}
|
||||||
|
onReopen={() => setCloning(true)}
|
||||||
onCloned={(lane, at) => {
|
onCloned={(lane, at) => {
|
||||||
setCloning(false);
|
setCloning(false);
|
||||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||||
@@ -1881,7 +1883,7 @@ export function FileExplorer({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string | null;
|
session?: string | null;
|
||||||
source?: number | null;
|
source?: GitSource | null;
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UnfoldVertical,
|
UnfoldVertical,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||||
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
|
|||||||
session?: string;
|
session?: string;
|
||||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||||
* wins over `session`, and is keyed on like it. */
|
* wins over `session`, and is keyed on like it. */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||||
onNewLane?: (branch?: string) => void;
|
onNewLane?: (branch?: string) => void;
|
||||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
applyChoices,
|
applyChoices,
|
||||||
parseConflicts,
|
parseConflicts,
|
||||||
@@ -47,7 +47,7 @@ export function LaneConflictView({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
path: string;
|
path: string;
|
||||||
/** The file left the conflict (resolved, or a whole side taken). */
|
/** The file left the conflict (resolved, or a whole side taken). */
|
||||||
onResolved: () => void;
|
onResolved: () => void;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
@@ -98,7 +98,7 @@ export function LaneGitBar({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts;
|
git: LaneGitFacts;
|
||||||
/** Rendered at the start of the action row: the header's branch line, so
|
/** Rendered at the start of the action row: the header's branch line, so
|
||||||
* the facts and the buttons that act on them read as one row. */
|
* the facts and the buttons that act on them read as one row. */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
import { LaneGitBar } from "./LaneGitBar";
|
import { LaneGitBar } from "./LaneGitBar";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
|
|||||||
laneId: number,
|
laneId: number,
|
||||||
/** Read that session's own worktree instead (`?session=`). */
|
/** Read that session's own worktree instead (`?session=`). */
|
||||||
session?: string,
|
session?: string,
|
||||||
/** Read that extra folder's repo instead (`?source=`). */
|
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||||
source?: number
|
source?: GitSource
|
||||||
): [LaneGitFacts | null, () => void] {
|
): [LaneGitFacts | null, () => void] {
|
||||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||||
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
|
|||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string;
|
session?: string;
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
/** Opens the Changes panel from the file list's "… more files". */
|
/** Opens the Changes panel from the file list's "… more files". */
|
||||||
onShowAll?: () => void;
|
onShowAll?: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -349,7 +349,7 @@ export function LaneGitHeader({
|
|||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
source?: number;
|
source?: GitSource;
|
||||||
git: LaneGitFacts | null;
|
git: LaneGitFacts | null;
|
||||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||||
* existing call: Rename (the row's inline field), New workspace here (the
|
* existing call: Rename (the row's inline field), New workspace here (the
|
||||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||||
* managed lane. The server's guards are the real ones; the menu only hides
|
* managed lane — plus Move to group (the sidebar's own groups,
|
||||||
|
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||||
|
* server's guards are the real ones; the menu only hides
|
||||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||||
* Escape / outside-click close) is `ContextMenu`.
|
* Escape / outside-click close) is `ContextMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -11,7 +13,9 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||||
|
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
|||||||
y,
|
y,
|
||||||
onClose,
|
onClose,
|
||||||
onAction,
|
onAction,
|
||||||
|
groups = [],
|
||||||
|
groupId = null,
|
||||||
|
onGroup,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Items the tree itself carries out (rename field, modals). */
|
/** Items the tree itself carries out (rename field, modals). */
|
||||||
onAction: (action: WorkspaceMenuAction) => void;
|
onAction: (action: WorkspaceMenuAction) => void;
|
||||||
|
/** The sidebar's groups and the one this workspace is in. */
|
||||||
|
groups?: WorkspaceGroup[];
|
||||||
|
groupId?: string | null;
|
||||||
|
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||||
|
onGroup?: (target: string | null | "new") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
|||||||
>
|
>
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{onGroup && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{t("menu.moveToGroup")}
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<MenuItem
|
||||||
|
key={g.id}
|
||||||
|
testId={`workspace-move-group-${g.id}`}
|
||||||
|
disabled={g.id === groupId}
|
||||||
|
onSelect={() => onGroup(g.id)}
|
||||||
|
>
|
||||||
|
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
|
||||||
|
<span className="truncate">{g.name}</span>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.newGroupWith")}
|
||||||
|
</MenuItem>
|
||||||
|
{groupId && (
|
||||||
|
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
|
||||||
|
<span className="w-3 shrink-0" />
|
||||||
|
{t("menu.removeFromGroup")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{lane.kind === "managed" && (
|
{lane.kind === "managed" && (
|
||||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
clone: vi.fn(),
|
clone: vi.fn(),
|
||||||
|
cloneProgress: vi.fn(),
|
||||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
|||||||
url: "git@host:o/repo.git",
|
url: "git@host:o/repo.git",
|
||||||
parent: "/w/one/src",
|
parent: "/w/one/src",
|
||||||
name: undefined,
|
name: undefined,
|
||||||
|
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(toast.success).toHaveBeenCalled();
|
expect(toast.success).toHaveBeenCalled();
|
||||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
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",
|
||||||
|
} 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"), "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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||||
vi.mocked(api.lanes.clone)
|
vi.mocked(api.lanes.clone)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
|
|||||||
@@ -18,7 +18,10 @@
|
|||||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
* shows as `[Image #n]` like a native drop — except a PASTED image in a
|
||||||
|
* `claude` pane of a local-mode dashboard opened on its own machine, which is
|
||||||
|
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
|
||||||
|
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
|
||||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
@@ -75,6 +78,8 @@ import {
|
|||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
|
import { readNativePaste } from "../../lib/nativePaste";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||||
|
|
||||||
@@ -239,6 +244,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
|
|
||||||
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
|
||||||
const ctrlVRef = useRef(false);
|
const ctrlVRef = useRef(false);
|
||||||
|
const { local: localMode } = usePermissions();
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
if (hasText(e)) {
|
if (hasText(e)) {
|
||||||
@@ -279,6 +285,20 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
// On the dashboard's own machine the browser's clipboard IS the pane's:
|
||||||
|
// `claude` reads a pasted image itself on ^V, as in a native terminal —
|
||||||
|
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
|
||||||
|
const nativeImage =
|
||||||
|
tool === "claude" &&
|
||||||
|
localMode &&
|
||||||
|
isLocalBrowser() &&
|
||||||
|
readNativePaste() &&
|
||||||
|
files.length > 0 &&
|
||||||
|
files.every((f) => f.type.startsWith("image/"));
|
||||||
|
if (nativeImage) {
|
||||||
|
termRef.current?.input("\x16", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||||
else termRef.current?.input("\x16", true);
|
else termRef.current?.input("\x16", true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -590,6 +590,37 @@ describe("TerminalView", () => {
|
|||||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
inputMock.mockClear();
|
||||||
|
pasteMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\x16", true);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(pasteMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
|
||||||
|
localStorage.setItem("agenthub.terminal.nativePaste", "off");
|
||||||
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||||
|
inputMock.mockClear();
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
|
||||||
|
fireEvent.paste(screen.getByTestId("terminal-view"), {
|
||||||
|
clipboardData: { files: [img], getData: () => "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
|
||||||
|
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem("agenthub.terminal.nativePaste");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
|
||||||
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
|
||||||
// pasted like any browser paste: sending ^V for it pasted nothing.
|
// pasted like any browser paste: sending ^V for it pasted nothing.
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
* `#terminal-font`, with a preview line), and — local mode only — whether a
|
||||||
|
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
|
||||||
|
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
|
||||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||||
@@ -39,6 +41,8 @@ import {
|
|||||||
} from "../../lib/terminalFont";
|
} from "../../lib/terminalFont";
|
||||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||||
|
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import {
|
import {
|
||||||
TERMINAL_FONT_SIZES,
|
TERMINAL_FONT_SIZES,
|
||||||
UI_FONT_SIZES,
|
UI_FONT_SIZES,
|
||||||
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
|
|||||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||||
|
const [nativePaste, setNativePaste] = useState(readNativePaste);
|
||||||
|
const { local } = usePermissions();
|
||||||
const pickTermFont = (font: TerminalFont) => {
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
writeTerminalFont(font);
|
writeTerminalFont(font);
|
||||||
setTermFont(font);
|
setTermFont(font);
|
||||||
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
|
|||||||
>
|
>
|
||||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||||
</p>
|
</p>
|
||||||
|
{local && (
|
||||||
|
<SettingsSwitchRow
|
||||||
|
id="native-paste"
|
||||||
|
label={t("nativePaste.label")}
|
||||||
|
description={t("nativePaste.description")}
|
||||||
|
checked={nativePaste}
|
||||||
|
onChange={(on) => {
|
||||||
|
writeNativePaste(on);
|
||||||
|
setNativePaste(on);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
|
||||||
|
* workspace folder that holds several git repositories shows each of them as
|
||||||
|
* its own section in the Changes panel (off by default: only the folder's own
|
||||||
|
* git). Writes the account preference in `lib/innerReposPref.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function ChangesPanelSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const mode = useInnerReposMode();
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("changesPanel.innerRepos")}
|
||||||
|
description={t("changesPanel.innerReposDesc")}
|
||||||
|
checked={mode === "all"}
|
||||||
|
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||||
* kind of host, the extra keys, the other hosts) — and, for
|
* kind of host, then the other hosts; a named key is a card of its own under
|
||||||
|
* its type's default one, with the same steps, and "Add another key" makes
|
||||||
|
* the next one below) — and, for
|
||||||
* a user with their own Linux account (S3), their own HTTPS
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. The private key and
|
||||||
@@ -24,17 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||||
Cloud,
|
|
||||||
Copy,
|
|
||||||
GitBranch,
|
|
||||||
KeyRound,
|
|
||||||
KeySquare,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
|
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One kind of git host and the key it takes: no key → a Generate button;
|
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||||
|
* steps. The default key of a type and every named one share it.
|
||||||
*/
|
*/
|
||||||
function KeyCard({
|
function KeyCard({
|
||||||
title,
|
title,
|
||||||
sub,
|
sub,
|
||||||
type,
|
label,
|
||||||
k,
|
k,
|
||||||
id,
|
writable,
|
||||||
busy,
|
busy,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -114,12 +108,13 @@ function KeyCard({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
type: "ed25519" | "rsa";
|
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||||
|
label: string;
|
||||||
k: Key | undefined;
|
k: Key | undefined;
|
||||||
id: GitIdentity;
|
writable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
onGenerate: () => Promise<void>;
|
||||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
onRemove: () => Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -130,23 +125,19 @@ function KeyCard({
|
|||||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||||
</div>
|
</div>
|
||||||
{id.writable && (
|
{writable && (
|
||||||
<button
|
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void onGenerate(type)}
|
|
||||||
>
|
|
||||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{k && id.writable && (
|
{k && writable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${BTN} hover:text-status-danger`}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void onRemove(type)}
|
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||||
|
onClick={() => void onRemove()}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.remove")}
|
{t("gitIdentity.remove")}
|
||||||
@@ -165,9 +156,9 @@ function KeyCard({
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||||
value={k.publicKey}
|
value={k.publicKey}
|
||||||
rows={type === "rsa" ? 3 : 2}
|
rows={k.type === "rsa" ? 3 : 2}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||||
/>
|
/>
|
||||||
@@ -182,6 +173,104 @@ 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.
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.awsKeyId")}
|
||||||
|
placeholder="APKA…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={v.keyId}
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||||
|
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||||
|
* offers only this key — the default key would log in as the other account.
|
||||||
|
*/
|
||||||
|
function GitHostForm({
|
||||||
|
busy,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
onSave: (host: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [host, setHost] = useState("github.com");
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(host.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherHost")}
|
||||||
|
placeholder="github.com"
|
||||||
|
autoComplete="off"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
|
|||||||
const keyOf = (type: "ed25519" | "rsa") =>
|
const keyOf = (type: "ed25519" | "rsa") =>
|
||||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||||
const named = keys.filter((k) => k.name);
|
const named = keys.filter((k) => k.name);
|
||||||
|
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||||
const keyLabel = (keyId: string | null | undefined) => {
|
const keyLabel = (keyId: string | null | undefined) => {
|
||||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||||
if (!k) return t("gitIdentity.anyKey");
|
if (!k) return t("gitIdentity.anyKey");
|
||||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||||
};
|
};
|
||||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||||
const makeNamed = async () => {
|
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||||
if (!newKey) return;
|
// account name of the user's own goes in its step 3.
|
||||||
const ok = await run(
|
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
const prefix = type === "rsa" ? "aws" : "git";
|
||||||
|
let n = 2;
|
||||||
|
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||||
|
t("gitIdentity.keyMade")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const addKey = (type: "ed25519" | "rsa") => (
|
||||||
|
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.addKey")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const replaceNamed = async (k: Key) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||||
t("gitIdentity.keyMade")
|
t("gitIdentity.keyMade")
|
||||||
);
|
);
|
||||||
if (ok) setNewKey(null);
|
|
||||||
};
|
};
|
||||||
const removeNamed = async (keyId: string) => {
|
const removeNamed = async (keyId: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
|
|||||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||||
});
|
});
|
||||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
// A login is shown under the key it names; one naming no key (or one gone)
|
||||||
const saveAws = async () => {
|
// under the default key.
|
||||||
let user = aws.keyId.trim();
|
const namedIds = new Set(named.map((k) => k.id));
|
||||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
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) => {
|
||||||
|
let user = v.keyId.trim();
|
||||||
|
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||||
if (m?.[1] && m[2]) {
|
if (m?.[1] && m[2]) {
|
||||||
user = m[1];
|
user = m[1];
|
||||||
host = m[2].toLowerCase();
|
host = m[2].toLowerCase();
|
||||||
}
|
}
|
||||||
const alias = aws.alias.trim().toLowerCase();
|
// A named key's account goes by the key's name unless another is typed.
|
||||||
const ok = await run(
|
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||||
|
return run(
|
||||||
() =>
|
() =>
|
||||||
api.gitIdentity.setSshUser({
|
api.gitIdentity.setSshUser({
|
||||||
host,
|
host,
|
||||||
user,
|
user,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(aws.key ? { key: aws.key } : {}),
|
...(k?.id ? { key: k.id } : {}),
|
||||||
}),
|
}),
|
||||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||||
);
|
);
|
||||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
|
||||||
};
|
};
|
||||||
|
const saveGitHost = (host: string, k: Key) => {
|
||||||
|
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||||
|
t("gitIdentity.otherSaved", { host: k.name })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** The logins of one key, each with its delete button. */
|
||||||
|
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||||
|
rows.map((r) => (
|
||||||
|
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{text(r)}
|
||||||
|
</span>
|
||||||
|
{id?.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||||
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||||
const saveOther = async () => {
|
const saveOther = async () => {
|
||||||
let host = other.host.trim();
|
let host = other.host.trim();
|
||||||
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
|
|||||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||||
};
|
};
|
||||||
/** The key picker of a login form: every key, or one. */
|
/** The key picker of a login form: every key, or one. */
|
||||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||||
<select
|
<select
|
||||||
aria-label={t("gitIdentity.loginKey")}
|
aria-label={t("gitIdentity.loginKey")}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
|
|||||||
>
|
>
|
||||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||||
{keys
|
{keys
|
||||||
.filter((k) => k.id && (!only || k.type === only))
|
.filter((k) => k.id)
|
||||||
.map((k) => (
|
.map((k) => (
|
||||||
<option key={k.id} value={k.id}>
|
<option key={k.id} value={k.id}>
|
||||||
{keyLabel(k.id)}
|
{keyLabel(k.id)}
|
||||||
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
|
|||||||
const sshTabs = [
|
const sshTabs = [
|
||||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||||
...(named.length > 0 || id.writable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "more",
|
|
||||||
label: named.length
|
|
||||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
|
||||||
: t("gitIdentity.tabMore"),
|
|
||||||
Icon: KeySquare,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||||
: []),
|
: []),
|
||||||
@@ -498,15 +630,16 @@ export function GitIdentitySettings() {
|
|||||||
onSelect={setSshTab}
|
onSelect={setSshTab}
|
||||||
>
|
>
|
||||||
{activeSshTab === "git" && (
|
{activeSshTab === "git" && (
|
||||||
|
<div className="space-y-2">
|
||||||
<KeyCard
|
<KeyCard
|
||||||
title={t("gitIdentity.gitHubTitle")}
|
title={t("gitIdentity.gitHubTitle")}
|
||||||
sub={t("gitIdentity.gitHubSub")}
|
sub={t("gitIdentity.gitHubSub")}
|
||||||
type="ed25519"
|
label="ED25519"
|
||||||
k={keyOf("ed25519")}
|
k={keyOf("ed25519")}
|
||||||
id={id}
|
writable={id.writable}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onGenerate={generate}
|
onGenerate={() => generate("ed25519")}
|
||||||
onRemove={removeKey}
|
onRemove={() => removeKey("ed25519")}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
<Step n={3}>
|
<Step n={3}>
|
||||||
@@ -514,96 +647,83 @@ export function GitIdentitySettings() {
|
|||||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||||
</Step>
|
</Step>
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
|
{namedOf("ed25519").map((k) => {
|
||||||
|
const rows = rowsOf(otherRows, k);
|
||||||
|
return (
|
||||||
|
<KeyCard
|
||||||
|
key={k.id}
|
||||||
|
title={k.name!}
|
||||||
|
sub={t("gitIdentity.namedGitSub")}
|
||||||
|
label={k.name!}
|
||||||
|
k={k}
|
||||||
|
writable={id.writable}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={() => replaceNamed(k)}
|
||||||
|
onRemove={() => removeNamed(k.id!)}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||||
|
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||||
|
{id.writable && rows.length === 0 && (
|
||||||
|
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||||
|
)}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Code key={r.alias ?? r.host}>
|
||||||
|
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||||
|
</Code>
|
||||||
|
))}
|
||||||
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{id.writable &&
|
||||||
|
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||||
|
addKey("ed25519")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "aws" && (
|
{activeSshTab === "aws" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||||
|
const isNamed = i > 0 && !!k;
|
||||||
|
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||||
|
return (
|
||||||
<KeyCard
|
<KeyCard
|
||||||
title={t("gitIdentity.awsTitle")}
|
key={k?.id ?? "id_rsa"}
|
||||||
sub={t("gitIdentity.awsSub")}
|
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||||
type="rsa"
|
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||||
k={keyOf("rsa")}
|
label={isNamed ? k!.name! : "RSA"}
|
||||||
id={id}
|
k={k}
|
||||||
|
writable={id.writable}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onGenerate={generate}
|
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||||
onRemove={removeKey}
|
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||||
<Step n={3}>
|
<Step n={3}>
|
||||||
{t("gitIdentity.awsStepKeyId")}
|
{t("gitIdentity.awsStepKeyId")}
|
||||||
{id.sshUsers === undefined ? null : (
|
{id.sshUsers === undefined ? null : (
|
||||||
<>
|
<>
|
||||||
{awsRows.map((r) => (
|
{loginRows(
|
||||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
rows,
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
(r) =>
|
||||||
{r.alias ? `${r.alias} · ` : ""}
|
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
|
||||||
</span>
|
|
||||||
{id.writable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${BTN} hover:text-status-danger`}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
|
||||||
onClick={() =>
|
|
||||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{id.writable && (
|
{id.writable && (
|
||||||
<form
|
<AwsLoginForm
|
||||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
busy={busy}
|
||||||
onSubmit={(e) => {
|
named={isNamed ? k!.name! : undefined}
|
||||||
e.preventDefault();
|
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||||
void saveAws();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.awsKeyId")}
|
|
||||||
placeholder="APKA…"
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.keyId}
|
|
||||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.awsRegion")}
|
|
||||||
value={aws.region}
|
|
||||||
onChange={(e) => setAws({ ...aws, 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={t("gitIdentity.aliasPlaceholder")}
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.alias}
|
|
||||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
|
||||||
{t("gitIdentity.awsSave")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Step>
|
</Step>
|
||||||
{awsRows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||||
{t("gitIdentity.awsReady")}
|
{t("gitIdentity.awsReady")}
|
||||||
{awsRows.map((r) => (
|
{rows.map((r) => (
|
||||||
<Code key={r.alias ?? r.host}>
|
<Code key={r.alias ?? r.host}>
|
||||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||||
</Code>
|
</Code>
|
||||||
@@ -611,115 +731,15 @@ export function GitIdentitySettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
{activeSshTab === "more" && (
|
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||||
<section
|
<>
|
||||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
{addKey("rsa")}
|
||||||
className="space-y-2 rounded border border-border p-2.5"
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||||
>
|
</>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs font-medium text-fg-primary">
|
|
||||||
{t("gitIdentity.moreKeysTitle")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
|
||||||
</div>
|
|
||||||
{id.writable && !newKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.newKey")}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{newKey && (
|
|
||||||
<form
|
|
||||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void makeNamed();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.keyName")}
|
|
||||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
value={newKey.name}
|
|
||||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.keyType")}
|
|
||||||
value={newKey.type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
|
||||||
}
|
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
<option value="rsa">RSA 4096 (AWS)</option>
|
|
||||||
<option value="ed25519">ED25519</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
|
||||||
{t("gitIdentity.generate")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
|
||||||
{t("gitCreds.cancel")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
{named.map((k) => (
|
|
||||||
<div
|
|
||||||
key={k.id}
|
|
||||||
className="space-y-1 rounded border border-border-light p-2"
|
|
||||||
data-testid="git-named-key"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
|
||||||
{k.name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
|
||||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
|
||||||
{k.fingerprint}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
onClick={() => void copyText(k.publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.copy")}
|
|
||||||
</button>
|
|
||||||
{id.writable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${BTN} hover:text-status-danger`}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
|
||||||
onClick={() => void removeNamed(k.id!)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
|
||||||
value={k.publicKey}
|
|
||||||
rows={2}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "other" && (
|
{activeSshTab === "other" && (
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file ChangesPanelSettings.test.tsx
|
||||||
|
* @description Settings → Git → Changes panel: the inner-repositories switch
|
||||||
|
* starts off, turning it on stores `all` in the synced account preference,
|
||||||
|
* and turning it off again removes the key (back to the default).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { ChangesPanelSettings } from "../ChangesPanelSettings";
|
||||||
|
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
|
||||||
|
|
||||||
|
describe("ChangesPanelSettings", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("is off by default and toggles the account preference", () => {
|
||||||
|
render(<ChangesPanelSettings />);
|
||||||
|
const sw = screen.getByRole("switch", {
|
||||||
|
name: /Show the repositories inside the workspace folder/,
|
||||||
|
});
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
|
||||||
|
expect(sw).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
fireEvent.click(sw);
|
||||||
|
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
|
||||||
|
expect(readInnerReposMode()).toBe("own");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||||
* the sections never mix; a named key
|
* the sections never mix; "Add another key" makes a named key as one more
|
||||||
* is made beside the default ones, and a second AWS account is saved under
|
* card in its own tab, a second AWS account is saved from that card under
|
||||||
* its own name with the one key it uses.
|
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
|
|||||||
fingerprint: "SHA256:r",
|
fingerprint: "SHA256:r",
|
||||||
};
|
};
|
||||||
const PROD = {
|
const PROD = {
|
||||||
id: "id_rsa_aws-prod",
|
id: "id_rsa_aws-2",
|
||||||
type: "rsa" as const,
|
type: "rsa" as const,
|
||||||
name: "aws-prod",
|
name: "aws-2",
|
||||||
publicKey: "ssh-rsa B prod",
|
publicKey: "ssh-rsa B prod",
|
||||||
fingerprint: "SHA256:p",
|
fingerprint: "SHA256:p",
|
||||||
};
|
};
|
||||||
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
|
|||||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
view({
|
view({
|
||||||
sshKeys: [RSA, PROD],
|
sshKeys: [RSA, PROD],
|
||||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
view({ sshKeys: [RSA], sshUsers: [] })
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
// Only an RSA key: the AWS tab opens first.
|
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||||
await tab("More keys");
|
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
// One click makes the key — nothing to type before it.
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
// Another key can be added below it.
|
||||||
|
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||||
|
|
||||||
await tab("AWS CodeCommit");
|
// Its own step 3: the account name defaults to the key's name.
|
||||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
|
||||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
host: H,
|
host: H,
|
||||||
user: "APKA2",
|
user: "APKA2",
|
||||||
alias: "aws-prod",
|
alias: "aws-2",
|
||||||
key: "id_rsa_aws-prod",
|
key: "id_rsa_aws-2",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).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();
|
||||||
|
|
||||||
await tab("More keys (1)");
|
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
});
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||||
|
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||||
|
const WORK = {
|
||||||
|
id: "id_ed25519_git-2",
|
||||||
|
type: "ed25519" as const,
|
||||||
|
name: "git-2",
|
||||||
|
publicKey: "ssh-ed25519 W work",
|
||||||
|
fingerprint: "SHA256:w",
|
||||||
|
};
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
|
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||||
);
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [ED, WORK],
|
||||||
|
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||||
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||||
|
const work = await screen.findByRole("region", { name: "git-2" });
|
||||||
|
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||||
|
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: "github.com",
|
||||||
|
user: "git",
|
||||||
|
alias: "git-2",
|
||||||
|
key: "id_ed25519_git-2",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* @file AccountPoolCard.tsx
|
* @file AccountPoolCard.tsx
|
||||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||||
* profile (Settings → AI tools → Profiles, `GET /api/accounts/profiles`), a
|
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||||
* pasted `.credentials.json`, or a `claude setup-token` token; for Codex a
|
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||||
* saved Codex profile (`?tool=codex`), an OpenAI API key or a pasted
|
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||||
* `auth.json` — or removes one, each time with their own
|
* other installed tool says that its users sign in themselves. A tab shows
|
||||||
* password again; an analyst only reads. The secret only goes IN: the server
|
* how many accounts its pool holds, and an empty pool says so instead of
|
||||||
* never sends it back. An account's Edit (pencil) picks the users who run on
|
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||||
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||||
* a Claude login goes into their home at once — no restart. A profile's
|
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||||
|
* `claude setup-token` token; for Codex a saved Codex profile
|
||||||
|
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
|
||||||
|
* Cursor API key — or removes one, each time with their own password again;
|
||||||
|
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||||
|
* back. An account's Edit (pencil) picks the users who run on it
|
||||||
|
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||||
|
* Claude login goes into their home at once — no restart. A profile's
|
||||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||||
* which opens Add on that profile; the header links back to the profiles.
|
* which opens that tool's tab and Add on that profile; the header links back
|
||||||
|
* to the profiles.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -21,22 +29,28 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AccountPin,
|
type AccountPin,
|
||||||
type AccountPool,
|
type AccountPool,
|
||||||
|
type OmpSharingState,
|
||||||
type PoolAccountInput,
|
type PoolAccountInput,
|
||||||
type PoolProfile,
|
type PoolProfile,
|
||||||
type PoolTool,
|
type PoolTool,
|
||||||
type ServerUserUsage,
|
type ServerUserUsage,
|
||||||
|
type CcUsageWindow,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||||
|
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -53,19 +67,81 @@ type Row = {
|
|||||||
tokens7d: number;
|
tokens7d: number;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
|
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||||
|
windows: QuotaWindow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function rowsOf(pool: AccountPool): Row[] {
|
/** One quota window of an account: % used and when it rolls over. */
|
||||||
return [
|
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
|
||||||
...pool.codex.map((a) => ({
|
/**
|
||||||
|
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||||
|
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||||
|
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||||
|
*/
|
||||||
|
function windowsOf(
|
||||||
|
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||||
|
): QuotaWindow[] {
|
||||||
|
const q = a.quota as
|
||||||
|
| {
|
||||||
|
fiveHour?: CcUsageWindow | null;
|
||||||
|
sevenDay?: CcUsageWindow | null;
|
||||||
|
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
const fromClaude = (w: CcUsageWindow | null | undefined) =>
|
||||||
|
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
|
||||||
|
const fromCodex = (label: string) => {
|
||||||
|
const w = q?.windows?.find((x) => x.label === label);
|
||||||
|
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
|
||||||
|
};
|
||||||
|
return (["5h", "wk"] as const).map((key) => {
|
||||||
|
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
|
||||||
|
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
used: passed ? 0 : (w?.used ?? null),
|
||||||
|
resetsAt: passed ? null : (w?.resetsAt ?? null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
|
||||||
|
function untilText(iso: string | null): string | null {
|
||||||
|
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||||
|
if (!(ms > 0)) return null;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const d = Math.floor(m / 1440);
|
||||||
|
const h = Math.floor((m % 1440) / 60);
|
||||||
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||||
|
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||||
|
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||||
|
const FIXED_TABS: { id: string; label: string }[] = [
|
||||||
|
{ id: "claude", label: "Claude Code" },
|
||||||
|
{ id: "codex", label: "Codex" },
|
||||||
|
{ id: "cursor", label: "Cursor CLI" },
|
||||||
|
{ id: "omp", label: "Oh My Pi" },
|
||||||
|
];
|
||||||
|
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||||
|
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||||
|
|
||||||
|
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
|
||||||
|
|
||||||
|
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
|
||||||
|
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
|
||||||
|
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
|
||||||
|
return list.map((a) => ({
|
||||||
...a,
|
...a,
|
||||||
tool: "codex" as const,
|
tool,
|
||||||
expired: false,
|
expired: false,
|
||||||
email: null,
|
email: null,
|
||||||
refreshError: null,
|
refreshError: null,
|
||||||
})),
|
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
|
||||||
];
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||||
@@ -79,18 +155,24 @@ function errorText(t: TFunction<"users">, e: unknown): string {
|
|||||||
|
|
||||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
|
const aiTools = useAiTools();
|
||||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||||
|
const [omp, setOmp] = useState<OmpSharingState | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||||
const preset = (() => {
|
const preset = (() => {
|
||||||
const tool = params.get("poolAdd");
|
const tool = params.get("poolAdd");
|
||||||
const profile = params.get("poolProfile");
|
const profile = params.get("poolProfile");
|
||||||
return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null;
|
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||||
})() as { tool: PoolTool; profile: string } | null;
|
})();
|
||||||
const [adding, setAdding] = useState(() => isAdmin && preset !== null);
|
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||||
|
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||||
|
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||||
|
isAdmin && preset ? preset : null
|
||||||
|
);
|
||||||
const closeAdd = () => {
|
const closeAdd = () => {
|
||||||
setAdding(false);
|
setAdding(null);
|
||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
next.delete("poolAdd");
|
next.delete("poolAdd");
|
||||||
@@ -115,16 +197,32 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
|
||||||
|
api.aiTools
|
||||||
|
.ompSharing()
|
||||||
|
.then(setOmp)
|
||||||
|
.catch(() => setOmp(null));
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const rows = pool ? rowsOf(pool) : [];
|
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
|
||||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
const tabs = [
|
||||||
|
...FIXED_TABS,
|
||||||
|
...aiTools.tools
|
||||||
|
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||||
|
.map((x) => ({ id: x.id, label: x.label })),
|
||||||
|
];
|
||||||
|
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||||
|
const badge = (id: string): string | null => {
|
||||||
|
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||||
|
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-[14rem]">
|
||||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,122 +240,74 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{isAdmin && (
|
</div>
|
||||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
<div
|
||||||
</button>
|
role="tablist"
|
||||||
|
aria-label={t("pool.title")}
|
||||||
|
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||||
|
>
|
||||||
|
{tabs.map((x) => {
|
||||||
|
const selected = x.id === current.id;
|
||||||
|
const count = badge(x.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={x.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`pool-tab-${x.id}`}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls="pool-panel"
|
||||||
|
onClick={() => setTab(x.id)}
|
||||||
|
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-accent text-fg-primary font-medium"
|
||||||
|
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||||
|
{x.label}
|
||||||
|
{count !== null && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
|
||||||
|
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{pool && rows.length === 0 && (
|
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
{isPoolTool(current.id) ? (
|
||||||
|
<PoolPanel
|
||||||
|
tool={current.id}
|
||||||
|
label={current.label}
|
||||||
|
pool={pool}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||||
|
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onRemove={setRemoving}
|
||||||
|
/>
|
||||||
|
) : current.id === "omp" ? (
|
||||||
|
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||||
|
) : (
|
||||||
|
<NoPoolPanel id={current.id} label={current.label} />
|
||||||
)}
|
)}
|
||||||
{rows.length > 0 && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[640px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className={th}>{t("pool.account")}</th>
|
|
||||||
<th className={th}>{t("pool.tool")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
|
||||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
|
||||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
|
||||||
<td className="px-5 py-2.5">
|
|
||||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
|
||||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
|
||||||
<div className="text-[11px] text-fg-muted">
|
|
||||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
|
||||||
</div>
|
</div>
|
||||||
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
|
||||||
<div className="text-[11px] text-fg-secondary">
|
|
||||||
{t("pool.pinned", {
|
|
||||||
users: pinsOf(pool, r.tool)
|
|
||||||
.filter((p) => p.account === r.name)
|
|
||||||
.map((p) => p.username)
|
|
||||||
.join(", "),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
||||||
{t(`pool.kind.${r.kind}`)}
|
|
||||||
{r.expired && (
|
|
||||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
|
||||||
{t("pool.expired")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{r.refreshError && !r.expired && (
|
|
||||||
<span
|
|
||||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
|
||||||
title={r.refreshError}
|
|
||||||
>
|
|
||||||
{t("pool.refreshFailed")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{r.full && (
|
|
||||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
|
||||||
{t("pool.full")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
||||||
{r.livePanes}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
||||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
|
||||||
<div className="text-sm text-fg-primary">
|
|
||||||
{fmtCost(r.spend5h)}
|
|
||||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
|
||||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-fg-muted">
|
|
||||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{isAdmin && (
|
|
||||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(r)}
|
|
||||||
title={t("pool.edit", { name: r.name })}
|
|
||||||
aria-label={t("pool.edit", { name: r.name })}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRemoving(r)}
|
|
||||||
title={t("pool.remove", { name: r.name })}
|
|
||||||
aria-label={t("pool.remove", { name: r.name })}
|
|
||||||
className="btn-icon"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
preset={preset}
|
tool={adding.tool}
|
||||||
|
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||||
|
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||||
onClose={closeAdd}
|
onClose={closeAdd}
|
||||||
onDone={(name) => {
|
onDone={(name) => {
|
||||||
toast.success(t("pool.done.added", { name }));
|
toast.success(t("pool.done.added", { name }));
|
||||||
@@ -301,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||||
|
function PoolPanel({
|
||||||
|
tool,
|
||||||
|
label,
|
||||||
|
pool,
|
||||||
|
isAdmin,
|
||||||
|
installed,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
tool: PoolTool;
|
||||||
|
label: string;
|
||||||
|
pool: AccountPool | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||||
|
onAdd: (profile?: string) => void;
|
||||||
|
onEdit: (r: Row) => void;
|
||||||
|
onRemove: (r: Row) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const rows = pool ? rowsOf(pool, tool) : [];
|
||||||
|
const grouped = (pool?.groups ?? []).length > 0;
|
||||||
|
const addButton = isAdmin && (
|
||||||
|
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||||
|
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||||
|
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||||
|
{t(`pool.about.${tool}`)}
|
||||||
|
{!installed && (
|
||||||
|
<span className="ml-1 text-status-warning">
|
||||||
|
{t("pool.notInstalled", { tool: label })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{rows.length > 0 && addButton}
|
||||||
|
</div>
|
||||||
|
{pool && rows.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||||
|
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||||
|
{addButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<AccountCard
|
||||||
|
key={r.name}
|
||||||
|
row={r}
|
||||||
|
pool={pool!}
|
||||||
|
grouped={grouped}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onEdit={() => onEdit(r)}
|
||||||
|
onRemove={() => onRemove(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||||
|
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||||
|
function AccountCard({
|
||||||
|
row: r,
|
||||||
|
pool,
|
||||||
|
grouped,
|
||||||
|
isAdmin,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
pool: AccountPool;
|
||||||
|
grouped: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const metered = METERED.has(r.tool);
|
||||||
|
const users = pinsOf(pool, r.tool)
|
||||||
|
.filter((p) => p.account === r.name)
|
||||||
|
.map((p) => p.username);
|
||||||
|
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||||
|
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||||
|
}`}
|
||||||
|
data-testid="pool-account"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||||
|
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
title={t("pool.edit", { name: r.name })}
|
||||||
|
aria-label={t("pool.edit", { name: r.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
title={t("pool.remove", { name: r.name })}
|
||||||
|
aria-label={t("pool.remove", { name: r.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(r.expired || r.refreshError || r.full) && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{r.expired && (
|
||||||
|
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||||
|
{t("pool.expired")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.refreshError && !r.expired && (
|
||||||
|
<span
|
||||||
|
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||||
|
title={r.refreshError}
|
||||||
|
>
|
||||||
|
{t("pool.refreshFailed")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.full && (
|
||||||
|
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||||
|
{t("pool.full")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t("pool.liveCount", { count: r.livePanes })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{metered && (
|
||||||
|
<div className="space-y-1.5" data-testid="pool-quota">
|
||||||
|
{r.windows.map((w) => (
|
||||||
|
<QuotaBar key={w.key} window={w} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metered && (
|
||||||
|
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||||
|
<div>
|
||||||
|
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||||
|
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||||
|
</div>
|
||||||
|
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{grouped && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||||
|
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||||
|
{inGroups.length === 0 ? (
|
||||||
|
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||||
|
) : (
|
||||||
|
inGroups.map((g) => (
|
||||||
|
<span key={g.id} className="badge py-0 text-[10px]">
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||||
|
<UserRound className="h-3 w-3" aria-hidden />
|
||||||
|
{t("pool.pinned", { users: users.join(", ") })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto text-[11px] text-fg-muted">
|
||||||
|
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||||
|
* 0% = used up), with when it resets.
|
||||||
|
*/
|
||||||
|
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||||
|
const resets = untilText(w.resetsAt);
|
||||||
|
return (
|
||||||
|
<div className="text-[11px]">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||||
|
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||||
|
<span
|
||||||
|
className={`block h-full ${
|
||||||
|
left == null
|
||||||
|
? ""
|
||||||
|
: left === 0
|
||||||
|
? "bg-status-danger"
|
||||||
|
: left <= 10
|
||||||
|
? "bg-status-warning"
|
||||||
|
: "bg-accent-solid"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${left ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
|
||||||
|
{left == null ? "—" : `${left}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{resets && (
|
||||||
|
<div
|
||||||
|
className="pl-[3.875rem] text-[10px] text-fg-muted"
|
||||||
|
title={formatDateTime(w.resetsAt!)}
|
||||||
|
>
|
||||||
|
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||||
|
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||||
|
* leaves the switcher, since a login's refresh token may have one holder.
|
||||||
|
*/
|
||||||
|
function SavedProfiles({
|
||||||
|
tool,
|
||||||
|
pool,
|
||||||
|
onShare,
|
||||||
|
}: {
|
||||||
|
tool: PoolTool;
|
||||||
|
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||||
|
pool: AccountPool | null;
|
||||||
|
onShare: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.accountPool
|
||||||
|
.profiles(tool)
|
||||||
|
.then((r) => setProfiles(r.profiles))
|
||||||
|
.catch(() => setProfiles([]));
|
||||||
|
}, [tool, pool]);
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t("pool.savedProfiles.title")}
|
||||||
|
</h3>
|
||||||
|
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||||
|
{t("pool.profilesLink")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{profiles !== null && profiles.length === 0 && (
|
||||||
|
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||||
|
)}
|
||||||
|
{profiles && profiles.length > 0 && (
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||||
|
<div className="truncate text-[11px] text-fg-muted">
|
||||||
|
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||||
|
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onShare(p.name)}
|
||||||
|
className="btn-ghost shrink-0 text-xs"
|
||||||
|
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||||
|
>
|
||||||
|
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||||
|
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||||
|
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
|
||||||
|
*/
|
||||||
|
function OmpPanel({
|
||||||
|
state,
|
||||||
|
isAdmin,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
state: OmpSharingState | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChange: (s: OmpSharingState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const ts = useTranslation("settings").t;
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!state?.available) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-6">
|
||||||
|
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const error = state.broker.error || state.gateway.error;
|
||||||
|
const status = !state.enabled
|
||||||
|
? t("pool.omp.off")
|
||||||
|
: !state.installed
|
||||||
|
? ts("ompSharing.notInstalled")
|
||||||
|
: state.broker.running && state.gateway.running
|
||||||
|
? ts("ompSharing.running")
|
||||||
|
: error
|
||||||
|
? ts("ompSharing.failed", { error })
|
||||||
|
: ts("ompSharing.starting");
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 space-y-2">
|
||||||
|
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
|
||||||
|
{isAdmin ? (
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={ts("ompSharing.switch")}
|
||||||
|
description={ts("ompSharing.hint")}
|
||||||
|
checked={state.enabled}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={async (on) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
onChange(await api.aiTools.setOmpSharing(on));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any other tool: no pool — say so, and why for the ones we know. */
|
||||||
|
function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-6">
|
||||||
|
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
|
||||||
|
<p className="text-xs text-fg-muted leading-relaxed">
|
||||||
|
{t(`pool.none.reason.${id}`, {
|
||||||
|
defaultValue: t("pool.none.reason.default", { tool: label }),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One account's Edit: tick the users whose sessions run on it. A user already
|
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||||
* on another account of the tool moves here when ticked; unticking one sends
|
* on another account of the tool moves here when ticked; unticking one sends
|
||||||
@@ -439,23 +881,29 @@ type ClaudeSource = "profile" | "credentials" | "token";
|
|||||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||||
claude: ["profile", "credentials", "token"],
|
claude: ["profile", "credentials", "token"],
|
||||||
codex: ["profile", "token"],
|
codex: ["profile", "token"],
|
||||||
|
cursor: ["token"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function AddAccountDialog({
|
function AddAccountDialog({
|
||||||
|
tool,
|
||||||
|
label,
|
||||||
preset,
|
preset,
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
|
/** The open tab's tool: the account is added to its pool. */
|
||||||
|
tool: PoolTool;
|
||||||
|
label: string;
|
||||||
/** A saved profile to move in, from its "Move to pool" link. */
|
/** A saved profile to move in, from its "Move to pool" link. */
|
||||||
preset?: { tool: PoolTool; profile: string } | null;
|
preset?: { tool: PoolTool; profile: string } | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (name: string) => void;
|
onDone: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
|
const sources = SOURCES[tool];
|
||||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
|
||||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||||
const [profile, setProfile] = useState(preset?.profile ?? "");
|
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
@@ -463,12 +911,12 @@ function AddAccountDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfiles(null);
|
if (!sources.includes("profile")) return;
|
||||||
api.accountPool
|
api.accountPool
|
||||||
.profiles(tool)
|
.profiles(tool)
|
||||||
.then((r) => setProfiles(r.profiles))
|
.then((r) => setProfiles(r.profiles))
|
||||||
.catch(() => setProfiles([]));
|
.catch(() => setProfiles([]));
|
||||||
}, [tool]);
|
}, [tool, sources]);
|
||||||
|
|
||||||
const fromProfile = source === "profile";
|
const fromProfile = source === "profile";
|
||||||
// A profile already names itself: its name is the default.
|
// A profile already names itself: its name is the default.
|
||||||
@@ -483,9 +931,7 @@ function AddAccountDialog({
|
|||||||
const body: PoolAccountInput =
|
const body: PoolAccountInput =
|
||||||
source === "profile"
|
source === "profile"
|
||||||
? { tool, name: name.trim() || profile, profile }
|
? { tool, name: name.trim() || profile, profile }
|
||||||
: tool === "codex"
|
: tool === "claude" && source === "credentials"
|
||||||
? { tool, name: name.trim(), token: secret.trim() }
|
|
||||||
: source === "credentials"
|
|
||||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||||
: { tool, name: name.trim(), token: secret.trim() };
|
: { tool, name: name.trim(), token: secret.trim() };
|
||||||
try {
|
try {
|
||||||
@@ -504,12 +950,13 @@ function AddAccountDialog({
|
|||||||
void submit();
|
void submit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const secretKind = tool === "codex" ? "codex" : source;
|
// Codex's and Cursor's one non-profile source is their own kind of key.
|
||||||
|
const secretKind = tool === "claude" ? source : tool;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open
|
open
|
||||||
title={t("pool.add")}
|
title={t("pool.addFor", { tool: label })}
|
||||||
confirmLabel={t("pool.add")}
|
confirmLabel={t("pool.add")}
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
@@ -519,24 +966,10 @@ function AddAccountDialog({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||||
<Field label={t("pool.tool")}>
|
{sources.length > 1 && (
|
||||||
<select
|
|
||||||
className="input w-full text-sm"
|
|
||||||
value={tool}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTool(e.target.value as PoolTool);
|
|
||||||
setProfile("");
|
|
||||||
setSource("profile");
|
|
||||||
}}
|
|
||||||
aria-label={t("pool.tool")}
|
|
||||||
>
|
|
||||||
<option value="claude">Claude Code</option>
|
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pool.source")}
|
label={t("pool.source")}
|
||||||
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
|
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
className="input w-full text-sm"
|
className="input w-full text-sm"
|
||||||
@@ -544,15 +977,16 @@ function AddAccountDialog({
|
|||||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||||
aria-label={t("pool.source")}
|
aria-label={t("pool.source")}
|
||||||
>
|
>
|
||||||
{SOURCES[tool].map((s) => (
|
{sources.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{tool === "codex" && s === "token"
|
{tool !== "claude" && s === "token"
|
||||||
? t("pool.secret.codex")
|
? t(`pool.secret.${tool}`)
|
||||||
: t(`pool.sources.${s}`)}
|
: t(`pool.sources.${s}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
{fromProfile && (
|
{fromProfile && (
|
||||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||||
<select
|
<select
|
||||||
@@ -592,7 +1026,7 @@ function AddAccountDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
{!fromProfile && (
|
{!fromProfile && (
|
||||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||||
{secretKind === "token" ? (
|
{secretKind === "token" || secretKind === "cursor" ? (
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="input w-full font-mono text-sm"
|
className="input w-full font-mono text-sm"
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* @file PoolGroups.tsx
|
||||||
|
* @description The account pool's GROUPS (`/api/accounts/groups`,
|
||||||
|
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
|
||||||
|
* members run only on the accounts its group lists, of every tool, while a
|
||||||
|
* user in no group runs on the shared pool (the accounts no group lists). A
|
||||||
|
* card shows the group's members and its accounts per tool; an admin creates,
|
||||||
|
* edits (name, members — one group per user, so ticking one moves them here —
|
||||||
|
* and accounts) or deletes one, each time with their own password again.
|
||||||
|
* `groupsOf` tells an account card which groups use it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type AccountPool,
|
||||||
|
type PoolGroup,
|
||||||
|
type PoolTool,
|
||||||
|
type ServerUserUsage,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
|
||||||
|
/** Pool tools with their tab labels, in tab order. */
|
||||||
|
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||||
|
{ id: "claude", label: "Claude Code" },
|
||||||
|
{ id: "codex", label: "Codex" },
|
||||||
|
{ id: "cursor", label: "Cursor CLI" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Account names of `tool` in the pool. */
|
||||||
|
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
|
||||||
|
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
|
||||||
|
return (list ?? []).map((a) => a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The groups that list account `name` of `tool`. */
|
||||||
|
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
|
||||||
|
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
|
const err = e as Error & { code?: string };
|
||||||
|
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||||
|
? t(`pool.errors.${err.code}`)
|
||||||
|
: err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PoolGroups({
|
||||||
|
pool,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
pool: AccountPool;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const groups = pool.groups ?? [];
|
||||||
|
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
|
<div className="flex-1 min-w-[14rem]">
|
||||||
|
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
|
||||||
|
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
|
||||||
|
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 p-3"
|
||||||
|
data-testid="pool-group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
|
||||||
|
<div className="text-[11px] text-fg-muted">
|
||||||
|
{t("pool.groups.members", { count: g.users.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(g)}
|
||||||
|
title={t("pool.groups.edit", { name: g.name })}
|
||||||
|
aria-label={t("pool.groups.edit", { name: g.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleting(g)}
|
||||||
|
title={t("pool.groups.delete", { name: g.name })}
|
||||||
|
aria-label={t("pool.groups.delete", { name: g.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{g.users.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{g.users.map((u) => (
|
||||||
|
<span key={u.userId} className="badge py-0 text-[11px]">
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{POOL_TOOL_LABELS.map(({ id, label }) => {
|
||||||
|
const names = g.accounts[id] ?? [];
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center gap-1.5 text-[11px]">
|
||||||
|
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-fg-secondary">{label}:</span>
|
||||||
|
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
|
||||||
|
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<GroupDialog
|
||||||
|
pool={pool}
|
||||||
|
group={editing === "new" ? null : editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onDone={(name) => {
|
||||||
|
toast.success(t("pool.groups.saved", { name }));
|
||||||
|
setEditing(null);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleting && (
|
||||||
|
<DeleteGroupDialog
|
||||||
|
group={deleting}
|
||||||
|
onClose={() => setDeleting(null)}
|
||||||
|
onDone={() => {
|
||||||
|
toast.success(t("pool.groups.deleted", { name: deleting.name }));
|
||||||
|
setDeleting(null);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a group, or edit one: its name, its members and its accounts. */
|
||||||
|
function GroupDialog({
|
||||||
|
pool,
|
||||||
|
group,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
pool: AccountPool;
|
||||||
|
group: PoolGroup | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [name, setName] = useState(group?.name ?? "");
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
|
const [members, setMembers] = useState<Set<number>>(
|
||||||
|
() => new Set(group?.users.map((u) => u.userId) ?? [])
|
||||||
|
);
|
||||||
|
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
|
||||||
|
claude: new Set(group?.accounts.claude ?? []),
|
||||||
|
codex: new Set(group?.accounts.codex ?? []),
|
||||||
|
cursor: new Set(group?.accounts.cursor ?? []),
|
||||||
|
}));
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groupOfUser = (userId: number) =>
|
||||||
|
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
|
||||||
|
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (on) next.add(v);
|
||||||
|
else next.delete(v);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const ready = name.trim().length > 0 && current.length > 0;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!ready || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const id = group
|
||||||
|
? group.id
|
||||||
|
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
|
||||||
|
await api.accountPool.updateGroup(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...(group ? { name: name.trim() } : {}),
|
||||||
|
userIds: [...members],
|
||||||
|
accounts: Object.fromEntries(
|
||||||
|
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
current
|
||||||
|
);
|
||||||
|
onDone(name.trim());
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
|
||||||
|
message={t("pool.groups.hint")}
|
||||||
|
confirmLabel={t("pool.save")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
destructive={false}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!ready}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1 w-full text-sm"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label={t("pool.groups.name")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">
|
||||||
|
{t("pool.groups.memberList")}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{users === null && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.map((u) => {
|
||||||
|
const other = groupOfUser(u.id);
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="px-3 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={members.has(u.id)}
|
||||||
|
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{u.username}
|
||||||
|
{other && (
|
||||||
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
|
{t("pool.groups.inGroup", { group: other.name })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
labelClassName="text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">
|
||||||
|
{t("pool.groups.accountList")}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
|
||||||
|
const names = accountNames(pool, tool);
|
||||||
|
return (
|
||||||
|
<div key={tool} className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
|
||||||
|
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{names.length === 0 ? (
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{names.map((n) => (
|
||||||
|
<Checkbox
|
||||||
|
key={n}
|
||||||
|
checked={accounts[tool].has(n)}
|
||||||
|
onChange={(on) =>
|
||||||
|
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
|
||||||
|
}
|
||||||
|
label={n}
|
||||||
|
labelClassName="text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteGroupDialog({
|
||||||
|
group,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
group: PoolGroup;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async () => {
|
||||||
|
if (!current || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.accountPool.deleteGroup(group.id, current);
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={t("pool.groups.delete", { name: group.name })}
|
||||||
|
message={t("pool.groups.deleteHint")}
|
||||||
|
confirmLabel={t("pool.removeButton")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<label className="mt-3 block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input mt-1 w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file AccountPoolCard.test.tsx
|
* @file AccountPoolCard.test.tsx
|
||||||
* @description The Users page's account pool card: lists the Claude and Codex
|
* @description The Users page's account pool card: one tab per AI tool — the
|
||||||
* accounts with their live panes and quota, says what an empty pool means, an
|
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||||
|
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||||
|
* tool; says what an empty pool means per tool, an
|
||||||
* admin adds an account only with their own password typed (the token goes
|
* admin adds an account only with their own password typed (the token goes
|
||||||
* in once and is not shown back) — a saved login moved in (the server's own
|
* in once and is not shown back) — a saved login moved in (the server's own
|
||||||
* included), or a setup-token — and removes one the same way, and an analyst
|
* included), or a setup-token — and removes one the same way, and an analyst
|
||||||
@@ -22,11 +24,20 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
setUsers: vi.fn(),
|
setUsers: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
updateGroup: vi.fn(),
|
||||||
|
deleteGroup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
const aiTools = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
ompSharing: vi.fn(),
|
||||||
|
setOmpSharing: vi.fn(),
|
||||||
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||||
|
}));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return { ...actual, api: { accountPool, users } };
|
return { ...actual, api: { accountPool, users, aiTools } };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AccountPoolCard } from "../AccountPoolCard";
|
import { AccountPoolCard } from "../AccountPoolCard";
|
||||||
@@ -53,9 +64,44 @@ const POOL: AccountPool = {
|
|||||||
spend7d: 12,
|
spend7d: 12,
|
||||||
tokens5h: 250_000,
|
tokens5h: 250_000,
|
||||||
tokens7d: 3_400_000,
|
tokens7d: 3_400_000,
|
||||||
|
quota: {
|
||||||
|
fiveHour: {
|
||||||
|
utilization: 42,
|
||||||
|
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
utilization: 15,
|
||||||
|
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
||||||
|
},
|
||||||
|
checkedAt: "2026-10-01T00:00:00Z",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
quotaPercent: 42,
|
quotaPercent: 42,
|
||||||
full: false,
|
full: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "team-2",
|
||||||
|
kind: "token",
|
||||||
|
email: null,
|
||||||
|
refreshError: null,
|
||||||
|
addedAt: "2026-10-01T00:00:00Z",
|
||||||
|
expiresAt: null,
|
||||||
|
expired: false,
|
||||||
|
livePanes: 0,
|
||||||
|
spend5h: 0,
|
||||||
|
spend7d: 0,
|
||||||
|
tokens5h: 0,
|
||||||
|
tokens7d: 0,
|
||||||
|
// A 5-hour window that has reset since the reading is full again.
|
||||||
|
quota: {
|
||||||
|
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
||||||
|
sevenDay: null,
|
||||||
|
checkedAt: "2026-10-01T00:00:00Z",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
quotaPercent: null,
|
||||||
|
full: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
codex: [
|
codex: [
|
||||||
{
|
{
|
||||||
@@ -74,8 +120,30 @@ const POOL: AccountPool = {
|
|||||||
blockPercent: 90,
|
blockPercent: 90,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
||||||
|
const OMP = {
|
||||||
|
available: true,
|
||||||
|
enabled: false,
|
||||||
|
installed: true,
|
||||||
|
broker: { running: false },
|
||||||
|
gateway: { running: false },
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||||
|
aiTools.list.mockResolvedValue({
|
||||||
|
tools: [
|
||||||
|
tool("claude", "Claude Code"),
|
||||||
|
tool("codex", "Codex"),
|
||||||
|
tool("cursor", "Cursor CLI"),
|
||||||
|
tool("omp", "Oh My Pi"),
|
||||||
|
tool("antigravity", "Antigravity"),
|
||||||
|
tool("goose", "Goose", false),
|
||||||
|
],
|
||||||
|
default: "claude",
|
||||||
|
});
|
||||||
|
aiTools.ompSharing.mockResolvedValue(OMP);
|
||||||
|
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
||||||
accountPool.list.mockResolvedValue(POOL);
|
accountPool.list.mockResolvedValue(POOL);
|
||||||
users.list.mockResolvedValue({
|
users.list.mockResolvedValue({
|
||||||
users: [
|
users: [
|
||||||
@@ -98,20 +166,145 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountPoolCard", () => {
|
describe("AccountPoolCard", () => {
|
||||||
it("lists every account, and explains an empty pool", async () => {
|
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
const { unmount } = render(card(false));
|
const { unmount } = render(card(false));
|
||||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
expect(screen.queryByText("cx-1")).toBeNull();
|
||||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
const [account, second] = screen.getAllByTestId("pool-account");
|
||||||
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
|
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||||
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
|
expect(second).toHaveTextContent("team-2");
|
||||||
|
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
||||||
|
expect(quota).toHaveTextContent("5 hours");
|
||||||
|
expect(quota).toHaveTextContent("58%");
|
||||||
|
expect(quota).toHaveTextContent("resets in 2h 10m");
|
||||||
|
expect(quota).toHaveTextContent("Week");
|
||||||
|
expect(quota).toHaveTextContent("85%");
|
||||||
|
expect(quota).toHaveTextContent("resets in 3d 1h");
|
||||||
|
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
||||||
|
expect(reset).toHaveTextContent("100%");
|
||||||
|
expect(reset).toHaveTextContent("—");
|
||||||
|
expect(account).toHaveTextContent("250.0K tokens");
|
||||||
|
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||||
|
expect(account).toHaveTextContent("2 live");
|
||||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
// Without groups nothing is said about them on an account.
|
||||||
|
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||||
|
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||||
|
expect(codexTab).toHaveTextContent("1");
|
||||||
|
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||||
|
await user.click(codexTab);
|
||||||
|
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("team-1")).toBeNull();
|
||||||
|
// A pool with no account still has its tab, with what that means.
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||||
|
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||||
|
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||||
|
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||||
unmount();
|
unmount();
|
||||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||||
render(card(true));
|
render(card(true));
|
||||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||||
|
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(screen.queryByLabelText("From")).toBeNull();
|
||||||
|
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||||
|
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(
|
||||||
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares a saved profile from its card", async () => {
|
||||||
|
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(
|
||||||
|
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.add).toHaveBeenCalledWith(
|
||||||
|
{ tool: "claude", name: "work", profile: "work" },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Team A",
|
||||||
|
users: [{ userId: 8, username: "leo" }],
|
||||||
|
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||||
|
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
const groupCard = await screen.findByTestId("pool-group");
|
||||||
|
expect(groupCard).toHaveTextContent("Team A");
|
||||||
|
expect(groupCard).toHaveTextContent("leo");
|
||||||
|
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||||
|
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||||
|
await screen.findByRole("dialog");
|
||||||
|
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||||
|
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||||
|
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||||
|
"boss-pass"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
||||||
|
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
||||||
|
await user.click(screen.getByRole("switch"));
|
||||||
|
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
||||||
|
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves a saved login in, the server's own included", async () => {
|
it("moves a saved login in, the server's own included", async () => {
|
||||||
@@ -119,7 +312,7 @@ describe("AccountPoolCard", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
await screen.findByText("team-1");
|
||||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
const select = await screen.findByLabelText("Profile");
|
const select = await screen.findByLabelText("Profile");
|
||||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||||
@@ -144,7 +337,7 @@ describe("AccountPoolCard", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
await screen.findByText("team-1");
|
await screen.findByText("team-1");
|
||||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||||
@@ -168,6 +361,8 @@ describe("AccountPoolCard", () => {
|
|||||||
accountPool.remove.mockResolvedValue({ ok: true });
|
accountPool.remove.mockResolvedValue({ ok: true });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(card(true));
|
render(card(true));
|
||||||
|
await screen.findByText("team-1");
|
||||||
|
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||||
@@ -230,7 +425,7 @@ describe("AccountPoolCard", () => {
|
|||||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||||
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
|
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
|||||||
@@ -183,5 +183,19 @@
|
|||||||
"stat.updated": "Updated",
|
"stat.updated": "Updated",
|
||||||
"detail.runAs": "Run it as",
|
"detail.runAs": "Run it as",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Added {{date}}"
|
"detail.added": "Added {{date}}",
|
||||||
|
"tab.unowned": "Unowned",
|
||||||
|
"tab.suggested": "Is this yours?",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||||
|
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||||
|
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||||
|
"unowned.mine": "It's mine",
|
||||||
|
"unowned.takeIt": "Take it",
|
||||||
|
"unowned.notMine": "Not mine",
|
||||||
|
"unowned.assign": "Assign to…",
|
||||||
|
"unowned.claimed": "Added to your library",
|
||||||
|
"unowned.declined": "Removed from your list",
|
||||||
|
"unowned.assigned": "Owner assigned",
|
||||||
|
"unowned.empty": "Nothing without an owner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||||
"clone.nameLabel": "Folder name",
|
"clone.nameLabel": "Folder name",
|
||||||
"clone.cloning": "Cloning…",
|
"clone.cloning": "Cloning…",
|
||||||
|
"clone.background": "Run in background",
|
||||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
@@ -656,5 +657,23 @@
|
|||||||
"fx.readOnly": "Read-only",
|
"fx.readOnly": "Read-only",
|
||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Scan for repositories again",
|
||||||
|
"tree.newGroup": "New group",
|
||||||
|
"tree.groupDefaultName": "New group",
|
||||||
|
"tree.ungrouped": "Ungrouped",
|
||||||
|
"tree.groupToggle": "Show or hide the {{name}} group",
|
||||||
|
"tree.groupMenu": "Group options",
|
||||||
|
"tree.groupRename": "Rename group",
|
||||||
|
"tree.groupDelete": "Delete group",
|
||||||
|
"tree.groupMoveUp": "Move group up",
|
||||||
|
"tree.groupMoveDown": "Move group down",
|
||||||
|
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
|
||||||
|
"tree.groupNameLabel": "Group name",
|
||||||
|
"menu.moveToGroup": "Move to group",
|
||||||
|
"menu.removeFromGroup": "Remove from group",
|
||||||
|
"menu.newGroupWith": "New group…",
|
||||||
|
"tree.groupAddWorkspace": "New workspace in this group…",
|
||||||
|
"tree.groupFoldAll": "Fold all groups",
|
||||||
|
"tree.groupUnfoldAll": "Unfold all groups"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -908,16 +908,14 @@
|
|||||||
"alias": "Account name",
|
"alias": "Account name",
|
||||||
"aliasPlaceholder": "name (optional)",
|
"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>/…",
|
"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? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. 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 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||||
"moreKeysTitle": "More SSH keys",
|
|
||||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
|
||||||
"newKey": "New key",
|
|
||||||
"keyName": "Key name",
|
|
||||||
"keyNamePlaceholder": "e.g. aws-prod",
|
|
||||||
"keyType": "Key type",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "More keys",
|
"tabOther": "Other hosts",
|
||||||
"tabOther": "Other hosts"
|
"addKey": "Add another key",
|
||||||
|
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||||
|
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||||
|
"namedAwsSub": "RSA · for one more AWS account.",
|
||||||
|
"removeKeyNamed": "Delete key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1077,6 +1075,12 @@
|
|||||||
"noMatch": "No setting matches “{{query}}”.",
|
"noMatch": "No setting matches “{{query}}”.",
|
||||||
"results": "Search results"
|
"results": "Search results"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Changes panel",
|
||||||
|
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
|
||||||
|
"innerRepos": "Show the repositories inside the workspace folder",
|
||||||
|
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktree suggestions",
|
"title": "Worktree suggestions",
|
||||||
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
|
||||||
@@ -1136,6 +1140,10 @@
|
|||||||
"18": "Larger"
|
"18": "Larger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Paste images natively",
|
||||||
|
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Terminal font",
|
"title": "Terminal font",
|
||||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||||
|
|||||||
@@ -35,5 +35,11 @@
|
|||||||
"autoFix": "Auto fix",
|
"autoFix": "Auto fix",
|
||||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
"autoFixWorkspace": "AgentHub update",
|
"autoFixWorkspace": "AgentHub update",
|
||||||
"autoFixError": "Could not start the Auto fix session"
|
"autoFixError": "Could not start the Auto fix session",
|
||||||
|
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
||||||
|
"chipTitle": "Updating to {{version}}…",
|
||||||
|
"chipHint": "The update runs in the background — click to show it",
|
||||||
|
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||||
|
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||||
|
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"role": "Role",
|
"role": "Role",
|
||||||
"workspaces": "Workspaces",
|
"workspaces": "Workspaces",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
|
"today": "Today",
|
||||||
"last7d": "7 days",
|
"last7d": "7 days",
|
||||||
"last30d": "30 days",
|
"last30d": "30 days",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
@@ -132,12 +133,10 @@
|
|||||||
},
|
},
|
||||||
"pool": {
|
"pool": {
|
||||||
"title": "Account pool",
|
"title": "Account pool",
|
||||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||||
"refresh": "Re-read quotas",
|
"refresh": "Re-read quotas",
|
||||||
"add": "Add account",
|
"add": "Add account",
|
||||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"tool": "Tool",
|
|
||||||
"livePanes": "Live",
|
"livePanes": "Live",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"spend": "5 hours",
|
"spend": "5 hours",
|
||||||
@@ -146,12 +145,6 @@
|
|||||||
"added": "added {{at}}",
|
"added": "added {{at}}",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
"full": "full",
|
"full": "full",
|
||||||
"kind": {
|
|
||||||
"token": "Claude · setup-token",
|
|
||||||
"login": "Claude · login",
|
|
||||||
"apikey": "Codex · API key",
|
|
||||||
"chatgpt": "Codex · ChatGPT"
|
|
||||||
},
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||||
"remove": "Remove {{name}}",
|
"remove": "Remove {{name}}",
|
||||||
@@ -173,7 +166,8 @@
|
|||||||
"ENOTFOUND": "That saved login no longer exists.",
|
"ENOTFOUND": "That saved login no longer exists.",
|
||||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||||
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||||
"ENOUSER": "That user no longer exists."
|
"ENOUSER": "That user no longer exists.",
|
||||||
|
"ENOGROUP": "That group no longer exists."
|
||||||
},
|
},
|
||||||
"source": "From",
|
"source": "From",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -185,7 +179,8 @@
|
|||||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||||
|
"cursor": "A Cursor API key from the Cursor dashboard (Integrations → API keys)."
|
||||||
},
|
},
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||||
@@ -197,12 +192,14 @@
|
|||||||
"secret": {
|
"secret": {
|
||||||
"credentials": "Credentials JSON",
|
"credentials": "Credentials JSON",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"codex": "API key or auth.json"
|
"codex": "API key or auth.json",
|
||||||
|
"cursor": "Cursor API key"
|
||||||
},
|
},
|
||||||
"secretHint": {
|
"secretHint": {
|
||||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account.",
|
||||||
|
"cursor": "Create one in the Cursor dashboard under Integrations → API keys."
|
||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed",
|
"refreshFailed": "refresh failed",
|
||||||
"pinned": "Used by: {{users}}",
|
"pinned": "Used by: {{users}}",
|
||||||
@@ -218,7 +215,70 @@
|
|||||||
"noUsers": "No users yet.",
|
"noUsers": "No users yet.",
|
||||||
"onAccount": "now on {{account}}",
|
"onAccount": "now on {{account}}",
|
||||||
"editDone": "{{name}} saved",
|
"editDone": "{{name}} saved",
|
||||||
"profilesLink": "Profiles in Settings"
|
"profilesLink": "Profiles in Settings",
|
||||||
|
"addFor": "Add {{tool}} account",
|
||||||
|
"type": "Type",
|
||||||
|
"about": {
|
||||||
|
"claude": "New Claude Code sessions run on these accounts. A setup-token goes in as an environment variable; a real login is written into each user's home by the server and refreshed by it.",
|
||||||
|
"codex": "New Codex sessions (not on an AI Proxy) run on these accounts. An OpenAI API key goes in as an environment variable; a ChatGPT login is written into each user's own Codex home.",
|
||||||
|
"cursor": "New Cursor CLI sessions run on these Cursor API keys, given to the session as CURSOR_API_KEY — it outranks the user's own `cursor-agent login` for that session only. Cursor reports no usage or quota here, so the account with the fewest live sessions is picked."
|
||||||
|
},
|
||||||
|
"notInstalled": "{{tool}} is not installed on this server yet.",
|
||||||
|
"emptyFor": "No {{tool}} accounts yet. Until you add one, each user's {{tool}} session needs that user's own sign-in.",
|
||||||
|
"kindShort": {
|
||||||
|
"token": "setup-token",
|
||||||
|
"login": "Login",
|
||||||
|
"apikey": "API key",
|
||||||
|
"chatgpt": "ChatGPT login"
|
||||||
|
},
|
||||||
|
"omp": {
|
||||||
|
"badgeOn": "shared",
|
||||||
|
"about": "Oh My Pi has no account pool: it shares this server's own omp login instead. Sign the host account in with `omp` (then /login), turn sharing on, and every user's omp session runs on it through omp's own gateway — with a key that works only while its pane runs.",
|
||||||
|
"off": "Sharing is off: each user's omp session uses that user's own login.",
|
||||||
|
"unavailable": "Oh My Pi has no account pool. Its login can be shared only on a server where every user has their own Linux account (server mode with per-user accounts); here each user's omp session uses that user's own login."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "{{tool}} has no account pool",
|
||||||
|
"reason": {
|
||||||
|
"antigravity": "Antigravity signs in with a Google account and keeps that login in the system keyring; it takes no key AgentHub could hand a session. Each user signs in to agy themselves.",
|
||||||
|
"default": "AgentHub cannot hand {{tool}} a shared login, so each user signs in to it themselves."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liveCount_one": "{{count}} live",
|
||||||
|
"liveCount_other": "{{count}} live",
|
||||||
|
"savedProfiles": {
|
||||||
|
"title": "Saved profiles you can share",
|
||||||
|
"empty": "No saved profiles for this tool. Save one in Settings → AI tools → Profiles, then share it here.",
|
||||||
|
"activeHere": "in use on this server",
|
||||||
|
"share": "Share to pool",
|
||||||
|
"shareNamed": "Share {{name}} to the pool"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"title": "Groups",
|
||||||
|
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||||
|
"new": "New group",
|
||||||
|
"empty": "No groups yet — every user shares every account.",
|
||||||
|
"members_one": "{{count}} member",
|
||||||
|
"members_other": "{{count}} members",
|
||||||
|
"edit": "Edit group {{name}}",
|
||||||
|
"delete": "Delete group {{name}}",
|
||||||
|
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||||
|
"noAccounts": "none — members sign in themselves",
|
||||||
|
"saved": "Group {{name}} saved",
|
||||||
|
"deleted": "Group {{name}} deleted",
|
||||||
|
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||||
|
"name": "Group name",
|
||||||
|
"memberList": "Members",
|
||||||
|
"inGroup": "now in {{group}}",
|
||||||
|
"accountList": "Accounts",
|
||||||
|
"noneInPool": "No accounts of this tool in the pool.",
|
||||||
|
"shared": "Shared pool"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"5h": "5 hours",
|
||||||
|
"wk": "Week"
|
||||||
|
},
|
||||||
|
"resetsIn": "resets in {{time}} · {{at}}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
|
|||||||
@@ -183,5 +183,19 @@
|
|||||||
"stat.updated": "Cập nhật",
|
"stat.updated": "Cập nhật",
|
||||||
"detail.runAs": "Gọi bằng",
|
"detail.runAs": "Gọi bằng",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Thêm lúc {{date}}"
|
"detail.added": "Thêm lúc {{date}}",
|
||||||
|
"tab.unowned": "Vô chủ",
|
||||||
|
"tab.suggested": "Có phải của bạn?",
|
||||||
|
"auto": "Tự động",
|
||||||
|
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||||
|
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||||
|
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||||
|
"unowned.mine": "Của tôi",
|
||||||
|
"unowned.takeIt": "Nhận về tôi",
|
||||||
|
"unowned.notMine": "Không phải của tôi",
|
||||||
|
"unowned.assign": "Gán cho…",
|
||||||
|
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||||
|
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||||
|
"unowned.assigned": "Đã gán chủ",
|
||||||
|
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||||
"clone.nameLabel": "Tên thư mục",
|
"clone.nameLabel": "Tên thư mục",
|
||||||
"clone.cloning": "Đang clone…",
|
"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.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.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
@@ -656,5 +657,23 @@
|
|||||||
"fx.readOnly": "Chỉ đọc",
|
"fx.readOnly": "Chỉ đọc",
|
||||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
|
||||||
|
"changes.rescanRepos": "Quét lại các repository",
|
||||||
|
"tree.newGroup": "Nhóm mới",
|
||||||
|
"tree.groupDefaultName": "Nhóm mới",
|
||||||
|
"tree.ungrouped": "Chưa phân nhóm",
|
||||||
|
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
|
||||||
|
"tree.groupMenu": "Tuỳ chọn nhóm",
|
||||||
|
"tree.groupRename": "Đổi tên nhóm",
|
||||||
|
"tree.groupDelete": "Xoá nhóm",
|
||||||
|
"tree.groupMoveUp": "Đưa nhóm lên",
|
||||||
|
"tree.groupMoveDown": "Đưa nhóm xuống",
|
||||||
|
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
|
||||||
|
"tree.groupNameLabel": "Tên nhóm",
|
||||||
|
"menu.moveToGroup": "Chuyển vào nhóm",
|
||||||
|
"menu.removeFromGroup": "Bỏ khỏi nhóm",
|
||||||
|
"menu.newGroupWith": "Nhóm mới…",
|
||||||
|
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
|
||||||
|
"tree.groupFoldAll": "Thu gọn mọi nhóm",
|
||||||
|
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,16 +896,14 @@
|
|||||||
"alias": "Tên tài khoản",
|
"alias": "Tên tài khoản",
|
||||||
"aliasPlaceholder": "tên (tuỳ chọ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>/…",
|
"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? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. 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 đó (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>.",
|
||||||
"moreKeysTitle": "Thêm SSH key",
|
|
||||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
|
||||||
"newKey": "Key mới",
|
|
||||||
"keyName": "Tên key",
|
|
||||||
"keyNamePlaceholder": "vd aws-prod",
|
|
||||||
"keyType": "Loại key",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "Key khác",
|
"tabOther": "Host khác",
|
||||||
"tabOther": "Host khác"
|
"addKey": "Thêm key khác",
|
||||||
|
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||||
|
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||||
|
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||||
|
"removeKeyNamed": "Xoá key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1065,6 +1063,12 @@
|
|||||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
|
||||||
"results": "Kết quả tìm kiếm"
|
"results": "Kết quả tìm kiếm"
|
||||||
},
|
},
|
||||||
|
"changesPanel": {
|
||||||
|
"title": "Panel Changes",
|
||||||
|
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
|
||||||
|
"innerRepos": "Hiện các repo bên trong thư mục workspace",
|
||||||
|
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
|
||||||
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Gợi ý worktree",
|
"title": "Gợi ý worktree",
|
||||||
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
|
||||||
@@ -1124,6 +1128,10 @@
|
|||||||
"18": "Rất lớn"
|
"18": "Rất lớn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nativePaste": {
|
||||||
|
"label": "Dán ảnh kiểu native",
|
||||||
|
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
|
||||||
|
},
|
||||||
"terminalFont": {
|
"terminalFont": {
|
||||||
"title": "Font terminal",
|
"title": "Font terminal",
|
||||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||||
|
|||||||
@@ -35,5 +35,11 @@
|
|||||||
"autoFix": "Tự sửa",
|
"autoFix": "Tự sửa",
|
||||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
"autoFixError": "Không mở được phiên Tự sửa"
|
"autoFixError": "Không mở được phiên Tự sửa",
|
||||||
|
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
||||||
|
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||||
|
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||||
|
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||||
|
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||||
|
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"role": "Vai trò",
|
"role": "Vai trò",
|
||||||
"workspaces": "Workspace",
|
"workspaces": "Workspace",
|
||||||
"sessions": "Phiên",
|
"sessions": "Phiên",
|
||||||
|
"today": "Hôm nay",
|
||||||
"last7d": "7 ngày",
|
"last7d": "7 ngày",
|
||||||
"last30d": "30 ngày",
|
"last30d": "30 ngày",
|
||||||
"storage": "Dung lượng",
|
"storage": "Dung lượng",
|
||||||
@@ -132,12 +133,10 @@
|
|||||||
},
|
},
|
||||||
"pool": {
|
"pool": {
|
||||||
"title": "Pool tài khoản",
|
"title": "Pool tài khoản",
|
||||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||||
"refresh": "Đọc lại quota",
|
"refresh": "Đọc lại quota",
|
||||||
"add": "Thêm tài khoản",
|
"add": "Thêm tài khoản",
|
||||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
|
||||||
"account": "Tài khoản",
|
"account": "Tài khoản",
|
||||||
"tool": "Công cụ",
|
|
||||||
"livePanes": "Đang chạy",
|
"livePanes": "Đang chạy",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
"spend": "5 giờ",
|
"spend": "5 giờ",
|
||||||
@@ -146,12 +145,6 @@
|
|||||||
"added": "thêm lúc {{at}}",
|
"added": "thêm lúc {{at}}",
|
||||||
"expired": "hết hạn",
|
"expired": "hết hạn",
|
||||||
"full": "đầy",
|
"full": "đầy",
|
||||||
"kind": {
|
|
||||||
"token": "Claude · setup-token",
|
|
||||||
"login": "Claude · login",
|
|
||||||
"apikey": "Codex · API key",
|
|
||||||
"chatgpt": "Codex · ChatGPT"
|
|
||||||
},
|
|
||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||||
"remove": "Xoá {{name}}",
|
"remove": "Xoá {{name}}",
|
||||||
@@ -173,7 +166,8 @@
|
|||||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||||
"ENOUSER": "User đó không còn tồn tại."
|
"ENOUSER": "User đó không còn tồn tại.",
|
||||||
|
"ENOGROUP": "Nhóm đó không còn tồn tại."
|
||||||
},
|
},
|
||||||
"source": "Nguồn",
|
"source": "Nguồn",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -185,7 +179,8 @@
|
|||||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
||||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||||
|
"cursor": "Cursor API key lấy từ Cursor dashboard (Integrations → API keys)."
|
||||||
},
|
},
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
||||||
@@ -197,12 +192,14 @@
|
|||||||
"secret": {
|
"secret": {
|
||||||
"credentials": "Credentials JSON",
|
"credentials": "Credentials JSON",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"codex": "API key hoặc auth.json"
|
"codex": "API key hoặc auth.json",
|
||||||
|
"cursor": "Cursor API key"
|
||||||
},
|
},
|
||||||
"secretHint": {
|
"secretHint": {
|
||||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT.",
|
||||||
|
"cursor": "Tạo trong Cursor dashboard tại Integrations → API keys."
|
||||||
},
|
},
|
||||||
"refreshFailed": "làm mới lỗi",
|
"refreshFailed": "làm mới lỗi",
|
||||||
"pinned": "Đang dùng: {{users}}",
|
"pinned": "Đang dùng: {{users}}",
|
||||||
@@ -218,7 +215,70 @@
|
|||||||
"noUsers": "Chưa có user nào.",
|
"noUsers": "Chưa có user nào.",
|
||||||
"onAccount": "đang ở {{account}}",
|
"onAccount": "đang ở {{account}}",
|
||||||
"editDone": "Đã lưu {{name}}",
|
"editDone": "Đã lưu {{name}}",
|
||||||
"profilesLink": "Hồ sơ trong Cài đặt"
|
"profilesLink": "Hồ sơ trong Cài đặt",
|
||||||
|
"addFor": "Thêm tài khoản {{tool}}",
|
||||||
|
"type": "Loại",
|
||||||
|
"about": {
|
||||||
|
"claude": "Phiên Claude Code mới chạy trên các tài khoản này. Setup-token được đưa vào dưới dạng biến môi trường; login thật được server ghi vào home của từng user và tự làm mới.",
|
||||||
|
"codex": "Phiên Codex mới (không qua AI Proxy) chạy trên các tài khoản này. OpenAI API key được đưa vào dưới dạng biến môi trường; login ChatGPT được ghi vào Codex home riêng của từng user.",
|
||||||
|
"cursor": "Phiên Cursor CLI mới chạy trên các Cursor API key này, đưa vào phiên qua CURSOR_API_KEY — chỉ trong phiên đó nó được ưu tiên hơn `cursor-agent login` của chính user. Cursor không báo mức dùng hay quota ở đây, nên tài khoản có ít phiên đang chạy nhất sẽ được chọn."
|
||||||
|
},
|
||||||
|
"notInstalled": "{{tool}} chưa được cài trên server này.",
|
||||||
|
"emptyFor": "Chưa có tài khoản {{tool}} nào. Khi chưa thêm, phiên {{tool}} của mỗi user cần chính user đó đăng nhập.",
|
||||||
|
"kindShort": {
|
||||||
|
"token": "setup-token",
|
||||||
|
"login": "Login",
|
||||||
|
"apikey": "API key",
|
||||||
|
"chatgpt": "Login ChatGPT"
|
||||||
|
},
|
||||||
|
"omp": {
|
||||||
|
"badgeOn": "chia sẻ",
|
||||||
|
"about": "Oh My Pi không có account pool: thay vào đó nó chia sẻ login omp của chính server. Đăng nhập tài khoản host bằng `omp` (rồi /login), bật chia sẻ, và phiên omp của mọi user chạy trên login đó qua gateway của omp — với một key chỉ dùng được khi pane còn chạy.",
|
||||||
|
"off": "Chia sẻ đang tắt: phiên omp của mỗi user dùng login của chính user đó.",
|
||||||
|
"unavailable": "Oh My Pi không có account pool. Login của nó chỉ chia sẻ được trên server mà mỗi user có tài khoản Linux riêng (server mode với tài khoản theo user); ở đây phiên omp của mỗi user dùng login của chính user đó."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "{{tool}} không có account pool",
|
||||||
|
"reason": {
|
||||||
|
"antigravity": "Antigravity đăng nhập bằng tài khoản Google và giữ login trong keyring của hệ thống; nó không nhận key nào để AgentHub đưa cho phiên. Mỗi user tự đăng nhập agy.",
|
||||||
|
"default": "AgentHub không thể đưa cho {{tool}} một login dùng chung, nên mỗi user tự đăng nhập."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liveCount_one": "{{count}} đang chạy",
|
||||||
|
"liveCount_other": "{{count}} đang chạy",
|
||||||
|
"savedProfiles": {
|
||||||
|
"title": "Profile đã lưu có thể chia sẻ",
|
||||||
|
"empty": "Chưa có profile nào của tool này. Lưu một profile trong Settings → AI tools → Profiles rồi chia sẻ ở đây.",
|
||||||
|
"activeHere": "đang dùng trên server này",
|
||||||
|
"share": "Chia sẻ vào pool",
|
||||||
|
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"title": "Nhóm",
|
||||||
|
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||||
|
"new": "Tạo nhóm",
|
||||||
|
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||||
|
"members_one": "{{count}} thành viên",
|
||||||
|
"members_other": "{{count}} thành viên",
|
||||||
|
"edit": "Sửa nhóm {{name}}",
|
||||||
|
"delete": "Xoá nhóm {{name}}",
|
||||||
|
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||||
|
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||||
|
"saved": "Đã lưu nhóm {{name}}",
|
||||||
|
"deleted": "Đã xoá nhóm {{name}}",
|
||||||
|
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||||
|
"name": "Tên nhóm",
|
||||||
|
"memberList": "Thành viên",
|
||||||
|
"inGroup": "đang ở {{group}}",
|
||||||
|
"accountList": "Tài khoản",
|
||||||
|
"noneInPool": "Pool chưa có tài khoản nào của tool này.",
|
||||||
|
"shared": "Pool chung"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"5h": "5 giờ",
|
||||||
|
"wk": "Tuần"
|
||||||
|
},
|
||||||
|
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Xoá user",
|
"button": "Xoá user",
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* @file workspaceGroups.test.ts
|
||||||
|
* @description Tests for the sidebar's workspace groups store: create, rename,
|
||||||
|
* move (by step or onto another group) and delete (which only ungroups), fold all, assignment, pruning of removed
|
||||||
|
* workspaces, a damaged stored value, and the arrangement the sidebar draws —
|
||||||
|
* groups in order, each keeping the drag order, ungrouped last.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
WORKSPACE_GROUPS_KEY,
|
||||||
|
arrangeByGroup,
|
||||||
|
assignGroup,
|
||||||
|
createGroup,
|
||||||
|
deleteGroup,
|
||||||
|
groupOf,
|
||||||
|
moveGroup,
|
||||||
|
moveGroupBefore,
|
||||||
|
pruneWorkspaceGroups,
|
||||||
|
readWorkspaceGroups,
|
||||||
|
renameGroup,
|
||||||
|
setAllFolded,
|
||||||
|
setGroupFolded,
|
||||||
|
} from "../workspaceGroups";
|
||||||
|
|
||||||
|
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
|
||||||
|
|
||||||
|
describe("workspaceGroups", () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it("starts empty and survives a damaged value", () => {
|
||||||
|
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||||
|
localStorage.setItem(WORKSPACE_GROUPS_KEY, "{nope");
|
||||||
|
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a group holding workspaces, renames, folds and deletes it", () => {
|
||||||
|
const id = createGroup(" Frontend ", [1, 2]);
|
||||||
|
let v = readWorkspaceGroups();
|
||||||
|
expect(v.groups).toEqual([{ id, name: "Frontend" }]);
|
||||||
|
expect(groupOf(v, 1)?.id).toBe(id);
|
||||||
|
renameGroup(id, " ");
|
||||||
|
expect(readWorkspaceGroups().groups[0]!.name).toBe("Frontend");
|
||||||
|
renameGroup(id, "Web");
|
||||||
|
setGroupFolded(id, true);
|
||||||
|
v = readWorkspaceGroups();
|
||||||
|
expect(v.groups[0]).toMatchObject({ name: "Web", folded: true });
|
||||||
|
deleteGroup(id);
|
||||||
|
v = readWorkspaceGroups();
|
||||||
|
expect(v.groups).toEqual([]);
|
||||||
|
expect(v.of).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a workspace between groups and out of them", () => {
|
||||||
|
const a = createGroup("A");
|
||||||
|
const b = createGroup("B");
|
||||||
|
assignGroup(5, a);
|
||||||
|
assignGroup(5, b);
|
||||||
|
expect(readWorkspaceGroups().of["5"]).toBe(b);
|
||||||
|
assignGroup(5, "missing");
|
||||||
|
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||||
|
assignGroup(5, a);
|
||||||
|
assignGroup(5, null);
|
||||||
|
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders groups, clamped at both ends", () => {
|
||||||
|
const a = createGroup("A");
|
||||||
|
const b = createGroup("B");
|
||||||
|
moveGroup(a, 1);
|
||||||
|
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||||
|
moveGroup(a, 5);
|
||||||
|
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets removed workspaces and ignores ids naming no group", () => {
|
||||||
|
const a = createGroup("A", [1, 2, 3]);
|
||||||
|
pruneWorkspaceGroups([1, 3]);
|
||||||
|
expect(readWorkspaceGroups().of).toEqual({ 1: a, 3: a });
|
||||||
|
localStorage.setItem(
|
||||||
|
WORKSPACE_GROUPS_KEY,
|
||||||
|
JSON.stringify({ groups: [{ id: a, name: "A" }], of: { 1: a, 2: "gone" } })
|
||||||
|
);
|
||||||
|
expect(readWorkspaceGroups().of).toEqual({ 1: a });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arranges groups in order, keeping the drag order, ungrouped last", () => {
|
||||||
|
const a = createGroup("A", [4, 2]);
|
||||||
|
const b = createGroup("B", [3]);
|
||||||
|
moveGroup(b, -1);
|
||||||
|
const v = readWorkspaceGroups();
|
||||||
|
expect(arrangeByGroup(lanes(1, 2, 3, 4, 5), v).map((l) => l.id)).toEqual([3, 2, 4, 1, 5]);
|
||||||
|
expect(groupOf(v, 2)?.id).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a group before another, or last, and folds every group at once", () => {
|
||||||
|
const a = createGroup("A");
|
||||||
|
const b = createGroup("B");
|
||||||
|
const c = createGroup("C");
|
||||||
|
const ids = () => readWorkspaceGroups().groups.map((g) => g.id);
|
||||||
|
moveGroupBefore(c, a);
|
||||||
|
expect(ids()).toEqual([c, a, b]);
|
||||||
|
moveGroupBefore(c, null);
|
||||||
|
expect(ids()).toEqual([a, b, c]);
|
||||||
|
moveGroupBefore(a, a);
|
||||||
|
moveGroupBefore("nope", b);
|
||||||
|
expect(ids()).toEqual([a, b, c]);
|
||||||
|
setAllFolded(true);
|
||||||
|
expect(readWorkspaceGroups().groups.every((g) => g.folded)).toBe(true);
|
||||||
|
setAllFolded(false);
|
||||||
|
expect(readWorkspaceGroups().groups.some((g) => g.folded)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
-12
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
workspaces: number;
|
workspaces: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
|
/** Since the viewer's local midnight (absent from an older server). */
|
||||||
|
today?: { cost: number; tokens: number };
|
||||||
last7d: { cost: number; tokens: number };
|
last7d: { cost: number; tokens: number };
|
||||||
last30d: { cost: number; tokens: number };
|
last30d: { cost: number; tokens: number };
|
||||||
/** Active login sessions (cookies not expired). */
|
/** Active login sessions (cookies not expired). */
|
||||||
@@ -660,6 +662,13 @@ export type PoolAccount = {
|
|||||||
spend7d: number;
|
spend7d: number;
|
||||||
tokens5h: number;
|
tokens5h: number;
|
||||||
tokens7d: number;
|
tokens7d: number;
|
||||||
|
/** The last usage reading of the account's own token, or null before one. */
|
||||||
|
quota?: {
|
||||||
|
fiveHour: CcUsageWindow | null;
|
||||||
|
sevenDay: CcUsageWindow | null;
|
||||||
|
checkedAt: string;
|
||||||
|
error: string | null;
|
||||||
|
} | null;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
};
|
};
|
||||||
@@ -673,6 +682,11 @@ export type CodexPoolAccount = {
|
|||||||
spend7d: number;
|
spend7d: number;
|
||||||
tokens5h: number;
|
tokens5h: number;
|
||||||
tokens7d: number;
|
tokens7d: number;
|
||||||
|
/** The newest `rate_limits` its sessions' rollouts carry (`5h`, `wk`, …), or null. */
|
||||||
|
quota?: {
|
||||||
|
windows: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||||
|
asOf: string;
|
||||||
|
} | null;
|
||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
};
|
};
|
||||||
@@ -683,14 +697,37 @@ export type AccountPin = {
|
|||||||
account: string;
|
account: string;
|
||||||
pinnedAt?: string;
|
pinnedAt?: string;
|
||||||
};
|
};
|
||||||
|
/** One Cursor pool account — a Cursor API key, never the key itself; no quota. */
|
||||||
|
export type CursorPoolAccount = Omit<CodexPoolAccount, "kind"> & { kind: "apikey" };
|
||||||
export type AccountPool = {
|
export type AccountPool = {
|
||||||
accounts: PoolAccount[];
|
accounts: PoolAccount[];
|
||||||
codex: CodexPoolAccount[];
|
codex: CodexPoolAccount[];
|
||||||
|
/** Absent from a server older than the Cursor pool. */
|
||||||
|
cursor?: CursorPoolAccount[];
|
||||||
/** Per tool; absent from an older server, empty in local mode. */
|
/** Per tool; absent from an older server, empty in local mode. */
|
||||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
pins?: Partial<Record<PoolTool, AccountPin[]>>;
|
||||||
|
/** Server mode's pool groups; absent from an older server, empty in local mode. */
|
||||||
|
groups?: PoolGroup[];
|
||||||
blockPercent: number;
|
blockPercent: number;
|
||||||
};
|
};
|
||||||
export type PoolTool = "claude" | "codex";
|
/**
|
||||||
|
* A pool group: its members (one group per user) run only on its accounts;
|
||||||
|
* a user in no group runs on the accounts no group lists (the shared pool).
|
||||||
|
*/
|
||||||
|
export type PoolGroup = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt?: string;
|
||||||
|
users: { userId: number; username: string }[];
|
||||||
|
accounts: Partial<Record<PoolTool, string[]>>;
|
||||||
|
};
|
||||||
|
/** What `PATCH /api/accounts/groups/:id` takes — every part optional. */
|
||||||
|
export type PoolGroupPatch = {
|
||||||
|
name?: string;
|
||||||
|
userIds?: number[];
|
||||||
|
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||||
|
};
|
||||||
|
export type PoolTool = "claude" | "codex" | "cursor";
|
||||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||||
export type PoolProfile = {
|
export type PoolProfile = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -858,11 +895,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
/**
|
/**
|
||||||
* What a workspace git call works on: the workspace (its id) or, with
|
* What a workspace git call works on: the workspace (its id) or, with
|
||||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||||
* `source`, one of the workspace's extra folders — the server resolves the
|
* `source`, one of the workspace's extra folders, or with `repo`, a repo
|
||||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
* inside the workspace folder — the server resolves the tree from the session
|
||||||
* never from a path.
|
* or `lane_sources` row (`?session=` / `?source=`), or accepts `?repo=` only
|
||||||
|
* when its own walk of the folder found that repo; never from a path.
|
||||||
*/
|
*/
|
||||||
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
export type GitTarget = number | { lane: number; session?: string; source?: number; repo?: string };
|
||||||
|
|
||||||
|
/** Which extra repo a Changes section reads: an extra folder's `lane_sources`
|
||||||
|
* id (number), or a repo inside the workspace folder, by its path relative
|
||||||
|
* to it (string, from `GET /lanes/:id/repos`). */
|
||||||
|
export type GitSource = number | string;
|
||||||
|
|
||||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||||
@@ -870,6 +913,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
|||||||
const lane = typeof target === "number" ? target : target.lane;
|
const lane = typeof target === "number" ? target : target.lane;
|
||||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||||
|
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||||
}
|
}
|
||||||
@@ -968,13 +1012,14 @@ export interface FsTrashItem {
|
|||||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||||
|
|
||||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
|
||||||
export function gitTarget(
|
export function gitTarget(
|
||||||
laneId: number,
|
laneId: number,
|
||||||
session?: string | null,
|
session?: string | null,
|
||||||
source?: number | null
|
source?: GitSource | null
|
||||||
): GitTarget {
|
): GitTarget {
|
||||||
if (source) return { lane: laneId, source };
|
if (typeof source === "string" && source) return { lane: laneId, repo: source };
|
||||||
|
if (typeof source === "number" && source) return { lane: laneId, source };
|
||||||
return session ? { lane: laneId, session } : laneId;
|
return session ? { lane: laneId, session } : laneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,7 +1093,8 @@ export const api = {
|
|||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
list: () =>
|
||||||
|
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||||
* account changes, newest first. */
|
* account changes, newest first. */
|
||||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||||
@@ -1182,6 +1228,24 @@ export const api = {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({ tool, currentPassword }),
|
body: JSON.stringify({ tool, currentPassword }),
|
||||||
}),
|
}),
|
||||||
|
/** POST /api/accounts/groups — admin: a new, empty pool group. */
|
||||||
|
createGroup: (name: string, currentPassword: string) =>
|
||||||
|
request<{ group: { id: number; name: string } }>("/accounts/groups", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, currentPassword }),
|
||||||
|
}),
|
||||||
|
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
|
||||||
|
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
|
||||||
|
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
|
}),
|
||||||
|
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
|
||||||
|
deleteGroup: (id: number, currentPassword: string) =>
|
||||||
|
request<{ ok: true }>(`/accounts/groups/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ currentPassword }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||||
/** Self-update status: whether this install is a git clone and, if so,
|
/** Self-update status: whether this install is a git clone and, if so,
|
||||||
@@ -2963,7 +3027,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
list: (
|
||||||
|
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||||
|
) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.scope) qs.set("scope", params.scope);
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
if (params.kind) qs.set("kind", params.kind);
|
if (params.kind) qs.set("kind", params.kind);
|
||||||
@@ -3039,6 +3105,18 @@ export const api = {
|
|||||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||||
},
|
},
|
||||||
|
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||||
|
claim: (id: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||||
|
/** An unowned row offered to the caller is not theirs. */
|
||||||
|
decline: (id: number) =>
|
||||||
|
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||||
|
/** Admin: give an unowned row to a user. */
|
||||||
|
assign: (id: number, userId: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
}),
|
||||||
update: (
|
update: (
|
||||||
id: number,
|
id: number,
|
||||||
body: {
|
body: {
|
||||||
@@ -4104,6 +4182,13 @@ export const api = {
|
|||||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||||
* folders and context files, and whether its tool can take them. */
|
* folders and context files, and whether its tool can take them. */
|
||||||
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
||||||
|
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
||||||
|
* (a parent folder of several repos), each a Changes section read with
|
||||||
|
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
||||||
|
repos: (id: number, refresh = false) =>
|
||||||
|
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
||||||
|
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||||
|
),
|
||||||
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||||
setInstructions: (id: number, instructions: string | null) =>
|
setInstructions: (id: number, instructions: string | null) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
||||||
@@ -4329,7 +4414,7 @@ export interface Artifact {
|
|||||||
market: string | null;
|
market: string | null;
|
||||||
/** page: the claude.ai link. */
|
/** page: the claude.ai link. */
|
||||||
url: string | null;
|
url: string | null;
|
||||||
files: { path: string; size: number }[];
|
files: { path: string; size: number; mtime?: number }[];
|
||||||
size: number | null;
|
size: number | null;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
sharedAt: string | null;
|
sharedAt: string | null;
|
||||||
@@ -4338,6 +4423,12 @@ export interface Artifact {
|
|||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
|
/** file: captured when its session stopped, not pinned by hand. */
|
||||||
|
auto?: boolean;
|
||||||
|
/** Server mode: nobody owns it yet. */
|
||||||
|
unowned?: boolean;
|
||||||
|
/** Unowned and offered to the caller to claim or decline. */
|
||||||
|
suggestedForMe?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @file innerReposPref — whether the Changes panel lists the git repositories
|
||||||
|
* INSIDE a workspace folder (a parent folder of several repos, one section
|
||||||
|
* each via `GET /api/lanes/:id/repos`) or only the folder's own git. Off by
|
||||||
|
* default ("own"): the panel neither walks the folder nor shows them until
|
||||||
|
* the account turns it on in Settings → Git → Changes panel. One account
|
||||||
|
* preference, `agenthub.changes.innerRepos` (`lib/userPrefs.ts`,
|
||||||
|
* `server/routes/me.js`), read through `useSyncExternalStore` and re-read
|
||||||
|
* when the account's preferences load (`PREFS_EVENT`) or another tab writes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { PREFS_EVENT } from "./workspaceGroups";
|
||||||
|
|
||||||
|
export const INNER_REPOS_KEY = "agenthub.changes.innerRepos";
|
||||||
|
export type InnerReposMode = "all" | "own";
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function readInnerReposMode(): InnerReposMode {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(INNER_REPOS_KEY) === "all" ? "all" : "own";
|
||||||
|
} catch {
|
||||||
|
return "own";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeInnerReposMode(mode: InnerReposMode): void {
|
||||||
|
try {
|
||||||
|
// The default is stored as no value, so an account that never chose keeps following it.
|
||||||
|
if (mode === "all") localStorage.setItem(INNER_REPOS_KEY, "all");
|
||||||
|
else localStorage.removeItem(INNER_REPOS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: nothing to remember */
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void) {
|
||||||
|
listeners.add(l);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === INNER_REPOS_KEY || e.key === null) l();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(PREFS_EVENT, l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(PREFS_EVENT, l);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInnerReposMode(): InnerReposMode {
|
||||||
|
return useSyncExternalStore(subscribe, readInnerReposMode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @file nativePaste — Settings → Appearance → "Paste images natively": a
|
||||||
|
* per-browser preference in `localStorage` (`agenthub.terminal.nativePaste`,
|
||||||
|
* deliberately NOT synced to the account — it is about this browser's
|
||||||
|
* machine). On (the default), a `claude` pane in a local-mode dashboard opened
|
||||||
|
* from this machine's own browser gets an image paste as `^V`, so `claude`
|
||||||
|
* reads the desktop clipboard itself like a native terminal — nothing is
|
||||||
|
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
|
||||||
|
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
|
||||||
|
* would read the SERVER's clipboard — that is what turning it off is for.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
|
||||||
|
|
||||||
|
/** True unless this browser turned native image paste off. */
|
||||||
|
export function readNativePaste(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(NATIVE_PASTE_KEY) !== "off";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeNativePaste(on: boolean) {
|
||||||
|
try {
|
||||||
|
if (on) window.localStorage.removeItem(NATIVE_PASTE_KEY);
|
||||||
|
else window.localStorage.setItem(NATIVE_PASTE_KEY, "off");
|
||||||
|
} catch {
|
||||||
|
/* storage blocked — the default */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,6 +375,12 @@ export const SETTINGS_INDEX: {
|
|||||||
},
|
},
|
||||||
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
|
||||||
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
|
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
|
||||||
|
{
|
||||||
|
section: "git",
|
||||||
|
titleKey: "changesPanel.innerRepos",
|
||||||
|
keywords: ["repositories", "nested", "monorepo", "changes"],
|
||||||
|
to: "#changes-panel",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function tier(hay: string | undefined, q: string, exact: number): number {
|
function tier(hay: string | undefined, q: string, exact: number): number {
|
||||||
|
|||||||
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
|
|||||||
plugin_root?: string;
|
plugin_root?: string;
|
||||||
finished_at?: string;
|
finished_at?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** The step a running plugin-update chain is on (null between runs or on
|
||||||
|
* a server that predates it). Polled while an update runs. */
|
||||||
|
plugin_update_progress?: {
|
||||||
|
step: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
started_at: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── Terminal run status ─────
|
// ───── Terminal run status ─────
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { api } from "./api";
|
|||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { applyStoredTheme } from "../hooks/useTheme";
|
import { applyStoredTheme } from "../hooks/useTheme";
|
||||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
||||||
|
import { PREFS_EVENT } from "./workspaceGroups";
|
||||||
|
|
||||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||||
export const PREF_KEYS = [
|
export const PREF_KEYS = [
|
||||||
@@ -44,6 +45,8 @@ export const PREF_KEYS = [
|
|||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
"agenthub.workspace.allTabs",
|
"agenthub.workspace.allTabs",
|
||||||
"agenthub.markdown.view",
|
"agenthub.markdown.view",
|
||||||
|
"agenthub.workspace.groups",
|
||||||
|
"agenthub.changes.innerRepos",
|
||||||
] as const;
|
] as const;
|
||||||
const SYNCED = new Set<string>(PREF_KEYS);
|
const SYNCED = new Set<string>(PREF_KEYS);
|
||||||
const PUSH_DELAY_MS = 400;
|
const PUSH_DELAY_MS = 400;
|
||||||
@@ -116,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
|
|||||||
window.dispatchEvent(new Event("sagi:prefs"));
|
window.dispatchEvent(new Event("sagi:prefs"));
|
||||||
applyUiFontSize();
|
applyUiFontSize();
|
||||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||||
|
// Every other store that reads these keys (the sidebar's workspace groups).
|
||||||
|
window.dispatchEvent(new Event(PREFS_EVENT));
|
||||||
const lng = prefs.i18nextLng;
|
const lng = prefs.i18nextLng;
|
||||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend",
|
||||||
|
* "Client A", …): their names, order and fold state, and which group each
|
||||||
|
* workspace sits in. One JSON value under `agenthub.workspace.groups`, one of
|
||||||
|
* the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so
|
||||||
|
* the groups are kept per ACCOUNT on the server and follow it to any browser,
|
||||||
|
* machine or port, and through updates. Like the drag order, it is a viewer's
|
||||||
|
* arrangement, not a workspace fact: nothing about a lane changes, the CLI and
|
||||||
|
* `GET /api/lanes` know nothing of it, and deleting a group only ungroups its
|
||||||
|
* workspaces. A `useSyncExternalStore` reader cached on the raw string, so
|
||||||
|
* every reader agrees; it re-reads when the account's preferences are loaded
|
||||||
|
* (`PREFS_EVENT`) and when another tab writes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups";
|
||||||
|
/** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */
|
||||||
|
export const PREFS_EVENT = "agenthub:prefs";
|
||||||
|
|
||||||
|
export interface WorkspaceGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Folded shut in the sidebar. */
|
||||||
|
folded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceGroups {
|
||||||
|
/** In display order. */
|
||||||
|
groups: WorkspaceGroup[];
|
||||||
|
/** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */
|
||||||
|
of: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: WorkspaceGroups = { groups: [], of: {} };
|
||||||
|
export const MAX_GROUP_NAME = 60;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY };
|
||||||
|
|
||||||
|
function readRaw(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(WORKSPACE_GROUPS_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(raw: string | null): WorkspaceGroups {
|
||||||
|
if (!raw) return EMPTY;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<WorkspaceGroups>;
|
||||||
|
const groups = (Array.isArray(v.groups) ? v.groups : []).filter(
|
||||||
|
(g): g is WorkspaceGroup =>
|
||||||
|
!!g && typeof g.id === "string" && !!g.id && typeof g.name === "string"
|
||||||
|
);
|
||||||
|
const ids = new Set(groups.map((g) => g.id));
|
||||||
|
const of: Record<string, string> = {};
|
||||||
|
if (v.of && typeof v.of === "object") {
|
||||||
|
for (const [lane, gid] of Object.entries(v.of)) {
|
||||||
|
if (typeof gid === "string" && ids.has(gid)) of[lane] = gid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { groups, of };
|
||||||
|
} catch {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWorkspaceGroups(): WorkspaceGroups {
|
||||||
|
const raw = readRaw();
|
||||||
|
if (raw === cache.raw) return cache.value;
|
||||||
|
cache = { raw, value: parse(raw) };
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(next: WorkspaceGroups): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* disabled storage: the groups live until the page closes */
|
||||||
|
cache = { raw: JSON.stringify(next), value: next };
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change the groups through `fn` (given the current value, never mutated). */
|
||||||
|
export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void {
|
||||||
|
write(fn(readWorkspaceGroups()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME);
|
||||||
|
|
||||||
|
/** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */
|
||||||
|
export function createGroup(name: string, laneIds: number[] = []): string {
|
||||||
|
const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
updateWorkspaceGroups((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
for (const lane of laneIds) of[lane] = id;
|
||||||
|
return { groups: [...cur.groups, { id, name: cleanName(name) }], of };
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename; a blank name keeps the old one. */
|
||||||
|
export function renameGroup(id: string, name: string): void {
|
||||||
|
const clean = cleanName(name);
|
||||||
|
if (!clean) return;
|
||||||
|
updateWorkspaceGroups((cur) => ({
|
||||||
|
...cur,
|
||||||
|
groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the group; its workspaces become ungrouped (nothing else changes). */
|
||||||
|
export function deleteGroup(id: string): void {
|
||||||
|
updateWorkspaceGroups((cur) => ({
|
||||||
|
groups: cur.groups.filter((g) => g.id !== id),
|
||||||
|
of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGroupFolded(id: string, folded: boolean): void {
|
||||||
|
updateWorkspaceGroups((cur) => ({
|
||||||
|
...cur,
|
||||||
|
groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the group `by` places (−1 up, +1 down), clamped. */
|
||||||
|
export function moveGroup(id: string, by: number): void {
|
||||||
|
updateWorkspaceGroups((cur) => {
|
||||||
|
const from = cur.groups.findIndex((g) => g.id === id);
|
||||||
|
const to = Math.max(0, Math.min(cur.groups.length - 1, from + by));
|
||||||
|
if (from < 0 || from === to) return cur;
|
||||||
|
const groups = [...cur.groups];
|
||||||
|
groups.splice(to, 0, ...groups.splice(from, 1));
|
||||||
|
return { ...cur, groups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */
|
||||||
|
export function moveGroupBefore(id: string, beforeId: string | null): void {
|
||||||
|
updateWorkspaceGroups((cur) => {
|
||||||
|
const moving = cur.groups.find((g) => g.id === id);
|
||||||
|
if (!moving || id === beforeId) return cur;
|
||||||
|
const rest = cur.groups.filter((g) => g.id !== id);
|
||||||
|
const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId);
|
||||||
|
const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)];
|
||||||
|
if (groups.every((g, i) => g === cur.groups[i])) return cur;
|
||||||
|
return { ...cur, groups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold (or unfold) every group at once. */
|
||||||
|
export function setAllFolded(folded: boolean): void {
|
||||||
|
updateWorkspaceGroups((cur) => ({
|
||||||
|
...cur,
|
||||||
|
groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put a workspace in a group, or (null) take it out of every group. */
|
||||||
|
export function assignGroup(laneId: number, groupId: string | null): void {
|
||||||
|
updateWorkspaceGroups((cur) => {
|
||||||
|
const of = { ...cur.of };
|
||||||
|
if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId;
|
||||||
|
else delete of[laneId];
|
||||||
|
return { ...cur, of };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the lanes that no longer exist, so the stored value stays small. */
|
||||||
|
export function pruneWorkspaceGroups(laneIds: number[]): void {
|
||||||
|
const cur = readWorkspaceGroups();
|
||||||
|
const live = new Set(laneIds.map(String));
|
||||||
|
const stale = Object.keys(cur.of).filter((k) => !live.has(k));
|
||||||
|
if (!stale.length) return;
|
||||||
|
write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `lanes` (already in the stored drag order) arranged by group: each group's
|
||||||
|
* workspaces in group order, keeping their relative order, then the
|
||||||
|
* ungrouped ones. Stable, never mutates.
|
||||||
|
*/
|
||||||
|
export function arrangeByGroup<T extends { id: number }>(lanes: T[], value: WorkspaceGroups): T[] {
|
||||||
|
const rank = new Map(value.groups.map((g, i) => [g.id, i]));
|
||||||
|
const at = (l: T) => {
|
||||||
|
const gid = value.of[l.id];
|
||||||
|
return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length;
|
||||||
|
};
|
||||||
|
return lanes
|
||||||
|
.map((l, i) => ({ l, i }))
|
||||||
|
.sort((a, b) => at(a.l) - at(b.l) || a.i - b.i)
|
||||||
|
.map((x) => x.l);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The group a workspace is in, or null. */
|
||||||
|
export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null {
|
||||||
|
const gid = value.of[laneId];
|
||||||
|
return (gid && value.groups.find((g) => g.id === gid)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void) {
|
||||||
|
listeners.add(l);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(PREFS_EVENT, l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(PREFS_EVENT, l);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceGroups(): WorkspaceGroups {
|
||||||
|
return useSyncExternalStore(subscribe, readWorkspaceGroups);
|
||||||
|
}
|
||||||
@@ -49,7 +49,9 @@ import {
|
|||||||
type ArtifactInstalls,
|
type ArtifactInstalls,
|
||||||
type ArtifactKind,
|
type ArtifactKind,
|
||||||
type ArtifactSettings,
|
type ArtifactSettings,
|
||||||
|
type ServerUserUsage,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
@@ -114,7 +116,11 @@ const footAction =
|
|||||||
export function Artifacts() {
|
export function Artifacts() {
|
||||||
const { t } = useTranslation("artifacts");
|
const { t } = useTranslation("artifacts");
|
||||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||||
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
|
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||||
|
const [unownedCount, setUnownedCount] = useState(0);
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||||
@@ -147,14 +153,45 @@ export function Artifacts() {
|
|||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, [load, q]);
|
}, [load, q]);
|
||||||
|
|
||||||
|
const serverMode = !!settings?.serverMode;
|
||||||
|
const countUnowned = useCallback(() => {
|
||||||
|
if (!serverMode) return;
|
||||||
|
api.artifacts
|
||||||
|
.list({ scope: "unowned" })
|
||||||
|
.then((r) => setUnownedCount(r.items.length))
|
||||||
|
.catch(() => setUnownedCount(0));
|
||||||
|
}, [serverMode]);
|
||||||
|
useEffect(countUnowned, [countUnowned]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "unowned" || !isAdmin || users.length) return;
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, [tab, isAdmin, users.length]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
eventBus.subscribe((msg) => {
|
eventBus.subscribe((msg) => {
|
||||||
if (msg.type === "artifact_changed") load();
|
if (msg.type === "artifact_changed") {
|
||||||
|
load();
|
||||||
|
countUnowned();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[load]
|
[load, countUnowned]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const settle = async (run: () => Promise<unknown>, done: string) => {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
toast.success(t(done));
|
||||||
|
load();
|
||||||
|
countUnowned();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The Add menu closes on Escape or a click anywhere outside it.
|
// The Add menu closes on Escape or a click anywhere outside it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!addMenu) return;
|
if (!addMenu) return;
|
||||||
@@ -297,7 +334,55 @@ export function Artifacts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const card = (a: Artifact) => (
|
const card = (a: Artifact) =>
|
||||||
|
tab === "unowned" ? (
|
||||||
|
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
|
||||||
|
<ArtifactCard
|
||||||
|
item={a}
|
||||||
|
server={server}
|
||||||
|
canInstall={!!settings?.installsEnabled}
|
||||||
|
{...actionsFor(a)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{(a.suggestedForMe || isAdmin) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary px-2 py-1 text-xs"
|
||||||
|
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
|
||||||
|
>
|
||||||
|
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.suggestedForMe && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={footAction}
|
||||||
|
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
|
||||||
|
>
|
||||||
|
{t("unowned.notMine")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && users.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input h-7 w-auto py-0 text-xs"
|
||||||
|
aria-label={t("unowned.assign")}
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const userId = Number(e.target.value);
|
||||||
|
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("unowned.assign")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.displayName || u.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ArtifactCard
|
<ArtifactCard
|
||||||
key={a.id}
|
key={a.id}
|
||||||
item={a}
|
item={a}
|
||||||
@@ -320,7 +405,7 @@ export function Artifacts() {
|
|||||||
// what you shared, My library into what you installed from others (the
|
// what you shared, My library into what you installed from others (the
|
||||||
// server adds those to `mine`) and your own.
|
// server adds those to `mine`) and your own.
|
||||||
const halves =
|
const halves =
|
||||||
server && items
|
server && items && tab !== "unowned"
|
||||||
? (["others", "own"] as const).map((who) => ({
|
? (["others", "own"] as const).map((who) => ({
|
||||||
key: `${tab}.${who}`,
|
key: `${tab}.${who}`,
|
||||||
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
||||||
@@ -424,12 +509,29 @@ export function Artifacts() {
|
|||||||
{t(`tab.${v}`)}
|
{t(`tab.${v}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tab === "unowned"}
|
||||||
|
onClick={() => setTab("unowned")}
|
||||||
|
className={segment(tab === "unowned")}
|
||||||
|
data-testid="artifacts-tab-unowned"
|
||||||
|
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
|
||||||
|
>
|
||||||
|
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
|
||||||
|
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items === null ? (
|
{items === null ? (
|
||||||
<SkeletonGrid label={t("loading")} />
|
<SkeletonGrid label={t("loading")} />
|
||||||
|
) : items.length === 0 && tab === "unowned" ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t("unowned.empty")}
|
||||||
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
hub={tab === "hub"}
|
hub={tab === "hub"}
|
||||||
@@ -755,6 +857,11 @@ function ArtifactCard({
|
|||||||
{t(hubOnly ? "hubOnly" : "shared")}
|
{t(hubOnly ? "hubOnly" : "shared")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{a.auto && (
|
||||||
|
<span className="badge self-start" title={t("autoHint")}>
|
||||||
|
{t("auto")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { ToolProfilesSettings } from "../components/settings/ToolProfilesSetting
|
|||||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
|
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import {
|
import {
|
||||||
rankSettings,
|
rankSettings,
|
||||||
@@ -424,6 +425,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<GitIdentitySettings />
|
<GitIdentitySettings />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
<div id="changes-panel" className="scroll-mt-24">
|
||||||
|
<ChangesPanelSettings />
|
||||||
|
</div>
|
||||||
<div id="worktrees" className="scroll-mt-24">
|
<div id="worktrees" className="scroll-mt-24">
|
||||||
<WorktreeVisibilitySettings />
|
<WorktreeVisibilitySettings />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @description The Users tab of the Administration page (`/admin`, which
|
* @description The Users tab of the Administration page (`/admin`, which
|
||||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||||
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card overflow-x-auto">
|
<div className="card overflow-x-auto">
|
||||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left">
|
<tr className="border-b border-border text-left">
|
||||||
<th className={th}>{t("name")}</th>
|
<th className={th}>{t("name")}</th>
|
||||||
<th className={th}>{t("role")}</th>
|
<th className={th}>{t("role")}</th>
|
||||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("today")}</th>
|
||||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||||
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
|||||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
{u.sessions}
|
{u.sessions}
|
||||||
</td>
|
</td>
|
||||||
|
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||||
<UsageCell usage={u.last7d} />
|
<UsageCell usage={u.last7d} />
|
||||||
<UsageCell usage={u.last30d} />
|
<UsageCell usage={u.last30d} />
|
||||||
<StorageCell
|
<StorageCell
|
||||||
|
|||||||
+111
-17
@@ -69,8 +69,10 @@ import {
|
|||||||
ChartColumn,
|
ChartColumn,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
GitBranch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api, type GitSource } from "../lib/api";
|
||||||
|
import { useInnerReposMode } from "../lib/innerReposPref";
|
||||||
import { usePermissions } from "../components/AuthGate";
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type {
|
import type {
|
||||||
CwdSuggestion,
|
CwdSuggestion,
|
||||||
@@ -940,6 +942,43 @@ export function Workspace() {
|
|||||||
);
|
);
|
||||||
}, [panelLaneId, takeGitFolders]);
|
}, [panelLaneId, takeGitFolders]);
|
||||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||||
|
// The repos INSIDE the workspace folder (a parent folder of several repos):
|
||||||
|
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
|
||||||
|
// with the panel's own refresh and every minute, so a clone shows up) —
|
||||||
|
// unless the account chose the folder's own git only (Settings → Git →
|
||||||
|
// Changes panel), which neither walks the folder nor shows them.
|
||||||
|
const innerMode = useInnerReposMode();
|
||||||
|
const [innerRepos, setInnerRepos] = useState<{
|
||||||
|
laneId: number;
|
||||||
|
root: boolean;
|
||||||
|
items: { path: string; name: string }[];
|
||||||
|
} | null>(null);
|
||||||
|
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
|
||||||
|
api.lanes.repos(laneId, refresh).then(
|
||||||
|
(v) =>
|
||||||
|
setInnerRepos((prev) =>
|
||||||
|
prev?.laneId === laneId &&
|
||||||
|
prev.root === v.root &&
|
||||||
|
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
|
||||||
|
? prev
|
||||||
|
: { laneId, root: v.root, items: v.items }
|
||||||
|
),
|
||||||
|
() => setInnerRepos({ laneId, root: true, items: [] })
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelLaneId === null || innerMode === "own") return;
|
||||||
|
loadInnerRepos(panelLaneId);
|
||||||
|
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [panelLaneId, loadInnerRepos, innerMode]);
|
||||||
|
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
|
||||||
|
const repos = inner?.items ?? [];
|
||||||
|
// A folder that is no repo itself but holds some: its own section would only
|
||||||
|
// say "not a git repository", so the repos stand alone.
|
||||||
|
const showRoot = !inner || inner.root || repos.length === 0;
|
||||||
|
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||||
|
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||||
@@ -990,15 +1029,19 @@ export function Workspace() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const panelLaneRef = useRef<number | null>(null);
|
const panelLaneRef = useRef<number | null>(null);
|
||||||
|
const repoOfRef = useRef(repoOf);
|
||||||
|
repoOfRef.current = repoOf;
|
||||||
panelLaneRef.current = panelAt?.laneId ?? null;
|
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||||
if (laneId !== panelLaneRef.current) return;
|
if (laneId !== panelLaneRef.current) return;
|
||||||
// Its path is the workspace folder's: unfold that section.
|
// Its path is the workspace folder's: unfold that section (or the
|
||||||
|
// inner repo's that holds it).
|
||||||
|
const repo = repoOfRef.current(path);
|
||||||
setFoldedSources((prev) => {
|
setFoldedSources((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete("");
|
next.delete(repo ? `repo:${repo.path}` : "");
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setChangesOpen(true);
|
setChangesOpen(true);
|
||||||
@@ -1612,9 +1655,43 @@ export function Workspace() {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||||
{panelAt &&
|
{panelAt &&
|
||||||
[
|
[
|
||||||
{ key: "", source: undefined as number | undefined },
|
...(showRoot
|
||||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
? [
|
||||||
].map(({ key, source }, i) => {
|
{
|
||||||
|
key: "",
|
||||||
|
source: undefined as GitSource | undefined,
|
||||||
|
name:
|
||||||
|
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
|
||||||
|
currentLane.cwd,
|
||||||
|
root: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...repos.map((r) => ({
|
||||||
|
key: `repo:${r.path}`,
|
||||||
|
source: r.path as GitSource,
|
||||||
|
name: r.name,
|
||||||
|
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
|
||||||
|
})),
|
||||||
|
...folders.map((f) => ({
|
||||||
|
key: String(f.id),
|
||||||
|
source: f.id as GitSource,
|
||||||
|
name: f.name,
|
||||||
|
root: f.path,
|
||||||
|
})),
|
||||||
|
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
|
||||||
|
// The chat's file request goes to the section holding it.
|
||||||
|
const req =
|
||||||
|
source === undefined
|
||||||
|
? changesRequest
|
||||||
|
: typeof source === "string" && changesRequest
|
||||||
|
? changesRequest.path.startsWith(`${source}/`)
|
||||||
|
? {
|
||||||
|
path: changesRequest.path.slice(source.length + 1),
|
||||||
|
nonce: changesRequest.nonce,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
const panel = (
|
const panel = (
|
||||||
<LaneChangesPanel
|
<LaneChangesPanel
|
||||||
// A new tree is a new panel: no read for the previous
|
// A new tree is a new panel: no read for the previous
|
||||||
@@ -1627,8 +1704,8 @@ export function Workspace() {
|
|||||||
full={changesFull}
|
full={changesFull}
|
||||||
wide={changesRatio(changesSize) >= 0.45}
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onDirtyChange={dirtyFor(key)}
|
onDirtyChange={dirtyFor(key)}
|
||||||
openRequest={source ? null : changesRequest}
|
openRequest={req}
|
||||||
filesChanged={source ? 0 : filesChanged}
|
filesChanged={source === undefined ? filesChanged : 0}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo
|
// A sibling worktree forks from the lane's SOURCE repo
|
||||||
// when it has one (this lane is itself a worktree of
|
// when it has one (this lane is itself a worktree of
|
||||||
// it); an adopted lane is its own repo.
|
// it); an adopted lane is its own repo.
|
||||||
@@ -1641,23 +1718,20 @@ export function Workspace() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
// No extra folders: the one panel, as it always was.
|
// One repo only: the one panel, as it always was.
|
||||||
if (!folders.length) return panel;
|
if (sections.length === 1) return panel;
|
||||||
// Else every repo is a section, stacked like VS Code's
|
// Else every repo is a section, stacked like VS Code's
|
||||||
// Source Control; the open ones share the height. A
|
// Source Control; the open ones share the height. A
|
||||||
// folded one stays mounted (CSS), so an unsaved edit in
|
// folded one stays mounted (CSS), so an unsaved edit in
|
||||||
// it survives.
|
// it survives.
|
||||||
const folded = foldedSources.has(key);
|
const folded = foldedSources.has(key);
|
||||||
const name = source
|
|
||||||
? folders[i - 1]!.name
|
|
||||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
key={key}
|
key={key}
|
||||||
data-testid="panel-git-source"
|
data-testid="panel-git-source"
|
||||||
// A row dragged from an extra folder lies outside
|
// A row dragged from an extra folder lies outside
|
||||||
// the pane's folder: `dragProjectPath` makes it absolute.
|
// the pane's folder: `dragProjectPath` makes it absolute.
|
||||||
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
data-drag-root={dragRoot}
|
||||||
className={`flex flex-col border-b border-border-light ${
|
className={`flex flex-col border-b border-border-light ${
|
||||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||||
}`}
|
}`}
|
||||||
@@ -1680,7 +1754,12 @@ export function Workspace() {
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{name}</span>
|
{typeof source === "string" && (
|
||||||
|
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="truncate" title={dragRoot}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||||
{panel}
|
{panel}
|
||||||
@@ -1695,7 +1774,18 @@ export function Workspace() {
|
|||||||
onClose={sourceCtx.close}
|
onClose={sourceCtx.close}
|
||||||
testId="panel-git-source-menu"
|
testId="panel-git-source-menu"
|
||||||
>
|
>
|
||||||
{sourceCtx.menu.item !== "" && (
|
{innerMode === "all" && (
|
||||||
|
<MenuItem
|
||||||
|
testId="panel-git-source-rescan"
|
||||||
|
onSelect={() => {
|
||||||
|
sourceCtx.close();
|
||||||
|
loadInnerRepos(panelAt.laneId, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tLanes("changes.rescanRepos")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{/^\d+$/.test(sourceCtx.menu.item) && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
testId="panel-git-source-close"
|
testId="panel-git-source-close"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -1777,7 +1867,11 @@ export function Workspace() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{panelAt &&
|
{panelAt &&
|
||||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
[
|
||||||
|
...(showRoot ? [undefined] : []),
|
||||||
|
...repos.map((r): GitSource => r.path),
|
||||||
|
...folders.map((f): GitSource => f.id),
|
||||||
|
].map((source) => (
|
||||||
<LaneGitRailCounts
|
<LaneGitRailCounts
|
||||||
key={source ?? ""}
|
key={source ?? ""}
|
||||||
laneId={panelAt.laneId}
|
laneId={panelAt.laneId}
|
||||||
|
|||||||
@@ -84,7 +84,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
...api,
|
...api,
|
||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
accountPool: {
|
||||||
|
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||||
|
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||||
|
},
|
||||||
|
aiTools: {
|
||||||
|
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||||
|
ompSharing: vi.fn(async () => ({ available: false })),
|
||||||
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
accountPool: {
|
accountPool: {
|
||||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||||
|
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||||
|
},
|
||||||
|
aiTools: {
|
||||||
|
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||||
|
ompSharing: vi.fn(async () => ({ available: false })),
|
||||||
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
: [],
|
: [],
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
// The repos inside the workspace folder (`?repo=`): none by default.
|
||||||
|
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
|
||||||
// Closing a folder's section removes it from the workspace.
|
// Closing a folder's section removes it from the workspace.
|
||||||
removeSource: r({
|
removeSource: r({
|
||||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||||
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
|
||||||
|
vi.mocked(api.lanes.repos).mockClear();
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||||
|
root: false,
|
||||||
|
items: [{ path: "api", name: "api" }],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const rail = screen.queryByTestId("lane-changes-expand");
|
||||||
|
if (rail) {
|
||||||
|
fireEvent.click(rail);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
expect(api.lanes.repos).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
|
||||||
|
// Settings → Git → Changes panel: show the repositories inside.
|
||||||
|
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({
|
||||||
|
root: false,
|
||||||
|
items: [
|
||||||
|
{ path: "api", name: "api" },
|
||||||
|
{ path: "apps/web", name: "apps/web" },
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.git).mockClear();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
const rail = screen.queryByTestId("lane-changes-expand");
|
||||||
|
if (rail) {
|
||||||
|
fireEvent.click(rail);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
const sections = screen.getAllByTestId("panel-git-source");
|
||||||
|
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
|
||||||
|
"api",
|
||||||
|
"apps/web",
|
||||||
|
]);
|
||||||
|
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
|
||||||
|
// An inner repo is the folder's own: its menu rescans, never closes it.
|
||||||
|
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
|
||||||
|
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||||
|
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||||
|
// A chat's file inside an inner repo opens in that repo's section.
|
||||||
|
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||||
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||||
|
act(() => {
|
||||||
|
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||||
|
{ lane: 2, repo: "apps/web" },
|
||||||
|
"src/x.ts",
|
||||||
|
"work"
|
||||||
|
);
|
||||||
|
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
lanes: {
|
lanes: {
|
||||||
// The Changes panel's folder picker reads the workspace's extra folders.
|
// The Changes panel's folder picker reads the workspace's extra folders.
|
||||||
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
||||||
|
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
|
||||||
list: vi.fn().mockImplementation(async () => {
|
list: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/lanes");
|
recordCall("GET", "/api/lanes");
|
||||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||||
|
|||||||
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.19 (rebuild client to match)
|
UI build v2.8.23 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+46
-10
@@ -89,13 +89,13 @@ 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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `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 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}` — read-only, cached 3 s, `usage` null off Linux), 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). 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?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), 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). 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, `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); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `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).
|
`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).
|
||||||
|
|
||||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
|
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||||
|
|
||||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||||
|
|
||||||
@@ -395,13 +395,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
|
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 —
|
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
|
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.
|
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.
|
||||||
|
|
||||||
#### Clone a repository into a new workspace
|
#### Clone a repository into a new workspace
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
|
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
|
||||||
→ 201 { lane, created: true, path }
|
→ 201 { lane, created: true, path }
|
||||||
|
→ 200 { lane, created: false, path } (the user's own workspace already at that folder + tool, its folder deleted since)
|
||||||
GET /api/lanes/clone/progress/:token
|
GET /api/lanes/clone/progress/:token
|
||||||
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
|
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
|
||||||
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
|
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
|
||||||
@@ -967,6 +971,34 @@ folder below its repo root) applies to it as to a workspace folder, and
|
|||||||
`GET /git` reports `base` as that repo's own default branch. A folder that is
|
`GET /git` reports `base` as that repo's own default branch. A folder that is
|
||||||
not a git repo answers `{available: false}`.
|
not a git repo answers `{available: false}`.
|
||||||
|
|
||||||
|
#### Repos inside a parent workspace folder (`?repo=`)
|
||||||
|
|
||||||
|
A workspace whose folder holds several repos side by side
|
||||||
|
(`~/work/shop/{api,web,apps/admin}`) lists them with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/lanes/:id/repos[?refresh=1]
|
||||||
|
→ { "root": false, "items": [{ "path": "api", "name": "api" }, …], "truncated": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/lib/nested-repos.js` walks the disk only (no git call): every directory
|
||||||
|
up to 2 levels below the workspace folder holding a `.git` entry (a directory,
|
||||||
|
or a submodule's / linked worktree's file), never through a symlink, a dot
|
||||||
|
folder or `node_modules`/`vendor`/`dist`/`build`/`target`, never inside a repo
|
||||||
|
it already found; at most 50 repos and 5000 directory entries. Cached 30 s per
|
||||||
|
folder; `?refresh=1` reads again. `root` is whether the workspace folder
|
||||||
|
itself is in a repo. The Changes panel calls it only for an account that
|
||||||
|
turned on Settings → Git → Changes panel (`agenthub.changes.innerRepos` =
|
||||||
|
`all`; off by default).
|
||||||
|
|
||||||
|
Every `/api/lanes/:id/git…` and `/fs…` route also takes `?repo=<path>` (the
|
||||||
|
`path` above): it is accepted only when that walk found it — re-walked at most
|
||||||
|
every 2 s for a name the cache lacks — otherwise `404 ENOREPO`, never a fall
|
||||||
|
back to the workspace folder. Like an extra folder, the repo is its own
|
||||||
|
boundary under every guard above, and `GET /git` reports its own default
|
||||||
|
branch as `base`. The Changes panel shows one section per repo (the workspace
|
||||||
|
folder's own section only when it is itself a repo).
|
||||||
|
|
||||||
#### Remove a session's worktree
|
#### Remove a session's worktree
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2925,7 +2957,7 @@ GET /api/run/interrupted {items} — sessions whose pane died under
|
|||||||
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
|
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
|
||||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel). Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
||||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||||
@@ -2976,7 +3008,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
|
|||||||
|
|
||||||
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
|
||||||
|
|
||||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||||
|
|
||||||
### AI tool login profiles
|
### AI tool login profiles
|
||||||
|
|
||||||
@@ -3087,7 +3119,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
|||||||
### Artifacts hub
|
### Artifacts hub
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
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
|
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}]}
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
@@ -3107,17 +3139,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
|||||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
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 is captured from its PostToolUse hook (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.
|
- **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. 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 — 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`, `*token*`…) `403 ESECRET`.
|
- **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`, `*token*`…) `403 ESECRET`.
|
||||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
- **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.
|
- **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.
|
- **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.
|
||||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||||
|
- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions.
|
||||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||||
@@ -3218,7 +3254,7 @@ POST /api/updates/check
|
|||||||
POST /api/updates/apply
|
POST /api/updates/apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, repo_root, … }` (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
||||||
|
|
||||||
### Import history
|
### Import history
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
|||||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). With `--tool cursor` the **Cursor account pool**: `add <name> --tool cursor` (a Cursor API key, typed hidden or on stdin). `pin <account> <user>` / `unpin <user>` (add `--tool codex` or `--tool cursor`) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||||
| `agenthub users list\|add <name> [--role admin\|analyst\|developer]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of developers' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
| `agenthub users list\|add <name> [--role admin\|analyst\|developer]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of developers' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||||
|
|
||||||
### Interactive REPL
|
### Interactive REPL
|
||||||
|
|||||||
+1
-1
@@ -513,7 +513,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
|
|||||||
|
|
||||||
### token_usage_hourly
|
### token_usage_hourly
|
||||||
|
|
||||||
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event — the session's own transcript plus its sub-agents' (`<session>/subagents/agent-*.jsonl`, `server/lib/subagent-usage.js`; the same sum goes into `token_usage`, so a sub-agent on the session's own model is counted too; Workflow agents stay under their own `workflow` tier) — (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
File diff suppressed because one or more lines are too long
+55
-4
@@ -160,7 +160,8 @@ Workspaces made before server mode, or by the host account, have none.
|
|||||||
An account's last login is kept in `users.last_login_at`, so it still shows
|
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||||
after the login itself has ended.
|
after the login itself has ended.
|
||||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
|
||||||
|
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
|
||||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||||
@@ -535,12 +536,62 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
|||||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||||
> `auth.json`, like the Claude pool's token.
|
> `auth.json`, like the Claude pool's token.
|
||||||
|
|
||||||
|
## Cursor account pool
|
||||||
|
|
||||||
|
Cursor CLI sessions get a pool too (`server/lib/cursor-pool.js`): Cursor API
|
||||||
|
keys (Cursor dashboard → Integrations → API keys), added on the Users page's
|
||||||
|
**Account pool → Cursor CLI** tab or from the shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub accounts add cursor-team --tool cursor # key typed hidden, or piped on stdin
|
||||||
|
agenthub accounts list --tool cursor
|
||||||
|
agenthub accounts remove cursor-team --tool cursor
|
||||||
|
agenthub accounts pin cursor-team mia --tool cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
- The key reaches the pane only as tmux `-e CURSOR_API_KEY=…` (a member's
|
||||||
|
launch file under S3), which `cursor-agent` prefers over the account's own
|
||||||
|
`cursor-agent login` for that process only. Nothing is written into a home.
|
||||||
|
- Cursor reports no usage or quota to AgentHub, so the account with the fewest
|
||||||
|
live Cursor panes is picked.
|
||||||
|
|
||||||
|
> **Ceiling:** a pane can read its own key (`$CURSOR_API_KEY`).
|
||||||
|
|
||||||
|
## Account pool groups
|
||||||
|
|
||||||
|
By default every user shares every pool account. **Groups** split the pool
|
||||||
|
by team (Users page → **Account pool → Groups**, server mode only,
|
||||||
|
`server/lib/pool-groups.js`):
|
||||||
|
|
||||||
|
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||||
|
Cursor). Its members' new sessions run only on its accounts of that tool; a
|
||||||
|
group with no account of a tool leaves its members on their own sign-in.
|
||||||
|
- A user belongs to at most one group (ticking them in another moves them).
|
||||||
|
An account may serve several groups.
|
||||||
|
- A user in no group runs on the **shared pool**: the accounts no group lists.
|
||||||
|
With no group at all, everything is shared, as before.
|
||||||
|
- A pin (Edit on an account) is an admin's explicit choice and wins over the
|
||||||
|
group.
|
||||||
|
- A login already live in a member's home stays until their panes end (one
|
||||||
|
`.credentials.json` / `auth.json` per home), even after a group change.
|
||||||
|
- Each change needs the admin's own password again and is audited. Removing
|
||||||
|
an account takes it out of every group; deleting a group sends its members
|
||||||
|
back to the shared pool and removes no account.
|
||||||
|
|
||||||
|
The tab bar also shows Oh My Pi (its login sharing, below) and every other
|
||||||
|
installed tool, which says it has no pool (Antigravity keeps its Google login
|
||||||
|
in the system keyring; there is no key AgentHub could hand a session).
|
||||||
|
|
||||||
|
The Claude and Codex tabs list the saved profiles of Settings → AI tools →
|
||||||
|
Profiles as cards; **Share to pool** moves one in (the profile leaves the
|
||||||
|
switcher, as with **Move to pool** in Settings).
|
||||||
|
|
||||||
## Oh My Pi login sharing
|
## Oh My Pi login sharing
|
||||||
|
|
||||||
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
||||||
login. An admin can let every member's omp run on the HOST account's omp
|
login. An admin can let every member's omp run on the HOST account's omp
|
||||||
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
|
logins instead: **Settings → AI tools → Oh My Pi login sharing**, or the
|
||||||
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
**Oh My Pi** tab of the Users page's Account pool (off by default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||||
broker and gateway (`server/lib/omp-share.js`):
|
broker and gateway (`server/lib/omp-share.js`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -688,7 +739,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.
|
- 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`.
|
- 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.
|
- 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 **More keys** (named keys for extra accounts) and **Other hosts** (advanced); 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…` 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.
|
||||||
- **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:
|
- **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 workspace prompt and its context files;
|
||||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
|||||||
|
|
||||||
| Kind | What it is | Where it comes from |
|
| Kind | What it is | Where it comes from |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
|
||||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
|
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
|
||||||
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
||||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||||
|
|
||||||
|
|||||||
+454
-4
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.19
|
version: 2.8.23
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -4632,6 +4632,36 @@ components:
|
|||||||
description: The pool account's name
|
description: The pool account's name
|
||||||
pinnedAt:
|
pinnedAt:
|
||||||
type: string
|
type: string
|
||||||
|
PoolGroup:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- users
|
||||||
|
- accounts
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
accounts:
|
||||||
|
type: object
|
||||||
|
description: Per tool (claude, codex, cursor), the pool account names it may use
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
UserPrefs:
|
UserPrefs:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -12009,6 +12039,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
- name: repo
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The file's bytes, as an attachment.
|
description: The file's bytes, as an attachment.
|
||||||
@@ -12052,6 +12087,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
- name: repo
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -12113,6 +12153,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
|
- name: repo
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The suggested message
|
description: The suggested message
|
||||||
@@ -14674,6 +14719,14 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
sessions:
|
sessions:
|
||||||
type: integer
|
type: integer
|
||||||
|
today:
|
||||||
|
type: object
|
||||||
|
description: Spent since the viewer's local midnight (`tz_offset`).
|
||||||
|
properties:
|
||||||
|
cost:
|
||||||
|
type: number
|
||||||
|
tokens:
|
||||||
|
type: integer
|
||||||
last7d:
|
last7d:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15227,7 +15280,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
operationId: listPoolAccounts
|
operationId: listPoolAccounts
|
||||||
summary: The Claude and Codex account pools as the balancer sees them (never a key or token)
|
summary: The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)
|
||||||
parameters:
|
parameters:
|
||||||
- name: force
|
- name: force
|
||||||
in: query
|
in: query
|
||||||
@@ -15289,6 +15342,31 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AccountPin'
|
$ref: '#/components/schemas/AccountPin'
|
||||||
|
cursor:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AccountPin'
|
||||||
|
groups:
|
||||||
|
type: array
|
||||||
|
description: 'Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group''s accounts, a user in no group on the accounts no group lists'
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PoolGroup'
|
||||||
|
cursor:
|
||||||
|
type: array
|
||||||
|
description: 'The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend'
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- apikey
|
||||||
|
addedAt:
|
||||||
|
type: string
|
||||||
|
livePanes:
|
||||||
|
type: integer
|
||||||
codex:
|
codex:
|
||||||
type: array
|
type: array
|
||||||
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
||||||
@@ -15326,7 +15404,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
operationId: addPoolAccount
|
operationId: addPoolAccount
|
||||||
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back'
|
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back'
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -15342,6 +15420,7 @@ paths:
|
|||||||
enum:
|
enum:
|
||||||
- claude
|
- claude
|
||||||
- codex
|
- codex
|
||||||
|
- cursor
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Defaults to the profile's name
|
description: Defaults to the profile's name
|
||||||
@@ -15353,7 +15432,7 @@ paths:
|
|||||||
description: 'Claude: the text of a .credentials.json'
|
description: 'Claude: the text of a .credentials.json'
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
description: 'Claude: a setup-token; Codex: an API key or auth.json text'
|
description: 'Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key'
|
||||||
currentPassword:
|
currentPassword:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -15411,6 +15490,7 @@ paths:
|
|||||||
enum:
|
enum:
|
||||||
- claude
|
- claude
|
||||||
- codex
|
- codex
|
||||||
|
- cursor
|
||||||
userId:
|
userId:
|
||||||
type: integer
|
type: integer
|
||||||
account:
|
account:
|
||||||
@@ -15482,6 +15562,7 @@ paths:
|
|||||||
enum:
|
enum:
|
||||||
- claude
|
- claude
|
||||||
- codex
|
- codex
|
||||||
|
- cursor
|
||||||
userIds:
|
userIds:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -15515,6 +15596,172 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/accounts/groups:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: createPoolGroup
|
||||||
|
summary: 'Admin: a new, empty pool group, with their own password again'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: '`{group: {id, name}}`'
|
||||||
|
'400':
|
||||||
|
description: EBADNAME
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: EEXISTS — a group has that name
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/accounts/groups/{id}:
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: updatePoolGroup
|
||||||
|
summary: 'Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again'
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
userIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
accounts:
|
||||||
|
type: object
|
||||||
|
description: '`{tool: [account names]}` for claude, codex, cursor'
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{group}`'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
group:
|
||||||
|
$ref: '#/components/schemas/PoolGroup'
|
||||||
|
'400':
|
||||||
|
description: EBADNAME, EBADUSER, EBADTOOL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOGROUP, ENOUSER, ENOACCOUNT
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: EEXISTS — a group has that name
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: deletePoolGroup
|
||||||
|
summary: 'Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again'
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{ok: true}`'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOGROUP
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/accounts/{name}:
|
/api/accounts/{name}:
|
||||||
delete:
|
delete:
|
||||||
tags:
|
tags:
|
||||||
@@ -15542,6 +15789,7 @@ paths:
|
|||||||
enum:
|
enum:
|
||||||
- claude
|
- claude
|
||||||
- codex
|
- codex
|
||||||
|
- cursor
|
||||||
currentPassword:
|
currentPassword:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -16827,8 +17075,10 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
|
- all
|
||||||
- mine
|
- mine
|
||||||
- hub
|
- hub
|
||||||
|
- unowned
|
||||||
- name: kind
|
- name: kind
|
||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
@@ -18061,6 +18311,49 @@ paths:
|
|||||||
description: ENOLANE — workspace not found.
|
description: ENOLANE — workspace not found.
|
||||||
'409':
|
'409':
|
||||||
description: ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).
|
description: ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).
|
||||||
|
/api/lanes/{id}/repos:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Lanes
|
||||||
|
summary: The git repositories inside a workspace folder
|
||||||
|
description: 'For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.'
|
||||||
|
operationId: getLaneRepos
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: refresh
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '{root, items: [{path, name}], truncated}.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
root:
|
||||||
|
type: boolean
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
truncated:
|
||||||
|
type: boolean
|
||||||
|
'404':
|
||||||
|
description: ENOLANE — workspace not found.
|
||||||
/api/lanes/{id}/sources:
|
/api/lanes/{id}/sources:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -21910,6 +22203,163 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/artifacts/{id}/claim:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Artifacts
|
||||||
|
summary: Take an unowned artifact
|
||||||
|
operationId: claimArtifact
|
||||||
|
description: Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Artifact id.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Claimed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
item:
|
||||||
|
type: object
|
||||||
|
'400':
|
||||||
|
description: ELOCALMODE.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: 'EFORBIDDEN: not the suggested user.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOTFOUND.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: 'EOWNED: it already has an owner.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/artifacts/{id}/decline:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Artifacts
|
||||||
|
summary: Say an unowned artifact offered to you is not yours
|
||||||
|
operationId: declineArtifact
|
||||||
|
description: Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Artifact id.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Declined.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
declined:
|
||||||
|
type: boolean
|
||||||
|
'400':
|
||||||
|
description: ELOCALMODE.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: 'EFORBIDDEN: not the suggested user.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOTFOUND.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: EOWNED.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/artifacts/{id}/assign:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Artifacts
|
||||||
|
summary: Give an unowned artifact to a user (admin)
|
||||||
|
operationId: assignArtifact
|
||||||
|
description: Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Artifact id.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Assigned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
item:
|
||||||
|
type: object
|
||||||
|
'400':
|
||||||
|
description: ELOCALMODE / EBADFIELD.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: 'EFORBIDDEN: not an admin.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOTFOUND.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: EOWNED.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/settings/import:
|
/api/settings/import:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.19",
|
"version": "2.8.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.19",
|
"version": "2.8.23",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.19",
|
"version": "2.8.23",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-20
@@ -1538,26 +1538,22 @@ function importSession(dbModule, session) {
|
|||||||
backfilled = true;
|
backfilled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile token usage. The earlier importer dropped subagent tokens
|
// Reconcile token usage on every pass. A session first imported while its
|
||||||
// entirely, so any session with subagent JSONLs has under-counted totals.
|
// transcript was still being written (a `claude -p` run with no hooks is
|
||||||
// replaceTokenUsage's baseline-shift logic guarantees this can never
|
// seen the moment its file appears) was stored with the usage of that
|
||||||
// reduce a session's totals — at worst it's a no-op.
|
// moment — often none — and its sub-agents' tokens arrive later still.
|
||||||
if (
|
// replaceTokenUsage keeps the highest total ever written, so this can only
|
||||||
session.parsedSubagents &&
|
// raise a bucket, never inflate it; it counts as a backfill only when the
|
||||||
session.parsedSubagents.some(
|
// session's total actually grew.
|
||||||
(s) =>
|
{
|
||||||
s.tokensByModel &&
|
const totalOf = db.prepare(
|
||||||
Object.values(s.tokensByModel).some(
|
`SELECT COALESCE(SUM(input_tokens + baseline_input + output_tokens + baseline_output
|
||||||
(t) => (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) > 0
|
+ cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write), 0) AS n
|
||||||
)
|
FROM token_usage WHERE session_id = ?`
|
||||||
)
|
|
||||||
) {
|
|
||||||
const written = writeSessionTokens(
|
|
||||||
dbModule,
|
|
||||||
session.sessionId,
|
|
||||||
combineSessionTokens(session)
|
|
||||||
);
|
);
|
||||||
if (written > 0) backfilled = true;
|
const before = totalOf.get(session.sessionId).n;
|
||||||
|
writeSessionTokens(dbModule, session.sessionId, combineSessionTokens(session));
|
||||||
|
if (totalOf.get(session.sessionId).n > before) backfilled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return backfilled ? { skipped: false, backfilled: true } : { skipped: true };
|
return backfilled ? { skipped: false, backfilled: true } : { skipped: true };
|
||||||
@@ -2129,6 +2125,9 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
|||||||
*/
|
*/
|
||||||
async function reconcileTokens(dbModule, options = {}) {
|
async function reconcileTokens(dbModule, options = {}) {
|
||||||
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
|
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
|
||||||
|
// `exact: false` only raises (replaceTokenUsage's high-water mark): a repair
|
||||||
|
// that adds what was never counted without touching anything already right.
|
||||||
|
const exact = options.exact !== false;
|
||||||
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
|
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
|
||||||
if (!fs.existsSync(getProjectsDir())) return counters;
|
if (!fs.existsSync(getProjectsDir())) return counters;
|
||||||
|
|
||||||
@@ -2164,7 +2163,7 @@ async function reconcileTokens(dbModule, options = {}) {
|
|||||||
|
|
||||||
const tx = dbModule.db.transaction((batch) => {
|
const tx = dbModule.db.transaction((batch) => {
|
||||||
for (const { sessionId, tokens } of batch) {
|
for (const { sessionId, tokens } of batch) {
|
||||||
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact: true });
|
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact });
|
||||||
if (written > 0) {
|
if (written > 0) {
|
||||||
counters.sessionsTouched++;
|
counters.sessionsTouched++;
|
||||||
counters.modelsWritten += written;
|
counters.modelsWritten += written;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
||||||
* only "Command failed: <argv>".
|
* only "Command failed: <argv>".
|
||||||
*
|
*
|
||||||
|
* While a step runs, `plugin-update-progress.json` beside it names that step
|
||||||
|
* (`progress`), so the dashboard can say how far the chain got rather than
|
||||||
|
* only that it is busy; a final verdict removes it.
|
||||||
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
|||||||
// main() — which a require never runs — writes these.
|
// main() — which a require never runs — writes these.
|
||||||
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
||||||
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
||||||
|
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
|
||||||
|
|
||||||
|
/** The chain's steps, in order, as `progress` reports them. */
|
||||||
|
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
|
||||||
|
|
||||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||||
// reach an argv — so they are re-validated here rather than trusted across the
|
// reach an argv — so they are re-validated here rather than trusted across the
|
||||||
@@ -59,13 +67,34 @@ function log(line) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(result) {
|
/** Every verdict but the pre-written handover one (`final: false`) ends the
|
||||||
|
* chain, so its progress record goes with it. */
|
||||||
|
function record(result, { final = true } = {}) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
RESULT_FILE,
|
RESULT_FILE,
|
||||||
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
||||||
);
|
);
|
||||||
|
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Say which step is running now — a courtesy for the UI, never a verdict. */
|
||||||
|
function progress(step) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
PROGRESS_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
step,
|
||||||
|
index: STEPS.indexOf(step) + 1,
|
||||||
|
total: STEPS.length,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
}
|
}
|
||||||
@@ -182,6 +211,7 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress("marketplace_update");
|
||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -189,6 +219,7 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress("plugin_update");
|
||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,12 +250,16 @@ function main() {
|
|||||||
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
||||||
// pre-written record is overwritten by the real outcome when this survives,
|
// pre-written record is overwritten by the real outcome when this survives,
|
||||||
// and is the outcome when it does not.
|
// and is the outcome when it does not.
|
||||||
record({
|
record(
|
||||||
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
step: "bootstrap",
|
step: "bootstrap",
|
||||||
plugin_root: newRoot,
|
plugin_root: newRoot,
|
||||||
note: "the new plugin version is installed; the server is being handed over",
|
note: "the new plugin version is installed; the server is being handed over",
|
||||||
});
|
},
|
||||||
|
{ final: false }
|
||||||
|
);
|
||||||
|
progress("bootstrap");
|
||||||
try {
|
try {
|
||||||
// Foreground and blocking: this stops the old server (us), waits for the
|
// Foreground and blocking: this stops the old server (us), waits for the
|
||||||
// ports, then starts the new one. It must finish before we record success.
|
// ports, then starts the new one. It must finish before we record success.
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ before(async () => {
|
|||||||
"scripted.json",
|
"scripted.json",
|
||||||
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
||||||
);
|
);
|
||||||
|
write(
|
||||||
|
"redirected.json",
|
||||||
|
tool("redirected", {
|
||||||
|
install: {
|
||||||
|
kind: "script",
|
||||||
|
url: "https://example.com/install",
|
||||||
|
redirect: "https://cdn.example.net/install.sh",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
||||||
write("plain.json", tool("plain"));
|
write("plain.json", tool("plain"));
|
||||||
exe(path.join(shellBin, "npm"));
|
exe(path.join(shellBin, "npm"));
|
||||||
@@ -342,6 +352,27 @@ describe("jobs", () => {
|
|||||||
assert.match(done.log, /larger than 1 MB/);
|
assert.match(done.log, /larger than 1 MB/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows a redirect off its site only to the catalog's exact redirect URL", async () => {
|
||||||
|
let landed = "https://cdn.example.net/install.sh?x=1";
|
||||||
|
globalThis.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
url: landed,
|
||||||
|
arrayBuffer: async () => Buffer.from("echo hi\n"),
|
||||||
|
});
|
||||||
|
await jobs.start("redirected", "install");
|
||||||
|
let done = await settle();
|
||||||
|
assert.equal(done.status, "failed");
|
||||||
|
assert.match(done.log, /redirected off example\.com/);
|
||||||
|
assert.equal(spawned.length, 0);
|
||||||
|
|
||||||
|
landed = "https://cdn.example.net/install.sh";
|
||||||
|
await jobs.start("redirected", "install");
|
||||||
|
done = await settle();
|
||||||
|
assert.equal(done.status, "ok");
|
||||||
|
assert.equal(spawned[0].argv[0], "bash");
|
||||||
|
});
|
||||||
|
|
||||||
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
||||||
// A slow login-shell read at the end: the job must not look finished before it.
|
// A slow login-shell read at the end: the job must not look finished before it.
|
||||||
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ describe("install/update fields and trust tiers", () => {
|
|||||||
update: { kind: "script" },
|
update: { kind: "script" },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
write(
|
||||||
|
tb,
|
||||||
|
"redir.json",
|
||||||
|
base("redir", {
|
||||||
|
install: { kind: "script", url: "https://example.com/i", redirect: "https://cdn.io/i.sh" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
tb,
|
||||||
|
"badredir.json",
|
||||||
|
base("badredir", {
|
||||||
|
install: { kind: "script", url: "https://example.com/i", redirect: "http://cdn.io/i" },
|
||||||
|
})
|
||||||
|
);
|
||||||
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
|
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
|
||||||
write(
|
write(
|
||||||
tb,
|
tb,
|
||||||
@@ -302,7 +316,8 @@ describe("install/update fields and trust tiers", () => {
|
|||||||
url: "https://example.com/install",
|
url: "https://example.com/install",
|
||||||
});
|
});
|
||||||
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
|
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
|
||||||
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
|
assert.equal(aiTools.get("redir").install.redirect, "https://cdn.io/i.sh");
|
||||||
|
for (const id of ["badpkg", "httpurl", "offhost", "badredir", "npmnoinst", "selfbad"]) {
|
||||||
assert.equal(aiTools.has(id), false, id);
|
assert.equal(aiTools.has(id), false, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||||
* developer, the admin's installs switch, and same-origin writes.
|
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||||
|
* docs captured at their birth, documents captured at a session's stop, and
|
||||||
|
* unowned rows claimed, declined and assigned.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -120,6 +122,42 @@ describe("pages", () => {
|
|||||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records a Claude Docs doc at its birth, never on a later batch", () => {
|
||||||
|
const tool = "mcp__claude_ai_Claude_Docs__batch";
|
||||||
|
assert.equal(artifacts.isPageTool(tool), true);
|
||||||
|
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
|
||||||
|
assert.equal(artifacts.isPageTool("Artifact"), true);
|
||||||
|
const row = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
|
||||||
|
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
|
||||||
|
});
|
||||||
|
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
|
||||||
|
assert.equal(row.title, "Q3 plan");
|
||||||
|
const edit = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
|
||||||
|
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
|
||||||
|
});
|
||||||
|
assert.equal(edit, null);
|
||||||
|
// The doc's own link wins over one its content cites earlier in the ack.
|
||||||
|
const cited = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
|
||||||
|
tool_response: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
JSON.stringify({
|
||||||
|
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
|
||||||
|
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
|
||||||
|
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||||
const uid = db
|
const uid = db
|
||||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||||
@@ -711,3 +749,101 @@ describe("search, frontmatter and marketplace limits", () => {
|
|||||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("outputs captured at a session's stop", () => {
|
||||||
|
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
|
||||||
|
const past = new Date(Date.now() - 3600 * 1000);
|
||||||
|
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
|
||||||
|
const deck = path.join(ws, "out", "deck.pptx");
|
||||||
|
fs.writeFileSync(deck, "PK-deck-v1");
|
||||||
|
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
|
||||||
|
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
|
||||||
|
fs.utimesSync(deck, soon, soon);
|
||||||
|
|
||||||
|
const first = await artifacts.captureOutputs("s1");
|
||||||
|
assert.deepEqual(
|
||||||
|
first.map((r) => r.source),
|
||||||
|
["out/deck.pptx"]
|
||||||
|
);
|
||||||
|
assert.equal(first[0].auto, 1);
|
||||||
|
assert.equal(first[0].lane_id, lane.id);
|
||||||
|
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
|
||||||
|
|
||||||
|
fs.writeFileSync(deck, "PK-deck-v2");
|
||||||
|
const later = new Date(Date.now() + 60 * 1000);
|
||||||
|
fs.utimesSync(deck, later, later);
|
||||||
|
const again = await artifacts.captureOutputs("s1");
|
||||||
|
assert.equal(again.length, 1);
|
||||||
|
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
|
||||||
|
assert.notEqual(again[0].snapshot, first[0].snapshot);
|
||||||
|
assert.equal(
|
||||||
|
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unowned rows", () => {
|
||||||
|
it("are suggested, claimed, declined and assigned only by the right people", () => {
|
||||||
|
const addUser = (name, role) =>
|
||||||
|
Number(
|
||||||
|
db
|
||||||
|
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
|
||||||
|
.run(name, role).lastInsertRowid
|
||||||
|
);
|
||||||
|
const bobId = addUser("bob-u", "developer");
|
||||||
|
const amyId = addUser("amy-u", "developer");
|
||||||
|
const adminId = addUser("root-u", "admin");
|
||||||
|
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
|
||||||
|
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
|
||||||
|
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
|
||||||
|
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
try {
|
||||||
|
// A host session with no workspace, run inside bob's workspace folder.
|
||||||
|
const row = artifacts.captureFromHook(
|
||||||
|
"loose-host",
|
||||||
|
{
|
||||||
|
tool_input: { action: "publish" },
|
||||||
|
tool_response: "https://claude.ai/artifact/un1",
|
||||||
|
cwd: ws,
|
||||||
|
},
|
||||||
|
{ kind: "host" }
|
||||||
|
);
|
||||||
|
assert.equal(row.owner_id, null);
|
||||||
|
assert.equal(row.suggested_owner_id, bobId);
|
||||||
|
assert.ok(
|
||||||
|
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
|
||||||
|
);
|
||||||
|
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
|
||||||
|
assert.equal(artifacts.canSeeId(amy, row.id), false);
|
||||||
|
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
|
||||||
|
|
||||||
|
const claimed = artifacts.claim(bob, row.id);
|
||||||
|
assert.equal(claimed.ownerId, bobId);
|
||||||
|
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
|
||||||
|
|
||||||
|
const other = artifacts.captureFromHook(
|
||||||
|
"loose-host-2",
|
||||||
|
{
|
||||||
|
tool_input: { action: "publish" },
|
||||||
|
tool_response: "https://claude.ai/artifact/un2",
|
||||||
|
cwd: ws,
|
||||||
|
},
|
||||||
|
{ kind: "host" }
|
||||||
|
);
|
||||||
|
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
|
||||||
|
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
|
||||||
|
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
|
||||||
|
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
|
||||||
|
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
}
|
||||||
|
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* @file cursor-pool.test.js
|
||||||
|
* @description The Cursor account pool (server/lib/cursor-pool.js), fake keys
|
||||||
|
* only: key validation and file modes, the balancer (fewest live Cursor panes,
|
||||||
|
* a pin wins), the key reaching a `cursor-agent` pane only through tmux `-e
|
||||||
|
* CURSOR_API_KEY` and never its argv, the account recorded on the run, pins
|
||||||
|
* for `cursor` and the account_pins rebuild that drops the old tool CHECK, and
|
||||||
|
* an empty pool leaving every spawn as it was.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = 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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cursor-pool-test-")));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||||
|
|
||||||
|
const { db } = require("../db");
|
||||||
|
const pool = require("../lib/cursor-pool");
|
||||||
|
const pins = require("../lib/account-pins");
|
||||||
|
const tmux = require("../lib/tmux");
|
||||||
|
const pty = require("../lib/pty-run");
|
||||||
|
|
||||||
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
|
let tmuxArgv;
|
||||||
|
|
||||||
|
const envOf = (argv, name) =>
|
||||||
|
argv
|
||||||
|
.slice(0, argv.indexOf("--"))
|
||||||
|
.find((x) => String(x).startsWith(`${name}=`))
|
||||||
|
?.slice(name.length + 1);
|
||||||
|
|
||||||
|
function run(id, account, status = "running") {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, tool)
|
||||||
|
VALUES (?, 'interactive', '/tmp', ?, ?, ?, 'cursor')`
|
||||||
|
).run(id, status, new Date().toISOString(), account);
|
||||||
|
}
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||||
|
if (args[0] === "new-session") tmuxArgv = args;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
tmux.__setExecImpl(null);
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||||
|
db.prepare("DELETE FROM dashboard_runs").run();
|
||||||
|
tmuxArgv = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor pool: accounts", () => {
|
||||||
|
it("stores a key 0600 in a 0700 dir and never lists it", () => {
|
||||||
|
const added = pool.addAccount("team", { apiKey: KEY("a") });
|
||||||
|
assert.deepEqual(Object.keys(added).sort(), ["addedAt", "kind", "name"]);
|
||||||
|
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
|
||||||
|
assert.equal(fs.statSync(path.join(pool.poolDir(), "team.json")).mode & 0o777, 0o600);
|
||||||
|
const listed = pool.poolStatus();
|
||||||
|
assert.equal(listed.length, 1);
|
||||||
|
assert.ok(!JSON.stringify(listed).includes(KEY("a")));
|
||||||
|
assert.equal(listed[0].quotaPercent, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad name, a bad key and a taken name", () => {
|
||||||
|
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
|
||||||
|
assert.throws(() => pool.addAccount("x", { apiKey: "short" }), { code: "EBADTOKEN" });
|
||||||
|
assert.throws(() => pool.addAccount("x", { apiKey: `${KEY("a")}\nX=1` }), {
|
||||||
|
code: "EBADTOKEN",
|
||||||
|
});
|
||||||
|
pool.addAccount("x", { apiKey: KEY("a") });
|
||||||
|
assert.throws(() => pool.addAccount("x", { apiKey: KEY("b") }), { code: "EEXISTS" });
|
||||||
|
pool.removeAccount("x");
|
||||||
|
assert.throws(() => pool.removeAccount("x"), { code: "ENOACCOUNT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the account with the fewest live panes", () => {
|
||||||
|
pool.addAccount("a", { apiKey: KEY("a") });
|
||||||
|
pool.addAccount("b", { apiKey: KEY("b") });
|
||||||
|
run("r1", "a");
|
||||||
|
run("r2", "b", "completed");
|
||||||
|
assert.equal(pool.pickAccount().name, "b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor pool: spawn", () => {
|
||||||
|
it("an empty pool leaves a Cursor spawn exactly as before", () => {
|
||||||
|
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor" });
|
||||||
|
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), undefined);
|
||||||
|
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||||
|
assert.equal(row.account, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the key reaches the pane through tmux -e only, and the run names its account", () => {
|
||||||
|
pool.addAccount("a", { apiKey: KEY("a") });
|
||||||
|
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor", initialPrompt: "hi" });
|
||||||
|
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), KEY("a"));
|
||||||
|
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
|
||||||
|
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
|
||||||
|
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||||
|
assert.equal(row.account, "a");
|
||||||
|
|
||||||
|
// Other tools never get it.
|
||||||
|
tmuxArgv = null;
|
||||||
|
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
|
||||||
|
assert.ok(!tmuxArgv.some((x) => String(x).includes("CURSOR_API_KEY")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor pool: pins", () => {
|
||||||
|
it("account_pins takes a cursor pin, and removing the account drops it", () => {
|
||||||
|
const sql = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||||
|
.get().sql;
|
||||||
|
assert.ok(!sql.includes("CHECK(tool IN"), "no tool CHECK left");
|
||||||
|
assert.ok(pins.TOOLS.includes("cursor"));
|
||||||
|
const uid = db
|
||||||
|
.prepare("INSERT INTO users (username, password_hash) VALUES ('pinned', 'x')")
|
||||||
|
.run().lastInsertRowid;
|
||||||
|
pool.addAccount("a", { apiKey: KEY("a") });
|
||||||
|
db.prepare("INSERT INTO account_pins (user_id, tool, account) VALUES (?, 'cursor', 'a')").run(
|
||||||
|
uid
|
||||||
|
);
|
||||||
|
assert.equal(pins.listPins("cursor").length, 1);
|
||||||
|
pool.removeAccount("a");
|
||||||
|
assert.equal(pins.listPins("cursor").length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,6 +143,25 @@ describe("POST /api/lanes/clone", () => {
|
|||||||
assert.equal(again.body.error.code, "EEXISTS");
|
assert.equal(again.body.error.code, "EEXISTS");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-attaches a workspace whose folder was deleted instead of failing UNIQUE", async () => {
|
||||||
|
const first = await request("POST", "/api/lanes/clone", {
|
||||||
|
url: "https://forge.test/o/r.git",
|
||||||
|
parent: WORK,
|
||||||
|
name: "gone",
|
||||||
|
});
|
||||||
|
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||||
|
fs.rmSync(path.join(WORK, "gone"), { recursive: true, force: true });
|
||||||
|
const again = await request("POST", "/api/lanes/clone", {
|
||||||
|
url: "https://forge.test/o/r.git",
|
||||||
|
parent: WORK,
|
||||||
|
name: "gone",
|
||||||
|
});
|
||||||
|
assert.equal(again.status, 200, JSON.stringify(again.body));
|
||||||
|
assert.equal(again.body.created, false);
|
||||||
|
assert.equal(again.body.lane.id, first.body.lane.id);
|
||||||
|
assert.ok(fs.existsSync(path.join(WORK, "gone", "README.md")));
|
||||||
|
});
|
||||||
|
|
||||||
it("clones over ssh with the user's key, kept in the clone's config", async () => {
|
it("clones over ssh with the user's key, kept in the clone's config", async () => {
|
||||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||||
assert.equal(made.status, 201);
|
assert.equal(made.status, 201);
|
||||||
|
|||||||
@@ -2092,6 +2092,95 @@ describe("an extra folder's own repo (?source=)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("repos inside a parent workspace folder (?repo=)", () => {
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const gitEnv = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
|
||||||
|
);
|
||||||
|
const gitIn = (dir, ...args) =>
|
||||||
|
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: gitEnv,
|
||||||
|
});
|
||||||
|
let parent, laneId, otherLaneId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-nested-")));
|
||||||
|
parent = path.join(root, "shop");
|
||||||
|
for (const rel of ["api", "web", "group/admin"]) {
|
||||||
|
const dir = path.join(parent, rel);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
gitIn(dir, "init", "-q", "-b", "main");
|
||||||
|
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
|
||||||
|
gitIn(dir, "add", ".");
|
||||||
|
gitIn(dir, "commit", "-qm", "init");
|
||||||
|
}
|
||||||
|
// Not repos: a plain folder, a dot folder and node_modules holding one,
|
||||||
|
// and a symlink to a repo (never followed).
|
||||||
|
fs.mkdirSync(path.join(parent, "docs"));
|
||||||
|
fs.mkdirSync(path.join(parent, ".cache/x/.git"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(parent, "node_modules/pkg/.git"), { recursive: true });
|
||||||
|
fs.symlinkSync(path.join(parent, "api"), path.join(parent, "api-link"));
|
||||||
|
// A repo found inside a repo is not walked into.
|
||||||
|
fs.mkdirSync(path.join(parent, "web/inner/.git"), { recursive: true });
|
||||||
|
laneId = (await request("POST", "/api/lanes", { cwd: parent })).body.lane.id;
|
||||||
|
otherLaneId = (await request("POST", "/api/lanes", { cwd: path.join(root, "other") })).body.lane
|
||||||
|
.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the repos below the workspace folder", async () => {
|
||||||
|
const r = await request("GET", `/api/lanes/${laneId}/repos`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.root, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
r.body.items.map((i) => i.path),
|
||||||
|
["api", "group/admin", "web"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and writes git in the named repo only", async () => {
|
||||||
|
fs.writeFileSync(path.join(parent, "group/admin/new.txt"), "x\n");
|
||||||
|
const changes = await request("GET", `/api/lanes/${laneId}/git/changes?repo=group/admin`);
|
||||||
|
assert.ok(changes.body.files.some((f) => f.path === "new.txt"));
|
||||||
|
const facts = await request("GET", `/api/lanes/${laneId}/git?repo=api`);
|
||||||
|
assert.equal(facts.body.available, true);
|
||||||
|
assert.equal(facts.body.base, "main");
|
||||||
|
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?repo=group/admin`, {
|
||||||
|
paths: ["new.txt"],
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.match(
|
||||||
|
gitIn(path.join(parent, "group/admin"), "diff", "--cached", "--name-only"),
|
||||||
|
/new\.txt/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers ENOREPO for any path the walk did not find", async () => {
|
||||||
|
for (const rel of ["docs", "..", "../other", "/etc", "api-link", ".cache/x", "web/inner", ""]) {
|
||||||
|
const r = await request(
|
||||||
|
"GET",
|
||||||
|
`/api/lanes/${laneId}/git/changes?repo=${encodeURIComponent(rel)}`
|
||||||
|
);
|
||||||
|
assert.equal(r.status, 404, rel);
|
||||||
|
assert.equal(r.body.error.code, "ENOREPO", rel);
|
||||||
|
}
|
||||||
|
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?repo=api`);
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
assert.equal(r.body.error.code, "ENOREPO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a repo cloned since the last read", async () => {
|
||||||
|
require("../lib/nested-repos").clearNestedRepoCache();
|
||||||
|
await request("GET", `/api/lanes/${laneId}/repos`);
|
||||||
|
await new Promise((r) => setTimeout(r, 2100));
|
||||||
|
const dir = path.join(parent, "late");
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
gitIn(dir, "init", "-q", "-b", "main");
|
||||||
|
const r = await request("GET", `/api/lanes/${laneId}/git?repo=late`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The server, not a stale client list, picks the tab and refuses a second pane
|
// The server, not a stale client list, picks the tab and refuses a second pane
|
||||||
// on a session one already holds.
|
// on a session one already holds.
|
||||||
describe("start: slot auto and a session already live", () => {
|
describe("start: slot auto and a session already live", () => {
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ describe("local mode: one account", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the sidebar's workspace groups, up to 64 KB", async () => {
|
||||||
|
const groups = JSON.stringify({ groups: [{ id: "g1", name: "Web" }], of: { 1: "g1" } });
|
||||||
|
const pad = "x".repeat(20 * 1024);
|
||||||
|
let r = await call("PUT", "/api/me/prefs", {
|
||||||
|
body: { prefs: { "agenthub.workspace.groups": groups + pad } },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.prefs["agenthub.workspace.groups"], groups + pad);
|
||||||
|
r = await call("PUT", "/api/me/prefs", {
|
||||||
|
body: { prefs: { "agenthub.workspace.groups": "x".repeat(65 * 1024) } },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
r = await call("PUT", "/api/me/prefs", {
|
||||||
|
body: { prefs: { "agenthub.workspace.groups": null } },
|
||||||
|
});
|
||||||
|
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||||
let r = await call("GET", "/api/me/instructions");
|
let r = await call("GET", "/api/me/instructions");
|
||||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
|
const {
|
||||||
|
readLastPluginUpdate,
|
||||||
|
readPluginUpdateProgress,
|
||||||
|
schedulePluginUpdate,
|
||||||
|
runtimeDir,
|
||||||
|
} = require("../lib/plugin-update");
|
||||||
|
|
||||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
||||||
|
|
||||||
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readPluginUpdateProgress", () => {
|
||||||
|
const write = (name, body) => {
|
||||||
|
const rt = path.join(tmpDir, name);
|
||||||
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
|
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
|
||||||
|
return rt;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is null with no file and for malformed JSON", () => {
|
||||||
|
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
|
||||||
|
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the running step", () => {
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const rt = write(
|
||||||
|
"prog-ok",
|
||||||
|
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
|
||||||
|
);
|
||||||
|
assert.deepEqual(readPluginUpdateProgress(rt), {
|
||||||
|
step: "plugin_update",
|
||||||
|
index: 2,
|
||||||
|
total: 3,
|
||||||
|
started_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a file left by a helper that died long ago", () => {
|
||||||
|
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const rt = write(
|
||||||
|
"prog-stale",
|
||||||
|
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
|
||||||
|
);
|
||||||
|
assert.equal(readPluginUpdateProgress(rt), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("schedulePluginUpdate", () => {
|
describe("schedulePluginUpdate", () => {
|
||||||
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
||||||
// Redirect the runtime dir into the temp tree — this must not touch the
|
// Redirect the runtime dir into the temp tree — this must not touch the
|
||||||
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
|
|||||||
assert.equal(v.step, "validate");
|
assert.equal(v.step, "validate");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a progress record with its final verdict", () => {
|
||||||
|
const rt = path.join(tmpDir, "guard-progress");
|
||||||
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(rt, "plugin-update-progress.json"),
|
||||||
|
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
|
||||||
|
);
|
||||||
|
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
|
||||||
|
stdio: "ignore",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
|
||||||
|
});
|
||||||
|
|
||||||
it("stamps every verdict with a finish time", () => {
|
it("stamps every verdict with a finish time", () => {
|
||||||
// The failure mode this file exists to prevent is a helper that dies
|
// The failure mode this file exists to prevent is a helper that dies
|
||||||
// silently: the tab would poll a server that never returns and say nothing.
|
// silently: the tab would poll a server that never returns and say nothing.
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* @file pool-groups.test.js
|
||||||
|
* @description Account pool groups (server/lib/pool-groups.js and the
|
||||||
|
* `/api/accounts/groups` routes), fake accounts only: with no group every
|
||||||
|
* account is shared as before; a group's member gets only the group's
|
||||||
|
* accounts of each tool (none → no pool account), a user in no group only
|
||||||
|
* the accounts no group lists, a pin wins over the group; one group per user;
|
||||||
|
* a removed account or deleted group leaves no rows behind; and the routes
|
||||||
|
* need an admin's own password again, are audited and refuse in local mode.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = 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(), "pool-groups-test-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const groups = require("../lib/pool-groups");
|
||||||
|
const cursorPool = require("../lib/cursor-pool");
|
||||||
|
const claudePool = require("../lib/claude-pool");
|
||||||
|
const pins = require("../lib/account-pins");
|
||||||
|
|
||||||
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
|
||||||
|
const BOSS_PASS = "admin-pass-1";
|
||||||
|
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||||
|
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
let MIA;
|
||||||
|
let LEO;
|
||||||
|
let ZOE;
|
||||||
|
|
||||||
|
async function call(method, p, { cookie, body } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.headers.get("set-cookie").split(";")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||||
|
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||||
|
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||||
|
ZOE = (await auth.createUser("zoe", "member-pass-3", "developer")).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.prepare("DELETE FROM pool_groups").run();
|
||||||
|
db.prepare("DELETE FROM account_pins").run();
|
||||||
|
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pool groups: who may take which account", () => {
|
||||||
|
it("shares every account while no group exists", () => {
|
||||||
|
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||||
|
assert.ok(["a", "b", "c"].every(groups.allowed(MIA, "cursor")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a member only the group's accounts, the rest only the shared ones", () => {
|
||||||
|
const g = groups.createGroup("Team A");
|
||||||
|
groups.setGroupUsers(g.id, [MIA]);
|
||||||
|
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||||
|
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||||
|
assert.equal(cursorPool.pickAccount(LEO).name, "a", "b is no longer shared");
|
||||||
|
// An account may serve several groups; a group with no account of a tool
|
||||||
|
// leaves its members on their own sign-in.
|
||||||
|
const h = groups.createGroup("Team B");
|
||||||
|
groups.setGroupUsers(h.id, [ZOE]);
|
||||||
|
assert.equal(cursorPool.pickAccount(ZOE), null);
|
||||||
|
groups.setGroupAccounts(h.id, "cursor", ["b", "c"]);
|
||||||
|
assert.ok(["b", "c"].includes(cursorPool.pickAccount(ZOE).name));
|
||||||
|
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||||
|
// Claude follows the same groups.
|
||||||
|
claudePool.addAccount("shared", TOKEN("s"));
|
||||||
|
claudePool.addAccount("team", TOKEN("t"));
|
||||||
|
groups.setGroupAccounts(g.id, "claude", ["team"]);
|
||||||
|
assert.equal(claudePool.pickAccount(null, MIA).name, "team");
|
||||||
|
assert.equal(claudePool.pickAccount(null, LEO).name, "shared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a user in one group only, and honours a pin outside it", () => {
|
||||||
|
const g = groups.createGroup("A");
|
||||||
|
const h = groups.createGroup("B");
|
||||||
|
groups.setGroupUsers(g.id, [MIA, LEO]);
|
||||||
|
groups.setGroupUsers(h.id, [MIA]);
|
||||||
|
const list = groups.listGroups();
|
||||||
|
assert.deepEqual(
|
||||||
|
list.find((x) => x.name === "A").users.map((u) => u.username),
|
||||||
|
["leo"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
list.find((x) => x.name === "B").users.map((u) => u.username),
|
||||||
|
["mia"]
|
||||||
|
);
|
||||||
|
groups.setGroupAccounts(h.id, "cursor", ["b"]);
|
||||||
|
pins.setPin(MIA, "cursor", "c");
|
||||||
|
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a removed account from its groups, and a group's rows with it", () => {
|
||||||
|
const g = groups.createGroup("A");
|
||||||
|
groups.setGroupUsers(g.id, [MIA]);
|
||||||
|
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||||
|
cursorPool.removeAccount("b");
|
||||||
|
assert.deepEqual(groups.listGroups()[0].accounts.cursor, []);
|
||||||
|
groups.deleteGroup(g.id);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM pool_group_users").get().n, 0);
|
||||||
|
assert.throws(() => groups.setGroupAccounts(g.id, "cursor", ["a"]), { code: "ENOGROUP" });
|
||||||
|
assert.throws(() => groups.createGroup(""), { code: "EBADNAME" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pool groups: routes", () => {
|
||||||
|
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const bad = await call("POST", "/api/accounts/groups", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { name: "Team A", currentPassword: "nope" },
|
||||||
|
});
|
||||||
|
assert.equal(bad.body.error.code, "EREAUTH");
|
||||||
|
const made = await call("POST", "/api/accounts/groups", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { name: "Team A", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(made.status, 201);
|
||||||
|
const id = made.body.group.id;
|
||||||
|
const dup = await call("POST", "/api/accounts/groups", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { name: "team a", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(dup.body.error.code, "EEXISTS");
|
||||||
|
const edit = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: {
|
||||||
|
name: "Team Alpha",
|
||||||
|
userIds: [MIA],
|
||||||
|
accounts: { cursor: ["a"] },
|
||||||
|
currentPassword: BOSS_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(edit.status, 200);
|
||||||
|
assert.equal(edit.body.group.name, "Team Alpha");
|
||||||
|
assert.deepEqual(edit.body.group.accounts.cursor, ["a"]);
|
||||||
|
const missing = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { accounts: { cursor: ["nope"] }, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(missing.body.error.code, "ENOACCOUNT");
|
||||||
|
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||||
|
assert.equal(list.body.groups[0].users[0].username, "mia");
|
||||||
|
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.match(audit.path, /group Team Alpha cursor accounts/);
|
||||||
|
const dev = await login("leo", "member-pass-2");
|
||||||
|
const refused = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||||
|
cookie: dev,
|
||||||
|
body: { currentPassword: "member-pass-2" },
|
||||||
|
});
|
||||||
|
assert.equal(refused.status, 403);
|
||||||
|
const del = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
const gone = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(gone.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,9 +81,13 @@ const RULES = [
|
|||||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||||
// A third-party project that really is on GitHub: the public model price
|
// Third-party projects that really are on GitHub: the public model price
|
||||||
// list server/lib/pricing-sync.js reads.
|
// list server/lib/pricing-sync.js reads, and the install script omp.sh
|
||||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
// redirects to (server/data/ai-tools/omp.json `install.redirect`).
|
||||||
|
skip: [
|
||||||
|
/raw\.githubusercontent\.com\/BerriAI\/litellm$/,
|
||||||
|
/raw\.githubusercontent\.com\/can1357\/oh-my-pi$/,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GitHub Pages host for a Smartgift site",
|
name: "GitHub Pages host for a Smartgift site",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file server-mode-account-pool.test.js
|
* @file server-mode-account-pool.test.js
|
||||||
* @description The Claude/Codex account pool from the Users page: only an
|
* @description The Claude/Codex/Cursor account pool from the Users page: only an
|
||||||
* admin may add or remove an account, each change needs the admin's OWN
|
* admin may add or remove an account, each change needs the admin's OWN
|
||||||
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||||
* list) ever carries it — the audit log names the account, not its secret, and
|
* list) ever carries it — the audit log names the account, not its secret, and
|
||||||
@@ -25,6 +25,7 @@ const { db } = require("../db");
|
|||||||
const auth = require("../lib/auth");
|
const auth = require("../lib/auth");
|
||||||
const claudePool = require("../lib/claude-pool");
|
const claudePool = require("../lib/claude-pool");
|
||||||
const codexPool = require("../lib/codex-pool");
|
const codexPool = require("../lib/codex-pool");
|
||||||
|
const cursorPool = require("../lib/cursor-pool");
|
||||||
|
|
||||||
// The quota read would go to the network; the pool's list must not.
|
// The quota read would go to the network; the pool's list must not.
|
||||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
@@ -113,6 +114,30 @@ describe("the account pool from the Users page", () => {
|
|||||||
assert.equal(gone.status, 404);
|
assert.equal(gone.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds a Cursor API key, lists it under `cursor` and removes it", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const CKEY = `key_${"c".repeat(40)}`;
|
||||||
|
const add = await call("POST", "/api/accounts", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "cursor", name: "cu-1", token: CKEY, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(add.status, 201);
|
||||||
|
assert.equal(cursorPool.__read("cu-1").key, CKEY);
|
||||||
|
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||||
|
assert.deepEqual(
|
||||||
|
list.body.cursor.map((a) => a.name),
|
||||||
|
["cu-1"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(list.body.pins.cursor, []);
|
||||||
|
assert.ok(!JSON.stringify(list.body).includes(CKEY));
|
||||||
|
const del = await call("DELETE", "/api/accounts/cu-1", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "cursor", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
assert.equal(cursorPool.__read("cu-1"), null);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||||
const boss = await login("boss", BOSS_PASS);
|
const boss = await login("boss", BOSS_PASS);
|
||||||
const add = (body) =>
|
const add = (body) =>
|
||||||
@@ -123,7 +148,7 @@ describe("the account pool from the Users page", () => {
|
|||||||
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||||
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||||
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||||
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
|
assert.equal((await add({ tool: "antigravity" })).body.error.code, "EBADTOOL");
|
||||||
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,22 @@ before(async () => {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
|
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
|
||||||
).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||||
|
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
|
||||||
|
).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||||
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
|
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
|
||||||
).run(new Date().toISOString().slice(0, 13));
|
).run(new Date().toISOString().slice(0, 13));
|
||||||
|
// A tool with no hourly rows (omp, Codex…): its total counts from its start.
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('so', 'mia omp', 'active', '/w/mia', ?, 'omp')"
|
||||||
|
).run(lane);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||||
|
VALUES ('so', 'claude-sonnet-4-5-20250929', 500000)`
|
||||||
|
).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
@@ -76,8 +88,13 @@ describe("staff views", () => {
|
|||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
const mia = r.body.users.find((u) => u.username === "mia");
|
const mia = r.body.users.find((u) => u.username === "mia");
|
||||||
assert.equal(mia.workspaces, 1);
|
assert.equal(mia.workspaces, 1);
|
||||||
assert.equal(mia.sessions, 1);
|
assert.equal(mia.sessions, 2);
|
||||||
|
assert.equal(mia.today.tokens, 1500000, "a session with no hourly rows counts too");
|
||||||
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
||||||
|
// This hour's spend is today's in every timezone.
|
||||||
|
assert.equal(mia.today.cost, mia.last7d.cost);
|
||||||
|
const east = await call("/api/users?tz_offset=-840", admin);
|
||||||
|
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
|
||||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||||
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
||||||
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for counting every token a Claude session spent: the hook
|
||||||
|
* writer adds the session's sub-agent transcripts (`lib/subagent-usage.js`)
|
||||||
|
* to its main one — total and per hour, a sub-agent on the session's own
|
||||||
|
* model included, and a sub-agent that grew alone is picked up — and the
|
||||||
|
* importer raises a session it first saw with an empty transcript once the
|
||||||
|
* transcript has grown (`importSession`).
|
||||||
|
* @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("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-subusage-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const dbModule = require("../db");
|
||||||
|
const { db } = dbModule;
|
||||||
|
const importHistory = require("../../scripts/import-history");
|
||||||
|
const { subagentUsage } = require("../lib/subagent-usage");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
function post(body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL("/api/hooks/event", BASE);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
const r = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on("end", () => resolve(res.statusCode));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
r.on("error", reject);
|
||||||
|
r.write(payload);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = (o) => JSON.stringify(o) + "\n";
|
||||||
|
const assistant = (id, inTok, ts, model = "claude-sonnet-5") => ({
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: ts,
|
||||||
|
message: {
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
usage: { input_tokens: inTok, output_tokens: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = (sid) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT model, SUM(input_tokens + baseline_input) AS input FROM token_usage WHERE session_id = ? GROUP BY model ORDER BY model"
|
||||||
|
)
|
||||||
|
.all(sid)
|
||||||
|
.map((r) => ({ ...r }));
|
||||||
|
const hours = (sid) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT hour, model, SUM(input_tokens) AS input FROM token_usage_hourly WHERE session_id = ? GROUP BY hour, model ORDER BY hour, model"
|
||||||
|
)
|
||||||
|
.all(sid)
|
||||||
|
.map((r) => ({ ...r }));
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sub-agent usage", () => {
|
||||||
|
it("counts a session's sub-agents, own model included, in its total and its hours", async () => {
|
||||||
|
const sid = "cdcdcdcd-1111-4111-8111-cdcdcdcdcdcd";
|
||||||
|
const cwd = path.join(TMP, "work");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const tpath = path.join(TMP, `${sid}.jsonl`);
|
||||||
|
fs.writeFileSync(tpath, line(assistant("m1", 100, "2026-10-22T01:00:00.000Z")));
|
||||||
|
const subDir = path.join(TMP, sid, "subagents");
|
||||||
|
fs.mkdirSync(subDir, { recursive: true });
|
||||||
|
const sub1 = path.join(subDir, "agent-a1.jsonl");
|
||||||
|
fs.writeFileSync(sub1, line(assistant("s1", 40, "2026-10-22T02:00:00.000Z")));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(subDir, "agent-a2.jsonl"),
|
||||||
|
line(assistant("s2", 7, "2026-10-22T02:30:00.000Z", "claude-haiku-5-5"))
|
||||||
|
);
|
||||||
|
// A Workflow agent is booked by workflow-ingest under its own tier: not here.
|
||||||
|
fs.mkdirSync(path.join(subDir, "workflows", "run1"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(subDir, "workflows", "run1", "agent-w.jsonl"),
|
||||||
|
line(assistant("w1", 1000, "2026-10-22T02:00:00.000Z"))
|
||||||
|
);
|
||||||
|
const data = { session_id: sid, cwd, transcript_path: tpath };
|
||||||
|
|
||||||
|
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
|
||||||
|
assert.deepEqual(total(sid), [
|
||||||
|
{ model: "claude-haiku-5-5", input: 7 },
|
||||||
|
{ model: "claude-sonnet-5", input: 140 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(hours(sid), [
|
||||||
|
{ hour: "2026-10-22T01", model: "claude-sonnet-5", input: 100 },
|
||||||
|
{ hour: "2026-10-22T02", model: "claude-haiku-5-5", input: 7 },
|
||||||
|
{ hour: "2026-10-22T02", model: "claude-sonnet-5", input: 40 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only the sub-agent grew: the next hook still counts it, once.
|
||||||
|
fs.appendFileSync(sub1, line(assistant("s3", 10, "2026-10-22T03:00:00.000Z")));
|
||||||
|
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
|
||||||
|
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
|
||||||
|
assert.deepEqual(total(sid), [
|
||||||
|
{ model: "claude-haiku-5-5", input: 7 },
|
||||||
|
{ model: "claude-sonnet-5", input: 150 },
|
||||||
|
]);
|
||||||
|
assert.equal(hours(sid).find((h) => h.hour === "2026-10-22T03").input, 10);
|
||||||
|
|
||||||
|
// The importer's full re-read agrees and changes nothing (high-water).
|
||||||
|
await importHistory.reconcileTokens(dbModule, { exact: false });
|
||||||
|
assert.deepEqual(total(sid), [
|
||||||
|
{ model: "claude-haiku-5-5", input: 7 },
|
||||||
|
{ model: "claude-sonnet-5", input: 150 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null for a session with no sub-agent transcript", () => {
|
||||||
|
assert.equal(subagentUsage(path.join(TMP, "nope.jsonl")), null);
|
||||||
|
assert.equal(subagentUsage(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises an imported session first seen before its transcript had any usage", async () => {
|
||||||
|
const sid = "efefefef-2222-4222-8222-efefefefefef";
|
||||||
|
const proj = path.join(TMP, "projects", "-tmp-eval");
|
||||||
|
fs.mkdirSync(proj, { recursive: true });
|
||||||
|
const tpath = path.join(proj, `${sid}.jsonl`);
|
||||||
|
const user = {
|
||||||
|
type: "user",
|
||||||
|
sessionId: sid,
|
||||||
|
cwd: "/tmp/eval",
|
||||||
|
timestamp: "2026-10-23T03:00:00.000Z",
|
||||||
|
message: { role: "user", content: "fix the bug" },
|
||||||
|
};
|
||||||
|
fs.writeFileSync(tpath, line(user));
|
||||||
|
const parse = async () => {
|
||||||
|
const s = await importHistory.parseSessionFile(tpath);
|
||||||
|
s.parsedSubagents = [];
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
importHistory.importSession(dbModule, await parse());
|
||||||
|
assert.deepEqual(total(sid), []);
|
||||||
|
|
||||||
|
fs.appendFileSync(
|
||||||
|
tpath,
|
||||||
|
line({ ...assistant("e1", 500, "2026-10-23T03:01:00.000Z"), sessionId: sid })
|
||||||
|
);
|
||||||
|
const again = importHistory.importSession(dbModule, await parse());
|
||||||
|
assert.equal(again.backfilled, true);
|
||||||
|
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
||||||
|
// Nothing new: no backfill reported, no change.
|
||||||
|
assert.equal(importHistory.importSession(dbModule, await parse()).backfilled, undefined);
|
||||||
|
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"faviconDomain": "omp.sh",
|
"faviconDomain": "omp.sh",
|
||||||
"install": {
|
"install": {
|
||||||
"kind": "script",
|
"kind": "script",
|
||||||
"url": "https://omp.sh/install"
|
"url": "https://omp.sh/install",
|
||||||
|
"redirect": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"kind": "script"
|
"kind": "script"
|
||||||
|
|||||||
+63
-2
@@ -960,6 +960,20 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
||||||
}
|
}
|
||||||
|
// `auto = 1`: a document file a session wrote, captured when it stopped
|
||||||
|
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||||
|
// no owner, the user whose folder the session ran in — they may claim it or
|
||||||
|
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||||
|
for (const [col, ddl] of [
|
||||||
|
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||||
|
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||||
|
} catch {
|
||||||
|
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||||
@@ -1958,16 +1972,63 @@ for (const [col, type] of [
|
|||||||
}
|
}
|
||||||
// An admin's pin of one server-mode user to one pool account per tool
|
// An admin's pin of one server-mode user to one pool account per tool
|
||||||
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||||
// The account is named, not referenced — the pool lives in files.
|
// The account is named, not referenced — the pool lives in files. The tool
|
||||||
|
// list is account-pins.js's `TOOLS`, not a CHECK (a new pool needs no rebuild).
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS account_pins (
|
CREATE TABLE IF NOT EXISTS account_pins (
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
|
tool TEXT NOT NULL,
|
||||||
account TEXT NOT NULL,
|
account TEXT NOT NULL,
|
||||||
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
PRIMARY KEY (user_id, tool)
|
PRIMARY KEY (user_id, tool)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||||
|
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||||
|
// may serve several groups. Accounts no group lists are the shared pool.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_group_users (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_group_accounts (
|
||||||
|
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, tool, account)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||||
|
// claude/codex. Rebuilt with every pin kept.
|
||||||
|
{
|
||||||
|
const meta = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||||
|
.get();
|
||||||
|
if (meta?.sql?.includes("CHECK(tool IN")) {
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE account_pins_new (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (user_id, tool)
|
||||||
|
);
|
||||||
|
INSERT INTO account_pins_new (user_id, tool, account, pinned_at)
|
||||||
|
SELECT user_id, tool, account, pinned_at FROM account_pins;
|
||||||
|
DROP TABLE account_pins;
|
||||||
|
ALTER TABLE account_pins_new RENAME TO account_pins;
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An existing database made before auto_vacuum: the setting is stored only by
|
// An existing database made before auto_vacuum: the setting is stored only by
|
||||||
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
||||||
|
|||||||
@@ -690,6 +690,31 @@ function startBackgroundServices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time (user_version 5) raise of every session's totals to its whole
|
||||||
|
// transcript plus its sub-agents' (raise only: `exact: false`). Before
|
||||||
|
// v2.8.23 a live session never counted a sub-agent on its own model, and a
|
||||||
|
// session imported the moment its file appeared (a `claude -p` run with no
|
||||||
|
// hooks) kept the usage of that moment, often none — both now counted as
|
||||||
|
// they happen (server/lib/subagent-usage.js, importSession). Waits for
|
||||||
|
// marker 4; written only on success, so a crash retries on the next start.
|
||||||
|
{
|
||||||
|
const dbModule = require("./db");
|
||||||
|
if (dbModule.db.pragma("user_version", { simple: true }) === 4) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
require("../scripts/import-history")
|
||||||
|
.reconcileTokens(dbModule, { exact: false })
|
||||||
|
.then((c) => {
|
||||||
|
console.log(
|
||||||
|
`Token catch-up: re-read ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
|
||||||
|
);
|
||||||
|
dbModule.db.pragma("user_version = 5");
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn("token catch-up failed:", err?.message || err));
|
||||||
|
}, 15_000);
|
||||||
|
if (t.unref) t.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
|
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
|
||||||
// daily cost split covers them too. Picks only sessions with no hourly rows,
|
// daily cost split covers them too. Picks only sessions with no hourly rows,
|
||||||
// so a later start re-reads nothing already done; non-blocking.
|
// so a later start re-reads nothing already done; non-blocking.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file account-pins.js
|
* @file account-pins.js
|
||||||
* @description Account pins: an admin's choice that one server-mode user's
|
* @description Account pins: an admin's choice that one server-mode user's
|
||||||
* new sessions of a tool run on one named pool account instead of the one the
|
* new sessions of a tool run on one named pool account instead of the one the
|
||||||
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
|
* balancer would pick (claude-pool.js / codex-pool.js / cursor-pool.js `pick`) — the Users
|
||||||
* page's "switch a user's account". One pin per user and tool
|
* page's "switch a user's account". One pin per user and tool
|
||||||
* (`account_pins`); the user is the workspace's OWNER
|
* (`account_pins`); the user is the workspace's OWNER
|
||||||
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
|
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
|
||||||
@@ -19,14 +19,15 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TOOLS = ["claude", "codex"];
|
const TOOLS = ["claude", "codex", "cursor"];
|
||||||
|
const POOLS = { claude: "./claude-pool", codex: "./codex-pool", cursor: "./cursor-pool" };
|
||||||
|
|
||||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
const db = () => require("../db").db;
|
const db = () => require("../db").db;
|
||||||
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
|
const poolOf = (tool) => require(POOLS[tool]);
|
||||||
|
|
||||||
function validTool(tool) {
|
function validTool(tool) {
|
||||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
|
if (!TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${TOOLS.join(", ")}`);
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +133,23 @@ async function applyPin(userId, tool) {
|
|||||||
return poolOf(tool).switchHome(member, pin);
|
return poolOf(tool).switchHome(member, pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A removed account takes its pins with it. */
|
/** A removed account takes its pins — and its place in every group — with it. */
|
||||||
function dropAccount(tool, account) {
|
function dropAccount(tool, account) {
|
||||||
try {
|
try {
|
||||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||||
} catch {
|
} catch {
|
||||||
/* no database (a bare CLI test): nothing pinned */
|
/* no database (a bare CLI test): nothing pinned */
|
||||||
}
|
}
|
||||||
|
require("./pool-groups").dropAccount(tool, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
|
module.exports = {
|
||||||
|
TOOLS,
|
||||||
|
poolOf,
|
||||||
|
pinFor,
|
||||||
|
listPins,
|
||||||
|
setPin,
|
||||||
|
setAccountUsers,
|
||||||
|
applyPin,
|
||||||
|
dropAccount,
|
||||||
|
};
|
||||||
|
|||||||
@@ -63,12 +63,14 @@ async function stepsFor(id, action) {
|
|||||||
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
|
||||||
return tool.install.kind === "npm"
|
return tool.install.kind === "npm"
|
||||||
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
|
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
|
||||||
: [{ script: tool.install.url }];
|
: [{ script: tool.install.url, redirect: tool.install.redirect }];
|
||||||
}
|
}
|
||||||
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
|
||||||
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
|
||||||
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
|
||||||
if (tool.update.kind === "script") return [{ script: tool.install.url }];
|
if (tool.update.kind === "script") {
|
||||||
|
return [{ script: tool.install.url, redirect: tool.install.redirect }];
|
||||||
|
}
|
||||||
const by = await versions.managedBy(id);
|
const by = await versions.managedBy(id);
|
||||||
if (!by) {
|
if (!by) {
|
||||||
throw makeErr(
|
throw makeErr(
|
||||||
@@ -85,12 +87,16 @@ function append(j, text) {
|
|||||||
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
|
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
|
/**
|
||||||
async function download(url) {
|
* Download a vendor script to a 0600 temp file; the final URL must stay on its
|
||||||
|
* site, or be exactly the catalog's `redirect`.
|
||||||
|
*/
|
||||||
|
async function download(url, redirect) {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
|
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
|
||||||
const final = new URL(res.url || url);
|
const final = new URL(res.url || url);
|
||||||
const site = aiTools.siteOf(new URL(url).hostname);
|
const site = aiTools.siteOf(new URL(url).hostname);
|
||||||
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
|
const onSite = final.protocol === "https:" && aiTools.siteOf(final.hostname) === site;
|
||||||
|
if (!onSite && final.href !== redirect) {
|
||||||
throw new Error(`script redirected off ${site}: ${final.href}`);
|
throw new Error(`script redirected off ${site}: ${final.href}`);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
|
||||||
@@ -169,7 +175,7 @@ async function runSteps(j, steps, onStepOk) {
|
|||||||
let tmp = null;
|
let tmp = null;
|
||||||
if (step.script) {
|
if (step.script) {
|
||||||
append(j, `$ download ${step.script}\n`);
|
append(j, `$ download ${step.script}\n`);
|
||||||
tmp = await download(step.script);
|
tmp = await download(step.script, step.redirect);
|
||||||
argv = ["bash", tmp];
|
argv = ["bash", tmp];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
+17
-4
@@ -44,18 +44,24 @@ const isStrArray = (v, re) =>
|
|||||||
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
|
||||||
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
|
||||||
|
|
||||||
/** The script URL, when it is https on the homepage's site; else null. */
|
/** A plain https URL (no userinfo, no port), normalised; else null. */
|
||||||
function scriptUrl(url, homepageUrl) {
|
function httpsUrl(url) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
const home = new URL(homepageUrl);
|
|
||||||
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
|
||||||
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
return u;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The script URL, when it is https on the homepage's site; else null. */
|
||||||
|
function scriptUrl(url, homepageUrl) {
|
||||||
|
const u = httpsUrl(url);
|
||||||
|
const home = httpsUrl(homepageUrl);
|
||||||
|
return u && home && siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** `{install, update}` normalised, or null when either is malformed. */
|
/** `{install, update}` normalised, or null when either is malformed. */
|
||||||
function installFields(doc) {
|
function installFields(doc) {
|
||||||
let install = null;
|
let install = null;
|
||||||
@@ -67,6 +73,13 @@ function installFields(doc) {
|
|||||||
const url = scriptUrl(i.url, doc.homepageUrl);
|
const url = scriptUrl(i.url, doc.homepageUrl);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
install = { kind: "script", url };
|
install = { kind: "script", url };
|
||||||
|
// The ONE exact URL the download may redirect to off the homepage's site
|
||||||
|
// (omp.sh/install → raw.githubusercontent.com); never a site wildcard.
|
||||||
|
if (i.redirect !== undefined) {
|
||||||
|
const to = typeof i.redirect === "string" && httpsUrl(i.redirect);
|
||||||
|
if (!to) return null;
|
||||||
|
install.redirect = to.href;
|
||||||
|
}
|
||||||
} else return null;
|
} else return null;
|
||||||
}
|
}
|
||||||
let update = null;
|
let update = null;
|
||||||
|
|||||||
+281
-18
@@ -6,8 +6,12 @@
|
|||||||
* Four kinds:
|
* Four kinds:
|
||||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||||
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
||||||
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
|
* tool's PostToolUse hook (`captureFromHook`; a Claude Docs doc's
|
||||||
* - `file` a file an agent wrote in a workspace, pinned by the user.
|
* birth too) or pasted by hand. In server mode a row nobody owns
|
||||||
|
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
|
||||||
|
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
|
||||||
|
* document (pptx/docx/xlsx/pdf/html) a session created, captured
|
||||||
|
* when it stops (`captureOutputs`, `auto`).
|
||||||
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
||||||
* `.claude/skills/<name>`).
|
* `.claude/skills/<name>`).
|
||||||
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
||||||
@@ -85,14 +89,21 @@ const installsOffFlag = () => path.join(root(), "installs-off");
|
|||||||
function visibleWhere(req) {
|
function visibleWhere(req) {
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||||
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
|
return {
|
||||||
|
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
|
||||||
|
params: [u.id, u.id],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSee = (req, row) => {
|
const canSee = (req, row) => {
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
||||||
return row.owner_id === u.id || row.shared === 1;
|
return (
|
||||||
|
row.owner_id === u.id ||
|
||||||
|
row.shared === 1 ||
|
||||||
|
(row.owner_id == null && row.suggested_owner_id === u.id)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||||
@@ -101,7 +112,10 @@ const canChange = (req, row) =>
|
|||||||
|
|
||||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||||
function canSeeId(req, id) {
|
function canSeeId(req, id) {
|
||||||
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
|
return canSee(
|
||||||
|
req,
|
||||||
|
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(req, id) {
|
function load(req, id) {
|
||||||
@@ -144,6 +158,10 @@ function view(row, req, owners = null) {
|
|||||||
sharedAt: row.shared_at,
|
sharedAt: row.shared_at,
|
||||||
inLibrary: row.in_library !== 0,
|
inLibrary: row.in_library !== 0,
|
||||||
installs: row.installs,
|
installs: row.installs,
|
||||||
|
auto: row.auto === 1,
|
||||||
|
// Server mode only: a row nobody owns waits for an admin or its suggested user.
|
||||||
|
unowned: isServer() && row.owner_id == null,
|
||||||
|
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -152,7 +170,9 @@ function view(row, req, owners = null) {
|
|||||||
/**
|
/**
|
||||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||||
* shared skills/plugins of others the caller has installed; every row in
|
* shared skills/plugins of others the caller has installed; every row in
|
||||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
|
||||||
|
* nobody owns — every one for an admin, those suggested to the caller for
|
||||||
|
* anyone else), anything else = all visible.
|
||||||
* Each row also carries `installed` (`installedCheck`).
|
* Each row also carries `installed` (`installedCheck`).
|
||||||
*/
|
*/
|
||||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||||
@@ -168,6 +188,13 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
|||||||
params.push(req.user.id, req.user.id);
|
params.push(req.user.id, req.user.id);
|
||||||
} else if (scope === "hub") {
|
} else if (scope === "hub") {
|
||||||
where.push("a.shared = 1");
|
where.push("a.shared = 1");
|
||||||
|
} else if (scope === "unowned") {
|
||||||
|
if (!isServer() || !req?.user) return [];
|
||||||
|
where.push("a.owner_id IS NULL");
|
||||||
|
if (req.user.role !== "admin") {
|
||||||
|
where.push("a.suggested_owner_id = ?");
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (kind) {
|
if (kind) {
|
||||||
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
||||||
@@ -297,23 +324,90 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
|||||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||||
* The host's account is shared by every admin, so it names nobody.
|
* The host's account is shared by every admin, so it names nobody.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Whose a row with no owner probably is, from the folder its session ran in:
|
||||||
|
* the owner of the workspace holding that folder, else (S3) the member whose
|
||||||
|
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
|
||||||
|
*/
|
||||||
|
function suggestOwner(cwd) {
|
||||||
|
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
|
||||||
|
const lane = require("./lanes").resolveLaneByCwd(cwd);
|
||||||
|
if (lane?.owner_id != null) return lane.owner_id;
|
||||||
|
try {
|
||||||
|
const acct = require("./run-as").ownerForRead(cwd);
|
||||||
|
if (!acct?.name) return null;
|
||||||
|
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
|
||||||
|
return row ? row.id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function peerOwner(peer) {
|
function peerOwner(peer) {
|
||||||
if (peer?.kind !== "member" || !peer.name) return null;
|
if (peer?.kind !== "member" || !peer.name) return null;
|
||||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||||
return row ? row.id : null;
|
return row ? row.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
|
||||||
|
* the one that births a doc. Slides, dashboards and designs are made through the
|
||||||
|
* `Artifact` tool (`type_url`) and are captured as its publish.
|
||||||
|
*/
|
||||||
|
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||||
|
|
||||||
|
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
|
||||||
|
function isPageTool(toolName) {
|
||||||
|
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
|
||||||
|
function docsBirth(input) {
|
||||||
|
let container = input.container;
|
||||||
|
if (typeof container === "string") {
|
||||||
|
try {
|
||||||
|
container = JSON.parse(container);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const create = container?.create;
|
||||||
|
return create && typeof create === "object" ? create : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
|
||||||
|
* trailer appended in the same text block, so it is matched, not parsed.
|
||||||
|
*/
|
||||||
|
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
|
||||||
|
function docsFrameUrl(response) {
|
||||||
|
const blocks = Array.isArray(response) ? response : [response];
|
||||||
|
for (const b of blocks) {
|
||||||
|
const raw = typeof b === "string" ? b : b?.text;
|
||||||
|
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
|
||||||
|
if (m && PAGE_URL_RE.test(m[1])) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
|
||||||
* owned by the session's workspace owner, else by the member who sent the
|
* doc answers with the doc's link the same way (later batches edit it, and are
|
||||||
* hook (`peerOwner`). Returns the row or null.
|
* skipped). Recorded once per (owner, url), owned by the session's workspace
|
||||||
|
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
|
||||||
|
* or null.
|
||||||
*/
|
*/
|
||||||
function captureFromHook(sessionId, data, peer = null) {
|
function captureFromHook(sessionId, data, peer = null) {
|
||||||
const input = data?.tool_input || {};
|
const input = data?.tool_input || {};
|
||||||
|
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
|
||||||
|
const birth = docs ? docsBirth(input) : null;
|
||||||
|
if (docs && !birth) return null;
|
||||||
if (input.action && input.action !== "publish") return null;
|
if (input.action && input.action !== "publish") return null;
|
||||||
|
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
|
||||||
|
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
|
||||||
const text = JSON.stringify(data?.tool_response ?? "");
|
const text = JSON.stringify(data?.tool_response ?? "");
|
||||||
const m = PAGE_IN_TEXT_RE.exec(text);
|
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const url = m[0].replace(/[\\"]+$/, "");
|
const url = m[0].replace(/[\\"]+$/, "");
|
||||||
if (!PAGE_URL_RE.test(url)) return null;
|
if (!PAGE_URL_RE.test(url)) return null;
|
||||||
@@ -327,13 +421,14 @@ function captureFromHook(sessionId, data, peer = null) {
|
|||||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||||
return insert({
|
return insert({
|
||||||
kind: "page",
|
kind: "page",
|
||||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
|
||||||
description: cleanDescription(input.description),
|
description: cleanDescription(input.description),
|
||||||
owner_id: ownerId,
|
owner_id: ownerId,
|
||||||
lane_id: laneId,
|
lane_id: laneId,
|
||||||
session_id: String(sessionId),
|
session_id: String(sessionId),
|
||||||
source: file ? file.slice(0, 1000) : null,
|
source: file ? file.slice(0, 1000) : null,
|
||||||
url,
|
url,
|
||||||
|
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +484,8 @@ function fileCandidates(laneId) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
/** A new snapshot of one workspace file, read under the read path's guards. */
|
||||||
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
async function snapshotLaneFile(lane, rel) {
|
||||||
const lane = laneOf(laneId);
|
|
||||||
const { openDownload } = require("./lane-file-transfer");
|
const { openDownload } = require("./lane-file-transfer");
|
||||||
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
||||||
if (file.size > FILE_MAX_BYTES) {
|
if (file.size > FILE_MAX_BYTES) {
|
||||||
@@ -399,10 +493,9 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||||
}
|
}
|
||||||
const snap = newSnapshotDir();
|
const snap = newSnapshotDir();
|
||||||
const name = file.name;
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
|
const out = fs.createWriteStream(path.join(snap.dir, file.name), { mode: 0o600 });
|
||||||
file.stream.on("error", reject);
|
file.stream.on("error", reject);
|
||||||
out.on("error", reject);
|
out.on("error", reject);
|
||||||
out.on("finish", resolve);
|
out.on("finish", resolve);
|
||||||
@@ -412,6 +505,13 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
dropSnapshot(snap.id);
|
dropSnapshot(snap.id);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
return { snap, name: file.name, size: file.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||||
|
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||||
|
const lane = laneOf(laneId);
|
||||||
|
const { snap, name, size } = await snapshotLaneFile(lane, rel);
|
||||||
const relPosix = require("./git-diff").lanePath(rel);
|
const relPosix = require("./git-diff").lanePath(rel);
|
||||||
return view(
|
return view(
|
||||||
insert({
|
insert({
|
||||||
@@ -423,13 +523,121 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
session_id: sessionId ? String(sessionId) : null,
|
session_id: sessionId ? String(sessionId) : null,
|
||||||
source: relPosix,
|
source: relPosix,
|
||||||
snapshot: snap.id,
|
snapshot: snap.id,
|
||||||
files: JSON.stringify([{ path: name, size: file.size }]),
|
files: JSON.stringify([{ path: name, size }]),
|
||||||
size: file.size,
|
size,
|
||||||
}),
|
}),
|
||||||
req
|
req
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Outputs captured when a session stops ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
|
||||||
|
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;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async function outputCandidates(lane, since) {
|
||||||
|
const gd = require("./git-diff");
|
||||||
|
let paths;
|
||||||
|
let tracked = new Set();
|
||||||
|
if (await gd.inRepo(lane.cwd)) {
|
||||||
|
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
|
||||||
|
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
|
||||||
|
tracked = new Set(modified);
|
||||||
|
paths = [...untracked, ...modified];
|
||||||
|
} else {
|
||||||
|
paths = (await gd.walkFolder(lane.cwd)).files;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const rel of new Set(paths)) {
|
||||||
|
if (!OUTPUT_EXT.test(rel)) continue;
|
||||||
|
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
|
||||||
|
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At a session's Stop: snapshot each document it created or changed into its
|
||||||
|
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
|
||||||
|
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
|
||||||
|
* pinned by hand is left to them. Returns the rows added or refreshed.
|
||||||
|
*/
|
||||||
|
function captureOutputs(sessionId) {
|
||||||
|
const laneId = sessionLane(sessionId);
|
||||||
|
if (laneId == null) return Promise.resolve([]);
|
||||||
|
// One capture per workspace at a time: two Stops in a row must not both insert.
|
||||||
|
const prev = capturing.get(laneId) || Promise.resolve();
|
||||||
|
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
|
||||||
|
capturing.set(laneId, run);
|
||||||
|
run.finally(() => {
|
||||||
|
if (capturing.get(laneId) === run) capturing.delete(laneId);
|
||||||
|
});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureOutputsNow(laneId, sessionId) {
|
||||||
|
const lane = require("./lanes").getLane(Number(laneId));
|
||||||
|
if (!lane) return [];
|
||||||
|
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
|
||||||
|
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
|
||||||
|
const done = [];
|
||||||
|
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
|
||||||
|
const rows = sql(
|
||||||
|
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
|
||||||
|
).all(lane.id, rel);
|
||||||
|
if (rows.some((r) => r.auto !== 1)) continue;
|
||||||
|
const row = rows[0];
|
||||||
|
const kept = row?.files ? JSON.parse(row.files)[0] : null;
|
||||||
|
if (kept && kept.mtime === mtimeMs) continue;
|
||||||
|
let shot;
|
||||||
|
try {
|
||||||
|
shot = await snapshotLaneFile(lane, rel);
|
||||||
|
} catch {
|
||||||
|
continue; // a secret name, a vanished file, one grown past the cap
|
||||||
|
}
|
||||||
|
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 = ?"
|
||||||
|
).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));
|
||||||
|
} else {
|
||||||
|
done.push(
|
||||||
|
insert({
|
||||||
|
kind: "file",
|
||||||
|
title: cleanTitle(null, shot.name),
|
||||||
|
owner_id: lane.owner_id ?? null,
|
||||||
|
lane_id: lane.id,
|
||||||
|
session_id: String(sessionId),
|
||||||
|
source: rel,
|
||||||
|
snapshot: shot.snap.id,
|
||||||
|
files,
|
||||||
|
size: shot.size,
|
||||||
|
auto: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
||||||
* one, under a name made from the upload's own (no folder, no path).
|
* one, under a name made from the upload's own (no folder, no path).
|
||||||
@@ -1171,6 +1379,56 @@ function addPlugin(req, { key, market, title, description }) {
|
|||||||
|
|
||||||
// ── change / delete ───────────────────────────────────────────────────────
|
// ── change / delete ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Unowned rows ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function unownedRow(req, id) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
|
||||||
|
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
|
||||||
|
function claim(req, id) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
|
||||||
|
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
|
||||||
|
}
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(req.user.id, row.id);
|
||||||
|
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
audit(req, next, "claim");
|
||||||
|
return view(next, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
|
||||||
|
function decline(req, id) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (row.suggested_owner_id !== req.user.id) {
|
||||||
|
throw fail("EFORBIDDEN", "only the suggested user can decline it");
|
||||||
|
}
|
||||||
|
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
|
||||||
|
audit(req, row, "decline");
|
||||||
|
return { id: row.id, declined: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An admin gives an unowned row to an enabled user. */
|
||||||
|
function assign(req, id, userId) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
|
||||||
|
const user = /^\d+$/.test(String(userId))
|
||||||
|
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
|
||||||
|
: null;
|
||||||
|
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(user.id, row.id);
|
||||||
|
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
audit(req, next, "assign");
|
||||||
|
return view(next, req);
|
||||||
|
}
|
||||||
|
|
||||||
function audit(req, row, action) {
|
function audit(req, row, action) {
|
||||||
if (!req?.user) return;
|
if (!req?.user) return;
|
||||||
try {
|
try {
|
||||||
@@ -1669,6 +1927,11 @@ module.exports = {
|
|||||||
canSeeId,
|
canSeeId,
|
||||||
addPage,
|
addPage,
|
||||||
captureFromHook,
|
captureFromHook,
|
||||||
|
captureOutputs,
|
||||||
|
isPageTool,
|
||||||
|
claim,
|
||||||
|
decline,
|
||||||
|
assign,
|
||||||
fileCandidates,
|
fileCandidates,
|
||||||
pinFile,
|
pinFile,
|
||||||
availableSkills,
|
availableSkills,
|
||||||
|
|||||||
@@ -503,6 +503,10 @@ function pick(member, ownerId = null) {
|
|||||||
live = liveLogin ? placed || liveLogin : null;
|
live = liveLogin ? placed || liveLogin : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||||
|
// in their home stays: one .credentials.json per home.
|
||||||
|
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||||
|
usable = usable.filter((a) => allowed(a.name));
|
||||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||||
// while another login holds this home.
|
// while another login holds this home.
|
||||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||||
|
|||||||
@@ -358,6 +358,10 @@ function pick(member, ownerId = null) {
|
|||||||
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||||
|
// in their home stays: one auth.json per home.
|
||||||
|
const allowed = require("./pool-groups").allowed(ownerId, "codex");
|
||||||
|
usable = usable.filter((a) => allowed(a.name));
|
||||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||||
// waits while another login holds this home.
|
// waits while another login holds this home.
|
||||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @file cursor-pool.js
|
||||||
|
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
|
||||||
|
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
|
||||||
|
* cursor`, or the Users page), and the balancer that gives every new
|
||||||
|
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
|
||||||
|
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
|
||||||
|
* it from there and it outranks the account's own `cursor-agent login` for
|
||||||
|
* that process only; nothing is written into anyone's home. An empty pool
|
||||||
|
* changes nothing: panes run on their home's own login as before.
|
||||||
|
*
|
||||||
|
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
|
||||||
|
* 0700, file 0600); no route returns a key.
|
||||||
|
*
|
||||||
|
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
|
||||||
|
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
|
||||||
|
* there is nothing else to rank by. A user an admin pinned to one account
|
||||||
|
* (account-pins.js) skips the ranking.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
|
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||||
|
// A Cursor API key is one opaque token; never whitespace, never a newline
|
||||||
|
// (it travels as an env value through tmux and a member's launch file).
|
||||||
|
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
|
||||||
|
const KEY_ENV = "CURSOR_API_KEY";
|
||||||
|
|
||||||
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
|
||||||
|
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
|
||||||
|
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||||||
|
|
||||||
|
function validName(name) {
|
||||||
|
const n = String(name || "").trim();
|
||||||
|
if (!NAME_RE.test(n))
|
||||||
|
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAccount(name) {
|
||||||
|
try {
|
||||||
|
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||||
|
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||||||
|
} catch {
|
||||||
|
/* absent or not ours */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allAccounts() {
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
.map((f) => f.slice(0, -5))
|
||||||
|
.filter((n) => NAME_RE.test(n))
|
||||||
|
.map(readAccount)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
|
||||||
|
function addAccount(name, { apiKey } = {}) {
|
||||||
|
const n = validName(name);
|
||||||
|
const key = String(apiKey || "").trim();
|
||||||
|
if (!KEY_RE.test(key)) {
|
||||||
|
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
|
||||||
|
}
|
||||||
|
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||||
|
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
|
||||||
|
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||||
|
fs.chmodSync(poolDir(), 0o700);
|
||||||
|
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
|
||||||
|
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAccount(name) {
|
||||||
|
const n = validName(name);
|
||||||
|
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||||
|
fs.rmSync(fileOf(n));
|
||||||
|
require("./account-pins").dropAccount("cursor", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function livePanes(account) {
|
||||||
|
const { db } = require("../db");
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||||||
|
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
|
||||||
|
)
|
||||||
|
.get(account).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every account with what the balancer sees — never a key. */
|
||||||
|
function poolStatus() {
|
||||||
|
return allAccounts().map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
kind: a.kind,
|
||||||
|
addedAt: a.addedAt,
|
||||||
|
livePanes: livePanes(a.name),
|
||||||
|
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
|
||||||
|
quota: null,
|
||||||
|
quotaPercent: null,
|
||||||
|
full: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record a new Cursor pane should run on, or null (empty pool). */
|
||||||
|
function pick(ownerId = null) {
|
||||||
|
// The owner's group's accounts only (pool-groups.js).
|
||||||
|
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||||||
|
const usable = allAccounts().filter((a) => allowed(a.name));
|
||||||
|
if (usable.length === 0) return null;
|
||||||
|
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||||||
|
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||||
|
if (pinned) return pinned;
|
||||||
|
return usable
|
||||||
|
.map((a) => ({ a, n: livePanes(a.name) }))
|
||||||
|
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
|
||||||
|
function pickAccount(ownerId = null) {
|
||||||
|
const a = pick(ownerId);
|
||||||
|
return a ? { name: a.name, kind: a.kind } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
|
||||||
|
* goes through tmux `-e` (a member's launch file), never argv.
|
||||||
|
*/
|
||||||
|
function forSpawn(ownerId = null) {
|
||||||
|
const a = pick(ownerId);
|
||||||
|
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `name` an account in the pool? */
|
||||||
|
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasAccount,
|
||||||
|
addAccount,
|
||||||
|
removeAccount,
|
||||||
|
poolStatus,
|
||||||
|
pickAccount,
|
||||||
|
forSpawn,
|
||||||
|
poolDir,
|
||||||
|
__read: readAccount,
|
||||||
|
};
|
||||||
@@ -1350,6 +1350,7 @@ module.exports = {
|
|||||||
laneStatus,
|
laneStatus,
|
||||||
listCommits,
|
listCommits,
|
||||||
listPaths,
|
listPaths,
|
||||||
|
lsFiles,
|
||||||
listTree,
|
listTree,
|
||||||
listTreeAll,
|
listTreeAll,
|
||||||
parseNumstat,
|
parseNumstat,
|
||||||
@@ -1358,6 +1359,7 @@ module.exports = {
|
|||||||
readFileAt,
|
readFileAt,
|
||||||
readImageAt,
|
readImageAt,
|
||||||
resolveBaseRef,
|
resolveBaseRef,
|
||||||
|
walkFolder,
|
||||||
defaultBranch,
|
defaultBranch,
|
||||||
DEFAULT_CONTEXT,
|
DEFAULT_CONTEXT,
|
||||||
MAX_CONTEXT,
|
MAX_CONTEXT,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* @file nested-repos.js
|
||||||
|
* @description The git repositories INSIDE a workspace folder — a parent
|
||||||
|
* folder holding several repos side by side (`~/work/shop/{api,web,admin}`),
|
||||||
|
* each with its own `.git` — so the Changes panel can show one section per
|
||||||
|
* repo, like VS Code's Source Control. Found by reading the disk only (no git
|
||||||
|
* call): a directory holding a `.git` entry (a directory, or the file a
|
||||||
|
* submodule or linked worktree has) is a repo, and the walk does not go
|
||||||
|
* inside one. At most `MAX_DEPTH` levels below the workspace folder, never
|
||||||
|
* through a symlink, never into a dot directory or `node_modules`/`vendor`,
|
||||||
|
* at most `MAX_REPOS` repos and `MAX_ENTRIES` directory entries read, so a
|
||||||
|
* folder with a huge tree (or a member flooding theirs) costs a bounded read.
|
||||||
|
* Cached per folder for `TTL_MS`; `refresh` reads again.
|
||||||
|
*
|
||||||
|
* `repoFolder` is the git routes' `?repo=<path>`: the request names a path
|
||||||
|
* RELATIVE to the workspace folder, and it is accepted only when it is one of
|
||||||
|
* the repos this walk found — never a path the walk did not produce, so the
|
||||||
|
* request can no more point the git routes at an arbitrary directory than
|
||||||
|
* `?source=` can.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const MAX_DEPTH = 2;
|
||||||
|
const MAX_REPOS = 50;
|
||||||
|
const MAX_ENTRIES = 5000;
|
||||||
|
const TTL_MS = 30_000;
|
||||||
|
const REREAD_MS = 2_000;
|
||||||
|
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "__pycache__"]);
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
function fail(code, message) {
|
||||||
|
return Object.assign(new Error(message), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasGit(dir) {
|
||||||
|
try {
|
||||||
|
await fs.promises.lstat(path.join(dir, ".git"));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `dir` or a folder above it holds a `.git` — the folder is itself in a repo. */
|
||||||
|
async function insideRepo(dir) {
|
||||||
|
for (let d = dir; ;) {
|
||||||
|
if (await hasGit(d)) return true;
|
||||||
|
const up = path.dirname(d);
|
||||||
|
if (up === d) return false;
|
||||||
|
d = up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(root) {
|
||||||
|
const repos = [];
|
||||||
|
let budget = MAX_ENTRIES;
|
||||||
|
let truncated = false;
|
||||||
|
const visit = async (dir, depth) => {
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await fs.promises.opendir(dir);
|
||||||
|
} catch {
|
||||||
|
return; // unreadable: not ours to report
|
||||||
|
}
|
||||||
|
const children = [];
|
||||||
|
try {
|
||||||
|
for await (const ent of handle) {
|
||||||
|
if (--budget < 0) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// `isDirectory()` is false for a symlink: a link is never followed.
|
||||||
|
if (!ent.isDirectory() || ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
|
||||||
|
children.push(ent.name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* a directory that vanished mid-read */
|
||||||
|
}
|
||||||
|
children.sort((a, b) => a.localeCompare(b));
|
||||||
|
for (const name of children) {
|
||||||
|
if (repos.length >= MAX_REPOS) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const abs = path.join(dir, name);
|
||||||
|
if (await hasGit(abs)) {
|
||||||
|
const rel = path.relative(root, abs).split(path.sep).join("/");
|
||||||
|
repos.push({ path: rel, name: rel });
|
||||||
|
} else if (depth < MAX_DEPTH) {
|
||||||
|
await visit(abs, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await visit(root, 1);
|
||||||
|
return { items: repos, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repos below `cwd`, plus whether `cwd` itself is in a repo (`root`).
|
||||||
|
* @param {string} cwd The workspace folder (absolute).
|
||||||
|
* @param {{refresh?: boolean}} [opts]
|
||||||
|
* @returns {Promise<{root: boolean, items: {path: string, name: string}[], truncated: boolean}>}
|
||||||
|
*/
|
||||||
|
async function nestedRepos(cwd, { refresh = false } = {}) {
|
||||||
|
const hit = cache.get(cwd);
|
||||||
|
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.value;
|
||||||
|
const pending = (async () => {
|
||||||
|
const [root, found] = await Promise.all([insideRepo(cwd), walk(cwd)]);
|
||||||
|
return { root, ...found };
|
||||||
|
})();
|
||||||
|
cache.set(cwd, { at: Date.now(), value: pending });
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch (err) {
|
||||||
|
cache.delete(cwd);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory `?repo=<rel>` names: only a repo `nestedRepos` found under
|
||||||
|
* the workspace folder (re-read if the cached list does not have it — a repo
|
||||||
|
* cloned in the last 30 s — but at most once per `REREAD_MS`, so a stream of
|
||||||
|
* bad names cannot keep the disk walking).
|
||||||
|
* @throws ENOREPO otherwise.
|
||||||
|
*/
|
||||||
|
async function repoFolder(cwd, rel) {
|
||||||
|
const want = String(rel);
|
||||||
|
const find = (list) => list.items.find((r) => r.path === want);
|
||||||
|
let hit = find(await nestedRepos(cwd));
|
||||||
|
if (!hit && Date.now() - (cache.get(cwd)?.at ?? 0) >= REREAD_MS) {
|
||||||
|
hit = find(await nestedRepos(cwd, { refresh: true }));
|
||||||
|
}
|
||||||
|
if (!hit) throw fail("ENOREPO", "that folder is not a repository inside this workspace");
|
||||||
|
return path.join(cwd, ...hit.path.split("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tests only. */
|
||||||
|
function clearNestedRepoCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { nestedRepos, repoFolder, clearNestedRepoCache, MAX_DEPTH, MAX_REPOS };
|
||||||
@@ -28,6 +28,9 @@ const path = require("path");
|
|||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
const { getDataDir } = require("./claude-home");
|
const { getDataDir } = require("./claude-home");
|
||||||
|
|
||||||
|
/** Longer than any one step's timeout in the helper (bootstrap: 15 min). */
|
||||||
|
const PROGRESS_STALE_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
||||||
* cache on purpose — that directory is replaced by the update itself. */
|
* cache on purpose — that directory is replaced by the update itself. */
|
||||||
function runtimeDir() {
|
function runtimeDir() {
|
||||||
@@ -53,6 +56,34 @@ function readLastPluginUpdate(rt = runtimeDir()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The step a running chain is on (`plugin-update-progress.json`, written by the
|
||||||
|
* helper as each step starts and removed with its final verdict), or null.
|
||||||
|
* Surfaced as `plugin_update_progress` so the dashboard can show how far an
|
||||||
|
* update got. A file older than the chain's longest run is left over from a
|
||||||
|
* helper that died, and reads as nothing.
|
||||||
|
*
|
||||||
|
* @returns {{step: string, index: number, total: number, started_at: string} | null}
|
||||||
|
*/
|
||||||
|
function readPluginUpdateProgress(rt = runtimeDir()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8")
|
||||||
|
);
|
||||||
|
if (!parsed || typeof parsed.step !== "string") return null;
|
||||||
|
const started = Date.parse(parsed.started_at);
|
||||||
|
if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null;
|
||||||
|
return {
|
||||||
|
step: parsed.step,
|
||||||
|
index: Number(parsed.index) || 0,
|
||||||
|
total: Number(parsed.total) || 0,
|
||||||
|
started_at: parsed.started_at,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn the detached update chain. Returns immediately; the caller must have
|
* Spawn the detached update chain. Returns immediately; the caller must have
|
||||||
* already written its HTTP response, because this process is about to be
|
* already written its HTTP response, because this process is about to be
|
||||||
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(rt, { recursive: true });
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
||||||
|
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort — a stale verdict is better than failing the update */
|
/* best effort — a stale verdict is better than failing the update */
|
||||||
}
|
}
|
||||||
@@ -86,4 +118,9 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
|||||||
return { spawned: true, runtime_dir: rt };
|
return { spawned: true, runtime_dir: rt };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
|
module.exports = {
|
||||||
|
schedulePluginUpdate,
|
||||||
|
readLastPluginUpdate,
|
||||||
|
readPluginUpdateProgress,
|
||||||
|
runtimeDir,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* @file pool-groups.js
|
||||||
|
* @description Account pool GROUPS (server mode): an admin splits the pool so
|
||||||
|
* each team's sessions run on that team's own accounts. A group is a name, its
|
||||||
|
* members (one group per user, `pool_group_users`) and the pool accounts it
|
||||||
|
* may use, of every tool (`pool_group_accounts`; one account may serve many
|
||||||
|
* groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask
|
||||||
|
* `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may
|
||||||
|
* take: a member of a group gets only that group's accounts of the tool —
|
||||||
|
* none means the user's own sign-in, as with an empty pool — and a user in no
|
||||||
|
* group gets the SHARED pool, the accounts no group lists. With no group at
|
||||||
|
* all every account is shared, exactly as before groups existed. A pin
|
||||||
|
* (account-pins.js) is an admin's explicit choice and is honoured whatever the
|
||||||
|
* group. Local mode has no users: everything is shared there.
|
||||||
|
*
|
||||||
|
* Accounts are named, not referenced — the pool lives in files — so removing
|
||||||
|
* an account drops its group rows (`dropAccount`); deleting a user or a group
|
||||||
|
* cascades.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const db = () => require("../db").db;
|
||||||
|
const pins = () => require("./account-pins");
|
||||||
|
|
||||||
|
const NAME_MAX = 64;
|
||||||
|
|
||||||
|
function validName(name) {
|
||||||
|
const n = String(name ?? "").trim();
|
||||||
|
if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) {
|
||||||
|
throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRow(id) {
|
||||||
|
const g = Number.isInteger(id)
|
||||||
|
? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id)
|
||||||
|
: null;
|
||||||
|
if (!g) throw err("ENOGROUP", `no group ${id}`);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every group with its members and accounts per tool. */
|
||||||
|
function listGroups() {
|
||||||
|
const groups = db()
|
||||||
|
.prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name")
|
||||||
|
.all();
|
||||||
|
const users = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT g.group_id AS groupId, u.id AS userId, u.username
|
||||||
|
FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const accounts = db()
|
||||||
|
.prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account")
|
||||||
|
.all();
|
||||||
|
return groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
users: users
|
||||||
|
.filter((u) => u.groupId === g.id)
|
||||||
|
.map(({ userId, username }) => ({ userId, username })),
|
||||||
|
accounts: Object.fromEntries(
|
||||||
|
pins().TOOLS.map((t) => [
|
||||||
|
t,
|
||||||
|
accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup(name) {
|
||||||
|
const n = validName(name);
|
||||||
|
try {
|
||||||
|
const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid;
|
||||||
|
return { id: Number(id), name: n };
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameGroup(id, name) {
|
||||||
|
const g = groupRow(id);
|
||||||
|
const n = validName(name);
|
||||||
|
try {
|
||||||
|
db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { id: g.id, name: n };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its members go back to the shared pool; its accounts become shared if no other group lists them. */
|
||||||
|
function deleteGroup(id) {
|
||||||
|
const g = groupRow(id);
|
||||||
|
db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly `userIds` are the group's members afterwards — a user in another
|
||||||
|
* group moves here (one group per user). One transaction.
|
||||||
|
*/
|
||||||
|
function setGroupUsers(id, userIds) {
|
||||||
|
const g = groupRow(id);
|
||||||
|
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
|
||||||
|
throw err("EBADUSER", "userIds: an array of user ids");
|
||||||
|
}
|
||||||
|
const want = [...new Set(userIds)];
|
||||||
|
for (const uid of want) {
|
||||||
|
if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid))
|
||||||
|
throw err("ENOUSER", `no user ${uid}`);
|
||||||
|
}
|
||||||
|
db().transaction(() => {
|
||||||
|
db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id);
|
||||||
|
const put = db().prepare(
|
||||||
|
`INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id`
|
||||||
|
);
|
||||||
|
for (const uid of want) put.run(uid, g.id);
|
||||||
|
})();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly `names` are the group's accounts of `tool` afterwards. */
|
||||||
|
function setGroupAccounts(id, tool, names) {
|
||||||
|
const g = groupRow(id);
|
||||||
|
if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`);
|
||||||
|
if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) {
|
||||||
|
throw err("EBADNAME", "accounts: an array of account names");
|
||||||
|
}
|
||||||
|
const want = [...new Set(names)];
|
||||||
|
const pool = pins().poolOf(tool);
|
||||||
|
for (const n of want) {
|
||||||
|
if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`);
|
||||||
|
}
|
||||||
|
db().transaction(() => {
|
||||||
|
db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool);
|
||||||
|
const put = db().prepare(
|
||||||
|
"INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)"
|
||||||
|
);
|
||||||
|
for (const n of want) put.run(g.id, tool, n);
|
||||||
|
})();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a
|
||||||
|
* predicate on the account name: their group's, else the shared ones; their
|
||||||
|
* pin always. Everything when groups do not apply (local mode, no group yet,
|
||||||
|
* or no database to ask).
|
||||||
|
*/
|
||||||
|
function allowed(ownerId, tool) {
|
||||||
|
if (!require("./auth").isServerMode()) return () => true;
|
||||||
|
try {
|
||||||
|
if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true;
|
||||||
|
const pin = pins().pinFor(ownerId, tool);
|
||||||
|
const group = Number.isInteger(ownerId)
|
||||||
|
? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId)
|
||||||
|
: null;
|
||||||
|
const names = new Set(
|
||||||
|
(group
|
||||||
|
? db()
|
||||||
|
.prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?")
|
||||||
|
.all(group.group_id, tool)
|
||||||
|
: []
|
||||||
|
).map((r) => r.account)
|
||||||
|
);
|
||||||
|
const grouped = group
|
||||||
|
? null
|
||||||
|
: new Set(
|
||||||
|
db()
|
||||||
|
.prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?")
|
||||||
|
.all(tool)
|
||||||
|
.map((r) => r.account)
|
||||||
|
);
|
||||||
|
return (name) => name === pin || (group ? names.has(name) : !grouped.has(name));
|
||||||
|
} catch {
|
||||||
|
return () => true; // a spawn never fails over a group lookup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A removed account leaves every group. */
|
||||||
|
function dropAccount(tool, account) {
|
||||||
|
try {
|
||||||
|
db()
|
||||||
|
.prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?")
|
||||||
|
.run(tool, account);
|
||||||
|
} catch {
|
||||||
|
/* no database (a bare CLI test): nothing grouped */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listGroups,
|
||||||
|
createGroup,
|
||||||
|
renameGroup,
|
||||||
|
deleteGroup,
|
||||||
|
setGroupUsers,
|
||||||
|
setGroupAccounts,
|
||||||
|
allowed,
|
||||||
|
dropAccount,
|
||||||
|
};
|
||||||
@@ -488,6 +488,8 @@ function spawnRun(args) {
|
|||||||
entry.launcher === "codex" && !proxyProfile
|
entry.launcher === "codex" && !proxyProfile
|
||||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||||
: null;
|
: null;
|
||||||
|
// Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`.
|
||||||
|
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null;
|
||||||
const paneCred = require("./auth").newPaneToken();
|
const paneCred = require("./auth").newPaneToken();
|
||||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||||
// models.yml points at the loopback gateway, with a key already listed there.
|
// models.yml points at the loopback gateway, with a key already listed there.
|
||||||
@@ -611,7 +613,7 @@ function spawnRun(args) {
|
|||||||
resumeSessionId: resumeSessionId || null,
|
resumeSessionId: resumeSessionId || null,
|
||||||
worktree: worktree === true || inWorktree === true,
|
worktree: worktree === true || inWorktree === true,
|
||||||
proxy: proxyProfile ? proxyProfile.name : null,
|
proxy: proxyProfile ? proxyProfile.name : null,
|
||||||
account: (poolAccount || codexPool)?.name ?? null,
|
account: (poolAccount || codexPool || cursorPool)?.name ?? null,
|
||||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||||
prompt: initialPrompt || "",
|
prompt: initialPrompt || "",
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -668,6 +670,7 @@ function spawnRun(args) {
|
|||||||
// this process only. Through tmux `-e`, never typed into the pane.
|
// this process only. Through tmux `-e`, never typed into the pane.
|
||||||
...(poolAccount ? poolAccount.env : {}),
|
...(poolAccount ? poolAccount.env : {}),
|
||||||
...(codexPool ? codexPool.env : {}),
|
...(codexPool ? codexPool.env : {}),
|
||||||
|
...(cursorPool ? cursorPool.env : {}),
|
||||||
...(ompShare ? ompShare.env : {}),
|
...(ompShare ? ompShare.env : {}),
|
||||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @file subagent-usage — the token usage of a Claude session's sub-agents (the
|
||||||
|
* `agent-*.jsonl` transcripts Claude Code writes beside the session's own, in
|
||||||
|
* `<dir>/<session id>/subagents/` or `<dir>/subagents/<session id>/` — the
|
||||||
|
* same places `findSessionSubagents` in scripts/import-history.js looks),
|
||||||
|
* summed per pricing bucket and per UTC hour. The hook writer adds it to the
|
||||||
|
* main transcript's usage, so a session's total and its hours count every
|
||||||
|
* sub-agent, the same total the importer's `combineSessionTokens` writes
|
||||||
|
* (both go through the high-water `replaceTokenUsage`, so neither can inflate
|
||||||
|
* the other). Workflow-tool agents (`subagents/workflows/…`) are left out:
|
||||||
|
* `workflow-ingest.js` books those under their own service tier. Parsing goes
|
||||||
|
* through a TranscriptCache of its own (stat-cached, incremental), so the main
|
||||||
|
* transcripts' cache is never crowded out.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const TranscriptCache = require("./transcript-cache");
|
||||||
|
const { emptyBucket, accumulateBucket } = require("./token-usage");
|
||||||
|
|
||||||
|
const cache = new TranscriptCache(500);
|
||||||
|
|
||||||
|
/** The session's sub-agent transcripts, flat ones only. */
|
||||||
|
function subagentFiles(transcriptPath) {
|
||||||
|
const dir = path.dirname(transcriptPath);
|
||||||
|
const sessionId = path.basename(transcriptPath, ".jsonl");
|
||||||
|
const out = [];
|
||||||
|
for (const d of [
|
||||||
|
path.join(dir, sessionId, "subagents"),
|
||||||
|
path.join(dir, "subagents", sessionId),
|
||||||
|
]) {
|
||||||
|
let names;
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(d);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const n of names) if (n.endsWith(".jsonl")) out.push(path.join(d, n));
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `a` plus `b`, bucket by bucket (an hour bucket keeps its `hour`); never mutates. */
|
||||||
|
function addTokens(a, b) {
|
||||||
|
const out = {};
|
||||||
|
for (const src of [a, b]) {
|
||||||
|
for (const [key, t] of Object.entries(src || {})) {
|
||||||
|
if (!out[key]) {
|
||||||
|
out[key] = emptyBucket(t.model, t.speed, t.geo, t.tier);
|
||||||
|
if (t.hour) out[key].hour = t.hour;
|
||||||
|
}
|
||||||
|
accumulateBucket(out[key], t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sub-agents' usage of the session whose transcript is `transcriptPath`:
|
||||||
|
* `{tokensByModel, tokensByHour, parts}` (`parts` = each file's cached parse
|
||||||
|
* result, the same object while that file is unchanged), or null when it has
|
||||||
|
* no sub-agent with usage.
|
||||||
|
*/
|
||||||
|
function subagentUsage(transcriptPath) {
|
||||||
|
if (typeof transcriptPath !== "string" || !transcriptPath.endsWith(".jsonl")) return null;
|
||||||
|
const parts = [];
|
||||||
|
for (const f of subagentFiles(transcriptPath)) {
|
||||||
|
let r = null;
|
||||||
|
try {
|
||||||
|
r = cache.extract(f);
|
||||||
|
} catch {
|
||||||
|
/* unreadable or vanished: skipped this time */
|
||||||
|
}
|
||||||
|
if (r && r.tokensByModel) parts.push(r);
|
||||||
|
}
|
||||||
|
if (!parts.length) return null;
|
||||||
|
let tokensByModel = {};
|
||||||
|
let tokensByHour = {};
|
||||||
|
for (const r of parts) {
|
||||||
|
tokensByModel = addTokens(tokensByModel, r.tokensByModel);
|
||||||
|
tokensByHour = addTokens(tokensByHour, r.tokensByHour);
|
||||||
|
}
|
||||||
|
return { tokensByModel, tokensByHour, parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two `parts` lists are the same parses (nothing grew in between). */
|
||||||
|
function sameParts(a, b) {
|
||||||
|
const x = a || [];
|
||||||
|
const y = b || [];
|
||||||
|
return x.length === y.length && x.every((r, i) => r === y[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subagentUsage, subagentFiles, addTokens, sameParts };
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFile } = require("child_process");
|
const { execFile } = require("child_process");
|
||||||
const { readLastPluginUpdate } = require("./plugin-update");
|
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
|
||||||
const { getClaudeHome } = require("./claude-home");
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||||
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
|
|||||||
if (!err) return resolve(String(stdout).trim());
|
if (!err) return resolve(String(stdout).trim());
|
||||||
if (err.killed) {
|
if (err.killed) {
|
||||||
return reject(
|
return reject(
|
||||||
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
|
new Error(
|
||||||
|
`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
|
const tail = String(stderr || err.message || err)
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.slice(-15)
|
||||||
|
.join("\n");
|
||||||
reject(new Error(`${step} failed:\n${tail}`));
|
reject(new Error(`${step} failed:\n${tail}`));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
|||||||
plugin_marketplace: pluginInstall.marketplace,
|
plugin_marketplace: pluginInstall.marketplace,
|
||||||
plugin_name: pluginInstall.plugin,
|
plugin_name: pluginInstall.plugin,
|
||||||
last_plugin_update: readLastPluginUpdate(),
|
last_plugin_update: readLastPluginUpdate(),
|
||||||
|
plugin_update_progress: readPluginUpdateProgress(),
|
||||||
|
|
||||||
local_version: localVersion,
|
local_version: localVersion,
|
||||||
// Not a clone, so there is nothing to be shallow about and no distances to
|
// Not a clone, so there is nothing to be shallow about and no distances to
|
||||||
@@ -785,6 +792,8 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|||||||
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
||||||
// a chain that died is visible instead of looking like a slow restart.
|
// a chain that died is visible instead of looking like a slow restart.
|
||||||
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
||||||
|
// The step a running chain is on, while it runs.
|
||||||
|
plugin_update_progress: pluginInstall ? readPluginUpdateProgress() : null,
|
||||||
local_version: localVersion,
|
local_version: localVersion,
|
||||||
// True when commit distances are unusable and version strings are the only
|
// True when commit distances are unusable and version strings are the only
|
||||||
// signal — the UI must not render an "N commits" badge in that case.
|
// signal — the UI must not render an "N commits" badge in that case.
|
||||||
|
|||||||
@@ -36,9 +36,17 @@ const schemas = {
|
|||||||
laneId: { type: "integer", nullable: true },
|
laneId: { type: "integer", nullable: true },
|
||||||
sessionId: { type: "string", nullable: true },
|
sessionId: { type: "string", nullable: true },
|
||||||
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
|
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
|
||||||
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
|
market: {
|
||||||
|
type: "string",
|
||||||
|
nullable: true,
|
||||||
|
description: "Plugin: owner/repo, git URL or marketplace.json URL.",
|
||||||
|
},
|
||||||
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
|
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
|
||||||
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
|
files: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object" },
|
||||||
|
description: "The files the snapshot recorded.",
|
||||||
|
},
|
||||||
size: { type: "integer", nullable: true },
|
size: { type: "integer", nullable: true },
|
||||||
shared: { type: "boolean" },
|
shared: { type: "boolean" },
|
||||||
sharedAt: { type: "string", nullable: true },
|
sharedAt: { type: "string", nullable: true },
|
||||||
@@ -82,13 +90,23 @@ const paths = {
|
|||||||
description:
|
description:
|
||||||
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
|
{
|
||||||
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
|
name: "scope",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kind",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
|
||||||
|
},
|
||||||
{ name: "q", in: "query", schema: { type: "string" } },
|
{ name: "q", in: "query", schema: { type: "string" } },
|
||||||
laneQuery,
|
laneQuery,
|
||||||
{ name: "sessionId", in: "query", schema: { type: "string" } },
|
{ name: "sessionId", in: "query", schema: { type: "string" } },
|
||||||
],
|
],
|
||||||
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
|
responses: {
|
||||||
|
200: { description: "`{items}`.", content: { "application/json": { schema: items } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/artifacts/settings": {
|
"/api/artifacts/settings": {
|
||||||
@@ -186,11 +204,14 @@ const paths = {
|
|||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
summary: "Keep one uploaded file (≤ 25 MB)",
|
summary: "Keep one uploaded file (≤ 25 MB)",
|
||||||
operationId: "uploadArtifactFile",
|
operationId: "uploadArtifactFile",
|
||||||
requestBody: multipart({
|
requestBody: multipart(
|
||||||
|
{
|
||||||
files: { type: "string", format: "binary" },
|
files: { type: "string", format: "binary" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
}, ["files"]),
|
},
|
||||||
|
["files"]
|
||||||
|
),
|
||||||
responses: created(item),
|
responses: created(item),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -218,13 +239,16 @@ const paths = {
|
|||||||
operationId: "uploadArtifactSkills",
|
operationId: "uploadArtifactSkills",
|
||||||
description:
|
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.",
|
||||||
requestBody: multipart({
|
requestBody: multipart(
|
||||||
|
{
|
||||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||||
paths: { type: "string" },
|
paths: { type: "string" },
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
}, ["files"]),
|
},
|
||||||
|
["files"]
|
||||||
|
),
|
||||||
responses: created(items),
|
responses: created(items),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -242,7 +266,10 @@ const paths = {
|
|||||||
},
|
},
|
||||||
["name", "description", "content"]
|
["name", "description", "content"]
|
||||||
),
|
),
|
||||||
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
|
responses: created({
|
||||||
|
type: "object",
|
||||||
|
properties: { item: item.properties.item, items: items.properties.items },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/artifacts/plugin/inspect": {
|
"/api/artifacts/plugin/inspect": {
|
||||||
@@ -250,10 +277,14 @@ const paths = {
|
|||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
summary: "Read a marketplace's plugin list",
|
summary: "Read a marketplace's plugin list",
|
||||||
operationId: "inspectArtifactMarket",
|
operationId: "inspectArtifactMarket",
|
||||||
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
description:
|
||||||
|
"Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
||||||
requestBody: json({ market: { type: "string" } }, ["market"]),
|
requestBody: json({ market: { type: "string" } }, ["market"]),
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
|
200: {
|
||||||
|
description:
|
||||||
|
"`{market, name, description, plugins: [{name, key, description, version, category}]}`.",
|
||||||
|
},
|
||||||
502: errs,
|
502: errs,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -281,7 +312,10 @@ const paths = {
|
|||||||
summary: "One artifact",
|
summary: "One artifact",
|
||||||
operationId: "getArtifact",
|
operationId: "getArtifact",
|
||||||
parameters: [idParam],
|
parameters: [idParam],
|
||||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
|
responses: {
|
||||||
|
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||||
|
404: errs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
patch: {
|
patch: {
|
||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
@@ -295,7 +329,11 @@ const paths = {
|
|||||||
source: { type: "string", description: "Plugin only." },
|
source: { type: "string", description: "Plugin only." },
|
||||||
market: { type: "string", description: "Plugin only." },
|
market: { type: "string", description: "Plugin only." },
|
||||||
}),
|
}),
|
||||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
|
responses: {
|
||||||
|
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||||
|
403: errs,
|
||||||
|
404: errs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
* - GET /api/auth/me (Auth)
|
* - GET /api/auth/me (Auth)
|
||||||
* - POST /api/auth/login (Auth)
|
* - POST /api/auth/login (Auth)
|
||||||
* - POST /api/auth/logout (Auth)
|
* - POST /api/auth/logout (Auth)
|
||||||
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
* - GET /api/accounts (Auth) — the Claude, Codex and Cursor account pools and
|
||||||
|
* the pool groups, no keys or tokens
|
||||||
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
||||||
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
||||||
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
||||||
|
* - POST /api/accounts/groups, PATCH/DELETE /api/accounts/groups/{id} (Auth) —
|
||||||
|
* pool groups: a team's members run only on the group's accounts
|
||||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||||
@@ -43,6 +46,27 @@ const schemas = {
|
|||||||
pinnedAt: { type: "string" },
|
pinnedAt: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
PoolGroup: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "name", "users", "accounts"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "integer" },
|
||||||
|
name: { type: "string" },
|
||||||
|
createdAt: { type: "string" },
|
||||||
|
users: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: { userId: { type: "integer" }, username: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
type: "object",
|
||||||
|
description: "Per tool (claude, codex, cursor), the pool account names it may use",
|
||||||
|
additionalProperties: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
UserPrefs: {
|
UserPrefs: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["prefs", "onboardedAt"],
|
required: ["prefs", "onboardedAt"],
|
||||||
@@ -369,6 +393,11 @@ const paths = {
|
|||||||
properties: {
|
properties: {
|
||||||
workspaces: { type: "integer" },
|
workspaces: { type: "integer" },
|
||||||
sessions: { type: "integer" },
|
sessions: { type: "integer" },
|
||||||
|
today: {
|
||||||
|
type: "object",
|
||||||
|
description: "Spent since the viewer's local midnight (`tz_offset`).",
|
||||||
|
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||||
|
},
|
||||||
last7d: {
|
last7d: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||||
@@ -636,7 +665,7 @@ const paths = {
|
|||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
operationId: "listPoolAccounts",
|
operationId: "listPoolAccounts",
|
||||||
summary:
|
summary:
|
||||||
"The Claude and Codex account pools as the balancer sees them (never a key or token)",
|
"The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)",
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: "force",
|
name: "force",
|
||||||
@@ -681,6 +710,27 @@ const paths = {
|
|||||||
properties: {
|
properties: {
|
||||||
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||||
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||||
|
cursor: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group's accounts, a user in no group on the accounts no group lists",
|
||||||
|
items: { $ref: "#/components/schemas/PoolGroup" },
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
kind: { type: "string", enum: ["apikey"] },
|
||||||
|
addedAt: { type: "string" },
|
||||||
|
livePanes: { type: "integer" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
@@ -715,7 +765,7 @@ const paths = {
|
|||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
operationId: "addPoolAccount",
|
operationId: "addPoolAccount",
|
||||||
summary:
|
summary:
|
||||||
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
|
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back",
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -724,7 +774,7 @@ const paths = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["tool", "currentPassword"],
|
required: ["tool", "currentPassword"],
|
||||||
properties: {
|
properties: {
|
||||||
tool: { type: "string", enum: ["claude", "codex"] },
|
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||||
name: { type: "string", description: "Defaults to the profile's name" },
|
name: { type: "string", description: "Defaults to the profile's name" },
|
||||||
profile: {
|
profile: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -736,7 +786,8 @@ const paths = {
|
|||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Claude: a setup-token; Codex: an API key or auth.json text",
|
description:
|
||||||
|
"Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key",
|
||||||
},
|
},
|
||||||
currentPassword: { type: "string" },
|
currentPassword: { type: "string" },
|
||||||
},
|
},
|
||||||
@@ -770,7 +821,7 @@ const paths = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["tool", "userId", "currentPassword"],
|
required: ["tool", "userId", "currentPassword"],
|
||||||
properties: {
|
properties: {
|
||||||
tool: { type: "string", enum: ["claude", "codex"] },
|
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||||
userId: { type: "integer" },
|
userId: { type: "integer" },
|
||||||
account: { type: "string", nullable: true },
|
account: { type: "string", nullable: true },
|
||||||
currentPassword: { type: "string" },
|
currentPassword: { type: "string" },
|
||||||
@@ -816,7 +867,7 @@ const paths = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["tool", "userIds", "currentPassword"],
|
required: ["tool", "userIds", "currentPassword"],
|
||||||
properties: {
|
properties: {
|
||||||
tool: { type: "string", enum: ["claude", "codex"] },
|
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||||
userIds: { type: "array", items: { type: "integer" } },
|
userIds: { type: "array", items: { type: "integer" } },
|
||||||
currentPassword: { type: "string" },
|
currentPassword: { type: "string" },
|
||||||
},
|
},
|
||||||
@@ -836,6 +887,105 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/accounts/groups": {
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "createPoolGroup",
|
||||||
|
summary: "Admin: a new, empty pool group, with their own password again",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["name", "currentPassword"],
|
||||||
|
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: "`{group: {id, name}}`" },
|
||||||
|
400: err("EBADNAME"),
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
409: err("EEXISTS — a group has that name"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/accounts/groups/{id}": {
|
||||||
|
patch: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "updatePoolGroup",
|
||||||
|
summary:
|
||||||
|
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
|
||||||
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["currentPassword"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
userIds: { type: "array", items: { type: "integer" } },
|
||||||
|
accounts: {
|
||||||
|
type: "object",
|
||||||
|
description: "`{tool: [account names]}` for claude, codex, cursor",
|
||||||
|
additionalProperties: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "`{group}`",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { group: { $ref: "#/components/schemas/PoolGroup" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: err("EBADNAME, EBADUSER, EBADTOOL"),
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
|
||||||
|
409: err("EEXISTS — a group has that name"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "deletePoolGroup",
|
||||||
|
summary:
|
||||||
|
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
|
||||||
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["currentPassword"],
|
||||||
|
properties: { currentPassword: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "`{ok: true}`" },
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
404: err("ENOGROUP"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/accounts/{name}": {
|
"/api/accounts/{name}": {
|
||||||
delete: {
|
delete: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
@@ -850,7 +1000,7 @@ const paths = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["tool", "currentPassword"],
|
required: ["tool", "currentPassword"],
|
||||||
properties: {
|
properties: {
|
||||||
tool: { type: "string", enum: ["claude", "codex"] },
|
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||||
currentPassword: { type: "string" },
|
currentPassword: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -532,6 +532,39 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/lanes/{id}/repos": {
|
||||||
|
get: {
|
||||||
|
tags: ["Lanes"],
|
||||||
|
summary: "The git repositories inside a workspace folder",
|
||||||
|
description:
|
||||||
|
"For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.",
|
||||||
|
operationId: "getLaneRepos",
|
||||||
|
parameters: [
|
||||||
|
idParam,
|
||||||
|
{ name: "refresh", in: "query", required: false, schema: { type: "string", enum: ["1"] } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "{root, items: [{path, name}], truncated}.",
|
||||||
|
...json({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
root: { type: "boolean" },
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: { path: { type: "string" }, name: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
truncated: { type: "boolean" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
404: notFound,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/lanes/{id}/sources": {
|
"/api/lanes/{id}/sources": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Lanes"],
|
tags: ["Lanes"],
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ const paths = {
|
|||||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||||
|
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "The file's bytes, as an attachment." },
|
200: { description: "The file's bytes, as an attachment." },
|
||||||
@@ -832,6 +833,7 @@ const paths = {
|
|||||||
},
|
},
|
||||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||||
|
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||||
],
|
],
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -874,6 +876,7 @@ const paths = {
|
|||||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||||
|
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
|
|||||||
@@ -1048,6 +1048,69 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/artifacts/{id}/claim": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Take an unowned artifact",
|
||||||
|
operationId: "claimArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
responses: {
|
||||||
|
200: ok("Claimed.", { item: { type: "object" } }),
|
||||||
|
400: errs("ELOCALMODE."),
|
||||||
|
403: errs("EFORBIDDEN: not the suggested user."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED: it already has an owner."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/artifacts/{id}/decline": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Say an unowned artifact offered to you is not yours",
|
||||||
|
operationId: "declineArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
responses: {
|
||||||
|
200: ok("Declined.", { id: { type: "integer" }, declined: { type: "boolean" } }),
|
||||||
|
400: errs("ELOCALMODE."),
|
||||||
|
403: errs("EFORBIDDEN: not the suggested user."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/artifacts/{id}/assign": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Give an unowned artifact to a user (admin)",
|
||||||
|
operationId: "assignArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["userId"],
|
||||||
|
properties: { userId: { type: "integer" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: ok("Assigned.", { item: { type: "object" } }),
|
||||||
|
400: errs("ELOCALMODE / EBADFIELD."),
|
||||||
|
403: errs("EFORBIDDEN: not an admin."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { tags, schemas, paths };
|
module.exports = { tags, schemas, paths };
|
||||||
|
|||||||
+91
-11
@@ -3,8 +3,9 @@
|
|||||||
* @description `GET /api/accounts` — the Claude account pool as the balancer
|
* @description `GET /api/accounts` — the Claude account pool as the balancer
|
||||||
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
||||||
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
||||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
* `codex` is the same for the Codex pool (server/lib/codex-pool.js), and
|
||||||
* key or a token. `?force=1` re-reads every quota first.
|
* `cursor` for the Cursor pool (server/lib/cursor-pool.js: API keys only, no
|
||||||
|
* quota). Never a key or a token. `?force=1` re-reads every quota first.
|
||||||
*
|
*
|
||||||
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
||||||
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
||||||
@@ -15,13 +16,17 @@
|
|||||||
* live login and the host's home becomes one more placement the server
|
* live login and the host's home becomes one more placement the server
|
||||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||||
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||||
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
* one is an OpenAI API key or the text of an `auth.json` (`token`); a Cursor
|
||||||
|
* one is a Cursor API key (`token`).
|
||||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||||
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
||||||
* login goes into their home at once, so running panes move without a
|
* login goes into their home at once, so running panes move without a
|
||||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||||
* lists the pins under `pins`.
|
* lists the pins under `pins`. Pool GROUPS (pool-groups.js) split the pool by
|
||||||
|
* team: `POST /groups` (`{name}`), `PATCH /groups/:id` (`{name?, userIds?,
|
||||||
|
* accounts?: {tool: [names]}}`), `DELETE /groups/:id`; `GET /` lists them
|
||||||
|
* under `groups`.
|
||||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||||
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
||||||
* can be moved in (identity only). Like every Users-page change
|
* can be moved in (identity only). Like every Users-page change
|
||||||
@@ -34,11 +39,13 @@
|
|||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const pool = require("../lib/claude-pool");
|
const pool = require("../lib/claude-pool");
|
||||||
const codexPool = require("../lib/codex-pool");
|
const codexPool = require("../lib/codex-pool");
|
||||||
|
const cursorPool = require("../lib/cursor-pool");
|
||||||
const auth = require("../lib/auth");
|
const auth = require("../lib/auth");
|
||||||
const { recordAccountChange } = require("../lib/audit");
|
const { recordAccountChange } = require("../lib/audit");
|
||||||
const profiles = require("../lib/cc-profiles");
|
const profiles = require("../lib/cc-profiles");
|
||||||
const toolProfiles = require("../lib/tool-profiles");
|
const toolProfiles = require("../lib/tool-profiles");
|
||||||
const pins = require("../lib/account-pins");
|
const pins = require("../lib/account-pins");
|
||||||
|
const groups = require("../lib/pool-groups");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -49,9 +56,12 @@ router.get("/", async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
accounts: pool.poolStatus(),
|
accounts: pool.poolStatus(),
|
||||||
codex: codexPool.poolStatus(),
|
codex: codexPool.poolStatus(),
|
||||||
|
cursor: cursorPool.poolStatus(),
|
||||||
pins: auth.isServerMode()
|
pins: auth.isServerMode()
|
||||||
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
|
? Object.fromEntries(pins.TOOLS.map((t) => [t, pins.listPins(t)]))
|
||||||
: { claude: [], codex: [] },
|
: Object.fromEntries(pins.TOOLS.map((t) => [t, []])),
|
||||||
|
// Server mode's pool groups (pool-groups.js); none in local mode.
|
||||||
|
groups: auth.isServerMode() ? groups.listGroups() : [],
|
||||||
blockPercent: pool.BLOCK_PERCENT,
|
blockPercent: pool.BLOCK_PERCENT,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -98,6 +108,7 @@ const STATUS = {
|
|||||||
EBADTOKEN: 400,
|
EBADTOKEN: 400,
|
||||||
ENOACCOUNT: 404,
|
ENOACCOUNT: 404,
|
||||||
ENOUSER: 404,
|
ENOUSER: 404,
|
||||||
|
ENOGROUP: 404,
|
||||||
EEXISTS: 409,
|
EEXISTS: 409,
|
||||||
ETOOMANY: 429,
|
ETOOMANY: 429,
|
||||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||||
@@ -105,7 +116,7 @@ const STATUS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** One pool change: server mode, the admin's password confirmed, audited. */
|
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||||
function poolChange(handler) {
|
function poolChange(handler, { anyTool = false } = {}) {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!auth.isServerMode() || !req.user) {
|
if (!auth.isServerMode() || !req.user) {
|
||||||
@@ -114,8 +125,8 @@ function poolChange(handler) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||||
if (body.tool !== "claude" && body.tool !== "codex") {
|
if (!anyTool && !pins.TOOLS.includes(body.tool)) {
|
||||||
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
|
throw Object.assign(new Error(`tool: ${pins.TOOLS.join(", ")}`), { code: "EBADTOOL" });
|
||||||
}
|
}
|
||||||
await auth.confirmPassword(req.user, body.currentPassword);
|
await auth.confirmPassword(req.user, body.currentPassword);
|
||||||
const out = await handler(req, body);
|
const out = await handler(req, body);
|
||||||
@@ -230,7 +241,9 @@ router.post(
|
|||||||
"/",
|
"/",
|
||||||
poolChange(async (_req, body) => {
|
poolChange(async (_req, body) => {
|
||||||
const account =
|
const account =
|
||||||
body.tool === "codex"
|
body.tool === "cursor"
|
||||||
|
? cursorPool.addAccount(body.name, { apiKey: body.token })
|
||||||
|
: body.tool === "codex"
|
||||||
? body.profile
|
? body.profile
|
||||||
? fromCodexProfile(String(body.profile), body.name)
|
? fromCodexProfile(String(body.profile), body.name)
|
||||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||||
@@ -247,6 +260,71 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Groups: each team's members run on the team's own accounts ─────────────
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/groups",
|
||||||
|
poolChange(
|
||||||
|
async (_req, body) => {
|
||||||
|
const g = groups.createGroup(body.name);
|
||||||
|
return { status: 201, json: { group: g }, action: `pool: create group ${g.name}` };
|
||||||
|
},
|
||||||
|
{ anyTool: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One group's edit, each part optional: `name`, `userIds` (exactly its members
|
||||||
|
* afterwards — one group per user, so a user in another group moves here),
|
||||||
|
* `accounts` (`{tool: [names]}`, exactly its accounts of each tool named).
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/groups/:id",
|
||||||
|
poolChange(
|
||||||
|
async (req, body) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
let g = groups.listGroups().find((x) => x.id === id);
|
||||||
|
if (!g) throw Object.assign(new Error(`no group ${req.params.id}`), { code: "ENOGROUP" });
|
||||||
|
const audits = [];
|
||||||
|
if (body.name !== undefined && body.name !== g.name) {
|
||||||
|
groups.renameGroup(id, body.name);
|
||||||
|
audits.push({ action: `pool: rename group ${g.name} to ${String(body.name).trim()}` });
|
||||||
|
}
|
||||||
|
const name = body.name !== undefined ? String(body.name).trim() : g.name;
|
||||||
|
if (body.userIds !== undefined) {
|
||||||
|
groups.setGroupUsers(id, body.userIds);
|
||||||
|
audits.push({ action: `pool: group ${name} members ${JSON.stringify(body.userIds)}` });
|
||||||
|
}
|
||||||
|
if (body.accounts !== undefined) {
|
||||||
|
if (!body.accounts || typeof body.accounts !== "object" || Array.isArray(body.accounts)) {
|
||||||
|
throw Object.assign(new Error("accounts: {tool: [names]}"), { code: "EBADNAME" });
|
||||||
|
}
|
||||||
|
for (const [tool, names] of Object.entries(body.accounts)) {
|
||||||
|
groups.setGroupAccounts(id, tool, names);
|
||||||
|
audits.push({ action: `pool: group ${name} ${tool} accounts ${JSON.stringify(names)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g = groups.listGroups().find((x) => x.id === id);
|
||||||
|
return {
|
||||||
|
json: { group: g },
|
||||||
|
audits: audits.length ? audits : [{ action: `pool: group ${name}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ anyTool: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/groups/:id",
|
||||||
|
poolChange(
|
||||||
|
async (req) => {
|
||||||
|
const g = groups.deleteGroup(Number(req.params.id));
|
||||||
|
return { json: { ok: true }, action: `pool: delete group ${g.name}` };
|
||||||
|
},
|
||||||
|
{ anyTool: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
"/pins",
|
"/pins",
|
||||||
poolChange(async (_req, body) => {
|
poolChange(async (_req, body) => {
|
||||||
@@ -302,7 +380,9 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:name",
|
"/:name",
|
||||||
poolChange((req, body) => {
|
poolChange((req, body) => {
|
||||||
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
|
({ claude: pool, codex: codexPool, cursor: cursorPool })[body.tool].removeAccount(
|
||||||
|
req.params.name
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
json: { ok: true },
|
json: { ok: true },
|
||||||
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const STATUS = {
|
|||||||
EMARKETFETCH: 502,
|
EMARKETFETCH: 502,
|
||||||
ENEEDLOGIN: 502,
|
ENEEDLOGIN: 502,
|
||||||
ETOOBIG: 413,
|
ETOOBIG: 413,
|
||||||
|
EOWNED: 409,
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendError(res, err) {
|
function sendError(res, err) {
|
||||||
@@ -362,6 +363,34 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A row nobody owns: its suggested user says "mine" or "not mine"; an admin
|
||||||
|
// takes it or gives it to someone (artifacts.js `claim`/`decline`/`assign`).
|
||||||
|
router.post(
|
||||||
|
"/:id/claim",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.claim(req, req.params.id);
|
||||||
|
changed(item);
|
||||||
|
res.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/decline",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => res.json(artifacts.decline(req, req.params.id)))
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/assign",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.assign(req, req.params.id, req.body?.userId);
|
||||||
|
changed(item);
|
||||||
|
res.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/uninstall",
|
"/:id/uninstall",
|
||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
|
|||||||
+38
-7
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
|
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts (the session's sub-agent transcripts included, server/lib/subagent-usage.js), detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ function broadcast(type, data) {
|
|||||||
}
|
}
|
||||||
const TranscriptCache = require("../lib/transcript-cache");
|
const TranscriptCache = require("../lib/transcript-cache");
|
||||||
const { writeHourly } = require("../lib/hourly-usage");
|
const { writeHourly } = require("../lib/hourly-usage");
|
||||||
|
const { subagentUsage, addTokens, sameParts } = require("../lib/subagent-usage");
|
||||||
const { longArgs } = require("../lib/token-usage");
|
const { longArgs } = require("../lib/token-usage");
|
||||||
const { scanAndImportSubagents } = require("../../scripts/import-history");
|
const { scanAndImportSubagents } = require("../../scripts/import-history");
|
||||||
const { evaluateEvent } = require("../lib/alerts");
|
const { evaluateEvent } = require("../lib/alerts");
|
||||||
@@ -987,7 +988,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
// Already handled (see transcriptMarks): the same result skips the
|
// Already handled (see transcriptMarks): the same result skips the
|
||||||
// idempotent writes below, a grown one handles only its new entries.
|
// idempotent writes below, a grown one handles only its new entries.
|
||||||
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
|
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
|
||||||
const unchanged = !!mark && mark.result === result;
|
// The session's sub-agents spend too: their transcripts' usage joins the
|
||||||
|
// main one's (`lib/subagent-usage.js`), and a sub-agent that grew is a
|
||||||
|
// change even when the main transcript is not.
|
||||||
|
const subs = subagentUsage(transcriptPath);
|
||||||
|
const unchanged = !!mark && mark.result === result && sameParts(mark.subParts, subs?.parts);
|
||||||
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
|
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
|
||||||
|
|
||||||
// Keep session.model in sync with the user's *current* model — the
|
// Keep session.model in sync with the user's *current* model — the
|
||||||
@@ -1074,10 +1079,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokensByModel && !unchanged) {
|
if ((tokensByModel || subs) && !unchanged) {
|
||||||
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
|
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
|
||||||
// the 1h cache-write split and server-tool request counts.
|
// the 1h cache-write split and server-tool request counts.
|
||||||
for (const tokens of Object.values(tokensByModel)) {
|
const allTokens = subs ? addTokens(tokensByModel, subs.tokensByModel) : tokensByModel;
|
||||||
|
for (const tokens of Object.values(allTokens)) {
|
||||||
stmts.replaceTokenUsage.run(
|
stmts.replaceTokenUsage.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
tokens.model,
|
tokens.model,
|
||||||
@@ -1105,7 +1111,10 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeHourly(sessionId, result.tokensByHour);
|
writeHourly(
|
||||||
|
sessionId,
|
||||||
|
subs ? addTokens(result.tokensByHour, subs.tokensByHour) : result.tokensByHour
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
|
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
|
||||||
@@ -1229,6 +1238,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
|
|
||||||
queueTranscriptMark(sessionId, transcriptPath, {
|
queueTranscriptMark(sessionId, transcriptPath, {
|
||||||
result,
|
result,
|
||||||
|
subParts: subs?.parts ?? null,
|
||||||
startedAt: session.started_at,
|
startedAt: session.started_at,
|
||||||
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
|
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
|
||||||
lastError: lastOf(result.errors),
|
lastError: lastOf(result.errors),
|
||||||
@@ -1295,9 +1305,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
};
|
};
|
||||||
broadcast("new_event", event);
|
broadcast("new_event", event);
|
||||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
// A Claude Docs doc counts too (`isPageTool`): it is a claude.ai page the session made.
|
||||||
|
if (hookType === "PostToolUse" && tool !== "codex") {
|
||||||
try {
|
try {
|
||||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
|
const artifacts = require("../lib/artifacts");
|
||||||
|
const row = artifacts.isPageTool(toolName)
|
||||||
|
? artifacts.captureFromHook(sessionId, data, peer)
|
||||||
|
: null;
|
||||||
if (row) {
|
if (row) {
|
||||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||||
}
|
}
|
||||||
@@ -1736,6 +1750,23 @@ router.post("/event", (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Documents the session made (slides, Word, Excel, PDF, HTML) go into its
|
||||||
|
// workspace owner's Artifacts when it stops. Off the response path, fail-safe.
|
||||||
|
if (["Stop", "SessionEnd"].includes(hook_type) && data.session_id) {
|
||||||
|
require("../lib/artifacts")
|
||||||
|
.captureOutputs(data.session_id)
|
||||||
|
.then((rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
broadcast("artifact_changed", {
|
||||||
|
id: row.id,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
shared: row.shared === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn("[artifacts] output capture failed:", err.message));
|
||||||
|
}
|
||||||
|
|
||||||
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
|
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
|
||||||
// hooks, so the journal (written at workflow completion) is the only source.
|
// hooks, so the journal (written at workflow completion) is the only source.
|
||||||
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user