Skip to content

Commit 4e16ff4

Browse files
phodalQoder-AI
andcommitted
feat(studio): treat artifacts as data and delegate rich formats to canvas viewers
The previous artifact increment compiled `.tsx`/`.jsx` artifacts into executable React modules, which promoted untrusted run output to executable code while the formats reviewers actually asked for (PPTX, XLSX, DOCX, GLB, Lottie) stayed unrendered. Replace that contract: every artifact kind is now an inert data presentation (code, diff, image, json, svg, text), and executable viewer code comes only from the operator-controlled Canvas viewer root discovered under `$QODER_HOME/canvas/canvases`. Adds `artifact-viewers.ts` (viewer discovery and renderer selection), `artifact-viewer-runtime.ts` (Canvas SDK runtime resolution and file serving), and `canvas-viewer-compile.ts`; removes `artifact-compile.ts` and the in-sandbox `artifact-host` bundle plus its build entry. Artifact indexing now rejects symlinks and paths escaping the physical root, route dispatch no longer crashes Studio on a rejected handler, and `--canvas-viewers`/`--canvas-sdk-root`/ `--canvas-sdk-media` make the viewer roots configurable. Artifact directories become optional preloads, so Studio can start without any input flag. Validated with `npx vitest run test/artifact-viewers.test.ts test/artifact-poc.test.ts test/server.test.ts test/studio-shell-model.test.ts` (56 passing); spec `docs/specs/2026-08-20-harness-studio-artifact-view.md` updated to Implemented. Co-authored-by: QoderAI <qoder_ai@qoder.com>
1 parent a8922dc commit 4e16ff4

22 files changed

Lines changed: 1553 additions & 1055 deletions

docs/specs/2026-08-20-harness-studio-artifact-view.md

Lines changed: 179 additions & 380 deletions
Large diffs are not rendered by default.

packages/harness-studio/scripts/build-app.mjs

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,8 @@ await build({
2525
define: { "process.env.NODE_ENV": '"production"' },
2626
logLevel: "warning",
2727
});
28-
// The artifact host is a separate IIFE bundle, not a chunk of the shell: it runs
29-
// inside the sandboxed artifact iframe, which has an opaque origin and can load
30-
// a classic script without CORS but not a module script.
31-
await build({
32-
entryPoints: { "artifact-host": join(packageRoot, "src", "app", "artifact-host.ts") },
33-
outdir: join(appDir, "assets"),
34-
entryNames: "[name]",
35-
bundle: true,
36-
format: "iife",
37-
globalName: "harnessArtifactHost",
38-
platform: "browser",
39-
target: "es2022",
40-
minify: true,
41-
sourcemap: true,
42-
define: { "process.env.NODE_ENV": '"production"' },
43-
logLevel: "warning",
44-
});
4528
await Promise.all([
4629
copyFile(join(packageRoot, "src", "app", "index.html"), join(appDir, "index.html")),
47-
copyFile(join(packageRoot, "src", "app", "artifact-host.html"), join(appDir, "artifact-host.html")),
4830
...["tokens.css", "shell.css", "workbench.css"].map((file) =>
4931
copyFile(join(packageRoot, "src", "app", "styles", file), join(appDir, "assets", file)),
5032
),

packages/harness-studio/src/app/App.tsx

Lines changed: 155 additions & 23 deletions
Large diffs are not rendered by default.

packages/harness-studio/src/app/RunView.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,12 @@ function stopConditionLabel(enabled: StopConditionState): string {
236236

237237
export function RunView({
238238
aguiEndpoint,
239+
artifactEndpoint,
239240
navigation,
240241
initialMode = "live",
241242
}: {
242243
aguiEndpoint: string;
244+
artifactEndpoint?: string;
243245
navigation?: ReactNode;
244246
initialMode?: SurfaceMode;
245247
}): React.JSX.Element {
@@ -427,7 +429,7 @@ export function RunView({
427429
<div className="debugger-grid">
428430
{live ? <LiveExecutionTree state={viewState} prompt={viewPrompt} /> : <ExecutionTree session={retainedSession} cursor={cursor} expanded={expandedNodes} onToggle={toggleExpanded} onSelect={selectNode} />}
429431
{live ? <LiveNotebook state={viewState} prompt={viewPrompt} groups={liveGroups} /> : <SessionNotebook session={retainedSession} cursor={cursor} expanded={expandedNodes} onSelect={selectCursor} onToggle={toggleExpanded} />}
430-
{live ? <LiveInspector state={viewState} /> : <StateInspector session={retainedSession} cursor={cursor} activeTab={inspectorTab} onTab={setInspectorTab} onPrevious={() => selectCursor(previousStateCursor(retainedSession, cursor))} />}
432+
{live ? <LiveInspector state={viewState} /> : <StateInspector session={retainedSession} cursor={cursor} activeTab={inspectorTab} artifactEndpoint={artifactEndpoint} onTab={setInspectorTab} onPrevious={() => selectCursor(previousStateCursor(retainedSession, cursor))} />}
431433
</div>
432434

433435
{live ? <LiveTimeline state={viewState} bins={liveBins} eventCount={liveTimeline.length} /> : <TimelineMinimap session={retainedSession} cursor={cursor} onSelect={selectCursor} />}
@@ -551,25 +553,25 @@ function ValidationCell({ event }: { event: DebuggerEvent }): React.JSX.Element
551553
return <section className={`validation-cell ${validation.status}`}><header><span><StatusIcon size={15} weight="fill" /><strong>{validation.command}</strong></span><time>{validation.duration}</time></header><p>{validation.summary}</p><ul>{validation.output.map((line) => <li key={line}>{line}</li>)}</ul></section>;
552554
}
553555

554-
function StateInspector(props: { session: DebuggerSession; cursor: DebuggerCursor; activeTab: InspectorTab; onTab: (tab: InspectorTab) => void; onPrevious: () => void }): React.JSX.Element {
556+
function StateInspector(props: { session: DebuggerSession; cursor: DebuggerCursor; activeTab: InspectorTab; artifactEndpoint?: string; onTab: (tab: InspectorTab) => void; onPrevious: () => void }): React.JSX.Element {
555557
const event = eventForCursor(props.session, props.cursor);
556558
const previous = priorStopEvent(props.session, props.cursor);
557559
const tablist = useRovingTablist({ ids: INSPECTOR_TABS.map((tab) => tab.id), active: props.activeTab, onSelect: props.onTab, panelId: "state-inspector-panel" });
558560
return <aside className="state-inspector" aria-label="State Inspector">
559561
<header><div><small>State Inspector</small><strong>{event.phase} · {event.timestamp}</strong></div><span>{props.cursor.toolCallId ? "Tool Call Cursor" : "Evidence Cursor"}</span></header>
560562
<nav className="inspector-tabs" aria-label="State Inspector views" {...tablist.tablistProps}>{INSPECTOR_TABS.map((tab) => { const TabIcon = tab.icon; return <button key={tab.id} type="button" {...tablist.getTabProps(tab.id)} onClick={() => props.onTab(tab.id)}><TabIcon size={14} /><span>{tab.label}</span></button>; })}</nav>
561-
<div className="inspector-scroll" id="state-inspector-panel" role="tabpanel"><div className="inspector-comparison"><strong>{INSPECTOR_TABS.find((tab) => tab.id === props.activeTab)?.label} at {event.timestamp}</strong><span>Compared with {previous?.timestamp ?? "session start"}</span></div><InspectorContent session={props.session} tab={props.activeTab} cursor={props.cursor} /></div>
563+
<div className="inspector-scroll" id="state-inspector-panel" role="tabpanel"><div className="inspector-comparison"><strong>{INSPECTOR_TABS.find((tab) => tab.id === props.activeTab)?.label} at {event.timestamp}</strong><span>Compared with {previous?.timestamp ?? "session start"}</span></div><InspectorContent session={props.session} tab={props.activeTab} cursor={props.cursor} artifactEndpoint={props.artifactEndpoint} /></div>
562564
<footer><button type="button" onClick={props.onPrevious}><ClockCounterClockwise size={13} />Previous State</button><button type="button"><Clock size={13} />View History</button></footer>
563565
</aside>;
564566
}
565567

566-
function InspectorContent({ session, tab, cursor }: { session: DebuggerSession; tab: InspectorTab; cursor: DebuggerCursor }): React.JSX.Element {
568+
function InspectorContent({ session, tab, cursor, artifactEndpoint }: { session: DebuggerSession; tab: InspectorTab; cursor: DebuggerCursor; artifactEndpoint?: string }): React.JSX.Element {
567569
const event = eventForCursor(session, cursor);
568570
const tool = toolForCursor(session, cursor);
569571
const cumulative = cumulativeFileChanges(session, cursor);
570572
if (tab === "changes") return <ChangesInspector event={event} cumulative={cumulative} />;
571573
if (tab === "files") return <FilesInspector session={session} files={cumulative} />;
572-
if (tab === "artifacts") return <ArtifactsInspector event={event} />;
574+
if (tab === "artifacts") return <ArtifactsInspector endpoint={artifactEndpoint} />;
573575
if (tab === "tests") return <TestsInspector session={session} event={event} />;
574576
if (tab === "terminal") return <TerminalInspector event={event} />;
575577
if (tab === "plan") return <PlanInspector event={event} />;
@@ -593,8 +595,25 @@ function FilesInspector({ session, files }: { session: DebuggerSession; files: D
593595
return <><InspectorSection title="Observed files" count={files.length}>{files.length > 0 ? <FileRows files={files} /> : <p className="inspector-empty">No modified file observed before this cursor.</p>}</InspectorSection><InspectorSection title="Exploration ledger" count={resources.length}>{resources.length === 0 ? <p className="inspector-empty">No retained file resources in this session.</p> : <ul className="simple-rows">{resources.map((file, index) => <li key={`${file}:${index}`}><FileText size={13} /><code>{file}</code><span>Retained</span></li>)}</ul>}</InspectorSection></>;
594596
}
595597

596-
function ArtifactsInspector({ event }: { event: DebuggerEvent }): React.JSX.Element {
597-
return <><InspectorSection title="Retained artifacts"><ul className="simple-rows"><li><ImageSquare size={13} /><span>acp-debugger-reference.png</span><em>Input</em></li><li><BracketsCurly size={13} /><span>session-debugger-state.json</span><em>{event.timestamp}</em></li></ul></InspectorSection><InspectorSection title="Boundary"><p className="inspector-note">Artifact rows belong to this recorded sample. They are not a restorable workspace snapshot.</p></InspectorSection></>;
598+
function ArtifactsInspector({ endpoint }: { endpoint?: string }): React.JSX.Element {
599+
const [artifacts, setArtifacts] = useState<Array<{ id: string; label: string; renderer: string }>>();
600+
const [failure, setFailure] = useState<string>();
601+
useEffect(() => {
602+
if (endpoint === undefined) return;
603+
const controller = new AbortController();
604+
void fetch(endpoint, { signal: controller.signal }).then(async (response) => {
605+
if (!response.ok) throw new Error(`Artifact catalog failed (${response.status}).`);
606+
const payload = await response.json() as { artifacts?: Array<{ id: string; label: string; renderer: string }> };
607+
setArtifacts(Array.isArray(payload.artifacts) ? payload.artifacts : []);
608+
}).catch((error: unknown) => {
609+
if (!controller.signal.aborted) setFailure(error instanceof Error ? error.message : String(error));
610+
});
611+
return () => controller.abort();
612+
}, [endpoint]);
613+
if (endpoint === undefined) return <InspectorSection title="Retained artifacts"><p className="inspector-empty">No artifact directory is configured for this Studio run.</p></InspectorSection>;
614+
if (failure !== undefined) return <InspectorSection title="Retained artifacts"><p className="inspector-empty">{failure}</p></InspectorSection>;
615+
if (artifacts === undefined) return <InspectorSection title="Retained artifacts"><p className="inspector-empty">Loading artifact catalog…</p></InspectorSection>;
616+
return <><InspectorSection title="Retained artifacts" count={artifacts.length}>{artifacts.length === 0 ? <p className="inspector-empty">The configured artifact directory is empty.</p> : <ul className="simple-rows">{artifacts.map((artifact) => <li key={artifact.id}><FileText size={13} /><span>{artifact.label}</span><em>{artifact.renderer}</em></li>)}</ul>}</InspectorSection><InspectorSection title="Boundary"><p className="inspector-note">These rows come from the configured read-only artifact catalog. They are not a restorable workspace snapshot.</p></InspectorSection></>;
598617
}
599618

600619
function TestsInspector({ session, event }: { session: DebuggerSession; event: DebuggerEvent }): React.JSX.Element {

packages/harness-studio/src/app/StudioDiff.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { FileDiff, type FileDiffMetadata } from "@pierre/diffs/react";
44
import type { DebuggerDiff } from "./session-debugger-model.js";
55
import { buildDebuggerPatch } from "./code-rendering-model.js";
66

7-
export default function StudioDiff({ diff }: { diff: DebuggerDiff }): React.JSX.Element {
8-
const fileDiff = useMemo(() => parseFileDiff(diff), [diff]);
7+
export default function StudioDiff(props: { diff?: DebuggerDiff; patch?: string }): React.JSX.Element {
8+
const patch = props.patch ?? (props.diff === undefined ? "" : buildDebuggerPatch(props.diff));
9+
const fileDiff = useMemo(() => parseFileDiff(patch), [patch]);
910
if (fileDiff === undefined) {
10-
return <pre className="studio-diff-fallback">{buildDebuggerPatch(diff)}</pre>;
11+
return <pre className="studio-diff-fallback">{patch}</pre>;
1112
}
1213
return <div className="studio-diff-renderer" data-code-diff="pierre">
1314
<FileDiff
@@ -28,9 +29,9 @@ export default function StudioDiff({ diff }: { diff: DebuggerDiff }): React.JSX.
2829
</div>;
2930
}
3031

31-
function parseFileDiff(diff: DebuggerDiff): FileDiffMetadata | undefined {
32+
function parseFileDiff(patch: string): FileDiffMetadata | undefined {
3233
try {
33-
return parsePatchFiles(buildDebuggerPatch(diff), `session-debugger:${diff.path}:${diff.before.length}:${diff.after.length}`)
34+
return parsePatchFiles(patch, `artifact:${patch.length}`)
3435
.flatMap((patch) => patch.files)
3536
.at(0);
3637
} catch {

packages/harness-studio/src/app/artifact-host.html

Lines changed: 0 additions & 50 deletions
This file was deleted.

packages/harness-studio/src/app/artifact-host.ts

Lines changed: 0 additions & 127 deletions
This file was deleted.

packages/harness-studio/src/app/studio-shell-model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export function studioDestinations(config: StudioConfig): readonly StudioDestina
4242
id: "artifacts",
4343
label: "Artifacts",
4444
group: "Observe",
45-
availability: config.artifactsEnabled ? "ready" : "foundation",
46-
status: config.artifactsEnabled ? "Run outputs" : "Artifact directory required",
45+
availability: config.artifactsEnabled ? "ready" : "partial",
46+
status: config.artifactsEnabled ? "Run outputs" : "Analyze artifacts",
4747
},
4848
{
4949
id: "debugger",

0 commit comments

Comments
 (0)