diff --git a/frontend/src/components/workflow/ConfigPanel.tsx b/frontend/src/components/workflow/ConfigPanel.tsx index dbdc174f..ae469aa3 100644 --- a/frontend/src/components/workflow/ConfigPanel.tsx +++ b/frontend/src/components/workflow/ConfigPanel.tsx @@ -31,6 +31,7 @@ import { useComponentStore } from '@/store/componentStore'; import { ParameterFieldWrapper } from './ParameterField'; import { WebhookDetails } from './WebhookDetails'; import { SecretSelect } from '@/components/inputs/SecretSelect'; +import { DynamicArtifactNameInput } from './DynamicArtifactNameInput'; import type { Node } from 'reactflow'; import type { FrontendNodeData } from '@/schemas/node'; import type { ComponentType, KeyboardEvent } from 'react'; @@ -913,6 +914,20 @@ export function ConfigPanel({ placeholder={manualPlaceholder} onChange={(value) => handleInputOverrideChange(input.id, value)} /> + ) : component?.id === 'core.artifact.writer' && + input.id === 'artifactName' ? ( + { + if (!value || value === '') { + handleInputOverrideChange(input.id, undefined); + } else { + handleInputOverrideChange(input.id, value); + } + }} + disabled={manualLocked} + placeholder="{{run_id}}-{{timestamp}}" + /> ) : ( )} - {manualLocked ? ( -

- Disconnect the port to edit manual input. -

- ) : ( -

- {isBooleanInput - ? 'Select a value or clear manual input to require a port connection.' - : isListOfTextInput - ? 'Add entries or clear manual input to require a port connection.' - : 'Leave blank to require a port connection.'} -

- )} + {/* Skip helper text for DynamicArtifactNameInput as it has its own */} + {!( + component?.id === 'core.artifact.writer' && input.id === 'artifactName' + ) && + (manualLocked ? ( +

+ Disconnect the port to edit manual input. +

+ ) : ( +

+ {isBooleanInput + ? 'Select a value or clear manual input to require a port connection.' + : isListOfTextInput + ? 'Add entries or clear manual input to require a port connection.' + : 'Leave blank to require a port connection.'} +

+ ))} )} diff --git a/frontend/src/components/workflow/DynamicArtifactNameInput.tsx b/frontend/src/components/workflow/DynamicArtifactNameInput.tsx new file mode 100644 index 00000000..0f838ba0 --- /dev/null +++ b/frontend/src/components/workflow/DynamicArtifactNameInput.tsx @@ -0,0 +1,77 @@ +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface DynamicParameter { + placeholder: string; + description: string; +} + +const DYNAMIC_PARAMETERS: DynamicParameter[] = [ + { placeholder: '{{run_id}}', description: 'Full workflow run ID' }, + { placeholder: '{{node_id}}', description: 'Component node ID in workflow' }, + { placeholder: '{{timestamp}}', description: 'Unix timestamp (ms)' }, + { placeholder: '{{date}}', description: 'Date (YYYY-MM-DD)' }, + { placeholder: '{{time}}', description: 'Time (HH-MM-SS)' }, +]; + +interface DynamicArtifactNameInputProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +export function DynamicArtifactNameInput({ + value, + onChange, + disabled = false, + placeholder = '{{run_id}}-{{timestamp}}', +}: DynamicArtifactNameInputProps) { + const currentValue = value || ''; + + const handleInsertPlaceholder = (selectedPlaceholder: string) => { + if (disabled) return; + // Insert at the end of current value + const newValue = currentValue ? `${currentValue}${selectedPlaceholder}` : selectedPlaceholder; + onChange(newValue); + }; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="text-sm font-mono" + disabled={disabled} + /> + +
+ +
+ +

+ Type directly or select a placeholder to insert. Example: scan-{'{{date}}'}-{'{{time}}'} +

+
+ ); +} diff --git a/worker/src/components/core/__tests__/artifact-writer.test.ts b/worker/src/components/core/__tests__/artifact-writer.test.ts index c05f6e25..d70e39b8 100644 --- a/worker/src/components/core/__tests__/artifact-writer.test.ts +++ b/worker/src/components/core/__tests__/artifact-writer.test.ts @@ -27,7 +27,7 @@ describe('core.artifact.writer component', () => { const uploadMock = vi.fn().mockResolvedValue({ artifactId: 'artifact-123', fileId: 'file-123', - name: 'playground-artifact.txt', + name: 'run-log.txt', destinations: ['run', 'library'], }); @@ -44,10 +44,11 @@ describe('core.artifact.writer component', () => { const executePayload = { inputs: { + artifactName: 'run-log', content: 'Hello artifacts!', }, params: { - fileName: 'run-log.txt', + fileExtension: '.txt', mimeType: 'text/plain', saveToRunArtifacts: true, publishToArtifactLibrary: true, @@ -65,16 +66,61 @@ describe('core.artifact.writer component', () => { expect(result.saved).toBe(true); expect(result.artifactId).toBe('artifact-123'); + expect(result.artifactName).toBe('run-log'); + expect(result.fileName).toBe('run-log.txt'); expect(result.destinations).toEqual(['run', 'library']); }); + it('substitutes dynamic placeholders in artifact name', async () => { + if (!component) throw new Error('Component not registered'); + + const uploadMock = vi.fn().mockResolvedValue({ + artifactId: 'artifact-456', + fileId: 'file-456', + name: 'test-artifact.json', + destinations: ['run'], + }); + + const mockArtifacts: IArtifactService = { + upload: uploadMock, + download: vi.fn(), + }; + + const context = createExecutionContext({ + runId: 'test-run-abc123', + componentRef: 'artifact-writer-2', + artifacts: mockArtifacts, + }); + + const executePayload = { + inputs: { + artifactName: '{{run_id}}-{{node_id}}', + content: { data: 'test' }, + }, + params: { + fileExtension: '.json', + mimeType: 'application/json', + saveToRunArtifacts: true, + publishToArtifactLibrary: false, + }, + }; + + const result = await component.execute(executePayload, context); + + expect(uploadMock).toHaveBeenCalledTimes(1); + const payload = uploadMock.mock.calls[0][0]; + expect(payload.name).toBe('test-run-abc123-artifact-writer-2.json'); + expect(result.artifactName).toBe('test-run-abc123-artifact-writer-2'); + expect(result.fileName).toBe('test-run-abc123-artifact-writer-2.json'); + }); + it('skips upload when no destinations are selected', async () => { if (!component) throw new Error('Component not registered'); const uploadMock = vi.fn(); const context = createExecutionContext({ runId: 'run-2', - componentRef: 'artifact-writer-2', + componentRef: 'artifact-writer-skip', artifacts: { upload: uploadMock, download: vi.fn(), @@ -83,10 +129,11 @@ describe('core.artifact.writer component', () => { const executePayload = { inputs: { + artifactName: 'noop', content: 'No destinations', }, params: { - fileName: 'noop.txt', + fileExtension: '.txt', saveToRunArtifacts: false, publishToArtifactLibrary: false, }, @@ -97,6 +144,8 @@ describe('core.artifact.writer component', () => { expect(uploadMock).not.toHaveBeenCalled(); expect(result.saved).toBe(false); expect(result.artifactId).toBeUndefined(); + expect(result.artifactName).toBe('noop'); + expect(result.fileName).toBe('noop.txt'); expect(result.destinations).toEqual([]); }); @@ -110,9 +159,11 @@ describe('core.artifact.writer component', () => { const executePayload = { inputs: { + artifactName: 'test-artifact', content: 'Need artifacts', }, params: { + fileExtension: '.txt', saveToRunArtifacts: true, publishToArtifactLibrary: false, }, @@ -122,4 +173,47 @@ describe('core.artifact.writer component', () => { 'Artifact service is not available', ); }); + + it('uses default artifact name template when not provided', async () => { + if (!component) throw new Error('Component not registered'); + + const uploadMock = vi.fn().mockResolvedValue({ + artifactId: 'artifact-default', + fileId: 'file-default', + name: 'default.txt', + destinations: ['run'], + }); + + const mockArtifacts: IArtifactService = { + upload: uploadMock, + download: vi.fn(), + }; + + const context = createExecutionContext({ + runId: 'run-default-test', + componentRef: 'artifact-writer-default', + artifacts: mockArtifacts, + }); + + const executePayload = { + inputs: { + // artifactName not provided, should use default template + content: 'Default name test', + }, + params: { + fileExtension: '.txt', + saveToRunArtifacts: true, + publishToArtifactLibrary: false, + }, + }; + + const result = await component.execute(executePayload, context); + + expect(uploadMock).toHaveBeenCalledTimes(1); + const payload = uploadMock.mock.calls[0][0]; + // Should contain run_id and timestamp pattern + expect(payload.name).toMatch(/^run-default-test-\d+\.txt$/); + expect(result.artifactName).toMatch(/^run-default-test-\d+$/); + expect(result.saved).toBe(true); + }); }); diff --git a/worker/src/components/core/artifact-writer.ts b/worker/src/components/core/artifact-writer.ts index 292741e2..a31d51fe 100644 --- a/worker/src/components/core/artifact-writer.ts +++ b/worker/src/components/core/artifact-writer.ts @@ -11,6 +11,20 @@ import { } from '@shipsec/component-sdk'; const inputSchema = inputs({ + artifactName: port( + z + .string() + .optional() + .describe( + 'Name for the artifact. Supports dynamic placeholders: {{run_id}}, {{node_id}}, {{timestamp}}, {{date}}, {{time}}. Defaults to {{run_id}}-{{timestamp}}.', + ), + { + label: 'Artifact Name', + description: + 'Name for the artifact file. Use dynamic placeholders like {{run_id}}, {{timestamp}} for unique names.', + editor: 'text', + }, + ), content: port( z .any() @@ -29,17 +43,40 @@ const inputSchema = inputs({ ), }); +/** + * Substitutes dynamic placeholders in artifact name template. + * Supported placeholders: + * - {{run_id}} - Full workflow run ID + * - {{node_id}} - Component's node ID in the workflow (e.g., "artifact-writer-1") + * - {{timestamp}} - Unix timestamp in milliseconds + * - {{date}} - ISO date (YYYY-MM-DD) + * - {{time}} - ISO time (HH-MM-SS) + */ +function substituteArtifactName( + template: string, + context: { runId: string; componentRef: string }, +): string { + const now = new Date(); + const timestamp = now.getTime().toString(); + const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + const isoTime = now.toISOString().split('T')[1].split('.')[0].replace(/:/g, '-'); // HH-MM-SS + + return template + .replace(/\{\{run_id\}\}/gi, context.runId) + .replace(/\{\{node_id\}\}/gi, context.componentRef) + .replace(/\{\{timestamp\}\}/gi, timestamp) + .replace(/\{\{date\}\}/gi, isoDate) + .replace(/\{\{time\}\}/gi, isoTime); +} + const parameterSchema = parameters({ - fileName: param( - z - .string() - .min(1, 'File name is required') - .default('artifact.txt') - .describe('File name to assign to the saved artifact.'), + fileExtension: param( + z.string().default('.txt').describe('File extension to append to the artifact name.'), { - label: 'File Name', + label: 'File Extension', editor: 'text', - description: 'File name to use when saving the artifact.', + description: + 'File extension (e.g., .txt, .json, .csv). Will be appended to the artifact name.', }, ), mimeType: param( @@ -76,9 +113,13 @@ const outputSchema = outputs({ label: 'Artifact ID', description: 'Identifier returned by the artifact service.', }), + artifactName: port(z.string(), { + label: 'Artifact Name', + description: 'Resolved name of the artifact (with placeholders substituted).', + }), fileName: port(z.string(), { label: 'File Name', - description: 'Name of the artifact file that was written.', + description: 'Full file name including extension.', }), size: port(z.number(), { label: 'Size', @@ -125,6 +166,19 @@ const definition = defineComponent({ destinations.push('library'); } + // Resolve artifact name with dynamic placeholders + const artifactNameTemplate = inputs.artifactName || '{{run_id}}-{{timestamp}}'; + const resolvedArtifactName = substituteArtifactName(artifactNameTemplate, { + runId: context.runId, + componentRef: context.componentRef, + }); + + // Build full filename with extension + const extension = params.fileExtension || '.txt'; + const fileName = resolvedArtifactName.endsWith(extension) + ? resolvedArtifactName + : `${resolvedArtifactName}${extension}`; + // Serialize content to string - if already a string, use as-is; otherwise JSON stringify const rawContent = inputs.content; let serializedContent: string; @@ -141,7 +195,8 @@ const definition = defineComponent({ context.logger.info('[ArtifactWriter] No destinations selected; skipping upload.'); return { artifactId: undefined, - fileName: params.fileName, + artifactName: resolvedArtifactName, + fileName, size: Buffer.byteLength(serializedContent), destinations: [], saved: false, @@ -157,13 +212,13 @@ const definition = defineComponent({ const buffer = Buffer.from(serializedContent, 'utf-8'); context.logger.info( - `[ArtifactWriter] Uploading '${params.fileName}' (${buffer.byteLength} bytes) to ${destinations.join( + `[ArtifactWriter] Uploading '${fileName}' (${buffer.byteLength} bytes) to ${destinations.join( ', ', )}`, ); const upload = await context.artifacts.upload({ - name: params.fileName, + name: fileName, mimeType: params.mimeType ?? 'text/plain', content: buffer, destinations, @@ -171,7 +226,8 @@ const definition = defineComponent({ return { artifactId: upload.artifactId, - fileName: params.fileName, + artifactName: resolvedArtifactName, + fileName, size: buffer.byteLength, destinations, saved: true,