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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user