chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump: - Clone: a queue with live progress (Add workspace and extra folders), clones run under nice/ionice so the machine stays responsive. - Settings → AI tools → Session defaults: a new session's default permission mode / bypass per account. - Chat lists the background subagents that have not reported back yet. - A file pasted or dropped into a claude pane goes in as an @ mention. - Changes panel: say why amend/undo is refused on a pushed commit. - openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
@@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [
|
||||
"/api/webhooks/{id}",
|
||||
"/api/webhooks/{id}/test",
|
||||
"/api/webhooks/{id}/deliveries",
|
||||
"/api/artifacts",
|
||||
"/api/artifacts/{id}/install",
|
||||
"/api/host",
|
||||
"/api/openapi.json",
|
||||
"/api/docs",
|
||||
"/api/redoc",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @file Tests for `server/lib/clone-queue.js`: no more clones run at once
|
||||
* than the limit, the rest start in arrival order as slots free, a failing
|
||||
* clone frees its slot too, the per-clone thread share, and a tracked
|
||||
* clone's progress: its queue position, git's --progress lines parsed, and
|
||||
* shown only to its owner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const os = require("node:os");
|
||||
|
||||
const queue = require("../lib/clone-queue");
|
||||
|
||||
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
|
||||
|
||||
function gate() {
|
||||
let open;
|
||||
const p = new Promise((r) => (open = r));
|
||||
return { p, open };
|
||||
}
|
||||
|
||||
describe("clone-queue", () => {
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
|
||||
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
|
||||
});
|
||||
|
||||
it("runs at most the limit at once, the rest in arrival order", async () => {
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = "2";
|
||||
const started = [];
|
||||
const gates = [gate(), gate(), gate(), gate()];
|
||||
const runs = gates.map((g, i) =>
|
||||
queue.withCloneSlot(async () => {
|
||||
started.push(i);
|
||||
await g.p;
|
||||
return i;
|
||||
})
|
||||
);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(started, [0, 1]);
|
||||
assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 });
|
||||
gates[1].open();
|
||||
await runs[1];
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(started, [0, 1, 2]);
|
||||
gates[0].open();
|
||||
gates[2].open();
|
||||
gates[3].open();
|
||||
assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]);
|
||||
assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 });
|
||||
});
|
||||
|
||||
it("frees the slot when a clone fails", async () => {
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
||||
await assert.rejects(
|
||||
queue.withCloneSlot(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
/boom/
|
||||
);
|
||||
assert.equal(await queue.withCloneSlot(async () => "ok"), "ok");
|
||||
assert.equal(queue.cloneQueueState().running, 0);
|
||||
});
|
||||
|
||||
it("splits the cores between the running clones", () => {
|
||||
const cores = os.availableParallelism?.() ?? os.cpus().length;
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
||||
assert.equal(queue.threads(), Math.max(1, cores));
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4);
|
||||
assert.equal(queue.threads(), 1);
|
||||
delete process.env.AGENTHUB_CLONE_CONCURRENCY;
|
||||
assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2)));
|
||||
});
|
||||
|
||||
it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => {
|
||||
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
|
||||
const first = gate();
|
||||
const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7);
|
||||
const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7);
|
||||
const ra = queue.withCloneSlot(() => first.p, a);
|
||||
let release;
|
||||
const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running");
|
||||
const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7);
|
||||
assert.equal(waitingView.state, "queued");
|
||||
assert.equal(waitingView.position, 1);
|
||||
assert.equal(waitingView.running, 1);
|
||||
// Another user — or nobody — never sees it.
|
||||
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null);
|
||||
assert.equal(queue.progress("tok-cccccccccccccccc", 7), null);
|
||||
|
||||
queue.report(
|
||||
a,
|
||||
"Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" +
|
||||
"Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s"
|
||||
);
|
||||
const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7);
|
||||
assert.equal(v.phase, "receiving");
|
||||
assert.equal(v.percent, 45);
|
||||
assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s");
|
||||
queue.report(a, "Resolving deltas: 100% (300/300), done.\n");
|
||||
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving");
|
||||
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300");
|
||||
|
||||
first.open();
|
||||
await ra;
|
||||
queue.finish(a, true);
|
||||
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done");
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running");
|
||||
release();
|
||||
await rb;
|
||||
queue.finish(b, false);
|
||||
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed");
|
||||
});
|
||||
|
||||
it("refuses a malformed or reused token, and keeps progress out of an error", () => {
|
||||
assert.throws(() => queue.track("short", null), { code: "EBADFIELD" });
|
||||
assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" });
|
||||
queue.track("tok-dddddddddddddddd", null);
|
||||
assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" });
|
||||
assert.equal(
|
||||
queue.stripProgress(
|
||||
"Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n"
|
||||
),
|
||||
"Cloning into 'r'...\nfatal: early EOF"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,8 @@ before(async () => {
|
||||
"init",
|
||||
]);
|
||||
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
|
||||
// A forge serves a partial clone only when it allows filters.
|
||||
execFileSync("git", ["-C", bare, "config", "uploadpack.allowFilter", "true"]);
|
||||
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
@@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => {
|
||||
assert.equal(r.body.error.code, "ENOREPO");
|
||||
});
|
||||
|
||||
it("makes a blobless clone only when asked, and refuses a non-boolean", async () => {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "r-partial",
|
||||
partial: true,
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const dir = path.join(WORK, "r-partial");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hi\n");
|
||||
const cfg = (k) =>
|
||||
execFileSync("git", ["-C", dir, "config", "--get", k], { encoding: "utf8" }).trim();
|
||||
assert.equal(cfg("remote.origin.partialclonefilter"), "blob:none");
|
||||
// The full clone above is not a promisor remote.
|
||||
assert.throws(() =>
|
||||
execFileSync("git", ["-C", path.join(WORK, "r"), "config", "--get", "remote.origin.promisor"])
|
||||
);
|
||||
const bad = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "r-bad",
|
||||
partial: "yes",
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
});
|
||||
|
||||
it("reports a clone's progress by its token, and refuses a bad token", async () => {
|
||||
const token = "progress-test-0000001";
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "r-progress",
|
||||
progressToken: token,
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const p = await request("GET", `/api/lanes/clone/progress/${token}`);
|
||||
assert.equal(p.status, 200);
|
||||
assert.equal(p.body.state, "done");
|
||||
assert.equal(p.body.position, null);
|
||||
assert.equal(
|
||||
(await request("GET", "/api/lanes/clone/progress/nope-nope-nope-nope")).status,
|
||||
404
|
||||
);
|
||||
const bad = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "r-badtoken",
|
||||
progressToken: "x",
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADFIELD");
|
||||
assert.ok(!fs.existsSync(path.join(WORK, "r-badtoken")));
|
||||
});
|
||||
|
||||
it("refuses a cross-origin request", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
@@ -649,6 +706,8 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
|
||||
);
|
||||
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
|
||||
// index-pack gets a share of the cores, never persisted into the clone.
|
||||
assert.match(args.slice(0, i).join(" "), /-c pack\.threads=\d+/);
|
||||
});
|
||||
|
||||
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
|
||||
|
||||
Reference in New Issue
Block a user