diff --git a/apps/api/src/handlers/artifacts/__tests__/create.test.ts b/apps/api/src/handlers/artifacts/__tests__/create.test.ts
index 369956881..b91a1d663 100644
--- a/apps/api/src/handlers/artifacts/__tests__/create.test.ts
+++ b/apps/api/src/handlers/artifacts/__tests__/create.test.ts
@@ -111,4 +111,37 @@ describe('createArtifact', () => {
signingKey: 'signing-key',
});
});
+
+ it('accepts architecture snapshots through the generic artifact route', async () => {
+ mockCreateTaskArtifactRecord.mockResolvedValue({
+ id: 'art-snapshot',
+ version: 2,
+ artifactType: 'architecture-snapshot',
+ });
+
+ const response = await createApp().request('http://localhost/artifacts', {
+ method: 'POST',
+ body: JSON.stringify({
+ taskId: 'task-1',
+ artifactType: 'architecture-snapshot',
+ contentType: 'application/json',
+ path: 'architecture-snapshots/current.json',
+ size: 100,
+ }),
+ headers: { 'content-type': 'application/json' },
+ });
+
+ expect(response.status).toBe(200);
+ expect(mockCreateTaskArtifactRecord).toHaveBeenCalledWith(
+ expect.objectContaining({
+ artifactType: 'architecture-snapshot',
+ path: 'architecture-snapshots/current.json',
+ }),
+ );
+ expect(await response.json()).toMatchObject({
+ id: 'art-snapshot',
+ version: 2,
+ artifactType: 'architecture-snapshot',
+ });
+ });
});
diff --git a/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx b/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx
new file mode 100644
index 000000000..467458842
--- /dev/null
+++ b/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx
@@ -0,0 +1,91 @@
+import type { ReactNode } from 'react';
+import { render, screen } from '@testing-library/react';
+
+const { createMermaidPluginMock } = vi.hoisted(() => ({
+ createMermaidPluginMock: vi.fn(() => ({ name: 'strict-mermaid' })),
+}));
+
+vi.mock('@streamdown/mermaid', () => ({
+ createMermaidPlugin: createMermaidPluginMock,
+}));
+
+vi.mock('streamdown', () => ({
+ Streamdown: ({ children }: { children: ReactNode }) => (
+
{children}
+ ),
+}));
+
+vi.mock('@/components/system', () => ({
+ Alert: ({ children }: { children: ReactNode }) => {children}
,
+ AlertTitle: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDescription: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ Badge: ({ children }: { children: ReactNode }) => {children} ,
+}));
+
+import {
+ ArchitectureSnapshotContent,
+ architectureSnapshotMermaidConfig,
+ toMermaidMarkdown,
+} from './ArchitectureSnapshotContent';
+
+const snapshot = JSON.stringify({
+ schemaVersion: 1,
+ title: 'Artifact publication flow',
+ mermaid: 'flowchart LR\n Agent --> API --> Store',
+ sources: [
+ {
+ repository: 'RooCodeInc/Roomote',
+ path: 'apps/api/src/handlers/artifacts/create.ts',
+ lineStart: 71,
+ lineEnd: 104,
+ description: 'Creates the versioned record.',
+ },
+ ],
+});
+
+describe('ArchitectureSnapshotContent', () => {
+ it('labels generated evidence and shows useful source references', () => {
+ render( );
+
+ expect(screen.getByText('Generated explanatory evidence')).toBeVisible();
+ expect(
+ screen.getByText(/not authoritative architecture documentation/i),
+ ).toBeVisible();
+ expect(screen.getByText('Contract v1')).toBeVisible();
+ expect(screen.getByText('RooCodeInc/Roomote')).toBeVisible();
+ expect(
+ screen.getByText('apps/api/src/handlers/artifacts/create.ts:71-104'),
+ ).toBeVisible();
+ expect(screen.getByText('Creates the versioned record.')).toBeVisible();
+ });
+
+ it('uses strict Mermaid rendering', () => {
+ render( );
+
+ expect(architectureSnapshotMermaidConfig).toEqual({
+ securityLevel: 'strict',
+ suppressErrorRendering: true,
+ });
+ expect(screen.getByTestId('mermaid-source')).toHaveTextContent(
+ 'flowchart LR Agent --> API --> Store',
+ );
+ });
+
+ it('contains backticks in a fence that Mermaid source cannot escape', () => {
+ const markdown = toMermaidMarkdown('flowchart LR\n```\nA --> B');
+
+ expect(markdown.startsWith('````mermaid\n')).toBe(true);
+ expect(markdown.endsWith('\n````')).toBe(true);
+ });
+
+ it('does not render invalid content as Mermaid', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Architecture snapshot unavailable')).toBeVisible();
+ expect(screen.queryByTestId('mermaid-source')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx b/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx
new file mode 100644
index 000000000..e7b48c8a0
--- /dev/null
+++ b/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import { Streamdown } from 'streamdown';
+import { createMermaidPlugin } from '@streamdown/mermaid';
+
+import {
+ parseArchitectureSnapshot,
+ type ArchitectureSnapshot,
+} from '@roomote/types';
+
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Badge,
+} from '@/components/system';
+
+export const architectureSnapshotMermaidConfig = {
+ securityLevel: 'strict',
+ suppressErrorRendering: true,
+} as const;
+
+const strictMermaidPlugin = createMermaidPlugin({
+ config: architectureSnapshotMermaidConfig,
+});
+
+function toMermaidMarkdown(source: string): string {
+ const longestBacktickRun = Math.max(
+ 0,
+ ...Array.from(source.matchAll(/`+/g), (match) => match[0].length),
+ );
+ const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
+ return `${fence}mermaid\n${source}\n${fence}`;
+}
+
+function formatSourceLocation(
+ source: ArchitectureSnapshot['sources'][number],
+): string {
+ if (source.lineStart === undefined) return source.path;
+ if (source.lineEnd === undefined || source.lineEnd === source.lineStart) {
+ return `${source.path}:${source.lineStart}`;
+ }
+ return `${source.path}:${source.lineStart}-${source.lineEnd}`;
+}
+
+export function ArchitectureSnapshotContent({ content }: { content: string }) {
+ const snapshot = parseArchitectureSnapshot(content);
+
+ if (!snapshot.success) {
+ return (
+
+
+ Architecture snapshot unavailable
+
+ This generated artifact does not match the supported snapshot
+ contract. Inspect the raw JSON or download the artifact instead.
+
+
+
+ );
+ }
+
+ return (
+
+
+ Generated explanatory evidence
+
+ This snapshot helps review the changed system boundary. It is not
+ authoritative architecture documentation.
+
+
+
+
+
+
+ {snapshot.data.title}
+
+
+ Contract v{snapshot.data.schemaVersion}
+
+
+
+
+ {toMermaidMarkdown(snapshot.data.mermaid)}
+
+
+
+
+
+
+ Source references
+
+
+ {snapshot.data.sources.map((source, index) => (
+
+
+ {source.repository}
+
+
+ {formatSourceLocation(source)}
+
+ {source.description ? (
+
+ {source.description}
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
+
+export { toMermaidMarkdown };
diff --git a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx
index 2c2268df2..d1173804d 100644
--- a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx
+++ b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx
@@ -51,6 +51,7 @@ vi.mock('@streamdown/math', () => ({
}));
vi.mock('@streamdown/mermaid', () => ({
+ createMermaidPlugin: vi.fn(() => ({})),
mermaid: {},
}));
@@ -62,19 +63,23 @@ vi.mock('sonner', () => ({
},
}));
-vi.mock('@roomote/types', () => ({
- ALL_REPOSITORIES: [],
- DEFAULT_MANAGED_DEPLOYMENT_ACCESS: {
- state: 'active',
- reason: null,
- revision: 1,
- effectiveAt: '2026-01-01T00:00:00.000Z',
- restrictionStartsAt: null,
- remediationUrl: null,
- },
- MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE:
- 'New tasks are paused due to a billing issue. Please check billing.',
-}));
+vi.mock('@roomote/types', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ALL_REPOSITORIES: [],
+ DEFAULT_MANAGED_DEPLOYMENT_ACCESS: {
+ state: 'active',
+ reason: null,
+ revision: 1,
+ effectiveAt: '2026-01-01T00:00:00.000Z',
+ restrictionStartsAt: null,
+ remediationUrl: null,
+ },
+ MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE:
+ 'New tasks are paused due to a billing issue. Please check billing.',
+ };
+});
vi.mock('@/trpc/client', () => ({
useTRPC: () => ({
@@ -180,6 +185,12 @@ vi.mock('@/components/system', () => ({
),
BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}>,
MediaViewerImage: () => image
,
+ Alert: ({ children }: { children: ReactNode }) => {children}
,
+ AlertTitle: ({ children }: { children: ReactNode }) => {children}
,
+ AlertDescription: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ Badge: ({ children }: { children: ReactNode }) => {children} ,
}));
vi.mock('@/components/ai-elements', () => ({
@@ -253,6 +264,41 @@ describe('ArtifactViewerContent', () => {
expect(screen.queryByText('Type: visual-proof')).not.toBeInTheDocument();
});
+ it('renders architecture snapshots in the artifact evidence viewer', () => {
+ render(
+ API',
+ sources: [
+ {
+ repository: 'RooCodeInc/Roomote',
+ path: 'apps/api/src/handlers/artifacts/create.ts',
+ },
+ ],
+ }),
+ }}
+ />,
+ );
+
+ expect(screen.getByText('Generated explanatory evidence')).toBeVisible();
+ expect(screen.getByText('Source references')).toBeVisible();
+ expect(screen.getByText('Raw')).toBeVisible();
+ expect(screen.queryByText('Build this')).not.toBeInTheDocument();
+ });
+
it('hides the Build action when a markdown plan has no fetched content', () => {
render(
= {
json: 'json',
@@ -227,16 +228,24 @@ export function ArtifactViewerContent({
const isMarkdown =
artifact.contentType.includes('markdown') || artifact.path.endsWith('.md');
+ const isArchitectureSnapshot =
+ artifact.artifactType === 'architecture-snapshot';
const isImage = artifact.contentType.startsWith('image/');
const isVideo = artifact.contentType.startsWith('video/');
const isPDF = artifact.contentType === 'application/pdf';
const isText =
- !isMarkdown && !isImage && !isVideo && !isPDF && !!artifact.content;
+ !isMarkdown &&
+ !isArchitectureSnapshot &&
+ !isImage &&
+ !isVideo &&
+ !isPDF &&
+ !!artifact.content;
const language = getLanguageFromPath(artifact.path);
const canRender =
isText ||
(isMarkdown && artifact.content) ||
+ (isArchitectureSnapshot && artifact.content) ||
((isImage || isVideo || isPDF) && artifact.downloadUrl);
const taskPayload = task?.taskRun?.payload as TaskPayload | undefined;
@@ -399,7 +408,7 @@ export function ArtifactViewerContent({
- {canRender && isMarkdown && (
+ {canRender && (isMarkdown || isArchitectureSnapshot) && (
Raw
@@ -418,7 +427,9 @@ export function ArtifactViewerContent({
)}
- {((isMarkdown && isRaw) || isText) && artifact.content && (
-
-
-
+ {isArchitectureSnapshot && !isRaw && artifact.content && (
+
)}
+ {(((isMarkdown || isArchitectureSnapshot) && isRaw) || isText) &&
+ artifact.content && (
+
+
+
+ )}
+
{isImage && (
{
expect.any(Object),
);
});
+
+ it('uses the generic endpoint for architecture snapshots', async () => {
+ global.fetch = vi.fn().mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: 'art-snapshot',
+ version: 1,
+ uploadUrl: 'https://s3.example.com/upload',
+ viewUrl: 'https://test-api.example.com/view',
+ artifactType: 'architecture-snapshot',
+ }),
+ });
+
+ await createArtifactRecord(config, {
+ taskId: 'task-1',
+ path: 'architecture-snapshots/current.json',
+ artifactType: 'architecture-snapshot',
+ contentType: 'application/json',
+ size: 100,
+ });
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://test-api.example.com/api/artifacts',
+ expect.any(Object),
+ );
+ });
});
describe('uploadToPresignedUrl', () => {
diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts
index 8f43c1dac..c281ff9f5 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts
@@ -412,6 +412,27 @@ describe('roomote MCP tool descriptions', () => {
);
});
+ it('documents optional architecture snapshot publication', async () => {
+ const { registeredTools } = await importRoomoteMcpServer();
+ const artifactsTool = getRegisteredTool(
+ registeredTools,
+ 'manage_artifacts',
+ );
+
+ expect(artifactsTool.config.description).toContain(
+ 'Use type "architecture-snapshot" for JSON shaped as',
+ );
+ expect(artifactsTool.config.description).toContain(
+ 'Source paths must be repository-relative.',
+ );
+ expect(artifactsTool.config.description).toContain(
+ 'optional generated explanatory evidence, not authoritative architecture documentation',
+ );
+ expect(artifactsTool.config.description).toContain(
+ 'upload failures must not block task completion',
+ );
+ });
+
it('documents the artifact list action on manage_artifacts', async () => {
const { registeredTools } = await importRoomoteMcpServer();
const artifactsTool = getRegisteredTool(
@@ -437,7 +458,7 @@ describe('roomote MCP tool descriptions', () => {
'Use it to reuse previously uploaded artifact links (for example visual-proof links) instead of relying on transcript memory or re-uploading.',
);
expect(artifactTypeField.description).toBe(
- 'Optional artifact type filter for list (one of "general", "plan", "visual-proof"). Omit to list all artifact types.',
+ 'Optional artifact type filter for list (one of "general", "plan", "visual-proof", "architecture-snapshot"). Omit to list all artifact types.',
);
});
diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/upload.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/upload.test.ts
index 7b6bb40b1..4be2def63 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/upload.test.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/upload.test.ts
@@ -62,6 +62,8 @@ describe('handleUpload', () => {
expect(parsed.success).toBe(true);
expect(parsed.artifactId).toBe('art-1');
expect(parsed.artifactType).toBe('general');
+ expect(parsed.version).toBe(1);
+ expect(parsed.path).toBe('test.md');
expect(parsed.viewUrl).toBe('https://test-api.example.com/view');
expect(parsed.rawUrl).toBeUndefined();
});
@@ -173,6 +175,99 @@ describe('handleUpload', () => {
);
});
+ it('uploads a valid extensionless architecture snapshot as JSON', async () => {
+ await writeFile(
+ join(testDir, 'architecture-snapshot'),
+ JSON.stringify({
+ schemaVersion: 1,
+ title: 'Artifact flow',
+ mermaid: 'flowchart LR\n Agent --> API',
+ sources: [
+ {
+ repository: 'RooCodeInc/Roomote',
+ path: 'apps/api/src/handlers/artifacts/create.ts',
+ },
+ ],
+ }),
+ );
+
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: 'art-snapshot',
+ version: 1,
+ uploadUrl: 'https://s3.example.com/upload',
+ viewUrl: 'https://test-api.example.com/view',
+ artifactType: 'architecture-snapshot',
+ }),
+ })
+ .mockResolvedValueOnce(successfulS3UploadResponse())
+ .mockResolvedValueOnce({ ok: true });
+ global.fetch = fetchMock;
+
+ const result = await handleUpload(
+ {
+ path: 'architecture-snapshot',
+ taskId: 'task-1',
+ artifactType: 'architecture-snapshot',
+ },
+ { ...config, workspacePath: testDir },
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(
+ JSON.parse(fetchMock.mock.calls[0]![1]!.body as string),
+ ).toMatchObject({
+ artifactType: 'architecture-snapshot',
+ contentType: 'application/json',
+ });
+ expect(JSON.parse(result.content[0]!.text)).toMatchObject({
+ success: true,
+ artifactId: 'art-snapshot',
+ artifactType: 'architecture-snapshot',
+ version: 1,
+ path: 'architecture-snapshot',
+ });
+ });
+
+ it('does not start an upload for an invalid architecture snapshot', async () => {
+ await writeFile(
+ join(testDir, 'architecture.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ title: 'Unsafe reference',
+ mermaid: 'flowchart LR\n A --> B',
+ sources: [
+ {
+ repository: 'RooCodeInc/Roomote',
+ path: '../outside.ts',
+ },
+ ],
+ }),
+ );
+ const fetchMock = vi.fn();
+ global.fetch = fetchMock;
+
+ const result = await handleUpload(
+ {
+ path: 'architecture.json',
+ taskId: 'task-1',
+ artifactType: 'architecture-snapshot',
+ },
+ { ...config, workspacePath: testDir },
+ );
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(JSON.parse(result.content[0]!.text)).toMatchObject({
+ success: false,
+ });
+ expect(result.content[0]!.text).toContain(
+ 'Path must be a repository-relative file path',
+ );
+ });
+
it('supports absolute /tmp image paths by storing a relative artifact path', async () => {
// Use /tmp directly (not os.tmpdir() which may differ, e.g. /var/folders on macOS)
const tmpTestDir = realpathSync('/tmp');
diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts
index 1ebe3e746..960ef8b2d 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/index.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts
@@ -91,7 +91,11 @@ export const roomoteMcpServer = new McpServer({
});
let hasSubmittedAutomationSlackSummary = false;
-const manageArtifactsUploadTypeSchema = z.enum(['general', 'visual-proof']);
+const manageArtifactsUploadTypeSchema = z.enum([
+ 'general',
+ 'visual-proof',
+ 'architecture-snapshot',
+]);
const nonEmptyStringSchema = z.string().refine((value) => value.length > 0, {
message: 'Value must be non-empty.',
});
@@ -271,6 +275,7 @@ roomoteMcpServer.registerTool(
'Use action "create_plan" to create a markdown plan artifact (requires title and content). Returns viewUrl for sharing. ' +
'Use action "upload" to upload a workspace-relative file or an absolute file under /tmp (requires path and type). Use type "general" for ordinary files. ' +
'Use type "visual-proof" for uploaded screenshots or proof artifacts that should be treated as visual proof. Visual-proof uploads are not posted to chat automatically; when the image should appear in the originating thread, pass returned artifact IDs to `send_chat_reply` via `imageArtifactIds` (or share `viewUrl`/`rawUrl` in the reply text for non-images). ' +
+ 'Use type "architecture-snapshot" for JSON shaped as {"schemaVersion":1,"title":string,"mermaid":string,"sources":[{"repository":string,"path":string,"lineStart"?:number,"lineEnd"?:number,"description"?:string}]}. Source paths must be repository-relative. Architecture snapshots are optional generated explanatory evidence, not authoritative architecture documentation, and upload failures must not block task completion. ' +
'Returns rawUrl for direct embedding (for example PR ). ' +
'Use action "download" to retrieve an artifact by task ID and artifact path (requires taskId and path). Downloads may target the current task or another task, so artifacts such as plans published by earlier tasks can be retrieved. ' +
'For download, the path must include the category prefix exactly as stored in Roomote (e.g., "plans/my-plan.md" or "tmp/capture.png", not just the filename). ' +
@@ -296,12 +301,12 @@ roomoteMcpServer.registerTool(
type: manageArtifactsUploadTypeSchema
.optional()
.describe(
- 'Artifact type for upload. Required for upload; use "general" for ordinary files and "visual-proof" for visual proof. Visual-proof uploads are not posted to chat automatically.',
+ 'Artifact type for upload. Required for upload; use "general" for ordinary files, "visual-proof" for visual proof, and "architecture-snapshot" for a valid architecture snapshot JSON artifact. Visual-proof uploads are not posted to chat automatically.',
),
artifactType: taskArtifactTypeSchema
.optional()
.describe(
- 'Optional artifact type filter for list (one of "general", "plan", "visual-proof"). Omit to list all artifact types.',
+ 'Optional artifact type filter for list (one of "general", "plan", "visual-proof", "architecture-snapshot"). Omit to list all artifact types.',
),
taskId: z
.string()
diff --git a/apps/worker/src/mcp/roomote-mcp-server/upload.ts b/apps/worker/src/mcp/roomote-mcp-server/upload.ts
index 8162854a2..40f993432 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/upload.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/upload.ts
@@ -1,4 +1,7 @@
-import type { TaskArtifactType } from '@roomote/types';
+import {
+ parseArchitectureSnapshot,
+ type TaskArtifactType,
+} from '@roomote/types';
import {
deletePreparedLocalArtifact,
@@ -10,7 +13,7 @@ import type { ArtifactConfig, ToolResult } from './types.js';
type ManageArtifactsUploadType = Extract<
TaskArtifactType,
- 'general' | 'visual-proof'
+ 'general' | 'visual-proof' | 'architecture-snapshot'
>;
export async function handleUpload(
@@ -31,6 +34,17 @@ export async function handleUpload(
input.path,
config.workspacePath,
);
+ if (input.artifactType === 'architecture-snapshot') {
+ const snapshot = parseArchitectureSnapshot(
+ preparedArtifact.content.toString('utf8'),
+ );
+ if (!snapshot.success) {
+ return errorResult(
+ `Invalid architecture snapshot: ${snapshot.error.issues[0]?.message ?? 'Invalid contract'}`,
+ );
+ }
+ preparedArtifact.contentType = 'application/json';
+ }
const result = await uploadPreparedArtifact(config, {
taskId: input.taskId,
artifactType: input.artifactType,
@@ -44,6 +58,8 @@ export async function handleUpload(
return successResult({
artifactId: result.artifactId,
artifactType: result.artifactType,
+ version: result.version,
+ path: preparedArtifact.artifactPath,
viewUrl: result.viewUrl,
...(result.rawUrl && { rawUrl: result.rawUrl }),
...(input.deleteAfterUpload && { deleted: true }),
diff --git a/packages/types/src/task-artifacts.test.ts b/packages/types/src/task-artifacts.test.ts
new file mode 100644
index 000000000..2db9765a6
--- /dev/null
+++ b/packages/types/src/task-artifacts.test.ts
@@ -0,0 +1,78 @@
+import {
+ architectureSnapshotSchema,
+ parseArchitectureSnapshot,
+ serializeArchitectureSnapshot,
+ taskArtifactTypeSchema,
+ uploadArtifactTypeSchema,
+} from './task-artifacts';
+
+const validSnapshot = {
+ schemaVersion: 1 as const,
+ title: 'Task artifact publication flow',
+ mermaid: 'flowchart LR\n Agent --> API --> Store',
+ sources: [
+ {
+ repository: 'RooCodeInc/Roomote',
+ path: 'apps/api/src/handlers/artifacts/create.ts',
+ lineStart: 71,
+ lineEnd: 104,
+ description: 'Validates and creates the artifact record.',
+ },
+ ],
+};
+
+describe('architectureSnapshotSchema', () => {
+ it('serializes and parses the versioned contract', () => {
+ const serialized = serializeArchitectureSnapshot(validSnapshot);
+ const parsed = parseArchitectureSnapshot(serialized);
+
+ expect(serialized.endsWith('\n')).toBe(true);
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toEqual(validSnapshot);
+ });
+
+ it.each([
+ '/etc/passwd',
+ '../secrets.txt',
+ 'apps/web/../secrets.txt',
+ 'https://example.com/source.ts',
+ 'apps\\web\\source.ts',
+ ])('rejects unsafe source path %s', (path) => {
+ expect(
+ architectureSnapshotSchema.safeParse({
+ ...validSnapshot,
+ sources: [{ ...validSnapshot.sources[0], path }],
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects unsupported versions and invalid line ranges', () => {
+ expect(
+ architectureSnapshotSchema.safeParse({
+ ...validSnapshot,
+ schemaVersion: 2,
+ }).success,
+ ).toBe(false);
+ expect(
+ architectureSnapshotSchema.safeParse({
+ ...validSnapshot,
+ sources: [{ ...validSnapshot.sources[0], lineStart: 20, lineEnd: 10 }],
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects malformed JSON', () => {
+ expect(parseArchitectureSnapshot('{').success).toBe(false);
+ });
+});
+
+describe('architecture-snapshot artifact type', () => {
+ it('is accepted by task and generic upload validation', () => {
+ expect(taskArtifactTypeSchema.parse('architecture-snapshot')).toBe(
+ 'architecture-snapshot',
+ );
+ expect(uploadArtifactTypeSchema.parse('architecture-snapshot')).toBe(
+ 'architecture-snapshot',
+ );
+ });
+});
diff --git a/packages/types/src/task-artifacts.ts b/packages/types/src/task-artifacts.ts
index 8e43cb230..d06f5dc8a 100644
--- a/packages/types/src/task-artifacts.ts
+++ b/packages/types/src/task-artifacts.ts
@@ -1,8 +1,16 @@
import { z } from 'zod';
-export const taskArtifactTypes = ['general', 'plan', 'visual-proof'] as const;
+export const taskArtifactTypes = [
+ 'general',
+ 'plan',
+ 'visual-proof',
+ 'architecture-snapshot',
+] as const;
-export const uploadArtifactTypes = ['general'] as const;
+export const uploadArtifactTypes = [
+ 'general',
+ 'architecture-snapshot',
+] as const;
export type TaskArtifactType = (typeof taskArtifactTypes)[number];
export type UploadArtifactType = (typeof uploadArtifactTypes)[number];
@@ -18,6 +26,105 @@ export const INVALID_TASK_ARTIFACT_TYPE_ERROR =
export const taskArtifactTypeSchema = z.enum(taskArtifactTypes);
export const uploadArtifactTypeSchema = z.enum(uploadArtifactTypes);
+export const ARCHITECTURE_SNAPSHOT_SCHEMA_VERSION = 1 as const;
+
+const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
+const urlSchemePattern = /^[a-z][a-z\d+.-]*:/i;
+
+export const architectureSnapshotSourceSchema = z
+ .object({
+ repository: z
+ .string()
+ .min(1)
+ .max(300)
+ .refine((value) => value === value.trim(), 'Repository must be trimmed')
+ .refine(
+ (value) => !controlCharacterPattern.test(value),
+ 'Repository must not contain control characters',
+ ),
+ path: z
+ .string()
+ .min(1)
+ .max(1000)
+ .refine((value) => value === value.trim(), 'Path must be trimmed')
+ .refine(
+ (value) => !controlCharacterPattern.test(value),
+ 'Path must not contain control characters',
+ )
+ .refine(
+ (value) =>
+ !value.startsWith('/') &&
+ !value.startsWith('\\') &&
+ !value.includes('\\') &&
+ !urlSchemePattern.test(value) &&
+ value
+ .split('/')
+ .every(
+ (segment) =>
+ segment.length > 0 && segment !== '.' && segment !== '..',
+ ),
+ 'Path must be a repository-relative file path',
+ ),
+ lineStart: z.number().int().positive().optional(),
+ lineEnd: z.number().int().positive().optional(),
+ description: z.string().min(1).max(500).optional(),
+ })
+ .strict()
+ .superRefine((source, ctx) => {
+ if (source.lineEnd !== undefined && source.lineStart === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['lineEnd'],
+ message: 'lineEnd requires lineStart',
+ });
+ }
+
+ if (
+ source.lineStart !== undefined &&
+ source.lineEnd !== undefined &&
+ source.lineEnd < source.lineStart
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['lineEnd'],
+ message: 'lineEnd must be greater than or equal to lineStart',
+ });
+ }
+ });
+
+export const architectureSnapshotSchema = z
+ .object({
+ schemaVersion: z.literal(ARCHITECTURE_SNAPSHOT_SCHEMA_VERSION),
+ title: z.string().min(1).max(200),
+ mermaid: z
+ .string()
+ .min(1)
+ .max(100_000)
+ .refine(
+ (value) => !value.includes('\u0000'),
+ 'Mermaid source must not contain null bytes',
+ ),
+ sources: z.array(architectureSnapshotSourceSchema).min(1).max(100),
+ })
+ .strict();
+
+export type ArchitectureSnapshot = z.infer;
+
+export function parseArchitectureSnapshot(content: string) {
+ try {
+ return architectureSnapshotSchema.safeParse(JSON.parse(content));
+ } catch {
+ return architectureSnapshotSchema.safeParse(undefined);
+ }
+}
+
+export function serializeArchitectureSnapshot(
+ snapshot: ArchitectureSnapshot,
+): string {
+ const validatedSnapshot = architectureSnapshotSchema.parse(snapshot);
+ return `${JSON.stringify(validatedSnapshot, null, 2)}\n`;
+}
+
export function resolveCreateArtifactType(params: {
rawArtifactType: unknown;
forcedArtifactType?: ReservedTaskArtifactType;