This commit is contained in:
j3d1 2026-07-23 05:17:44 +02:00
parent 796fef81be
commit 7df585d774
11 changed files with 246 additions and 72 deletions

View file

@ -125,12 +125,12 @@ class StorageLocation(models.Model):
class WorkflowInstance(models.Model): class WorkflowInstance(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
state = models.CharField(max_length=255) state = models.CharField(max_length=255)
payload = models.JSONField(default=dict, blank=True) payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.status})" return f"{self.name} ({self.state})"

View file

@ -1,4 +1,5 @@
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
import json
class CategoryTestMixin: class CategoryTestMixin:
@ -58,28 +59,29 @@ class LocationTestMixin:
class WorkflowTestMixin: class WorkflowTestMixin:
def prepare_workflows(self): def prepare_workflows(self):
# `payload` is an opaque, frontend-serialized JSON string on the backend.
self.f['workflow1'] = WorkflowInstance.objects.create( self.f['workflow1'] = WorkflowInstance.objects.create(
name='workflow1', name='workflow1',
state='initial', state='initial',
payload={}, payload=json.dumps({}),
owner=self.f['local_user1'] owner=self.f['local_user1']
) )
self.f['workflow2'] = WorkflowInstance.objects.create( self.f['workflow2'] = WorkflowInstance.objects.create(
name='workflow1', name='workflow1',
state='upload', state='upload',
payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}, payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}),
owner=self.f['local_user1'] owner=self.f['local_user1']
) )
self.f['workflow3'] = WorkflowInstance.objects.create( self.f['workflow3'] = WorkflowInstance.objects.create(
name='workflow1', name='workflow1',
state='describe', state='describe',
payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'], payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']}, 'descriptions': ['file 1 description']}),
owner=self.f['local_user1'] owner=self.f['local_user1']
) )
self.f['workflow_user2'] = WorkflowInstance.objects.create( self.f['workflow_user2'] = WorkflowInstance.objects.create(
name='workflow2', name='workflow2',
state='initial', state='initial',
payload={}, payload=json.dumps({}),
owner=self.f['local_user2'] owner=self.f['local_user2']
) )

View file

@ -29,9 +29,9 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
self.assertEqual(reply.data[0]['state'], 'initial') self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(reply.data[1]['state'], 'upload') self.assertEqual(reply.data[1]['state'], 'upload')
self.assertEqual(reply.data[2]['state'], 'describe') self.assertEqual(reply.data[2]['state'], 'describe')
self.assertEqual(reply.data[0]['payload'], {}) self.assertEqual(json.loads(reply.data[0]['payload']), {})
self.assertEqual(reply.data[1]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}) self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
self.assertEqual(reply.data[2]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'b1c2d3e4f5a60718293b4c5d6e7f8090'], 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']}) 'descriptions': ['file 1 description']})
@ -44,5 +44,5 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
self.assertEqual(len(reply.data), 1) self.assertEqual(len(reply.data), 1)
self.assertEqual(reply.data[0]['name'], 'workflow2') self.assertEqual(reply.data[0]['name'], 'workflow2')
self.assertEqual(reply.data[0]['state'], 'initial') self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(reply.data[0]['payload'], {}) self.assertEqual(json.loads(reply.data[0]['payload']), {})

View file

@ -11,6 +11,7 @@ import FotoFirstStep2 from './steps/FotoFirstStep2.vue';
import FotoFirstStep3 from './steps/FotoFirstStep3.vue'; import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
import FotoFirstStep4 from './steps/FotoFirstStep4.vue'; import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
import BulkImportStep1 from './steps/BulkImportStep1.vue'; import BulkImportStep1 from './steps/BulkImportStep1.vue';
import BulkImportStep6 from './steps/BulkImportStep6.vue';
/** /**
* Component registry mapping workflow types and steps to components * Component registry mapping workflow types and steps to components
@ -25,6 +26,7 @@ const componentRegistry = {
// Bulk Item Import Workflow components // Bulk Item Import Workflow components
'import-items-1': BulkImportStep1, 'import-items-1': BulkImportStep1,
'import-items-6': BulkImportStep6,
// Additional steps can be added as needed // Additional steps can be added as needed
// 'import-items-2': BulkImportStep2, // 'import-items-2': BulkImportStep2,
// 'import-items-3': BulkImportStep3, // 'import-items-3': BulkImportStep3,

View file

@ -36,7 +36,8 @@ The `stepComponent` computed property now uses the registry:
```javascript ```javascript
stepComponent() { stepComponent() {
const workflowType = this.workflowInstance?.workflow_type; // `name` on the WorkflowInstance doubles as the workflow type identifier
const workflowType = this.workflowInstance?.name;
if (workflowType && this.currentStep) { if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep); return getStepComponent(workflowType, this.currentStep);
} }

View file

@ -218,6 +218,7 @@ export default {
isDragOver: false, isDragOver: false,
fileAnalysis: null, fileAnalysis: null,
detectedColumns: [], detectedColumns: [],
parsedRows: [],
previewData: [], previewData: [],
columnMapping: {}, columnMapping: {},
requiredFields: [ requiredFields: [
@ -239,11 +240,11 @@ export default {
}, },
mounted() { mounted() {
// Load existing data if resuming // Load existing data if resuming
if (this.payload.uploaded_file) { if (this.payload.file_name) {
this.uploadedFile = this.payload.uploaded_file;
this.uploadTime = this.payload.upload_time; this.uploadTime = this.payload.upload_time;
this.fileAnalysis = this.payload.file_analysis; this.fileAnalysis = this.payload.file_analysis;
this.detectedColumns = this.payload.detected_columns || []; this.detectedColumns = this.payload.detected_columns || [];
this.parsedRows = this.payload.parsed_rows || [];
this.previewData = this.payload.preview_data || []; this.previewData = this.payload.preview_data || [];
this.columnMapping = this.payload.column_mapping || {}; this.columnMapping = this.payload.column_mapping || {};
} }
@ -296,55 +297,66 @@ export default {
}, },
async analyzeFile(file) { async analyzeFile(file) {
// This is a simplified version - in reality, you'd use a library like Papa Parse for CSV // Parsing happens entirely client-side. The backend never sees the
// or SheetJS for Excel files // raw file - only the structured `items` list produced here, sent
// later as generic payload/request body.
if (file.name.endsWith('.csv')) { if (file.name.endsWith('.csv')) {
const text = await file.text(); const text = await file.text();
const lines = text.split('\n').filter(line => line.trim()); this.parsedRows = this.parseCsv(text);
if (lines.length > 0) {
// Parse CSV header
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
this.detectedColumns = headers;
// Parse preview data
this.previewData = lines.slice(1, 6).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
this.fileAnalysis = {
totalRows: lines.length - 1, // Exclude header
totalColumns: headers.length,
validRows: lines.length - 1, // Simplified - assume all valid for demo
errorRows: 0
};
}
} else { } else {
// For Excel files, you'd use a library like SheetJS // For Excel files, you'd use a library like SheetJS to parse
// This is a mock implementation // client-side. This is a mock implementation.
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description']; this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
this.previewData = [ this.parsedRows = [
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' }, { Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' } { Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
]; ];
this.fileAnalysis = {
totalRows: 100,
totalColumns: 5,
validRows: 98,
errorRows: 2
};
} }
this.previewData = this.parsedRows.slice(0, 5);
this.fileAnalysis = {
totalRows: this.parsedRows.length,
totalColumns: this.detectedColumns.length,
validRows: this.parsedRows.length,
errorRows: 0
};
// Auto-map columns based on common names // Auto-map columns based on common names
this.autoMapColumns(); this.autoMapColumns();
}, },
// Simple CSV parser: no external dependency, entirely client-side.
// Returns an array of row objects keyed by the detected header columns.
parseCsv(text) {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) {
this.detectedColumns = [];
return [];
}
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
this.detectedColumns = headers;
return lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
},
// Convert the fully parsed rows + column mapping into the generic item
// dicts consumed by the "Import Items" step, which creates each one via
// the standard generic InventoryItem create endpoint.
buildItemsFromMapping() {
return this.parsedRows.map(row => ({
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
description: this.columnMapping.description ? row[this.columnMapping.description] : '',
category: this.columnMapping.category ? row[this.columnMapping.category] : '',
quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
})).filter(item => item.name);
},
autoMapColumns() { autoMapColumns() {
const mapping = {}; const mapping = {};
@ -401,6 +413,7 @@ export default {
this.uploadTime = null; this.uploadTime = null;
this.fileAnalysis = null; this.fileAnalysis = null;
this.detectedColumns = []; this.detectedColumns = [];
this.parsedRows = [];
this.previewData = []; this.previewData = [];
this.columnMapping = {}; this.columnMapping = {};
this.updatePayload(); this.updatePayload();
@ -429,12 +442,14 @@ export default {
updatePayload() { updatePayload() {
this.$emit('update', { this.$emit('update', {
uploaded_file: this.uploadedFile, file_name: this.uploadedFile?.name || null,
upload_time: this.uploadTime, upload_time: this.uploadTime,
file_analysis: this.fileAnalysis, file_analysis: this.fileAnalysis,
detected_columns: this.detectedColumns, detected_columns: this.detectedColumns,
parsed_rows: this.parsedRows,
preview_data: this.previewData, preview_data: this.previewData,
column_mapping: this.columnMapping column_mapping: this.columnMapping,
items: this.buildItemsFromMapping()
}); });
}, },

View file

@ -0,0 +1,124 @@
<template>
<div class="bulk-import-step-6">
<div class="step-header mb-4">
<h4 class="mb-2">Import Items</h4>
<p class="text-muted">
Create inventory items from the rows parsed in the File Upload step.
The file itself was never sent to the server - only these structured
item rows are.
</p>
</div>
<div v-if="!results" class="card mb-4">
<div class="card-body text-center py-4">
<p class="mb-3">
<strong>{{ items.length }}</strong> item(s) ready to import.
</p>
<button class="btn btn-primary" :disabled="importing || items.length === 0" @click="runImport">
<span v-if="importing" class="spinner-border spinner-border-sm me-2"></span>
Import {{ items.length }} item(s)
</button>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
</div>
</div>
<div v-else class="card mb-4">
<div class="card-body">
<h6 class="mb-2">Import complete</h6>
<p class="mb-2">
<span class="badge bg-success me-2">{{ results.created_count }} created</span>
<span v-if="results.errors && results.errors.length" class="badge bg-warning text-dark">
{{ results.errors.length }} issue(s)
</span>
</p>
<ul v-if="results.errors && results.errors.length" class="small text-muted mb-0">
<li v-for="(err, index) in results.errors" :key="index">{{ err }}</li>
</ul>
</div>
</div>
<div class="step-navigation d-flex justify-content-between">
<button class="btn btn-outline-secondary" @click="$emit('prev')">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button class="btn btn-primary" :disabled="!results" @click="$emit('next')">
Next: Generate Report
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import { mapActions } from 'vuex';
export default {
name: 'BulkImportStep6',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
importing: false,
error: null,
results: this.payload.import_results || null
}
},
computed: {
items() {
return this.payload.items || [];
}
},
methods: {
...mapActions(['createInventoryItem']),
async runImport() {
this.importing = true;
this.error = null;
const created_item_ids = [];
const errors = [];
// Items were fully parsed client-side (File Upload step). The backend
// never sees the source file or any bulk-import-specific endpoint -
// each row is created through the same generic
// POST /api/inventory_items/ endpoint any other client would use.
for (const [index, row] of this.items.entries()) {
if (!row.name) {
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
continue;
}
try {
const created = await this.createInventoryItem({
name: row.name,
description: row.description || '',
owned_quantity: parseInt(row.quantity) || 1,
...(row.category ? {category: row.category} : {})
});
created_item_ids.push(created.id);
} catch (err) {
console.error('Failed to create item:', row, err);
errors.push(`Row ${index + 1} ("${row.name}"): failed to create.`);
}
}
this.results = {created_item_ids, created_count: created_item_ids.length, errors};
this.$emit('update', {import_results: this.results});
this.importing = false;
}
}
}
</script>

View file

@ -4,6 +4,7 @@ import FallBackResolver from "@/dns";
import NeighborsCache from "@/neigbors"; import NeighborsCache from "@/neigbors";
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation"; import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity"; import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js";
//import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin";
@ -549,30 +550,33 @@ export default createStore({
} }
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/workflows/') const data = await servers.get(getters.signAuth, '/api/workflows/')
commit('setActiveWorkflows', data) const workflows = data.map(deserializeWorkflowPayload)
commit('setActiveWorkflows', workflows)
state.last_load.active_workflows = Date.now() state.last_load.active_workflows = Date.now()
return data return workflows
}, },
async createWorkflow({state, commit, dispatch, getters}, workflowData) { async createWorkflow({state, commit, dispatch, getters}, workflowData) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/workflows/', workflowData) // The backend stores `payload` as an opaque string - the frontend is
// responsible for serializing/deserializing the JSON itself.
const data = await servers.post(getters.signAuth, '/api/workflows/', serializeWorkflowPayload(workflowData))
state.last_load.active_workflows = 0 // Invalidate cache state.last_load.active_workflows = 0 // Invalidate cache
return data return deserializeWorkflowPayload(data)
}, },
async updateWorkflow({state, commit, dispatch, getters}, workflow) { async updateWorkflow({state, commit, dispatch, getters}, workflow) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', workflow) const data = await servers.patch(getters.signAuth, '/api/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow))
state.last_load.active_workflows = 0 // Invalidate cache state.last_load.active_workflows = 0 // Invalidate cache
return data return deserializeWorkflowPayload(data)
}, },
async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) { async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', { const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/update_step/', {
current_step: currentStep, current_step: currentStep,
payload: payload payload: JSON.stringify(payload)
}) })
state.last_load.active_workflows = 0 // Invalidate cache state.last_load.active_workflows = 0 // Invalidate cache
return data return deserializeWorkflowPayload(data)
}, },
async deleteWorkflow({state, commit, dispatch, getters}, workflowId) { async deleteWorkflow({state, commit, dispatch, getters}, workflowId) {
const servers = await dispatch('getHomeServers') const servers = await dispatch('getHomeServers')

View file

@ -10,10 +10,10 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link> <router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
</li> </li>
<li class="breadcrumb-item active" aria-current="page">{{ workflowInstance?.workflow_type || 'Loading...' }}</li> <li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.name || workflowInstance?.name || 'Loading...' }}</li>
</ol> </ol>
</nav> </nav>
<h1 class="h3 mb-0">{{ workflowInstance?.workflow_type || 'Workflow Detail' }}</h1> <h1 class="h3 mb-0">{{ workflowDefinition?.name || workflowInstance?.name || 'Workflow Detail' }}</h1>
</div> </div>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @click="$router.go(-1)"> <button class="btn btn-outline-secondary" @click="$router.go(-1)">
@ -236,8 +236,9 @@ export default {
}, },
workflowDefinition() { workflowDefinition() {
if (!this.workflowInstance?.workflow_type) return null; // `name` doubles as the workflow type identifier (e.g. 'import-items').
return workflowRegistry.get(this.workflowInstance.workflow_type); if (!this.workflowInstance?.name) return null;
return workflowRegistry.get(this.workflowInstance.name);
}, },
stepDefinitions() { stepDefinitions() {
@ -258,8 +259,7 @@ export default {
stepComponent() { stepComponent() {
// Return step-specific component if it exists using the component registry // Return step-specific component if it exists using the component registry
const workflowType = this.workflowInstance?.workflow_type; const workflowType = this.workflowInstance?.name;
console.log('Determining step component for workflow type:', this.workflowInstance, 'and step:', this.currentStep);
if (workflowType && this.currentStep) { if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep); return getStepComponent(workflowType, this.currentStep);
} }
@ -467,7 +467,7 @@ export default {
hasStepComponent(stepNumber) { hasStepComponent(stepNumber) {
// Check if a specific step has a custom component available // Check if a specific step has a custom component available
const workflowType = this.workflowInstance?.workflow_type; const workflowType = this.workflowInstance?.name;
return workflowType ? hasStepComponent(workflowType, stepNumber) : false; return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
} }
} }

View file

@ -27,7 +27,7 @@
<tbody> <tbody>
<tr v-for="workflow in activeWorkflows" :key="workflow.id"> <tr v-for="workflow in activeWorkflows" :key="workflow.id">
<td> <td>
<strong>{{ workflow.workflow_type }}</strong> <strong>{{ getWorkflowDisplayName(workflow) }}</strong>
<br> <br>
<small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small> <small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small>
</td> </td>
@ -215,10 +215,14 @@ export default {
}, },
isWorkflowRunning(workflowId) { isWorkflowRunning(workflowId) {
return this.activeWorkflows.some(active => return this.activeWorkflows.some(active =>
active.workflow_type === workflowId && active.name === workflowId &&
active.state === 'running' active.state === 'running'
); );
}, },
getWorkflowDisplayName(workflow) {
// `name` doubles as the workflow type identifier (e.g. 'import-items').
return workflowRegistry.get(workflow.name)?.name || workflow.name;
},
async startWorkflow(workflow) { async startWorkflow(workflow) {
try { try {
this.loading = true; this.loading = true;
@ -266,16 +270,17 @@ export default {
}); });
}, },
async abortWorkflowInstance(workflow) { async abortWorkflowInstance(workflow) {
if (confirm(`Are you sure you want to abort "${workflow.workflow_type}"?`)) { const displayName = this.getWorkflowDisplayName(workflow);
if (confirm(`Are you sure you want to abort "${displayName}"?`)) {
try { try {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
await this.deleteWorkflow(workflow.id); await this.deleteWorkflow(workflow.id);
await this.loadActiveWorkflows(); await this.loadActiveWorkflows();
console.log('Workflow aborted successfully:', workflow.workflow_type); console.log('Workflow aborted successfully:', displayName);
} catch (error) { } catch (error) {
console.error('Error aborting workflow:', error); console.error('Error aborting workflow:', error);
this.error = `Failed to abort ${workflow.workflow_type}`; this.error = `Failed to abort ${displayName}`;
} finally { } finally {
this.loading = false; this.loading = false;
} }

View file

@ -404,6 +404,27 @@ class WorkflowRegistry {
// Create and export the default registry instance // Create and export the default registry instance
export const workflowRegistry = new WorkflowRegistry(); export const workflowRegistry = new WorkflowRegistry();
/**
* The backend stores WorkflowInstance.payload as an opaque string - it never
* parses or understands it as JSON. The frontend is fully responsible for
* serializing it before sending and deserializing it after receiving.
*/
export function serializeWorkflowPayload(workflow) {
if (!workflow || !('payload' in workflow)) return workflow;
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
}
export function deserializeWorkflowPayload(workflow) {
if (!workflow) return workflow;
let payload = {};
try {
payload = workflow.payload ? JSON.parse(workflow.payload) : {};
} catch (e) {
console.error('Failed to parse workflow payload JSON:', e);
}
return {...workflow, payload};
}
// Export individual classes for direct use // Export individual classes for direct use
export { export {
BaseWorkflow, BaseWorkflow,