Skip to content

Commit e8d2f23

Browse files
committed
feat: implement save workflow to file
Signed-off-by: Simon Emms <simon@simonemms.com>
1 parent 2bc04a0 commit e8d2f23

6 files changed

Lines changed: 375 additions & 42 deletions

File tree

src/lib/server/workflows-dir.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2025 - 2026 Zigflow authors <https://github.com/zigflow/studio/graphs/contributors>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { env } from '$env/dynamic/public';
17+
import { resolve } from 'node:path';
18+
19+
/**
20+
* Absolute path to the workflows directory.
21+
*
22+
* Configured via PUBLIC_WORKFLOWS_DIR env var; defaults to ./workflows
23+
* relative to the process working directory.
24+
*/
25+
export const WORKFLOWS_DIR: string =
26+
env.PUBLIC_WORKFLOWS_DIR ?? resolve(process.cwd(), 'workflows');

src/lib/tasks/registry.ts

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ function emptyGraph(): FlowGraph {
5656
}
5757

5858
function taskNode(name: string, type: NodeType): TaskNode {
59+
const nid = id();
5960
return {
60-
id: id(),
61+
id: nid,
6162
type: 'task',
6263
name,
6364
config: defaultConfig(type),
64-
metadata: { __zigflow_id: id() },
65+
metadata: { __zigflow_id: nid },
6566
} as TaskNode;
6667
}
6768

@@ -197,53 +198,65 @@ export const TASK_REGISTRY: readonly TaskDefinition[] = [
197198
label: 'Switch',
198199
category: 'control',
199200
description: 'Branch on a condition',
200-
create: (): SwitchNode => ({
201-
id: id(),
202-
type: 'switch',
203-
name: 'switch',
204-
branches: [],
205-
metadata: { __zigflow_id: id() },
206-
}),
201+
create: (): SwitchNode => {
202+
const nid = id();
203+
return {
204+
id: nid,
205+
type: 'switch',
206+
name: 'switch',
207+
branches: [],
208+
metadata: { __zigflow_id: nid },
209+
};
210+
},
207211
},
208212
{
209213
type: 'fork',
210214
label: 'Fork',
211215
category: 'control',
212216
description: 'Run branches in parallel',
213-
create: (): ForkNode => ({
214-
id: id(),
215-
type: 'fork',
216-
name: 'fork',
217-
compete: false,
218-
branches: [],
219-
metadata: { __zigflow_id: id() },
220-
}),
217+
create: (): ForkNode => {
218+
const nid = id();
219+
return {
220+
id: nid,
221+
type: 'fork',
222+
name: 'fork',
223+
compete: false,
224+
branches: [],
225+
metadata: { __zigflow_id: nid },
226+
};
227+
},
221228
},
222229
{
223230
type: 'try',
224231
label: 'Try / Catch',
225232
category: 'control',
226233
description: 'Execute with error handling',
227-
create: (): TryNode => ({
228-
id: id(),
229-
type: 'try',
230-
name: 'try-catch',
231-
tryGraph: emptyGraph(),
232-
metadata: { __zigflow_id: id() },
233-
}),
234+
create: (): TryNode => {
235+
const nid = id();
236+
return {
237+
id: nid,
238+
type: 'try',
239+
name: 'try-catch',
240+
tryGraph: emptyGraph(),
241+
metadata: { __zigflow_id: nid },
242+
};
243+
},
234244
},
235245
{
236246
type: 'loop',
237247
label: 'Loop',
238248
category: 'control',
239249
description: 'Iterate over a collection',
240-
create: (): LoopNode => ({
241-
id: id(),
242-
type: 'loop',
243-
name: 'loop',
244-
in: '$.',
245-
bodyGraph: emptyGraph(),
246-
metadata: { __zigflow_id: id() },
247-
}),
250+
create: (): LoopNode => {
251+
const nid = id();
252+
return {
253+
id: nid,
254+
type: 'loop',
255+
name: 'loop',
256+
in: '$.',
257+
bodyGraph: emptyGraph(),
258+
metadata: { __zigflow_id: nid },
259+
};
260+
},
248261
},
249262
];
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2025 - 2026 Zigflow authors <https://github.com/zigflow/studio/graphs/contributors>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { exportToYaml } from '$lib/export/yaml';
17+
import { WORKFLOWS_DIR } from '$lib/server/workflows-dir';
18+
import type { WorkflowFile } from '$lib/tasks/model';
19+
import { json } from '@sveltejs/kit';
20+
import { randomUUID } from 'node:crypto';
21+
import { promises as fs } from 'node:fs';
22+
import { basename, dirname, resolve } from 'node:path';
23+
24+
import type { RequestHandler } from './$types';
25+
26+
export const PUT: RequestHandler = async ({ params, request }) => {
27+
const fileName = params.workflowId;
28+
29+
// Enforce .yaml / .yml extension.
30+
if (!fileName.endsWith('.yaml') && !fileName.endsWith('.yml')) {
31+
return json(
32+
{ ok: false, error: 'File must have a .yaml or .yml extension' },
33+
{ status: 400 },
34+
);
35+
}
36+
37+
// Validate path is inside WORKFLOWS_DIR — prevent path traversal.
38+
const filePath = resolve(WORKFLOWS_DIR, fileName);
39+
if (!filePath.startsWith(WORKFLOWS_DIR + '/') && filePath !== WORKFLOWS_DIR) {
40+
return json({ ok: false, error: 'Invalid workflow path' }, { status: 400 });
41+
}
42+
43+
// Parse JSON body.
44+
let workflowFile: WorkflowFile;
45+
try {
46+
const body: unknown = await request.json();
47+
if (
48+
typeof body !== 'object' ||
49+
body === null ||
50+
!('workflowFile' in body)
51+
) {
52+
return json(
53+
{ ok: false, error: 'Missing workflowFile in request body' },
54+
{ status: 400 },
55+
);
56+
}
57+
workflowFile = (body as { workflowFile: WorkflowFile }).workflowFile;
58+
} catch {
59+
return json({ ok: false, error: 'Invalid JSON body' }, { status: 400 });
60+
}
61+
62+
// Export WorkflowFile → YAML (also validates the graph).
63+
const exportResult = exportToYaml(workflowFile);
64+
if (!exportResult.ok) {
65+
return json(
66+
{ ok: false, error: `Export failed: ${exportResult.errors.join('; ')}` },
67+
{ status: 400 },
68+
);
69+
}
70+
71+
// Atomic write: write to a temp file then rename so readers never see a
72+
// partial file.
73+
const dir = dirname(filePath);
74+
const tempPath = resolve(dir, `.${basename(filePath)}.tmp.${randomUUID()}`);
75+
try {
76+
await fs.writeFile(tempPath, exportResult.yaml, 'utf-8');
77+
await fs.rename(tempPath, filePath);
78+
} catch (err) {
79+
// Best-effort cleanup of the temp file.
80+
await fs.unlink(tempPath).catch(() => undefined);
81+
return json(
82+
{ ok: false, error: `Write failed: ${String(err)}` },
83+
{ status: 500 },
84+
);
85+
}
86+
87+
return json({ ok: true });
88+
};

src/routes/workflows/[...workflowId]/+page.server.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,15 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { env } from '$env/dynamic/public';
1716
import { exportToYaml } from '$lib/export/yaml';
17+
import { WORKFLOWS_DIR } from '$lib/server/workflows-dir';
1818
import { parseWorkflowFile } from '$lib/tasks/parse';
1919
import { error } from '@sveltejs/kit';
20-
import { promises as fs } from 'fs';
21-
import { resolve } from 'path';
20+
import { promises as fs } from 'node:fs';
21+
import { resolve } from 'node:path';
2222

2323
import type { PageServerLoad } from './$types';
2424

25-
const WORKFLOWS_DIR =
26-
env.PUBLIC_WORKFLOWS_DIR ?? resolve(process.cwd(), 'workflows');
27-
2825
export const load: PageServerLoad = async ({ params }) => {
2926
const fileName = params.workflowId;
3027
const filePath = resolve(WORKFLOWS_DIR, fileName);

0 commit comments

Comments
 (0)