chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend
- Plugin update reports its running step (plugin-update-progress.json → plugin_update_progress); a hidden update or clone leaves a progress chip. - Clone here can run in the background. - Git identity: extra SSH keys are added inline per host (no More keys tab). - Users: spend today (viewer's local day, ?tz_offset=). - Prettier on the files touched since v2.8.20.
This commit is contained in:
@@ -19,7 +19,12 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
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");
|
||||
|
||||
@@ -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", () => {
|
||||
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
|
||||
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
|
||||
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", () => {
|
||||
// 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.
|
||||
|
||||
@@ -78,6 +78,10 @@ describe("staff views", () => {
|
||||
assert.equal(mia.workspaces, 1);
|
||||
assert.equal(mia.sessions, 1);
|
||||
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.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"));
|
||||
|
||||
@@ -598,10 +598,9 @@ async function captureOutputsNow(laneId, 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
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,9 @@ const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
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
|
||||
* cache on purpose — that directory is replaced by the update itself. */
|
||||
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
|
||||
* already written its HTTP response, because this process is about to be
|
||||
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
||||
try {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
||||
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
|
||||
} catch {
|
||||
/* 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 };
|
||||
}
|
||||
|
||||
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
|
||||
module.exports = {
|
||||
schedulePluginUpdate,
|
||||
readLastPluginUpdate,
|
||||
readPluginUpdateProgress,
|
||||
runtimeDir,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFile } = require("child_process");
|
||||
const { readLastPluginUpdate } = require("./plugin-update");
|
||||
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
|
||||
if (!err) return resolve(String(stdout).trim());
|
||||
if (err.killed) {
|
||||
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}`));
|
||||
}
|
||||
);
|
||||
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
plugin_marketplace: pluginInstall.marketplace,
|
||||
plugin_name: pluginInstall.plugin,
|
||||
last_plugin_update: readLastPluginUpdate(),
|
||||
plugin_update_progress: readPluginUpdateProgress(),
|
||||
|
||||
local_version: localVersion,
|
||||
// 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
|
||||
// a chain that died is visible instead of looking like a slow restart.
|
||||
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,
|
||||
// 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.
|
||||
|
||||
@@ -36,9 +36,17 @@ const schemas = {
|
||||
laneId: { type: "integer", nullable: true },
|
||||
sessionId: { type: "string", nullable: true },
|
||||
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." },
|
||||
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 },
|
||||
shared: { type: "boolean" },
|
||||
sharedAt: { type: "string", nullable: true },
|
||||
@@ -87,12 +95,18 @@ const paths = {
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
|
||||
},
|
||||
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
|
||||
{
|
||||
name: "kind",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
|
||||
},
|
||||
{ name: "q", in: "query", schema: { type: "string" } },
|
||||
laneQuery,
|
||||
{ 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": {
|
||||
@@ -190,11 +204,14 @@ const paths = {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Keep one uploaded file (≤ 25 MB)",
|
||||
operationId: "uploadArtifactFile",
|
||||
requestBody: multipart({
|
||||
files: { type: "string", format: "binary" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
}, ["files"]),
|
||||
requestBody: multipart(
|
||||
{
|
||||
files: { type: "string", format: "binary" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
["files"]
|
||||
),
|
||||
responses: created(item),
|
||||
},
|
||||
},
|
||||
@@ -222,13 +239,16 @@ const paths = {
|
||||
operationId: "uploadArtifactSkills",
|
||||
description:
|
||||
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
|
||||
requestBody: multipart({
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
paths: { type: "string" },
|
||||
name: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
}, ["files"]),
|
||||
requestBody: multipart(
|
||||
{
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
paths: { type: "string" },
|
||||
name: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
["files"]
|
||||
),
|
||||
responses: created(items),
|
||||
},
|
||||
},
|
||||
@@ -246,7 +266,10 @@ const paths = {
|
||||
},
|
||||
["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": {
|
||||
@@ -254,10 +277,14 @@ const paths = {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Read a marketplace's plugin list",
|
||||
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"]),
|
||||
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,
|
||||
},
|
||||
},
|
||||
@@ -285,7 +312,10 @@ const paths = {
|
||||
summary: "One artifact",
|
||||
operationId: "getArtifact",
|
||||
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: {
|
||||
tags: ["Artifacts"],
|
||||
@@ -299,7 +329,11 @@ const paths = {
|
||||
source: { 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: {
|
||||
tags: ["Artifacts"],
|
||||
|
||||
@@ -393,6 +393,11 @@ const paths = {
|
||||
properties: {
|
||||
workspaces: { 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: {
|
||||
type: "object",
|
||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||
|
||||
+19
-4
@@ -2,9 +2,10 @@
|
||||
* @file users.js
|
||||
* @description `/api/users` — server mode's per-user view for an admin or
|
||||
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent in the
|
||||
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
|
||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
||||
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
|
||||
* and in the last 7 and 30 days (from `token_usage_hourly`, sessions owned
|
||||
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||
*
|
||||
@@ -99,10 +100,22 @@ function usageSince(sinceHour, rules) {
|
||||
|
||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||
|
||||
/**
|
||||
* The UTC hour the viewer's local day began in — `offset` is the browser's
|
||||
* getTimezoneOffset() (minutes local time is BEHIND UTC; UTC when missing).
|
||||
* Buckets are whole UTC hours, so a half-hour zone's day starts up to 30 min early.
|
||||
*/
|
||||
function todayHour(offset) {
|
||||
const off = Number.isFinite(offset) && Math.abs(offset) <= 840 ? offset : 0;
|
||||
const local = new Date(Date.now() - off * 60e3);
|
||||
local.setUTCHours(0, 0, 0, 0);
|
||||
return new Date(local.getTime() + off * 60e3).toISOString().slice(0, 13);
|
||||
}
|
||||
|
||||
/** Map(key → n) from rows of `{k, n}`. */
|
||||
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
router.get("/", (req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
// A fixed number of grouped queries, however many accounts there are.
|
||||
const logins = auth.allLoginStats();
|
||||
@@ -112,6 +125,7 @@ router.get("/", (_req, res) => {
|
||||
const sessions = counts(
|
||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
||||
);
|
||||
const today = usageSince(todayHour(parseInt(req.query.tz_offset, 10)), rules);
|
||||
const last7d = usageSince(hourAgo(7), rules);
|
||||
const last30d = usageSince(hourAgo(30), rules);
|
||||
const none = { cost: 0, tokens: 0 };
|
||||
@@ -120,6 +134,7 @@ router.get("/", (_req, res) => {
|
||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||
workspaces: workspaces.get(u.id) || 0,
|
||||
sessions: sessions.get(u.id) || 0,
|
||||
today: today.get(u.id) || none,
|
||||
last7d: last7d.get(u.id) || none,
|
||||
last30d: last30d.get(u.id) || none,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user