diff --git a/docs/API.md b/docs/API.md index 2088b8a1..6e691206 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3176,7 +3176,7 @@ 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 — 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 `/artifacts//`. +- **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 and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — 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 `/artifacts//`. - **skill** — the caller's own `~/.claude/skills/` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`. - **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin. - **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept). diff --git a/server/__tests__/artifacts.test.js b/server/__tests__/artifacts.test.js index a037f377..9db6da69 100644 --- a/server/__tests__/artifacts.test.js +++ b/server/__tests__/artifacts.test.js @@ -981,6 +981,23 @@ describe("outputs captured at a session's stop", () => { ); assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []); }); + + it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => { + const put = (rel, text = "

x

") => { + fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true }); + fs.writeFileSync(path.join(ws, rel), text); + const soon = new Date(Date.now() + 5 * 60 * 1000); + fs.utimesSync(path.join(ws, rel), soon, soon); + }; + put("app/index.html"); + put("app/www/page.html"); + put("widget/view.html"); + put("widget/view.js", "x()"); + put("summary-report.html"); + put("out/q3.html"); + const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort(); + assert.deepEqual(got, ["out/q3.html", "summary-report.html"]); + }); }); describe("unowned rows", () => { diff --git a/server/lib/artifacts.js b/server/lib/artifacts.js index 43cccfce..5677ea21 100644 --- a/server/lib/artifacts.js +++ b/server/lib/artifacts.js @@ -551,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId } 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; +/** Folders that hold a site's own pages, never a report someone reads. */ +const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i; +/** What sits next to a site's page: its scripts and styles. */ +const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i; + +/** + * An HTML file that is a web app's own page, not a report (`app/www/index.html` + * of a project not yet committed): an `index.html`, one under a site folder + * (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The + * workspace root is exempt from that last test — a repo keeps its config + * `.js` there next to the reports people write. + */ +async function isSitePage(cwd, rel) { + const parts = rel.split("/"); + const name = parts.pop(); + if (/^index\.html?$/i.test(name)) return true; + if (parts.some((d) => SITE_DIR_RE.test(d))) return true; + if (!parts.length) return false; + try { + const names = await fs.promises.readdir(path.join(cwd, ...parts)); + return names.some((n) => SITE_ASSET_RE.test(n)); + } catch { + return false; + } +} 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. + * untracked or modified files in a repo (an HTML file only when untracked and + * not a site's own page — `isSitePage`), every file of a plain folder. Paths + * are workspace-relative. */ async function outputCandidates(lane, since) { const gd = require("./git-diff"); @@ -574,7 +599,7 @@ async function outputCandidates(lane, since) { const out = []; for (const rel of new Set(paths)) { if (!OUTPUT_EXT.test(rel)) continue; - if (/\.html?$/i.test(rel) && tracked.has(rel)) continue; + if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue; let st; try { st = await fs.promises.lstat(path.join(lane.cwd, rel));