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:
@@ -183,5 +183,19 @@
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
"detail.added": "Added {{date}}",
|
||||
"tab.unowned": "Unowned",
|
||||
"tab.suggested": "Is this yours?",
|
||||
"auto": "Auto",
|
||||
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||
"unowned.mine": "It's mine",
|
||||
"unowned.takeIt": "Take it",
|
||||
"unowned.notMine": "Not mine",
|
||||
"unowned.assign": "Assign to…",
|
||||
"unowned.claimed": "Added to your library",
|
||||
"unowned.declined": "Removed from your list",
|
||||
"unowned.assigned": "Owner assigned",
|
||||
"unowned.empty": "Nothing without an owner."
|
||||
}
|
||||
|
||||
@@ -183,5 +183,19 @@
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
"tab.unowned": "Vô chủ",
|
||||
"tab.suggested": "Có phải của bạn?",
|
||||
"auto": "Tự động",
|
||||
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||
"unowned.mine": "Của tôi",
|
||||
"unowned.takeIt": "Nhận về tôi",
|
||||
"unowned.notMine": "Không phải của tôi",
|
||||
"unowned.assign": "Gán cho…",
|
||||
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||
"unowned.assigned": "Đã gán chủ",
|
||||
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||
}
|
||||
|
||||
+22
-2
@@ -3016,7 +3016,9 @@ export const api = {
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
@@ -3092,6 +3094,18 @@ export const api = {
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||
claim: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||
/** An unowned row offered to the caller is not theirs. */
|
||||
decline: (id: number) =>
|
||||
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||
/** Admin: give an unowned row to a user. */
|
||||
assign: (id: number, userId: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
}),
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
@@ -4382,7 +4396,7 @@ export interface Artifact {
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
files: { path: string; size: number; mtime?: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
@@ -4391,6 +4405,12 @@ export interface Artifact {
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
/** file: captured when its session stopped, not pinned by hand. */
|
||||
auto?: boolean;
|
||||
/** Server mode: nobody owns it yet. */
|
||||
unowned?: boolean;
|
||||
/** Unowned and offered to the caller to claim or decline. */
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
+120
-13
@@ -49,7 +49,9 @@ import {
|
||||
type ArtifactInstalls,
|
||||
type ArtifactKind,
|
||||
type ArtifactSettings,
|
||||
type ServerUserUsage,
|
||||
} from "../lib/api";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
@@ -114,7 +116,11 @@ const footAction =
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
||||
const { isAdmin } = usePermissions();
|
||||
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||
const [unownedCount, setUnownedCount] = useState(0);
|
||||
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
@@ -147,14 +153,45 @@ export function Artifacts() {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [load, q]);
|
||||
|
||||
const serverMode = !!settings?.serverMode;
|
||||
const countUnowned = useCallback(() => {
|
||||
if (!serverMode) return;
|
||||
api.artifacts
|
||||
.list({ scope: "unowned" })
|
||||
.then((r) => setUnownedCount(r.items.length))
|
||||
.catch(() => setUnownedCount(0));
|
||||
}, [serverMode]);
|
||||
useEffect(countUnowned, [countUnowned]);
|
||||
useEffect(() => {
|
||||
if (tab !== "unowned" || !isAdmin || users.length) return;
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
|
||||
.catch(() => setUsers([]));
|
||||
}, [tab, isAdmin, users.length]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "artifact_changed") load();
|
||||
if (msg.type === "artifact_changed") {
|
||||
load();
|
||||
countUnowned();
|
||||
}
|
||||
}),
|
||||
[load]
|
||||
[load, countUnowned]
|
||||
);
|
||||
|
||||
const settle = async (run: () => Promise<unknown>, done: string) => {
|
||||
try {
|
||||
await run();
|
||||
toast.success(t(done));
|
||||
load();
|
||||
countUnowned();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// The Add menu closes on Escape or a click anywhere outside it.
|
||||
useEffect(() => {
|
||||
if (!addMenu) return;
|
||||
@@ -297,15 +334,63 @@ export function Artifacts() {
|
||||
},
|
||||
});
|
||||
|
||||
const card = (a: Artifact) => (
|
||||
<ArtifactCard
|
||||
key={a.id}
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
);
|
||||
const card = (a: Artifact) =>
|
||||
tab === "unowned" ? (
|
||||
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
|
||||
<ArtifactCard
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{(a.suggestedForMe || isAdmin) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-2 py-1 text-xs"
|
||||
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
|
||||
>
|
||||
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
|
||||
</button>
|
||||
)}
|
||||
{a.suggestedForMe && (
|
||||
<button
|
||||
type="button"
|
||||
className={footAction}
|
||||
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
|
||||
>
|
||||
{t("unowned.notMine")}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && users.length > 0 && (
|
||||
<select
|
||||
className="input h-7 w-auto py-0 text-xs"
|
||||
aria-label={t("unowned.assign")}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const userId = Number(e.target.value);
|
||||
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("unowned.assign")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName || u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactCard
|
||||
key={a.id}
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Local mode (everything is yours): the unfiltered page is shelved by
|
||||
// kind; a category or a search is one flat grid of results.
|
||||
@@ -320,7 +405,7 @@ export function Artifacts() {
|
||||
// what you shared, My library into what you installed from others (the
|
||||
// server adds those to `mine`) and your own.
|
||||
const halves =
|
||||
server && items
|
||||
server && items && tab !== "unowned"
|
||||
? (["others", "own"] as const).map((who) => ({
|
||||
key: `${tab}.${who}`,
|
||||
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
||||
@@ -424,12 +509,29 @@ export function Artifacts() {
|
||||
{t(`tab.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tab === "unowned"}
|
||||
onClick={() => setTab("unowned")}
|
||||
className={segment(tab === "unowned")}
|
||||
data-testid="artifacts-tab-unowned"
|
||||
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
|
||||
>
|
||||
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
|
||||
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null ? (
|
||||
<SkeletonGrid label={t("loading")} />
|
||||
) : items.length === 0 && tab === "unowned" ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t("unowned.empty")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
hub={tab === "hub"}
|
||||
@@ -755,6 +857,11 @@ function ArtifactCard({
|
||||
{t(hubOnly ? "hubOnly" : "shared")}
|
||||
</span>
|
||||
)}
|
||||
{a.auto && (
|
||||
<span className="badge self-start" title={t("autoHint")}>
|
||||
{t("auto")}
|
||||
</span>
|
||||
)}
|
||||
{a.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
||||
)}
|
||||
|
||||
+7
-3
@@ -3087,7 +3087,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
### Artifacts hub
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
@@ -3107,17 +3107,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
```
|
||||
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
|
||||
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
|
||||
| Kind | What it is | Where it comes from |
|
||||
|---|---|---|
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
|
||||
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user