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:
+282
-18
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user