fix(artifacts): auto-capture leaves a web app's own HTML pages alone

At a session's Stop every untracked .html was snapshotted as a report, so a
web project not yet committed had its index.html taken into its owner's
Artifacts. An index.html, a page under a site folder (www, public, dist,
build, src, static, templates, assets, site, _site) or one beside scripts or
stylesheets (outside the workspace root) is now a site's own page and left
alone; a standalone HTML report is still captured.
This commit is contained in:
2026-10-30 15:22:19 +07:00
parent d09c9d6633
commit 240444eafd
3 changed files with 47 additions and 5 deletions
+1 -1
View File
@@ -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 `<data dir>/artifacts/<random>/`.
- **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 `<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`, `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/<name>` 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).
+17
View File
@@ -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 = "<p>x</p>") => {
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", () => {
+29 -4
View File
@@ -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));