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");
});
});