Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 |
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. */
|
||||
const HTML_MAX = 10 * 1024 * 1024;
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
|
||||
@@ -1470,25 +1470,40 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||
// that names how many untracked files it will delete.
|
||||
const discardTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t("changes.discardTickedConfirm", {
|
||||
count: tickedPaths.length,
|
||||
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
confirmLabel: t("changes.discardTicked"),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { paths, hunks } = await selection();
|
||||
await api.lanes.gitAction(target, "discard", {
|
||||
paths,
|
||||
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||
confirm: true,
|
||||
});
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
@@ -2067,8 +2082,7 @@ export function LaneChangesPanel({
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() =>
|
||||
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
|
||||
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
@@ -2863,13 +2877,13 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void discardTicked()}
|
||||
title={t("changes.discardTickedTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -837,10 +837,12 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
@@ -849,10 +851,14 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Nothing ticked: nothing to discard.
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
@@ -1673,6 +1679,25 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
|
||||
* fields unminified in its build. Undefined on any other shape. */
|
||||
type AtlasPages = { pages: { version: number }[] };
|
||||
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
|
||||
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
|
||||
?._charAtlas;
|
||||
return Array.isArray(atlas?.pages) ? atlas : undefined;
|
||||
};
|
||||
/** Last version handed out by `restampAtlas`, shared by every pane. */
|
||||
let atlasStamp = 0;
|
||||
/**
|
||||
* Gives every page of the addon's glyph atlas a version no texture slot has
|
||||
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
|
||||
* its canvas on its next draw — what addon-webgl 0.20 does with globally
|
||||
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
|
||||
* pages down a slot can hold a stale page whose counter happens to match, and
|
||||
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
|
||||
* per-glyph increments a page makes before the next restamp. False when the
|
||||
* addon is not the shape this knows.
|
||||
*/
|
||||
const restampAtlas = (addon: WebglAddon): boolean => {
|
||||
const atlas = atlasOf(addon);
|
||||
if (!atlas) return false;
|
||||
for (const page of atlas.pages) {
|
||||
atlasStamp += 2 ** 24;
|
||||
page.version = atlasStamp;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||
const readFlag = (key: string) => {
|
||||
try {
|
||||
@@ -762,13 +792,41 @@ export const TerminalView = memo(function TerminalView({
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without
|
||||
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or
|
||||
// zoom change on macOS): xterm keeps slicing the old coordinates and every
|
||||
// cell shows a clipped or foreign glyph while the buffer stays correct.
|
||||
// Rebuilding the atlas when the page comes back is cheap and redraws clean.
|
||||
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
|
||||
// with the same font. When it fills up it merges four pages into one
|
||||
// larger page and shifts the rest down, and a GPU texture is re-uploaded
|
||||
// only when its page's `version` differs from the uploaded one — after a
|
||||
// shift those per-page counters can coincide, so a slot keeps a stale
|
||||
// page of another size and every glyph is drawn squeezed or foreign
|
||||
// while the buffer stays correct (fixed upstream only in 0.20 betas).
|
||||
// Any change to the page list therefore restamps every page's version
|
||||
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
|
||||
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
|
||||
// but redraws only this pane, leaving its siblings slicing reused space.
|
||||
// An addon of another shape falls back to toggling the family's
|
||||
// trailing space on the next frame, which makes each renderer re-acquire
|
||||
// its atlas (as onFontLoaded does).
|
||||
let atlasFrame = 0;
|
||||
const rebuildAtlas = () => {
|
||||
if (gpu && restampAtlas(gpu)) return true;
|
||||
if (atlasFrame) return false;
|
||||
// Never inside the render that is rasterizing the merging glyph.
|
||||
atlasFrame = requestAnimationFrame(() => {
|
||||
atlasFrame = 0;
|
||||
const family = term.options.fontFamily ?? "";
|
||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||
});
|
||||
return false;
|
||||
};
|
||||
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
|
||||
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
|
||||
// The browser may also drop the textures without a context-loss event
|
||||
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
|
||||
// back, and repaint, since nothing else asks this pane to draw.
|
||||
const onPageShown = () => {
|
||||
if (document.visibilityState === "visible") term.clearTextureAtlas();
|
||||
if (document.visibilityState === "visible" && rebuildAtlas()) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onPageShown);
|
||||
window.addEventListener("focus", onPageShown);
|
||||
@@ -790,6 +848,9 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
if (atlasFrame) cancelAnimationFrame(atlasFrame);
|
||||
atlasMerged?.dispose();
|
||||
atlasGrew?.dispose();
|
||||
document.removeEventListener("visibilitychange", onPageShown);
|
||||
window.removeEventListener("focus", onPageShown);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
|
||||
@@ -63,6 +63,9 @@ const termState = {
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const refreshMock = vi.fn();
|
||||
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||||
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
dispose: disposeMock,
|
||||
refresh: refreshMock,
|
||||
loadAddon: loadAddonMock,
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||
const gpu: {
|
||||
last: {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
lose: () => void;
|
||||
merge: () => void;
|
||||
grow: () => void;
|
||||
} | null;
|
||||
} = {
|
||||
last: null,
|
||||
};
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn().mockImplementation(() => {
|
||||
let onLoss = () => {};
|
||||
let onMerge = () => {};
|
||||
let onGrow = () => {};
|
||||
const addon = {
|
||||
__webgl: true,
|
||||
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||||
dispose: vi.fn(),
|
||||
onContextLoss: (fn: () => void) => {
|
||||
onLoss = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||||
onMerge = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||||
onGrow = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
};
|
||||
gpu.last = {
|
||||
dispose: addon.dispose,
|
||||
lose: () => onLoss(),
|
||||
merge: () => onMerge(),
|
||||
grow: () => onGrow(),
|
||||
};
|
||||
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||
return addon;
|
||||
}),
|
||||
}));
|
||||
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
|
||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||
});
|
||||
|
||||
it("restamps every atlas page at once when the page list changes", () => {
|
||||
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||||
// pages a texture slot can keep a stale page whose counter matches. Every
|
||||
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||||
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||||
nextAtlas.pages = pages;
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
gpu.last!.merge();
|
||||
const first = pages.map((p) => p.version);
|
||||
expect(new Set(first).size).toBe(3);
|
||||
expect(first.every((v) => v > 7)).toBe(true);
|
||||
gpu.last!.grow();
|
||||
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||||
true
|
||||
);
|
||||
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||||
// The page coming back re-uploads and repaints this pane.
|
||||
refreshMock.mockClear();
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||||
} finally {
|
||||
nextAtlas.pages = null;
|
||||
raf.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||||
// An addon whose atlas this cannot reach: any option change makes each
|
||||
// renderer re-acquire its atlas and re-upload every page.
|
||||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const opts = termOptions as { fontFamily?: string };
|
||||
opts.fontFamily = "JetBrains Mono";
|
||||
gpu.last!.merge();
|
||||
gpu.last!.merge(); // coalesced into one frame
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||||
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||||
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||||
raf.mockRestore();
|
||||
});
|
||||
|
||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* account — and, for an admin, whether they own the host's Linux account, so
|
||||
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), its spend quota (the
|
||||
@@ -87,6 +88,7 @@ export function UserEditDialog({
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
@@ -139,6 +141,7 @@ export function UserEditDialog({
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
@@ -364,6 +367,17 @@ export function UserEditDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{role === "admin" && (
|
||||
<div className="mt-3">
|
||||
<Checkbox
|
||||
checked={hostAccount}
|
||||
onChange={setHostAccount}
|
||||
label={t("edit.hostAccount")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||
"changes.lineElided": "… {{size}} more characters on this line",
|
||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
@@ -531,9 +532,9 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"changes.discardTicked": "Discard ({{count}})",
|
||||
"changes.discardTickedTitle": "Discard the ticked changes?",
|
||||
"changes.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||
"hostAccount": "Owns this server's host account",
|
||||
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
|
||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
@@ -531,9 +532,9 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"changes.discardTicked": "Bỏ ({{count}})",
|
||||
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
|
||||
"changes.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"status": "Trạng thái",
|
||||
"active": "Đang hoạt động",
|
||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||
"hostAccount": "Sở hữu tài khoản host của máy chủ",
|
||||
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
|
||||
@@ -800,6 +800,8 @@ export type UserPatch = {
|
||||
addWorkspaces?: number[];
|
||||
removeWorkspaces?: number[];
|
||||
signOut?: true;
|
||||
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
|
||||
hostAccount?: boolean;
|
||||
/** Their own spend quota (`default` = the default applies). */
|
||||
quota?: UserQuotaSetting;
|
||||
};
|
||||
@@ -983,6 +985,8 @@ export type AuthUser = {
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
|
||||
hostAccount?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
|
||||
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
|
||||
/** Line number on the right (after) side; null on a removed line. */
|
||||
newLine: number | null;
|
||||
text: string;
|
||||
/** Characters cut off the end of a very long row (a base64 blob, a minified
|
||||
* bundle): `text` holds only its start. Absent on a whole row. */
|
||||
elided?: number;
|
||||
}
|
||||
|
||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.40 (rebuild client to match)
|
||||
UI build v2.8.44 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+6
-1
@@ -817,6 +817,11 @@ that file.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- A row longer than **4,000** characters (an inlined base64 image or video, a
|
||||
minified bundle) comes back cut to its first 1,000 with `elided: <characters
|
||||
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
|
||||
the hunks after it. Each file's raw patch is read up to 16 M characters; past
|
||||
that it is still `truncated`.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
@@ -1058,7 +1063,7 @@ cannot collide on `index.lock`.
|
||||
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||
|
||||
+11
-1
@@ -732,7 +732,17 @@ An admin limits what each user may spend, in dollars, on the Users page
|
||||
every account change; each one re-applies at once (`sweep`).
|
||||
|
||||
Spend is the users page's own figure: the tokens of the sessions the user
|
||||
OWNS through a workspace, priced by `calculateCost`. The Users table shows
|
||||
OWNS, priced by `calculateCost`. A session is owned through its workspace;
|
||||
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
|
||||
plain terminal — is owned by the user of the Linux account it ran as
|
||||
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
|
||||
else the session's folder): a member's account bills to that member, the
|
||||
host's to the admin ticked **Owns this server's host account** in Edit user
|
||||
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
|
||||
ticked to the only admin. The same rule feeds the status bar's **Today**.
|
||||
A process that drops the pane's environment also drops
|
||||
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
|
||||
spend still counts, and stops the user's next session. The Users table shows
|
||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||
in the status bar (`GET /api/me/quota`).
|
||||
|
||||
|
||||
+7
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.30
|
||||
version: 2.8.40
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -15410,6 +15410,9 @@ paths:
|
||||
type: boolean
|
||||
enum:
|
||||
- true
|
||||
hostAccount:
|
||||
type: boolean
|
||||
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
|
||||
quota:
|
||||
$ref: '#/components/schemas/UserQuotaSetting'
|
||||
currentPassword:
|
||||
@@ -19932,6 +19935,9 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
description: Files the switch wrote.
|
||||
following:
|
||||
type: boolean
|
||||
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
|
||||
'400':
|
||||
description: '`EBADNAME` / `EBADLABEL`.'
|
||||
content:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.40",
|
||||
"version": "2.8.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.40",
|
||||
"version": "2.8.44",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.40",
|
||||
"version": "2.8.44",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
|
||||
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
|
||||
|
||||
it("cuts the long rows down and still shows the hunks after them", async () => {
|
||||
const body = (mid, end) =>
|
||||
[
|
||||
"<h1>title</h1>",
|
||||
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
||||
mid,
|
||||
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
|
||||
end,
|
||||
"",
|
||||
].join("\n");
|
||||
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "deck");
|
||||
write(
|
||||
LANE,
|
||||
"deck.html",
|
||||
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
|
||||
"<h1>title</h1>",
|
||||
"<h1>new title</h1>"
|
||||
)
|
||||
);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
|
||||
const f = out.files[0];
|
||||
assert.equal(f.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
const lines = f.hunks.flatMap((h) => h.lines);
|
||||
const long = lines.filter((l) => l.elided);
|
||||
assert.equal(long.length, 2);
|
||||
for (const l of long) {
|
||||
assert.ok(l.text.length < 2000);
|
||||
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
|
||||
}
|
||||
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
|
||||
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
|
||||
});
|
||||
|
||||
it("leaves ordinary rows whole", async () => {
|
||||
write(LANE, "a.txt", "one\nTWO\n");
|
||||
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
|
||||
const lines = out.files[0].hunks.flatMap((h) => h.lines);
|
||||
assert.ok(lines.every((l) => l.elided === undefined));
|
||||
assert.ok(lines.some((l) => l.text === "TWO"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||
|
||||
|
||||
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still returns content when a huge file has no newline to cut on", async () => {
|
||||
// A minified bundle is ONE line. Cutting the patch at the last newline
|
||||
// inside the cap would land at index -1 here and hand back an empty
|
||||
// patch — a file changed by a megabyte rendering as "no textual change".
|
||||
it("still returns content when a huge file is one line", async () => {
|
||||
// A minified bundle is ONE line. It used to be cut at the per-file cap
|
||||
// (and, cut at the last newline, could come back empty — a file changed
|
||||
// by a megabyte rendering as "no textual change"); now the row itself is
|
||||
// cut down and says how much it hides, and nothing is truncated.
|
||||
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
||||
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
||||
const entry = out.files[0];
|
||||
assert.equal(entry.truncated, true);
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
|
||||
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
|
||||
assert.equal(entry.truncated, undefined);
|
||||
assert.equal(out.truncated, false);
|
||||
assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
|
||||
const row = entry.hunks[0].lines.find((l) => l.type === "add");
|
||||
assert.ok(row.text.startsWith("xxx"));
|
||||
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
|
||||
});
|
||||
|
||||
it("reports a binary file without trying to diff it", async () => {
|
||||
@@ -848,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
||||
});
|
||||
|
||||
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
|
||||
const edited = threeBlocks();
|
||||
write(LANE, "keep.txt", "untouched by discard\n");
|
||||
write(LANE, "drop.txt", "gone\n");
|
||||
const [hunk] = await hunkOf();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runGitAction(LANE, "discard", {
|
||||
paths: ["drop.txt"],
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||
}),
|
||||
{ code: "ECONFIRM" }
|
||||
);
|
||||
await runGitAction(LANE, "discard", {
|
||||
paths: ["drop.txt"],
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||
confirm: true,
|
||||
});
|
||||
const want = [...edited];
|
||||
want.splice(11, 0, "line 11");
|
||||
want.splice(3, 2, "line 4", "line 5");
|
||||
assert.deepEqual(read(), [...want, ""]);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
|
||||
});
|
||||
|
||||
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
|
||||
const edited = threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runGitAction(LANE, "discard", {
|
||||
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
|
||||
confirm: true,
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
assert.deepEqual(read(), [...edited, ""]);
|
||||
await runGitAction(LANE, "discard", {
|
||||
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
|
||||
confirm: true,
|
||||
});
|
||||
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
|
||||
});
|
||||
|
||||
it("refuses a block the hunk does not have", async () => {
|
||||
threeBlocks();
|
||||
const [hunk] = await hunkOf();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file session-account.test.js
|
||||
* @description A session no workspace owns is billed to the user of the Linux
|
||||
* account it ran as (server/lib/session-account.js): a member's account to
|
||||
* that member, the host's to the admin marked as its owner (or the only
|
||||
* admin). A workspace's ownership always wins, a pushed session never gets an
|
||||
* account, and the hook peer is stamped once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "session-account-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const sa = require("../lib/session-account");
|
||||
const scope = require("../lib/scope");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
|
||||
const own = (id) => {
|
||||
const w = scope.ownSessionWhere({ user: { id } });
|
||||
return db
|
||||
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
||||
.all(...w.params)
|
||||
.map((r) => r.id);
|
||||
};
|
||||
|
||||
before(() => {
|
||||
const user = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
user.run(1, "boss", "admin");
|
||||
user.run(2, "dev", "developer");
|
||||
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
||||
);
|
||||
s.run("host-free", "eval", null, "", "local");
|
||||
s.run("dev-free", "eval", null, "dev", "local");
|
||||
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
||||
s.run("unknown", "old", null, null, "local");
|
||||
s.run("gone-member", "x", null, "ghost", "local");
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("session-account", () => {
|
||||
it("bills a member's account to that member and the host's to the only admin", () => {
|
||||
assert.equal(sa.hostOwner(), 1);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
||||
});
|
||||
|
||||
it("lets the workspace's owner win over the account", () => {
|
||||
assert.ok(!own(1).includes("host-in-dev-lane"));
|
||||
});
|
||||
|
||||
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
||||
).run();
|
||||
assert.equal(sa.hostOwner(), null);
|
||||
assert.deepEqual(own(1), []);
|
||||
sa.setHostOwner(3);
|
||||
assert.equal(sa.hostOwner(), 3);
|
||||
assert.deepEqual(own(3), ["host-free"]);
|
||||
sa.setHostOwner(1);
|
||||
assert.deepEqual(own(3), []);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
});
|
||||
|
||||
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
||||
const rows = db.prepare(OWNED_SESSIONS).all();
|
||||
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
||||
assert.equal(by["host-free"], 1);
|
||||
assert.equal(by["dev-free"], 2);
|
||||
assert.equal(by["host-in-dev-lane"], 2);
|
||||
assert.equal(by.unknown, undefined);
|
||||
assert.equal(by["gone-member"], undefined);
|
||||
});
|
||||
|
||||
it("stamps the hook peer once and never a pushed session", () => {
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
||||
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
||||
sa.stampFromPeer("fresh", { kind: "host" });
|
||||
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
||||
sa.stampFromPeer("pushed", { kind: "refused" });
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
assert.equal(acct("fresh"), "dev");
|
||||
assert.equal(acct("pushed"), null);
|
||||
});
|
||||
|
||||
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
||||
).run();
|
||||
sa.stampFromFolders();
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
// Outside S3 every folder is the host's.
|
||||
assert.equal(acct("unknown"), "");
|
||||
assert.equal(acct("pushed"), "");
|
||||
assert.equal(acct("remote"), null);
|
||||
});
|
||||
});
|
||||
@@ -1445,6 +1445,16 @@ try {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add `account` to sessions — the Linux account a session ran as
|
||||
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
|
||||
// NULL = not known yet. A session no workspace owns is billed to the user of
|
||||
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
|
||||
try {
|
||||
db.prepare("SELECT account FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
|
||||
}
|
||||
|
||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||
@@ -1952,6 +1962,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
// The admin who owns the HOST's Linux account: its sessions outside every
|
||||
// workspace are billed to them (server/lib/session-account.js). At most one.
|
||||
["host_account", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
|
||||
+4
-1
@@ -873,7 +873,9 @@ function startBackgroundServices() {
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop. Off with
|
||||
// flag files their hooks read, the panes no hook can stop — after giving
|
||||
// the sessions no hook made their Linux account (session-account.js), so a
|
||||
// poller's session is billed to someone too. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
@@ -883,6 +885,7 @@ function startBackgroundServices() {
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/session-account").stampFromFolders();
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
|
||||
+7
-1
@@ -213,6 +213,9 @@ const publicUser = (u) =>
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
// Owns the host's Linux account: its sessions outside every workspace
|
||||
// are billed to this admin (server/lib/session-account.js).
|
||||
hostAccount: Boolean(u.host_account),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -415,7 +418,10 @@ function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
// Only an admin owns the host account (session-account.js): a demotion drops the mark.
|
||||
sql(
|
||||
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
|
||||
).run(role, role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
|
||||
+60
-8
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
|
||||
const MAX_FILES = 300;
|
||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||
const MAX_FILE_BYTES = 200_000;
|
||||
/** Characters of one file's raw patch read before its long lines are cut down
|
||||
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
|
||||
* or a font as base64 is a few lines of megabytes each: read at
|
||||
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
|
||||
* after it was lost. Past this the patch is still truncated. */
|
||||
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
|
||||
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
|
||||
* characters, with how many it hides (`elided` on the parsed line). */
|
||||
const LONG_LINE_CHARS = 4000;
|
||||
const LONG_LINE_KEEP = 1000;
|
||||
/** Ends a cut-down row in the raw patch: a private-use character, then the
|
||||
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
|
||||
const ELIDED_MARK = "\uE000";
|
||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||
const MAX_TOTAL_BYTES = 1_000_000;
|
||||
/** An untracked file larger than this is reported without content. */
|
||||
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
|
||||
// row of its own and carries no line number.
|
||||
if (raw.startsWith("\\")) continue;
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
let text = raw.slice(1);
|
||||
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
|
||||
let elided = 0;
|
||||
const cut = text.lastIndexOf(ELIDED_MARK);
|
||||
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
|
||||
elided = Number(text.slice(cut + 1));
|
||||
text = text.slice(0, cut);
|
||||
}
|
||||
const extra = elided ? { elided } : {};
|
||||
if (marker === "+") {
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
|
||||
} else if (marker === "-") {
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
|
||||
} else if (marker === " ") {
|
||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||
// string is the tail of the final "\n", not a row.
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
|
||||
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
|
||||
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
|
||||
* re-read git's own diff and match hunks by header, which this never changes.
|
||||
*/
|
||||
function elideLongLines(patch) {
|
||||
if (patch.length <= LONG_LINE_CHARS) return patch;
|
||||
return patch
|
||||
.split("\n")
|
||||
.map((row) =>
|
||||
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
|
||||
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
|
||||
: row
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,7 +689,15 @@ async function collectChanges(
|
||||
untracked: entry.untracked,
|
||||
context: ctx,
|
||||
});
|
||||
// A read that hit its own ceiling came back partial: still cut short,
|
||||
// however small eliding its long rows makes it.
|
||||
const partial = patch.length >= MAX_PATCH_READ_CHARS;
|
||||
patch = elideLongLines(patch);
|
||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||
if (partial) {
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
if (patch.length > cap) {
|
||||
// Cut on a line boundary so the last row is a whole line — but only
|
||||
// when that boundary is near the cap. A minified file is one enormous
|
||||
@@ -1192,16 +1241,18 @@ async function diffHunks(dir, file, context) {
|
||||
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
||||
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
||||
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
||||
* git wrote it — `git apply` locates a hunk by its old side.
|
||||
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
|
||||
* it for `apply -R` off the working tree instead (the partial discard, #87),
|
||||
* where the NEW side is the one checked and every start stays git's own.
|
||||
*/
|
||||
async function selectionPatch(dir, file, context, picks) {
|
||||
async function selectionPatch(dir, file, context, picks, reverse = false) {
|
||||
const diff = await diffHunks(dir, file, context);
|
||||
if (!diff || !picks.length) return null;
|
||||
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
||||
const bodies = [];
|
||||
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
|
||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
|
||||
if (!body) return null;
|
||||
bodies.push(body);
|
||||
}
|
||||
@@ -1334,6 +1385,7 @@ async function plainTreeAll(dir) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
elideLongLines,
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
|
||||
+56
-14
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
|
||||
|
||||
if (action === "discard") {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
// Ticked change blocks of partly ticked files (#87) go first: each patch
|
||||
// is built before any is applied, so a stale one refuses before the
|
||||
// irreversible whole-file part below has run.
|
||||
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
|
||||
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
|
||||
return done(action, partial);
|
||||
}
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const tracked = paths
|
||||
.filter((p) => p.code !== "??")
|
||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
const out = [];
|
||||
const out = partial ? [partial] : [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
// one state rather than half-reverted.
|
||||
@@ -825,18 +832,11 @@ function hasHunks(body) {
|
||||
const MAX_PARTIAL_FILES = 300;
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
* `body.hunks` checked for shape, and its files resolved as changed paths
|
||||
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
|
||||
* conflict, and not also named whole in `body.paths`.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
async function partialPicks(dir, body, verb) {
|
||||
const picks = Object.entries(body.hunks);
|
||||
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
||||
for (const [, list] of picks) {
|
||||
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
|
||||
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||
for (const p of part) {
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
|
||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
|
||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
|
||||
if (whole.some((w) => w.path === p.path)) {
|
||||
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
||||
}
|
||||
}
|
||||
return { picks, whole, part };
|
||||
}
|
||||
|
||||
/**
|
||||
* The partial discard (#87): per file in `hunks`, only the ticked change
|
||||
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
|
||||
* own fresh text, never the request's — exactly what one `discard-hunk` per
|
||||
* block would do, in one step. Every patch is built before the first is
|
||||
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
|
||||
*/
|
||||
async function discardSelection(dir, body) {
|
||||
const { picks } = await partialPicks(dir, body, "discard");
|
||||
const patches = [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list, true);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
patches.push(patch);
|
||||
}
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
const out = [];
|
||||
for (const patch of patches)
|
||||
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
|
||||
return mergeResults(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||
* from it, so the real index and the working tree are never in the way; after
|
||||
* the commit the real index entries of exactly those paths are reset to the
|
||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||
*/
|
||||
async function commitSelection(dir, args, body) {
|
||||
const { picks, whole, part } = await partialPicks(dir, body, "commit");
|
||||
const wholePaths = whole.flatMap((p) =>
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
|
||||
+12
-6
@@ -5,7 +5,9 @@
|
||||
*
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
@@ -17,6 +19,7 @@
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { ACCOUNT_OWNED } = require("./session-account");
|
||||
|
||||
/** The user whose rows this request is limited to, or null for "all". */
|
||||
function memberId(req) {
|
||||
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
||||
return {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
|
||||
params: [uid, uid],
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
|
||||
|
||||
const STAFF = { staffOnly: true };
|
||||
|
||||
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
|
||||
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||||
function sessionOwners(sessionId) {
|
||||
if (!sessionId) return new Set();
|
||||
const rows = sql(
|
||||
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
||||
UNION
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
|
||||
).all(String(sessionId), String(sessionId));
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||
UNION
|
||||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file session-account.js
|
||||
* @description Which Linux account a session ran as (`sessions.account`: ""
|
||||
* = the host's, else an S3 member's name), and whose bill a session no
|
||||
* workspace owns goes on. A workspace owns a session through `lane_id` or a
|
||||
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
|
||||
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
|
||||
* neither, and without this its spend counted for nobody: not in anyone's
|
||||
* Today, the Users page or a spend quota.
|
||||
*
|
||||
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
|
||||
* loopback connection, never the body) when a hook creates the session; a
|
||||
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
|
||||
* gets it from its folder, the S3 rule `accountForPath` already applies to
|
||||
* every pane. A pushed session (another machine's) never gets one. A member
|
||||
* account bills to the user of that name; the host account to the admin an
|
||||
* admin marked as its owner (`users.host_account`), or — with no mark — the
|
||||
* only enabled admin, if there is exactly one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
|
||||
const HOST_OWNER = `COALESCE(
|
||||
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
|
||||
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||
|
||||
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||
// a run of one — exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||
|
||||
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
|
||||
const ACCOUNT_OWNED = `
|
||||
SELECT s.id AS session_id, u.id AS uid
|
||||
FROM sessions s JOIN users u
|
||||
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
|
||||
ELSE (SELECT id FROM users WHERE username = s.account) END
|
||||
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
|
||||
|
||||
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
|
||||
function accountOfPeer(peer) {
|
||||
if (!peer || peer.kind === "host") return "";
|
||||
if (peer.kind === "member" && peer.name) return peer.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
|
||||
function stampFromPeer(sessionId, peer, pushSource = null) {
|
||||
if (pushSource || !sessionId) return;
|
||||
const account = accountOfPeer(peer);
|
||||
if (account == null) return;
|
||||
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
|
||||
}
|
||||
|
||||
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
|
||||
function accountOfPath(cwd) {
|
||||
const linux = require("./linux-accounts");
|
||||
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||
const root = conf?.usersRoot;
|
||||
const path = require("path");
|
||||
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
|
||||
const rel = path.relative(root, path.resolve(cwd));
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
|
||||
// The users root itself is nobody's.
|
||||
return rel.split(path.sep)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every local session still without an account the one its folder runs
|
||||
* as (sessions the pollers made, and history from before the column). Cheap
|
||||
* once caught up: only `account IS NULL` rows are read.
|
||||
*/
|
||||
function stampFromFolders() {
|
||||
const rows = sql(
|
||||
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
|
||||
).all();
|
||||
if (!rows.length) return 0;
|
||||
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
|
||||
let n = 0;
|
||||
require("../db").db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const account = accountOfPath(r.cwd);
|
||||
if (account != null) n += set.run(account, r.id).changes;
|
||||
}
|
||||
})();
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
|
||||
function setHostOwner(userId) {
|
||||
require("../db").db.transaction(() => {
|
||||
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
|
||||
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** The user id the host account bills to right now, or null. */
|
||||
function hostOwner() {
|
||||
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_OWNED,
|
||||
accountOfPeer,
|
||||
stampFromPeer,
|
||||
accountOfPath,
|
||||
stampFromFolders,
|
||||
setHostOwner,
|
||||
hostOwner,
|
||||
};
|
||||
@@ -790,6 +790,11 @@ const paths = {
|
||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
signOut: { type: "boolean", enum: [true] },
|
||||
hostAccount: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
|
||||
},
|
||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
}),
|
||||
responses: {
|
||||
|
||||
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
} else {
|
||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+22
-3
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
// Which account each session belongs to — through its workspace, a dashboard
|
||||
// run on one, or (no workspace owns it) the Linux account it ran as
|
||||
// (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
SELECT s.id AS session_id, l.owner_id AS uid
|
||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
|
||||
UNION
|
||||
SELECT s.id, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
${require("../lib/session-account").ACCOUNT_OWNED}`;
|
||||
|
||||
/**
|
||||
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||
@@ -580,6 +583,18 @@ router.patch(
|
||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||
}
|
||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||
if (body.hostAccount !== undefined) {
|
||||
if (typeof body.hostAccount !== "boolean") {
|
||||
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
|
||||
}
|
||||
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
|
||||
throw Object.assign(new Error("only an admin can own the host account"), {
|
||||
code: "EBADROLE",
|
||||
});
|
||||
}
|
||||
}
|
||||
const hostChange =
|
||||
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
|
||||
if (body.password !== undefined) {
|
||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||
if (self) {
|
||||
@@ -619,6 +634,10 @@ router.patch(
|
||||
auth.setRole(u.username, body.role);
|
||||
done.push(`role ${body.role}`);
|
||||
}
|
||||
if (hostChange) {
|
||||
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
|
||||
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
|
||||
}
|
||||
for (const id of add) auth.assignWorkspace(id, u.username);
|
||||
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
||||
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
||||
|
||||
Reference in New Issue
Block a user