Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions backend/src/storage/artifacts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { Controller, Get, Query, Param, Res, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import {
Controller,
Get,
Delete,
Query,
Param,
Res,
StreamableFile,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiOkResponse, ApiNoContentResponse, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from 'nestjs-zod';
import type { Response } from 'express';

Expand Down Expand Up @@ -60,4 +70,16 @@ export class ArtifactsController {

return new StreamableFile(buffer);
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse({
description: 'Artifact deleted successfully',
})
async deleteArtifact(
@CurrentAuth() auth: AuthContext | null,
@Param(new ZodValidationPipe(ArtifactIdParamSchema)) params: ArtifactIdParamDto,
) {
await this.artifactsService.deleteArtifact(auth, params.id);
}
}
10 changes: 10 additions & 0 deletions backend/src/storage/artifacts.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ export class ArtifactsRepository {
return and(base, eq(artifactsTable.organizationId, organizationId));
}

async delete(id: string, options: { organizationId?: string | null } = {}): Promise<boolean> {
const filters = [eq(artifactsTable.id, id)];
if (options.organizationId) {
filters.push(eq(artifactsTable.organizationId, options.organizationId));
}
const where = filters.length > 1 ? and(...filters) : filters[0];
const result = await this.db.delete(artifactsTable).where(where).returning();
return result.length > 0;
}

private buildFilters(options: ArtifactQueryOptions) {
const filters = [];

Expand Down
19 changes: 19 additions & 0 deletions backend/src/storage/artifacts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ export class ArtifactsService {
};
}

async deleteArtifact(auth: AuthContext | null, artifactId: string): Promise<void> {
const artifact = await this.getArtifactRecord(auth, artifactId);
const organizationId = this.requireOrganizationId(auth);

// Delete the associated file first
try {
await this.filesService.deleteFile(auth, artifact.fileId);
} catch (error) {
// Log but don't fail if file is already deleted
console.warn(`Failed to delete file ${artifact.fileId} for artifact ${artifactId}:`, error);
}

// Delete the artifact record
const deleted = await this.repository.delete(artifactId, { organizationId });
if (!deleted) {
throw new NotFoundException(`Artifact ${artifactId} not found`);
}
}

private toMetadata(record: ArtifactRecord): ArtifactMetadataDto {
return {
id: record.id,
Expand Down
7 changes: 7 additions & 0 deletions backend/src/storage/dto/artifacts.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ export const ArtifactIdParamSchema = z.object({
});

export class ArtifactIdParamDto extends createZodDto(ArtifactIdParamSchema) {}

// Schema for run artifact downloads where the path param is :artifactId
export const RunArtifactIdParamSchema = z.object({
artifactId: z.string().uuid(),
});

export class RunArtifactIdParamDto extends createZodDto(RunArtifactIdParamSchema) {}
6 changes: 3 additions & 3 deletions backend/src/workflows/workflows.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { CurrentAuth } from '../auth/auth-context.decorator';
import type { AuthContext } from '../auth/types';
import { RequireWorkflowRole, WorkflowRoleGuard } from './workflow-role.guard';
import { RunArtifactsResponseDto } from '../storage/dto/artifact.dto';
import { ArtifactIdParamDto, ArtifactIdParamSchema } from '../storage/dto/artifacts.dto';
import { RunArtifactIdParamDto, RunArtifactIdParamSchema } from '../storage/dto/artifacts.dto';
import type { WorkflowTerminalRecord } from '../database/schema';
import { NodeIOService } from '../node-io/node-io.service';

Expand Down Expand Up @@ -757,14 +757,14 @@ export class WorkflowsController {
})
async downloadRunArtifact(
@Param('runId') runId: string,
@Param(new ZodValidationPipe(ArtifactIdParamSchema)) params: ArtifactIdParamDto,
@Param(new ZodValidationPipe(RunArtifactIdParamSchema)) params: RunArtifactIdParamDto,
@CurrentAuth() auth: AuthContext | null,
@Res({ passthrough: true }) res: Response,
) {
const { artifact, buffer, file } = await this.artifactsService.downloadArtifactForRun(
auth,
runId,
params.id,
params.artifactId,
);

res.setHeader('Content-Type', file.mimeType);
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/components/artifacts/RunArtifactsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) {
copiedRemoteUri,
]);

const handleDownloadAll = () => {
if (!entry || !entry.artifacts.length) return;
entry.artifacts.forEach((artifact) => {
downloadArtifact(artifact, { runId: runId || undefined });
});
};

return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-background/70 px-4 py-2">
Expand All @@ -168,6 +175,18 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) {
Refresh
</Button>
) : null}
{entry?.artifacts && entry.artifacts.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDownloadAll}
className="gap-2"
>
<Download className="h-4 w-4" />
Download All
</Button>
)}
</div>
{content}
</div>
Expand All @@ -177,8 +196,6 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) {
function ArtifactRow({
artifact,
onDownload,
onCopy,
copied,
onCopyRemoteUri,
copiedRemoteUri,
isDownloading,
Expand Down Expand Up @@ -240,12 +257,12 @@ function ArtifactRow({
<td className="px-4 py-3 align-top text-sm text-muted-foreground">
{formatTimestamp(artifact.createdAt)}
</td>
<td className="px-4 py-3 align-top text-right">
<td className="px-4 py-3 align-top text-right overflow-x-auto">
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCopy} className="gap-2">
{/* <Button type="button" variant="ghost" size="sm" onClick={onCopy} className="gap-2">
<Copy className="h-4 w-4" />
{copied ? 'Copied' : 'Copy ID'}
</Button>
</Button> */}
<Button
type="button"
variant="ghost"
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/features/workflow-builder/WorkflowBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,9 +594,10 @@ function WorkflowBuilderContent() {

// Initialize execution state only when there's no run context; otherwise the execution
// lifecycle hook will load the appropriate version for the routed run.
// IMPORTANT: Always initialize when execution nodes are empty to prevent blank canvas.
const executionNodesEmpty = executionNodesRef.current.length === 0;
const shouldInitializeExecution =
!hasRunContext &&
(executionNodesRef.current.length === 0 || executionEdgesRef.current.length === 0);
executionNodesEmpty || (!hasRunContext && executionEdgesRef.current.length === 0);

if (shouldInitializeExecution) {
setExecutionNodes(cloneNodes(workflowNodes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function useWorkflowExecutionLifecycle({
// If execution graph is empty when navigating directly to a run, hydrate from the latest
// design snapshot so the canvas isn't blank while we load the historical version.
if (executionNodesRef.current.length === 0 && executionEdgesRef.current.length === 0) {
if (designSavedSnapshotRef.current) {
if (designSavedSnapshotRef.current && designSavedSnapshotRef.current.nodes.length > 0) {
const savedNodes = cloneNodes(designSavedSnapshotRef.current.nodes);
const savedEdges = cloneEdges(designSavedSnapshotRef.current.edges);
const terminalNodes = executionNodesRef.current.filter((n) => n.type === 'terminal');
Expand All @@ -263,7 +263,7 @@ export function useWorkflowExecutionLifecycle({
nodes: cloneNodes(savedNodes),
edges: cloneEdges(savedEdges),
};
} else {
} else if (designNodesRef.current.length > 0) {
const designNodesCopy = cloneNodes(designNodesRef.current);
const designEdgesCopy = cloneEdges(designEdgesRef.current);
const terminalNodes = executionNodesRef.current.filter((n) => n.type === 'terminal');
Expand All @@ -273,6 +273,11 @@ export function useWorkflowExecutionLifecycle({
nodes: cloneNodes(designNodesCopy),
edges: cloneEdges(designEdgesCopy),
};
} else {
// Both execution and design graphs are empty - workflow hasn't loaded yet.
// Return early and let the workflow load effect populate the design state first.
// This effect will re-run when the design state is populated.
return;
}
}

Expand Down
98 changes: 61 additions & 37 deletions frontend/src/pages/ArtifactLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, RefreshCw, Search, Copy, ExternalLink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Download, RefreshCw, Search, Copy, ExternalLink, Trash2 } from 'lucide-react';
import { useArtifactStore } from '@/store/artifactStore';
import { api } from '@/services/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { ArtifactMetadata } from '@shipsec/shared';
Expand All @@ -26,21 +28,33 @@ const formatTimestamp = (value: string) => {

export function ArtifactLibrary() {
const [searchQuery, setSearchQuery] = useState('');
const { library, libraryLoading, libraryError, fetchLibrary, downloadArtifact, downloading } =
useArtifactStore();
const [copiedId, setCopiedId] = useState<string | null>(null);
const {
library,
libraryLoading,
libraryError,
fetchLibrary,
downloadArtifact,
downloading,
deleteArtifact,
deleting,
} = useArtifactStore();
const [copiedRemoteUri, setCopiedRemoteUri] = useState<string | null>(null);
const [workflows, setWorkflows] = useState<Record<string, string>>({});

const handleCopy = useCallback(async (artifactId: string) => {
try {
await navigator.clipboard.writeText(artifactId);
setCopiedId(artifactId);
setTimeout(() => {
setCopiedId((current) => (current === artifactId ? null : current));
}, 2000);
} catch (error) {
console.error('Failed to copy artifact ID', error);
}
useEffect(() => {
const loadWorkflows = async () => {
try {
const list = await api.workflows.list();
const map: Record<string, string> = {};
list.forEach((w) => {
if (w.id) map[w.id] = w.name;
});
setWorkflows(map);
} catch (err) {
console.error('Failed to load workflows', err);
}
};
loadWorkflows();
}, []);

useEffect(() => {
Expand Down Expand Up @@ -128,29 +142,28 @@ export function ArtifactLibrary() {
<thead className="sticky top-0 bg-background shadow-sm">
<tr className="text-left text-xs uppercase text-muted-foreground">
<th className="px-3 md:px-6 py-3 font-medium min-w-[150px]">Name</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[150px] hidden sm:table-cell">
Workflow
</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[100px] hidden sm:table-cell">
Run
</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[100px] hidden md:table-cell">
Component
</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[60px]">Size</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[100px] hidden lg:table-cell">
Created
</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[120px] text-right">
Actions
</th>
<th className="px-3 md:px-4 py-3 font-medium min-w-[120px] text-left">Actions</th>
</tr>
</thead>
<tbody>
{library.map((artifact) => (
<ArtifactLibraryRow
key={artifact.id}
artifact={artifact}
workflowName={workflows[artifact.workflowId] || 'Unknown Workflow'}
onDownload={() => downloadArtifact(artifact)}
onCopy={() => handleCopy(artifact.id)}
copied={copiedId === artifact.id}
onDelete={() => deleteArtifact(artifact.id)}
isDeleting={Boolean(deleting[artifact.id])}
onCopyRemoteUri={async (uri: string) => {
try {
await navigator.clipboard.writeText(uri);
Expand All @@ -177,20 +190,22 @@ export function ArtifactLibrary() {

function ArtifactLibraryRow({
artifact,
workflowName,
onDownload,
onCopy,
copied,
onDelete,
onCopyRemoteUri,
copiedRemoteUri,
isDownloading,
isDeleting,
}: {
artifact: ArtifactMetadata;
workflowName: string;
onDownload: () => void;
onCopy: () => void;
copied: boolean;
onDelete: () => void;
onCopyRemoteUri: (uri: string) => void;
copiedRemoteUri: string | null;
isDownloading: boolean;
isDeleting: boolean;
}) {
const remoteUploads = getRemoteUploads(artifact);

Expand Down Expand Up @@ -242,29 +257,38 @@ function ArtifactLibraryRow({
</div>
)}
</td>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm text-muted-foreground font-mono hidden sm:table-cell">
<span className="truncate max-w-[80px] md:max-w-none block">{artifact.runId}</span>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm text-muted-foreground hidden sm:table-cell">
<span className="truncate max-w-[150px] block" title={workflowName}>
{workflowName}
</span>
</td>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm text-muted-foreground hidden md:table-cell">
<span className="truncate max-w-[100px] block">{artifact.componentRef}</span>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm text-primary hidden sm:table-cell">
<Link to={`/runs/${artifact.runId}`} className="hover:underline font-mono">
{artifact.runId.substring(0, 8)}…
</Link>
</td>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm">
{formatBytes(artifact.size)}
</td>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-xs md:text-sm text-muted-foreground hidden lg:table-cell">
{formatTimestamp(artifact.createdAt)}
</td>
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-right">
<div className="flex flex-wrap justify-end gap-1 md:gap-2">
<td className="px-3 md:px-4 py-3 md:py-4 align-top text-left">
<div className="flex flex-wrap justify-start gap-1 md:gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1 md:gap-2 h-8 px-2 md:px-3"
onClick={onCopy}
className="gap-1 md:gap-2 h-8 px-2 md:px-3 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm('Are you sure you want to delete this artifact?')) {
onDelete();
}
}}
disabled={isDeleting}
>
<Copy className="h-4 w-4" />
<span className="hidden md:inline">{copied ? 'Copied' : 'Copy ID'}</span>
<Trash2 className="h-4 w-4" />
<span className="hidden md:inline">{isDeleting ? 'Deleting…' : 'Delete'}</span>
</Button>
<Button
type="button"
Expand Down
Loading