feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows

- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
This commit is contained in:
2026-10-23 08:33:55 +07:00
parent 241e19b713
commit 62b4520507
14 changed files with 736 additions and 44 deletions
+137 -1
View File
@@ -9,7 +9,9 @@
* file, a folder, a zip, several skills at once — name and frontmatter set,
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, and same-origin writes.
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -120,6 +122,42 @@ describe("pages", () => {
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
});
it("records a Claude Docs doc at its birth, never on a later batch", () => {
const tool = "mcp__claude_ai_Claude_Docs__batch";
assert.equal(artifacts.isPageTool(tool), true);
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
assert.equal(artifacts.isPageTool("Artifact"), true);
const row = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
});
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
assert.equal(row.title, "Q3 plan");
const edit = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
});
assert.equal(edit, null);
// The doc's own link wins over one its content cites earlier in the ack.
const cited = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
tool_response: [
{
type: "text",
text:
JSON.stringify({
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
},
],
});
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
});
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
const uid = db
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
@@ -711,3 +749,101 @@ describe("search, frontmatter and marketplace limits", () => {
assert.ok(pulled < 30, `read ${pulled} chunks`);
});
});
describe("outputs captured at a session's stop", () => {
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
const past = new Date(Date.now() - 3600 * 1000);
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
const deck = path.join(ws, "out", "deck.pptx");
fs.writeFileSync(deck, "PK-deck-v1");
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
fs.utimesSync(deck, soon, soon);
const first = await artifacts.captureOutputs("s1");
assert.deepEqual(
first.map((r) => r.source),
["out/deck.pptx"]
);
assert.equal(first[0].auto, 1);
assert.equal(first[0].lane_id, lane.id);
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
fs.writeFileSync(deck, "PK-deck-v2");
const later = new Date(Date.now() + 60 * 1000);
fs.utimesSync(deck, later, later);
const again = await artifacts.captureOutputs("s1");
assert.equal(again.length, 1);
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
assert.notEqual(again[0].snapshot, first[0].snapshot);
assert.equal(
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
true
);
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
});
});
describe("unowned rows", () => {
it("are suggested, claimed, declined and assigned only by the right people", () => {
const addUser = (name, role) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
.run(name, role).lastInsertRowid
);
const bobId = addUser("bob-u", "developer");
const amyId = addUser("amy-u", "developer");
const adminId = addUser("root-u", "admin");
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
process.env.AGENTHUB_MODE = "server";
try {
// A host session with no workspace, run inside bob's workspace folder.
const row = artifacts.captureFromHook(
"loose-host",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un1",
cwd: ws,
},
{ kind: "host" }
);
assert.equal(row.owner_id, null);
assert.equal(row.suggested_owner_id, bobId);
assert.ok(
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
);
assert.ok(
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
);
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
assert.equal(artifacts.canSeeId(amy, row.id), false);
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
const claimed = artifacts.claim(bob, row.id);
assert.equal(claimed.ownerId, bobId);
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
const other = artifacts.captureFromHook(
"loose-host-2",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un2",
cwd: ws,
},
{ kind: "host" }
);
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
} finally {
delete process.env.AGENTHUB_MODE;
}
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
});
});
+14
View File
@@ -960,6 +960,20 @@ try {
} catch {
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
}
// `auto = 1`: a document file a session wrote, captured when it stopped
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
// no owner, the user whose folder the session ran in — they may claim it or
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
} catch {
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
}
}
// One row per install of an artifact (server/lib/artifacts.js `install`):
// who, when, and the folders it was written to — `artifacts.installs` only
+282 -18
View File
@@ -6,8 +6,12 @@
* Four kinds:
* - `page` a claude.ai Artifact a session published: only a link, accepted
* only as `https://claude.ai/…`. Captured from the `Artifact`
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
* - `file` a file an agent wrote in a workspace, pinned by the user.
* tool's PostToolUse hook (`captureFromHook`; a Claude Docs doc's
* birth too) or pasted by hand. In server mode a row nobody owns
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
* document (pptx/docx/xlsx/pdf/html) a session created, captured
* when it stops (`captureOutputs`, `auto`).
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
* `.claude/skills/<name>`).
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
@@ -85,14 +89,21 @@ const installsOffFlag = () => path.join(root(), "installs-off");
function visibleWhere(req) {
const u = req?.user;
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
return {
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
params: [u.id, u.id],
};
}
const canSee = (req, row) => {
const u = req?.user;
if (!row) return false;
if (!u || u.role === "admin" || u.role === "analyst") return true;
return row.owner_id === u.id || row.shared === 1;
return (
row.owner_id === u.id ||
row.shared === 1 ||
(row.owner_id == null && row.suggested_owner_id === u.id)
);
};
/** Only the owner changes a row — or an admin, or anyone in local mode. */
@@ -101,7 +112,10 @@ const canChange = (req, row) =>
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
function canSeeId(req, id) {
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
return canSee(
req,
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
);
}
function load(req, id) {
@@ -144,6 +158,10 @@ function view(row, req, owners = null) {
sharedAt: row.shared_at,
inLibrary: row.in_library !== 0,
installs: row.installs,
auto: row.auto === 1,
// Server mode only: a row nobody owns waits for an admin or its suggested user.
unowned: isServer() && row.owner_id == null,
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -152,7 +170,9 @@ function view(row, req, owners = null) {
/**
* The rows this request may see. `scope`: `mine` (own library rows plus the
* shared skills/plugins of others the caller has installed; every row in
* local mode), `hub` (shared rows, server mode), anything else = all visible.
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
* nobody owns — every one for an admin, those suggested to the caller for
* anyone else), anything else = all visible.
* Each row also carries `installed` (`installedCheck`).
*/
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
@@ -168,6 +188,13 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
params.push(req.user.id, req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
} else if (scope === "unowned") {
if (!isServer() || !req?.user) return [];
where.push("a.owner_id IS NULL");
if (req.user.role !== "admin") {
where.push("a.suggested_owner_id = ?");
params.push(req.user.id);
}
}
if (kind) {
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
@@ -297,23 +324,90 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
* The host's account is shared by every admin, so it names nobody.
*/
/**
* Whose a row with no owner probably is, from the folder its session ran in:
* the owner of the workspace holding that folder, else (S3) the member whose
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
*/
function suggestOwner(cwd) {
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
const lane = require("./lanes").resolveLaneByCwd(cwd);
if (lane?.owner_id != null) return lane.owner_id;
try {
const acct = require("./run-as").ownerForRead(cwd);
if (!acct?.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
return row ? row.id : null;
} catch {
return null;
}
}
function peerOwner(peer) {
if (peer?.kind !== "member" || !peer.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
return row ? row.id : null;
}
/**
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
* the one that births a doc. Slides, dashboards and designs are made through the
* `Artifact` tool (`type_url`) and are captured as its publish.
*/
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
function isPageTool(toolName) {
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
}
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
function docsBirth(input) {
let container = input.container;
if (typeof container === "string") {
try {
container = JSON.parse(container);
} catch {
return null;
}
}
const create = container?.create;
return create && typeof create === "object" ? create : null;
}
/**
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
* trailer appended in the same text block, so it is matched, not parsed.
*/
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
function docsFrameUrl(response) {
const blocks = Array.isArray(response) ? response : [response];
for (const b of blocks) {
const raw = typeof b === "string" ? b : b?.text;
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
if (m && PAGE_URL_RE.test(m[1])) return m[1];
}
return null;
}
/**
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
* owned by the session's workspace owner, else by the member who sent the
* hook (`peerOwner`). Returns the row or null.
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
* doc answers with the doc's link the same way (later batches edit it, and are
* skipped). Recorded once per (owner, url), owned by the session's workspace
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
* or null.
*/
function captureFromHook(sessionId, data, peer = null) {
const input = data?.tool_input || {};
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
const birth = docs ? docsBirth(input) : null;
if (docs && !birth) return null;
if (input.action && input.action !== "publish") return null;
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
const text = JSON.stringify(data?.tool_response ?? "");
const m = PAGE_IN_TEXT_RE.exec(text);
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
if (!m) return null;
const url = m[0].replace(/[\\"]+$/, "");
if (!PAGE_URL_RE.test(url)) return null;
@@ -327,13 +421,14 @@ function captureFromHook(sessionId, data, peer = null) {
const file = typeof input.file_path === "string" ? input.file_path : "";
return insert({
kind: "page",
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
owner_id: ownerId,
lane_id: laneId,
session_id: String(sessionId),
source: file ? file.slice(0, 1000) : null,
url,
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
});
}
@@ -389,9 +484,8 @@ function fileCandidates(laneId) {
return out;
}
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
const lane = laneOf(laneId);
/** A new snapshot of one workspace file, read under the read path's guards. */
async function snapshotLaneFile(lane, rel) {
const { openDownload } = require("./lane-file-transfer");
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
if (file.size > FILE_MAX_BYTES) {
@@ -399,10 +493,9 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
}
const snap = newSnapshotDir();
const name = file.name;
try {
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
const out = fs.createWriteStream(path.join(snap.dir, file.name), { mode: 0o600 });
file.stream.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
@@ -412,6 +505,13 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
dropSnapshot(snap.id);
throw err;
}
return { snap, name: file.name, size: file.size };
}
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
const lane = laneOf(laneId);
const { snap, name, size } = await snapshotLaneFile(lane, rel);
const relPosix = require("./git-diff").lanePath(rel);
return view(
insert({
@@ -423,13 +523,122 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
session_id: sessionId ? String(sessionId) : null,
source: relPosix,
snapshot: snap.id,
files: JSON.stringify([{ path: name, size: file.size }]),
size: file.size,
files: JSON.stringify([{ path: name, size }]),
size,
}),
req
);
}
// ── Outputs captured when a session stops ───────────────────────────────────
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20;
const capturing = new Map();
/**
* The workspace's documents a session created or changed since it started:
* untracked or modified files in a repo (an HTML file only when untracked, so a
* site's own pages are not taken), every file of a plain folder. Paths are
* workspace-relative.
*/
async function outputCandidates(lane, since) {
const gd = require("./git-diff");
let paths;
let tracked = new Set();
if (await gd.inRepo(lane.cwd)) {
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
tracked = new Set(modified);
paths = [...untracked, ...modified];
} else {
paths = (await gd.walkFolder(lane.cwd)).files;
}
const out = [];
for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
let st;
try {
st = await fs.promises.lstat(path.join(lane.cwd, rel));
} catch {
continue;
}
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
}
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
}
/**
* At a session's Stop: snapshot each document it created or changed into its
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
* pinned by hand is left to them. Returns the rows added or refreshed.
*/
function captureOutputs(sessionId) {
const laneId = sessionLane(sessionId);
if (laneId == null) return Promise.resolve([]);
// One capture per workspace at a time: two Stops in a row must not both insert.
const prev = capturing.get(laneId) || Promise.resolve();
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
capturing.set(laneId, run);
run.finally(() => {
if (capturing.get(laneId) === run) capturing.delete(laneId);
});
return run;
}
async function captureOutputsNow(laneId, sessionId) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane) return [];
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
const done = [];
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
const rows = sql("SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?").all(
lane.id,
rel
);
if (rows.some((r) => r.auto !== 1)) continue;
const row = rows[0];
const kept = row?.files ? JSON.parse(row.files)[0] : null;
if (kept && kept.mtime === mtimeMs) continue;
let shot;
try {
shot = await snapshotLaneFile(lane, rel);
} catch {
continue; // a secret name, a vanished file, one grown past the cap
}
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
if (row) {
sql(
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
dropSnapshot(row.snapshot);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} else {
done.push(
insert({
kind: "file",
title: cleanTitle(null, shot.name),
owner_id: lane.owner_id ?? null,
lane_id: lane.id,
session_id: String(sessionId),
source: rel,
snapshot: shot.snap.id,
files,
size: shot.size,
auto: 1,
})
);
}
}
return done;
}
/**
* A file uploaded from the user's computer: kept as a snapshot like a pinned
* one, under a name made from the upload's own (no folder, no path).
@@ -1171,6 +1380,56 @@ function addPlugin(req, { key, market, title, description }) {
// ── change / delete ───────────────────────────────────────────────────────
// ── Unowned rows ──────────────────────────────────────────────────────────
function unownedRow(req, id) {
const row = load(req, id);
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
return row;
}
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
function claim(req, id) {
const row = unownedRow(req, id);
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
}
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(req.user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "claim");
return view(next, req);
}
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
function decline(req, id) {
const row = unownedRow(req, id);
if (row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user can decline it");
}
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
audit(req, row, "decline");
return { id: row.id, declined: true };
}
/** An admin gives an unowned row to an enabled user. */
function assign(req, id, userId) {
const row = unownedRow(req, id);
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
const user = /^\d+$/.test(String(userId))
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
: null;
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "assign");
return view(next, req);
}
function audit(req, row, action) {
if (!req?.user) return;
try {
@@ -1669,6 +1928,11 @@ module.exports = {
canSeeId,
addPage,
captureFromHook,
captureOutputs,
isPageTool,
claim,
decline,
assign,
fileCandidates,
pinFile,
availableSkills,
+2
View File
@@ -1350,6 +1350,7 @@ module.exports = {
laneStatus,
listCommits,
listPaths,
lsFiles,
listTree,
listTreeAll,
parseNumstat,
@@ -1358,6 +1359,7 @@ module.exports = {
readFileAt,
readImageAt,
resolveBaseRef,
walkFolder,
defaultBranch,
DEFAULT_CONTEXT,
MAX_CONTEXT,
+5 -1
View File
@@ -82,7 +82,11 @@ const paths = {
description:
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
parameters: [
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
{
name: "scope",
in: "query",
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
},
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
{ name: "q", in: "query", schema: { type: "string" } },
laneQuery,
+63
View File
@@ -1048,6 +1048,69 @@ const paths = {
},
},
},
"/api/artifacts/{id}/claim": {
post: {
tags: ["Artifacts"],
summary: "Take an unowned artifact",
operationId: "claimArtifact",
description:
"Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Claimed.", { item: { type: "object" } }),
400: errs("ELOCALMODE."),
403: errs("EFORBIDDEN: not the suggested user."),
404: errs("ENOTFOUND."),
409: errs("EOWNED: it already has an owner."),
},
},
},
"/api/artifacts/{id}/decline": {
post: {
tags: ["Artifacts"],
summary: "Say an unowned artifact offered to you is not yours",
operationId: "declineArtifact",
description:
"Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Declined.", { id: { type: "integer" }, declined: { type: "boolean" } }),
400: errs("ELOCALMODE."),
403: errs("EFORBIDDEN: not the suggested user."),
404: errs("ENOTFOUND."),
409: errs("EOWNED."),
},
},
},
"/api/artifacts/{id}/assign": {
post: {
tags: ["Artifacts"],
summary: "Give an unowned artifact to a user (admin)",
operationId: "assignArtifact",
description:
"Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["userId"],
properties: { userId: { type: "integer" } },
},
},
},
},
responses: {
200: ok("Assigned.", { item: { type: "object" } }),
400: errs("ELOCALMODE / EBADFIELD."),
403: errs("EFORBIDDEN: not an admin."),
404: errs("ENOTFOUND."),
409: errs("EOWNED."),
},
},
},
};
module.exports = { tags, schemas, paths };
+29
View File
@@ -38,6 +38,7 @@ const STATUS = {
EMARKETFETCH: 502,
ENEEDLOGIN: 502,
ETOOBIG: 413,
EOWNED: 409,
};
function sendError(res, err) {
@@ -362,6 +363,34 @@ router.post(
})
);
// A row nobody owns: its suggested user says "mine" or "not mine"; an admin
// takes it or gives it to someone (artifacts.js `claim`/`decline`/`assign`).
router.post(
"/:id/claim",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.claim(req, req.params.id);
changed(item);
res.json({ item });
})
);
router.post(
"/:id/decline",
sameOriginGuard,
wrap((req, res) => res.json(artifacts.decline(req, req.params.id)))
);
router.post(
"/:id/assign",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.assign(req, req.params.id, req.body?.userId);
changed(item);
res.json({ item });
})
);
router.post(
"/:id/uninstall",
sameOriginGuard,
+23 -2
View File
@@ -1295,9 +1295,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
};
broadcast("new_event", event);
// A claude.ai page the session published goes into its owner's Artifacts.
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
// A Claude Docs doc counts too (`isPageTool`): it is a claude.ai page the session made.
if (hookType === "PostToolUse" && tool !== "codex") {
try {
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
const artifacts = require("../lib/artifacts");
const row = artifacts.isPageTool(toolName)
? artifacts.captureFromHook(sessionId, data, peer)
: null;
if (row) {
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
}
@@ -1736,6 +1740,23 @@ router.post("/event", (req, res) => {
});
}
// Documents the session made (slides, Word, Excel, PDF, HTML) go into its
// workspace owner's Artifacts when it stops. Off the response path, fail-safe.
if (["Stop", "SessionEnd"].includes(hook_type) && data.session_id) {
require("../lib/artifacts")
.captureOutputs(data.session_id)
.then((rows) => {
for (const row of rows) {
broadcast("artifact_changed", {
id: row.id,
ownerId: row.owner_id,
shared: row.shared === 1,
});
}
})
.catch((err) => console.warn("[artifacts] output capture failed:", err.message));
}
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
// hooks, so the journal (written at workflow completion) is the only source.
// Run on the lifecycle hooks that bracket a workflow finishing, off the