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:
+1
-1
@@ -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).
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user