Export a flow as a JSON file from the actions menu

A flow could be cloned or nested in a container, but never left the installation
it lived in - no copy for a ticket, for another machine, or for outside a
database that run_service.sh recreates from scratch on every restart.

The file is an envelope, not a bare graph: the name and description survive, and
there is somewhere to put a format version. What the server decides for itself
stays behind - id, author, published, finalized, projectId, status - so an
import can never be a way to mint a public or finalized flow. Node ids inside the
graph are kept: each flow stores its own, and rewriting them would mean remapping
every connection.

Export re-reads the flow rather than trusting the cached row, and unlike opening
it does not fall back to that row on failure: a file that looks complete and is
not would be worse than an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-04 16:48:01 +02:00
parent 43bcc38b9e
commit c41ad1092c
7 changed files with 548 additions and 4 deletions

View File

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
/**
* Hands a file to the browser.
*
* A service rather than a bare function because this is the only part of the export that touches
* the DOM: as a service a spec replaces it with a stub instead of having to fake `document`.
*/
@Injectable({ providedIn: 'root' })
export class FileDownload {
saveJson(fileName: string, payload: unknown) {
this.save(fileName, JSON.stringify(payload, null, 2), 'application/json');
}
private save(fileName: string, content: string, mimeType: string) {
const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// Revoked, or the blob is held for the life of the page - but not in the same tick: some
// browsers abort a save whose URL is revoked before they have finished reading it.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
}

View File

@ -0,0 +1,208 @@
import { Flow, FlowData } from '@models/flow';
import {
buildFlowExport,
collectNodeTypeNames,
flowExportFileName,
parseFlowImport,
resolveLegacyTypeName
} from './flow-transfer';
function makeFlowData(overrides: Partial<FlowData> = {}): FlowData {
return {
blocks: [],
containers: [],
connections: [],
dependencies: [],
globalInputs: [],
lanes: [],
...overrides
};
}
function makeFlow(overrides: Partial<Flow> = {}): Flow {
return {
id: 'flow-1',
name: 'Bias rerun',
description: 'Re-runs the interview flow',
visibility: 'PRIVATE',
data: makeFlowData(),
author: 'lucio.lelii',
status: 'DRAFT',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
published: true,
finalized: true,
projectId: 'project-9',
...overrides
} as Flow;
}
describe('flow transfer', () => {
describe('the exported envelope', () => {
it('carries the graph, the name and the description', () => {
const data = makeFlowData({ blocks: [{ id: 'b1' } as any] });
const file = buildFlowExport(makeFlow({ data }), new Date('2026-09-04T10:00:00.000Z'));
expect(file).toEqual({
formatVersion: 1,
exportedAt: '2026-09-04T10:00:00.000Z',
name: 'Bias rerun',
description: 'Re-runs the interview flow',
flow: data
});
});
it('leaves behind everything the importing server decides for itself', () => {
// Not a style preference: an exported id invites mistaking an import for an update, and
// published/finalized/projectId belong to one installation and one user, not to the graph.
const file = buildFlowExport(makeFlow()) as unknown as Record<string, unknown>;
for (const key of ['id', 'author', 'owner', 'published', 'finalized', 'projectId', 'status',
'createdAt', 'updatedAt', 'validationErrors']) {
expect(key in file).toBe(false);
}
});
it('keeps the node ids as they are', () => {
// Each flow stores its own graph, so ids never collide across flows - and rewriting them
// would mean remapping every connection and dependency that points at them.
const data = makeFlowData({
blocks: [{ id: 'block-42' } as any],
connections: [{ id: 'c1', sourceId: 'block-42', targetId: 'block-43' } as any]
});
const file = buildFlowExport(makeFlow({ data }));
expect(file.flow.blocks[0].id).toBe('block-42');
expect(file.flow.connections[0].sourceId).toBe('block-42');
});
it('turns the flow name into a readable file name', () => {
expect(flowExportFileName('Bias rerun')).toBe('bias-rerun.flow.json');
expect(flowExportFileName('Analisi però / 2')).toBe('analisi-pero-2.flow.json');
expect(flowExportFileName('***')).toBe('flow.flow.json');
});
});
describe('reading a file back', () => {
it('round trips an exported flow, subflows included', () => {
// A container's subflow lives inside its specificConfiguration, so it has to survive the
// trip without any code of its own. This is the test that says so.
const data = makeFlowData({
blocks: [{ id: 'b1', typeName: 'LLMBlock' } as any],
containers: [{
id: 'c1',
typeName: 'IteratorContainer',
specificConfiguration: { subFlow: { blocks: [{ id: 'inner' }] } }
} as any]
});
const file = buildFlowExport(makeFlow({ data }));
const parsed = parseFlowImport(JSON.stringify(file), 'ignored');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.name).toBe('Bias rerun');
expect(parsed.description).toBe('Re-runs the interview flow');
expect(parsed.data).toEqual(data);
expect((parsed.data.containers[0] as any).specificConfiguration.subFlow.blocks[0].id).toBe('inner');
});
it('accepts a bare graph, naming it after the file', () => {
// The backend hands whole graphs around in this shape - the container import retriever does
// exactly that - so a JSON copied from there still imports.
const parsed = parseFlowImport(JSON.stringify({ blocks: [{ id: 'b1' }] }), 'copied-graph');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.name).toBe('copied-graph');
expect(parsed.data.blocks).toHaveLength(1);
expect(parsed.data.lanes).toEqual([]);
});
it('fills in the collections a hand-written file leaves out', () => {
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: 'Small', flow: { blocks: [] } }), 'x');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.data).toEqual({
blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: []
});
});
it('rejects malformed JSON', () => {
const parsed = parseFlowImport('{ "blocks": [', 'x');
expect(parsed.ok).toBe(false);
if (parsed.ok) return;
expect(parsed.message).toContain('not valid JSON');
});
it('rejects JSON that is not a flow', () => {
expect(parseFlowImport('[1, 2, 3]', 'x').ok).toBe(false);
expect(parseFlowImport('{"hello":"world"}', 'x').ok).toBe(false);
expect(parseFlowImport('{"formatVersion":1,"name":"n"}', 'x').ok).toBe(false);
});
it('refuses a file from a newer format instead of guessing', () => {
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 2, flow: { blocks: [] } }), 'x');
expect(parsed.ok).toBe(false);
if (parsed.ok) return;
expect(parsed.message).toContain('newer version');
});
it('falls back to the file name when the envelope has no usable name', () => {
const parsed = parseFlowImport(JSON.stringify({ formatVersion: 1, name: ' ', flow: { blocks: [] } }), 'from-disk');
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.name).toBe('from-disk');
});
});
describe('the type names a file refers to', () => {
it('reaches the nodes inside a container subflow', () => {
// A type this server does not know is just as fatal inside a subflow as at the top level.
const data = makeFlowData({
blocks: [{ typeName: 'LLMBlock' } as any],
containers: [{
id: 'c1',
name: 'Loop',
typeName: 'IteratorContainer',
specificConfiguration: {
subFlow: makeFlowData({ blocks: [{ typeName: 'MysteryBlock' } as any] })
}
} as any]
});
expect(collectNodeTypeNames(data).blocks).toEqual(['LLMBlock', 'MysteryBlock']);
});
it('maps the two type names the server has renamed', () => {
// BlockTypes.get still answers to both, so refusing them client-side would reject a file
// the server would have accepted.
expect(resolveLegacyTypeName('ChatHumanInteraction')).toBe('ChatInteraction');
expect(resolveLegacyTypeName('ExclusiveMergeBlock')).toBe('BranchRejoinBlock');
expect(resolveLegacyTypeName('LLMBlock')).toBe('LLMBlock');
});
it('lists each block and container type once', () => {
const data = makeFlowData({
blocks: [
{ typeName: 'LLMBlock' } as any,
{ typeName: 'LLMBlock' } as any,
{ typeName: 'EndBlock' } as any,
{ } as any
],
containers: [{ typeName: 'IteratorContainer' } as any]
});
expect(collectNodeTypeNames(data)).toEqual({
blocks: ['LLMBlock', 'EndBlock'],
containers: ['IteratorContainer']
});
});
});
});

View File

@ -0,0 +1,191 @@
import { Flow, FlowData } from '@models/flow';
import { listFlowSubflows, resolveFlowSubflow } from '@utilities/flow-subflows';
/**
* Carrying a flow in and out of the app as a file.
*
* Pure on purpose: no DOM, no services, no HTTP. Saving the file and creating the flow are the
* caller's job, which keeps the format itself testable without a TestBed.
*/
export const FLOW_EXPORT_FORMAT_VERSION = 1;
/**
* The exported envelope.
*
* Not the bare `FlowData`, so the name and description survive the trip and there is somewhere to
* put the version. The shape mirrors the `ImportedFlow` record the backend seed importer already
* reads from its own `flows.json`.
*/
export type FlowExportFile = {
formatVersion: number;
exportedAt: string;
name: string;
description: string;
flow: FlowData;
};
export type FlowImportParse =
| { ok: true; name: string; description: string; data: FlowData }
| { ok: false; message: string };
/**
* What travels is the graph and its name. Everything else is deliberately left behind:
*
* - `id`, `createdAt`, `updatedAt`: the server assigns its own on import, and carrying an id
* invites mistaking an import for an update;
* - `author`: whoever imports the file owns the result;
* - `published` and `finalized`: visibility and lifecycle are not part of the content, so an
* imported flow starts as an editable draft;
* - `projectId`: projects belong to one user;
* - `status` and `validationErrors`: the server derives both from the graph.
*
* Node ids *inside* the graph are kept as they are: each flow stores its own graph, so they never
* collide, and rewriting them would mean remapping every connection and dependency.
*/
export function buildFlowExport(
flow: Pick<Flow, 'name' | 'description' | 'data'>,
now: Date = new Date()
): FlowExportFile {
return {
formatVersion: FLOW_EXPORT_FORMAT_VERSION,
exportedAt: now.toISOString(),
name: flow.name,
description: flow.description ?? '',
flow: flow.data
};
}
/** `Bias rerun` becomes `bias-rerun.flow.json`, so a folder of exports stays readable. */
export function flowExportFileName(name: string): string {
const slug = (name ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
return `${slug || 'flow'}.flow.json`;
}
/**
* Reads a file back.
*
* Accepts a bare `FlowData` as well as an envelope: the backend hands whole graphs around in that
* shape - the container import retriever does exactly this - so a JSON copied from there, or out
* of the database, still imports. In that case the name comes from the file name, which is why the
* caller has to supply one.
*/
export function parseFlowImport(text: string, fallbackName: string): FlowImportParse {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, message: 'That file is not valid JSON.' };
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, message: 'That file does not contain a flow.' };
}
const record = parsed as Record<string, unknown>;
const version = record['formatVersion'];
if (typeof version === 'number') {
if (version > FLOW_EXPORT_FORMAT_VERSION) {
return {
ok: false,
message: `That file was exported by a newer version of HumAIn Flow (format ${version}).`
};
}
const data = toFlowData(record['flow']);
if (!data) return { ok: false, message: 'That file does not contain a flow.' };
return {
ok: true,
name: readName(record['name']) ?? fallbackName,
description: typeof record['description'] === 'string' ? record['description'] : '',
data
};
}
// No envelope: accept the graph on its own.
const bare = toFlowData(record);
if (!bare) return { ok: false, message: 'That file does not contain a flow.' };
return { ok: true, name: fallbackName, description: '', data: bare };
}
/**
* The node type names the file refers to, so a caller can check them against its catalogs.
*
* Containers carry their own graph inside their configuration, and a type this server does not
* know is just as fatal in there, so the walk goes all the way down - reusing the app's own
* definition of where a subflow lives rather than growing a second one.
*/
export function collectNodeTypeNames(data: FlowData): { blocks: string[]; containers: string[] } {
const blocks = new Set<string>();
const containers = new Set<string>();
const collect = (graph: FlowData) => {
for (const name of typeNamesOf(graph.blocks)) blocks.add(name);
for (const name of typeNamesOf(graph.containers)) containers.add(name);
};
collect(data);
for (const entry of listFlowSubflows(data)) {
const nested = resolveFlowSubflow(data, entry.locator);
if (nested) collect(nested);
}
return { blocks: [...blocks], containers: [...containers] };
}
/**
* The server still answers to two type names it has renamed (BlockTypes.get). A file written
* before those renames imports fine, so the client check has to know them too - otherwise it
* would refuse a flow the server would have accepted.
*/
const LEGACY_TYPE_NAMES: Readonly<Record<string, string>> = {
ChatHumanInteraction: 'ChatInteraction',
ExclusiveMergeBlock: 'BranchRejoinBlock'
};
export function resolveLegacyTypeName(typeName: string): string {
return LEGACY_TYPE_NAMES[typeName] ?? typeName;
}
function typeNamesOf(nodes: ReadonlyArray<{ typeName?: string }> | undefined): string[] {
const found: string[] = [];
for (const node of nodes ?? []) {
const typeName = node?.typeName;
if (typeof typeName === 'string' && typeName.trim().length) found.push(typeName);
}
return found;
}
/**
* A flow is recognised by having a `blocks` array. The other collections are normalised to empty
* rather than demanded: an older or hand-written file that omits `lanes` or `dependencies` is
* still a flow, and the editor treats those as empty anyway.
*/
function toFlowData(value: unknown): FlowData | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (!Array.isArray(record['blocks'])) return null;
return {
blocks: record['blocks'] as FlowData['blocks'],
containers: asArray(record['containers']) as FlowData['containers'],
connections: asArray(record['connections']) as FlowData['connections'],
dependencies: asArray(record['dependencies']) as FlowData['dependencies'],
globalInputs: asArray(record['globalInputs']) as FlowData['globalInputs'],
lanes: asArray(record['lanes']) as FlowData['lanes']
};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readName(value: unknown): string | null {
return typeof value === 'string' && value.trim().length ? value.trim() : null;
}

View File

@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { Flow } from '@models/flow';
import { Flow, FlowData } from '@models/flow';
import { FlowsCallServiceBase } from './flows-call.base';
import { catchError, firstValueFrom, Observable, of, switchMap, tap, throwError } from 'rxjs';
@ -210,6 +210,28 @@ export class FlowsService {
);
}
/**
* Creates a flow from a file the user picked.
*
* The name is de-duplicated the same way "Empty flow" does it, so importing the same file twice
* gives two flows you can tell apart. `status` is always DRAFT: the server derives the real one
* from the graph, so sending back an exported status would be inventing a fact.
*/
importFlow(imported: { name: string; description?: string; data: FlowData }): Observable<Flow> {
return this.createFlow({
name: this.nextFileName(imported.name),
description: imported.description,
data: imported.data,
status: 'DRAFT'
}).pipe(
tap(() => this.refresh()),
catchError(err => {
console.error('Importing flow failed', err);
return throwError(() => err);
})
);
}
createNewFlow(name? : string) {
return this.createFlow({
name: name || this.nextFileName('New Flow'),

View File

@ -40,6 +40,14 @@
<span>Clone flow</span>
</button>
<button
mat-menu-item
type="button"
(click)="exportFlow()">
<mat-icon fontIcon="download"></mat-icon>
<span>Export flow</span>
</button>
@if (projectsEnabled) {
<button
mat-menu-item

View File

@ -3,9 +3,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FileDownload } from '@services/files/file-download';
import { FlowsService } from '@services/flows/flows';
import { NotificationService } from '@services/notifications/notification';
import { EditorStateHolder } from '@stores/flow-editor';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { FlowItem } from './flow-item';
@ -13,8 +15,31 @@ import { FlowItem } from './flow-item';
describe('FlowItem', () => {
let component: FlowItem;
let fixture: ComponentFixture<FlowItem>;
let saveJson: ReturnType<typeof vi.fn>;
let getFlowById: ReturnType<typeof vi.fn>;
let notify: ReturnType<typeof vi.fn>;
/** What the server holds - deliberately richer than the list row the card was given. */
const storedFlow = {
id: 'flow-1',
name: 'Test flow',
visibility: 'PRIVATE',
description: 'A flow',
data: {
blocks: [{ id: 'b1', typeName: 'LLMBlock' }],
containers: [], connections: [], dependencies: [], globalInputs: [], lanes: []
},
author: 'author',
createdAt: new Date(),
status: 'DRAFT',
updatedAt: new Date()
};
beforeEach(async () => {
saveJson = vi.fn();
notify = vi.fn();
getFlowById = vi.fn().mockReturnValue(of(storedFlow));
await TestBed.configureTestingModule({
imports: [FlowItem],
providers: [
@ -35,9 +60,12 @@ describe('FlowItem', () => {
provide: FlowsService,
useValue: {
cloneFlow: vi.fn().mockReturnValue(of(null)),
deleteFlow: vi.fn().mockReturnValue(of(null))
deleteFlow: vi.fn().mockReturnValue(of(null)),
getFlowById
}
},
{ provide: FileDownload, useValue: { saveJson } },
{ provide: NotificationService, useValue: { show: notify } },
{
provide: EditorStateHolder,
useValue: {
@ -83,4 +111,33 @@ describe('FlowItem', () => {
expect(indicator).not.toBeNull();
expect(indicator.tagName.toLowerCase()).toBe('mat-icon');
});
describe('exporting a flow', () => {
it('writes what the server holds, not the row the card was handed', () => {
// The card's own input carries an empty graph here. Exporting that would produce a file that
// looks complete and is not, which is exactly the failure worth a test.
expect(component.flow().data.blocks).toHaveLength(0);
});
it('re-reads the flow and hands the envelope to the download', async () => {
await component.exportFlow();
expect(getFlowById).toHaveBeenCalledWith('flow-1', true);
const [fileName, payload] = saveJson.mock.calls.at(-1)!;
expect(fileName).toBe('test-flow.flow.json');
expect(payload.formatVersion).toBe(1);
expect(payload.name).toBe('Test flow');
expect(payload.flow.blocks).toHaveLength(1);
});
it('reports a failure instead of writing a partial file', async () => {
getFlowById.mockReturnValue(throwError(() => new Error('offline')));
await component.exportFlow();
expect(saveJson).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith('Could not export this flow.', 'error');
});
});
});

View File

@ -9,6 +9,8 @@ import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FileDownload } from '@services/files/file-download';
import { buildFlowExport, flowExportFileName } from '@services/flows/flow-transfer';
import { FlowsService } from '@services/flows/flows';
import { NotificationService } from '@services/notifications/notification';
import { ProjectsService } from '@services/projects/projects';
@ -32,6 +34,7 @@ export class FlowItem {
private flowsService = inject(FlowsService);
private projectsService = inject(ProjectsService);
private notifications = inject(NotificationService);
private fileDownload = inject(FileDownload);
readonly projectsEnabled = PROJECTS_ENABLED;
readonly assignableProjects = this.projectsService.projects;
@ -100,9 +103,17 @@ export class FlowItem {
await this.editorState.openDocument(await this.loadCompleteFlow());
}
/**
* Throws. Opening a flow can fall back to the cached row, but exporting one cannot: a failure
* that quietly returns the row would write a file that looks complete and is not.
*/
private fetchCompleteFlow(): Promise<Flow> {
return firstValueFrom(this.flowsService.getFlowById(this.flow().id, true));
}
private async loadCompleteFlow(): Promise<Flow> {
try {
return await firstValueFrom(this.flowsService.getFlowById(this.flow().id, true));
return await this.fetchCompleteFlow();
} catch (error) {
console.error('Error loading complete flow', error);
return this.flow();
@ -115,6 +126,24 @@ export class FlowItem {
});
}
/**
* Exports what the server has, not what the cache holds - the same reason `open()` re-reads the
* flow. If the flow is open with unsaved changes that means the file is the last saved version,
* which is worth saying out loud rather than leaving to be discovered.
*/
async exportFlow() {
try {
const complete = await this.fetchCompleteFlow();
this.fileDownload.saveJson(flowExportFileName(complete.name), buildFlowExport(complete));
if (this.isRootOpen() && this.editorState.isDirty()) {
this.notifications.show('Exported the last saved version of this flow.', 'info');
}
} catch (error) {
console.error('Error exporting flow', error);
this.notifications.show('Could not export this flow.', 'error');
}
}
async remove() {
if (!this.canDelete()) return;