feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -129,6 +129,23 @@ describe("/api/lanes", () => {
|
||||
assert.equal(typeof r.body.counts.needs_you, "number");
|
||||
});
|
||||
|
||||
it("serves the same routes under /api/workspaces, the UI's word", async () => {
|
||||
const lanes = await request("GET", "/api/lanes");
|
||||
const list = await request("GET", "/api/workspaces");
|
||||
assert.equal(list.status, 200);
|
||||
assert.deepEqual(
|
||||
list.body.lanes.map((l) => l.id),
|
||||
lanes.body.lanes.map((l) => l.id)
|
||||
);
|
||||
const one = await request("GET", `/api/workspaces/${laneId}`);
|
||||
assert.equal(one.status, 200);
|
||||
assert.equal(one.body.lane.id, laneId);
|
||||
// Mounted through the sources router, which sits before the lanes router.
|
||||
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
|
||||
assert.equal(sources.status, 200);
|
||||
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
|
||||
});
|
||||
|
||||
it("exposes pipeline templates", async () => {
|
||||
const r = await request("GET", "/api/lanes/pipelines");
|
||||
assert.equal(r.status, 200);
|
||||
@@ -220,6 +237,42 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
|
||||
const nd = require("../lib/native-dialog");
|
||||
const desktop = require("../lib/desktop-env");
|
||||
const calls = [];
|
||||
nd.__setExecImpl(async (cmd) => {
|
||||
calls.push(cmd);
|
||||
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
|
||||
});
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
try {
|
||||
for (const headers of [
|
||||
{ Origin: "https://attacker.example" },
|
||||
{ "X-Forwarded-For": "203.0.113.9" },
|
||||
{ Forwarded: "for=203.0.113.9" },
|
||||
]) {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
if (process.platform === "linux") {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.path, "/tmp/picked");
|
||||
nd.__setExecImpl(async () => ({ missing: true }));
|
||||
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
|
||||
assert.equal(none.status, 501);
|
||||
assert.equal(none.body.error.code, "ENODIALOG");
|
||||
}
|
||||
} finally {
|
||||
nd.__setExecImpl(null);
|
||||
desktop.__setExecImpl(() => {
|
||||
throw new Error("no systemd user manager in this test");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on create", async () => {
|
||||
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -425,8 +478,13 @@ describe("lane start input", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// Nothing after `--`: tmux runs the user's own default shell.
|
||||
assert.equal(created[created.length - 1], "--");
|
||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
||||
// default shell (issue #55) — nothing from the request.
|
||||
const argv = created.slice(created.indexOf("--") + 1);
|
||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
||||
assert.equal(argv.length, 3);
|
||||
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
||||
assert.ok(!created.includes("ignored"));
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.run_id, null);
|
||||
assert.notEqual(lane.status, "running");
|
||||
@@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
|
||||
});
|
||||
|
||||
it("answers ERUNLIVE when every slot is busy", async () => {
|
||||
live.add(`agenthub-lane-${id}-3`);
|
||||
it("answers ERUNLIVE when all ten slots are busy", async () => {
|
||||
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
|
||||
// Slot 9 is the tenth and last.
|
||||
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
|
||||
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
|
||||
live.add(`agenthub-lane-${id}-9`);
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ERUNLIVE");
|
||||
live.delete(`agenthub-lane-${id}-3`);
|
||||
live.delete(`agenthub-lane-${id}-2`);
|
||||
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
|
||||
});
|
||||
|
||||
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
|
||||
|
||||
Reference in New Issue
Block a user