minimal-workflow 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/index.html +12 -0
  2. package/package.json +44 -0
  3. package/public/manifest.fallback.json +482 -0
  4. package/scripts/cli.ts +318 -0
  5. package/scripts/server.ts +131 -0
  6. package/src/App.tsx +119 -0
  7. package/src/api/files.ts +32 -0
  8. package/src/api/manifest.ts +17 -0
  9. package/src/canvas/WorkflowCanvas.tsx +161 -0
  10. package/src/canvas/edges/SequentialEdge.tsx +14 -0
  11. package/src/canvas/nodes/AssertNode.tsx +17 -0
  12. package/src/canvas/nodes/BaseNode.tsx +55 -0
  13. package/src/canvas/nodes/BranchNode.tsx +21 -0
  14. package/src/canvas/nodes/LlmNode.tsx +29 -0
  15. package/src/canvas/nodes/LoopNode.tsx +22 -0
  16. package/src/canvas/nodes/PauseNode.tsx +17 -0
  17. package/src/canvas/nodes/SkillNode.tsx +27 -0
  18. package/src/canvas/nodes/ToolNode.tsx +27 -0
  19. package/src/index.css +379 -0
  20. package/src/inspector/Inspector.tsx +224 -0
  21. package/src/inspector/LlmNodeForm.tsx +59 -0
  22. package/src/inspector/SkillNodeForm.tsx +62 -0
  23. package/src/inspector/ToolNodeForm.tsx +120 -0
  24. package/src/main.tsx +10 -0
  25. package/src/palette/ControlFlowSection.tsx +45 -0
  26. package/src/palette/Palette.tsx +65 -0
  27. package/src/palette/SkillPaletteSection.tsx +31 -0
  28. package/src/palette/ToolPaletteSection.tsx +31 -0
  29. package/src/store/filesStore.ts +29 -0
  30. package/src/store/manifestStore.ts +25 -0
  31. package/src/store/workflowStore.ts +140 -0
  32. package/src/types.ts +83 -0
  33. package/src/utils/safePath.ts +49 -0
  34. package/src/yaml-view/YamlEditor.tsx +71 -0
  35. package/src/yaml-view/sync.ts +246 -0
  36. package/tsconfig.json +24 -0
  37. package/vite.config.ts +21 -0
@@ -0,0 +1,120 @@
1
+ import { useMemo } from 'react';
2
+ import { useManifestStore } from '../store/manifestStore.ts';
3
+ import type { StepDef, ToolMeta } from '../types.ts';
4
+
5
+ interface Props {
6
+ step: StepDef;
7
+ onPatch: (patch: Partial<StepDef>) => void;
8
+ }
9
+
10
+ export function ToolNodeForm({ step, onPatch }: Props) {
11
+ const manifest = useManifestStore((s) => s.manifest);
12
+ const tools = manifest?.tools ?? [];
13
+ const currentTool = useMemo(
14
+ () => tools.find((t) => t.name === step.tool),
15
+ [tools, step.tool],
16
+ );
17
+
18
+ const argsText = useMemo(() => {
19
+ try {
20
+ return JSON.stringify(step.args ?? {}, null, 2);
21
+ } catch {
22
+ return '{}';
23
+ }
24
+ }, [step.args]);
25
+
26
+ const onArgsChange = (text: string) => {
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
30
+ onPatch({ args: parsed as Record<string, unknown> });
31
+ }
32
+ } catch {
33
+ /* 静默忽略中间态 */
34
+ }
35
+ };
36
+
37
+ return (
38
+ <>
39
+ <div className="field">
40
+ <label>tool(工具名)</label>
41
+ <select
42
+ value={step.tool ?? ''}
43
+ onChange={(e) => onPatch({ tool: e.target.value })}
44
+ >
45
+ <option value="">— 选择工具 —</option>
46
+ {tools.map((t) => (
47
+ <option key={t.name} value={t.name}>
48
+ {t.name}
49
+ </option>
50
+ ))}
51
+ </select>
52
+ {currentTool && (
53
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
54
+ {currentTool.description}
55
+ </small>
56
+ )}
57
+ </div>
58
+
59
+ {currentTool && <ParamHints tool={currentTool} />}
60
+
61
+ <div className="field">
62
+ <label>args(JSON)</label>
63
+ <textarea
64
+ value={argsText}
65
+ rows={6}
66
+ spellCheck={false}
67
+ onChange={(e) => onArgsChange(e.target.value)}
68
+ style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
69
+ />
70
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
71
+ 支持 ${'{var}'} 模板引用前序 capture 变量
72
+ </small>
73
+ </div>
74
+ </>
75
+ );
76
+ }
77
+
78
+ function ParamHints({ tool }: { tool: ToolMeta }) {
79
+ const params = tool.parameters;
80
+ const props =
81
+ params && typeof params === 'object'
82
+ ? ((params as Record<string, unknown>).properties as
83
+ | Record<string, unknown>
84
+ | undefined)
85
+ : undefined;
86
+ if (!props || typeof props !== 'object') return null;
87
+ const required =
88
+ (params as { required?: string[] }).required ?? [];
89
+ const entries = Object.entries(props);
90
+ if (entries.length === 0) return null;
91
+ return (
92
+ <div
93
+ style={{
94
+ background: '#f9fafb',
95
+ border: '1px solid #e5e7eb',
96
+ borderRadius: 3,
97
+ padding: 6,
98
+ fontSize: 11,
99
+ marginBottom: 8,
100
+ }}
101
+ >
102
+ <div style={{ fontWeight: 600, marginBottom: 4 }}>参数提示</div>
103
+ {entries.map(([k, v]) => {
104
+ const desc = (v as { description?: string }).description ?? '';
105
+ const type = (v as { type?: string }).type ?? 'any';
106
+ const isReq = required.includes(k);
107
+ return (
108
+ <div key={k} style={{ marginBottom: 2 }}>
109
+ <code>{k}</code>
110
+ <span style={{ color: '#6b7280' }}>
111
+ {' '}
112
+ ({type}){isReq ? ' *' : ''}
113
+ </span>
114
+ {desc && <span style={{ color: '#6b7280' }}> — {desc}</span>}
115
+ </div>
116
+ );
117
+ })}
118
+ </div>
119
+ );
120
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App.tsx';
4
+ import './index.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -0,0 +1,45 @@
1
+ import type { StepKind } from '../types.ts';
2
+
3
+ interface FlowItem {
4
+ kind: StepKind;
5
+ icon: string;
6
+ label: string;
7
+ desc: string;
8
+ }
9
+
10
+ const ITEMS: FlowItem[] = [
11
+ { kind: 'llm', icon: '🤖', label: 'LLM 调用', desc: '单轮 LLM 推理,不进主历史' },
12
+ { kind: 'assert', icon: '⚠', label: 'assert', desc: '条件不成立则终止整个 workflow' },
13
+ { kind: 'pause', icon: '⏸', label: 'pause', desc: 'HITL 暂停点(非交互模式跳过)' },
14
+ { kind: 'branch', icon: '🔀', label: 'branch', desc: '二选一分支(then / else)' },
15
+ { kind: 'loop', icon: '🔁', label: 'loop', desc: '遍历数组,对每项执行子步' },
16
+ ];
17
+
18
+ export function ControlFlowSection() {
19
+ return (
20
+ <div className="palette-section">
21
+ <h2>控制流</h2>
22
+ {ITEMS.map((it) => (
23
+ <FlowItemView key={it.kind} item={it} />
24
+ ))}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ function FlowItemView({ item }: { item: FlowItem }) {
30
+ const onDragStart = (e: React.DragEvent) => {
31
+ e.dataTransfer.setData(
32
+ 'application/workflow-ui-node',
33
+ JSON.stringify({ kind: item.kind }),
34
+ );
35
+ e.dataTransfer.effectAllowed = 'move';
36
+ };
37
+ return (
38
+ <div className="palette-item" draggable onDragStart={onDragStart}>
39
+ <div className="name">
40
+ {item.icon} {item.label}
41
+ </div>
42
+ <div className="desc">{item.desc}</div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,65 @@
1
+ import { ToolPaletteSection } from './ToolPaletteSection.tsx';
2
+ import { SkillPaletteSection } from './SkillPaletteSection.tsx';
3
+ import { ControlFlowSection } from './ControlFlowSection.tsx';
4
+ import { useFilesStore } from '../store/filesStore.ts';
5
+ import { useWorkflowStore } from '../store/workflowStore.ts';
6
+ import { useEffect } from 'react';
7
+ import { parseWorkflowYaml } from '../yaml-view/sync.ts';
8
+
9
+ export function Palette() {
10
+ const files = useFilesStore((s) => s.files);
11
+ const refresh = useFilesStore((s) => s.refresh);
12
+ const open = useFilesStore((s) => s.open);
13
+ const error = useFilesStore((s) => s.error);
14
+ const currentFile = useWorkflowStore((s) => s.currentFile);
15
+ const loadWorkflow = useWorkflowStore((s) => s.loadWorkflow);
16
+ const setCurrentFile = useWorkflowStore((s) => s.setCurrentFile);
17
+
18
+ useEffect(() => {
19
+ refresh().catch(() => {});
20
+ }, [refresh]);
21
+
22
+ const handleOpen = async (file: string) => {
23
+ try {
24
+ const text = await open(file);
25
+ const def = parseWorkflowYaml(text);
26
+ loadWorkflow(def);
27
+ setCurrentFile(file);
28
+ } catch (e) {
29
+ alert(`打开 ${file} 失败:${(e as Error).message}`);
30
+ }
31
+ };
32
+
33
+ return (
34
+ <aside className="palette">
35
+ <div className="file-list">
36
+ <div className="palette-section">
37
+ <h2>工作流文件</h2>
38
+ </div>
39
+ {error && (
40
+ <div style={{ padding: '6px 12px', color: '#dc2626', fontSize: 11 }}>
41
+ 服务未启动?{error}
42
+ </div>
43
+ )}
44
+ {files.length === 0 && !error && (
45
+ <div style={{ padding: '6px 12px', color: '#9ca3af', fontSize: 11 }}>
46
+ workflows/ 为空
47
+ </div>
48
+ )}
49
+ {files.map((f) => (
50
+ <div
51
+ key={f.name}
52
+ className={`file-item ${currentFile === f.name ? 'active' : ''}`}
53
+ onClick={() => handleOpen(f.name)}
54
+ >
55
+ <span>{f.name}</span>
56
+ </div>
57
+ ))}
58
+ </div>
59
+
60
+ <ToolPaletteSection />
61
+ <SkillPaletteSection />
62
+ <ControlFlowSection />
63
+ </aside>
64
+ );
65
+ }
@@ -0,0 +1,31 @@
1
+ import { useManifestStore } from '../store/manifestStore.ts';
2
+ import type { SkillMeta } from '../types.ts';
3
+
4
+ export function SkillPaletteSection() {
5
+ const manifest = useManifestStore((s) => s.manifest);
6
+ if (!manifest) return null;
7
+ return (
8
+ <div className="palette-section">
9
+ <h2>技能({manifest.skills.length})</h2>
10
+ {manifest.skills.map((skill) => (
11
+ <SkillItem key={skill.name} skill={skill} />
12
+ ))}
13
+ </div>
14
+ );
15
+ }
16
+
17
+ function SkillItem({ skill }: { skill: SkillMeta }) {
18
+ const onDragStart = (e: React.DragEvent) => {
19
+ e.dataTransfer.setData(
20
+ 'application/workflow-ui-node',
21
+ JSON.stringify({ kind: 'skill', name: skill.name }),
22
+ );
23
+ e.dataTransfer.effectAllowed = 'move';
24
+ };
25
+ return (
26
+ <div className="palette-item" draggable onDragStart={onDragStart}>
27
+ <div className="name">📚 {skill.name}</div>
28
+ <div className="desc">{skill.description}</div>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,31 @@
1
+ import { useManifestStore } from '../store/manifestStore.ts';
2
+ import type { ToolMeta } from '../types.ts';
3
+
4
+ export function ToolPaletteSection() {
5
+ const manifest = useManifestStore((s) => s.manifest);
6
+ if (!manifest) return null;
7
+ return (
8
+ <div className="palette-section">
9
+ <h2>工具({manifest.tools.length})</h2>
10
+ {manifest.tools.map((tool) => (
11
+ <ToolItem key={tool.name} tool={tool} />
12
+ ))}
13
+ </div>
14
+ );
15
+ }
16
+
17
+ function ToolItem({ tool }: { tool: ToolMeta }) {
18
+ const onDragStart = (e: React.DragEvent) => {
19
+ e.dataTransfer.setData(
20
+ 'application/workflow-ui-node',
21
+ JSON.stringify({ kind: 'tool', name: tool.name }),
22
+ );
23
+ e.dataTransfer.effectAllowed = 'move';
24
+ };
25
+ return (
26
+ <div className="palette-item" draggable onDragStart={onDragStart}>
27
+ <div className="name">🔧 {tool.name}</div>
28
+ <div className="desc">{tool.description}</div>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,29 @@
1
+ import { create } from 'zustand';
2
+ import { listWorkflows, readWorkflow, writeWorkflow } from '../api/files.ts';
3
+ import type { WorkflowFileSummary } from '../api/files.ts';
4
+
5
+ interface FilesState {
6
+ files: WorkflowFileSummary[];
7
+ loading: boolean;
8
+ error: string | null;
9
+ refresh: () => Promise<void>;
10
+ open: (file: string) => Promise<string>;
11
+ save: (file: string, content: string) => Promise<void>;
12
+ }
13
+
14
+ export const useFilesStore = create<FilesState>((set) => ({
15
+ files: [],
16
+ loading: false,
17
+ error: null,
18
+ refresh: async () => {
19
+ set({ loading: true, error: null });
20
+ try {
21
+ const files = await listWorkflows();
22
+ set({ files, loading: false });
23
+ } catch (e) {
24
+ set({ error: (e as Error).message, loading: false });
25
+ }
26
+ },
27
+ open: async (file) => readWorkflow(file),
28
+ save: async (file, content) => writeWorkflow(file, content),
29
+ }));
@@ -0,0 +1,25 @@
1
+ import { create } from 'zustand';
2
+ import type { Manifest } from '../types.ts';
3
+ import { loadManifest } from '../api/manifest.ts';
4
+
5
+ interface ManifestState {
6
+ manifest: Manifest | null;
7
+ loading: boolean;
8
+ error: string | null;
9
+ load: () => Promise<void>;
10
+ }
11
+
12
+ export const useManifestStore = create<ManifestState>((set) => ({
13
+ manifest: null,
14
+ loading: false,
15
+ error: null,
16
+ load: async () => {
17
+ set({ loading: true, error: null });
18
+ try {
19
+ const m = await loadManifest();
20
+ set({ manifest: m, loading: false });
21
+ } catch (e) {
22
+ set({ error: (e as Error).message, loading: false });
23
+ }
24
+ },
25
+ }));
@@ -0,0 +1,140 @@
1
+ import { create } from 'zustand';
2
+ import type { Edge, Node } from '@xyflow/react';
3
+ import type { CanvasNodeData, StepDef, WorkflowDef } from '../types.ts';
4
+
5
+ interface WorkflowState {
6
+ /** 当前打开的 workflow 文件名 */
7
+ currentFile: string | null;
8
+ /** 当前 workflow 元信息(除 steps 之外的字段) */
9
+ meta: Pick<WorkflowDef, 'name' | 'description' | 'version' | 'inputs'>;
10
+ /** React Flow 节点 */
11
+ nodes: Node<CanvasNodeData>[];
12
+ /** React Flow 边 */
13
+ edges: Edge[];
14
+ /** 当前选中节点 id */
15
+ selectedNodeId: string | null;
16
+ /** YAML 是否未保存 */
17
+ dirty: boolean;
18
+
19
+ setCurrentFile: (file: string | null) => void;
20
+ loadWorkflow: (def: WorkflowDef) => void;
21
+ setNodes: (nodes: Node<CanvasNodeData>[]) => void;
22
+ setEdges: (edges: Edge[]) => void;
23
+ selectNode: (id: string | null) => void;
24
+ updateStep: (id: string, patch: Partial<StepDef>) => void;
25
+ addStep: (step: StepDef, position: { x: number; y: number }) => void;
26
+ removeStep: (id: string) => void;
27
+ markDirty: (dirty: boolean) => void;
28
+ /** 生成当前 workflow 完整 def(含 __meta.layout) */
29
+ toWorkflowDef: () => WorkflowDef;
30
+ }
31
+
32
+ const initialMeta = {
33
+ name: 'untitled',
34
+ description: '',
35
+ version: '0.1',
36
+ inputs: [],
37
+ };
38
+
39
+ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
40
+ currentFile: null,
41
+ meta: initialMeta,
42
+ nodes: [],
43
+ edges: [],
44
+ selectedNodeId: null,
45
+ dirty: false,
46
+
47
+ setCurrentFile: (file) => set({ currentFile: file }),
48
+
49
+ loadWorkflow: (def) => {
50
+ const steps = def.steps ?? [];
51
+ const layout = def.__meta?.layout ?? [];
52
+ const nodes: Node<CanvasNodeData>[] = steps.map((step, i) => {
53
+ const pos = layout.find((l) => l.id === step.id);
54
+ return {
55
+ id: step.id,
56
+ type: detectKind(step),
57
+ position: pos ? { x: pos.x, y: pos.y } : { x: 100, y: 80 + i * 120 },
58
+ data: { step, kind: detectKind(step) },
59
+ };
60
+ });
61
+ const edges: Edge[] = nodes.slice(0, -1).map((n, i) => ({
62
+ id: `seq-${n.id}-${nodes[i + 1].id}`,
63
+ source: n.id,
64
+ target: nodes[i + 1].id,
65
+ type: 'sequential',
66
+ }));
67
+ set({
68
+ meta: {
69
+ name: def.name,
70
+ description: def.description ?? '',
71
+ version: def.version ?? '0.1',
72
+ inputs: def.inputs ?? [],
73
+ },
74
+ nodes,
75
+ edges,
76
+ selectedNodeId: null,
77
+ dirty: false,
78
+ });
79
+ },
80
+
81
+ setNodes: (nodes) => set({ nodes, dirty: true }),
82
+ setEdges: (edges) => set({ edges, dirty: true }),
83
+ selectNode: (id) => set({ selectedNodeId: id }),
84
+
85
+ updateStep: (id, patch) => {
86
+ const nodes = get().nodes.map((n) =>
87
+ n.id === id
88
+ ? { ...n, data: { ...n.data, step: { ...n.data.step, ...patch } } }
89
+ : n,
90
+ );
91
+ set({ nodes, dirty: true });
92
+ },
93
+
94
+ addStep: (step, position) => {
95
+ const node: Node<CanvasNodeData> = {
96
+ id: step.id,
97
+ type: detectKind(step),
98
+ position,
99
+ data: { step, kind: detectKind(step) },
100
+ };
101
+ const nodes = [...get().nodes, node];
102
+ set({ nodes, dirty: true });
103
+ },
104
+
105
+ removeStep: (id) => {
106
+ const nodes = get().nodes.filter((n) => n.id !== id);
107
+ const edges = get()
108
+ .edges.filter((e) => e.source !== id && e.target !== id);
109
+ set({ nodes, edges, dirty: true, selectedNodeId: null });
110
+ },
111
+
112
+ markDirty: (dirty) => set({ dirty }),
113
+
114
+ toWorkflowDef: () => {
115
+ const { meta, nodes } = get();
116
+ const ordered = [...nodes].sort(
117
+ (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
118
+ );
119
+ return {
120
+ ...meta,
121
+ name: meta.name || 'untitled',
122
+ steps: ordered.map((n) => n.data.step),
123
+ __meta: {
124
+ layout: ordered.map((n) => ({
125
+ id: n.id,
126
+ x: Math.round(n.position.x),
127
+ y: Math.round(n.position.y),
128
+ })),
129
+ },
130
+ };
131
+ },
132
+ }));
133
+
134
+ function detectKind(step: StepDef): string {
135
+ if (step.type) return step.type;
136
+ if (step.tool) return 'tool';
137
+ if (step.skill) return 'skill';
138
+ if (step.llm) return 'llm';
139
+ return 'assert';
140
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 编辑器内部共享类型。与 plugins/workflow-runner/src/types.ts 同形但**禁止 import**
3
+ * 主项目代码(D2 解耦红线)。schema 漂移时手动同步。
4
+ */
5
+
6
+ export type StepKind =
7
+ | 'tool'
8
+ | 'llm'
9
+ | 'skill'
10
+ | 'assert'
11
+ | 'branch'
12
+ | 'loop'
13
+ | 'pause';
14
+
15
+ export interface WorkflowInput {
16
+ name: string;
17
+ type?: 'string' | 'number' | 'boolean';
18
+ required?: boolean;
19
+ default?: unknown;
20
+ }
21
+
22
+ export interface StepDef {
23
+ id: string;
24
+ type?: StepKind;
25
+ tool?: string;
26
+ llm?: string | Record<string, unknown>;
27
+ skill?: string;
28
+ args?: Record<string, unknown>;
29
+ input?: string;
30
+ prompt?: string;
31
+ condition?: string;
32
+ over?: string;
33
+ as?: string;
34
+ do?: StepDef[];
35
+ then?: StepDef[];
36
+ else?: StepDef[];
37
+ when?: string;
38
+ capture?: Record<string, string>;
39
+ onError?: 'halt' | 'continue';
40
+ onFail?: string;
41
+ maxTurns?: number;
42
+ }
43
+
44
+ export interface WorkflowDef {
45
+ name: string;
46
+ description?: string;
47
+ version?: string;
48
+ inputs?: WorkflowInput[];
49
+ steps: StepDef[];
50
+ __meta?: {
51
+ layout?: { id: string; x: number; y: number }[];
52
+ };
53
+ }
54
+
55
+ /** manifest.json 形状 —— 参见 docs/workflow-editor-design.md §2.2 */
56
+ export interface ToolMeta {
57
+ name: string;
58
+ description: string;
59
+ parameters: Record<string, unknown>;
60
+ isReadOnly?: boolean;
61
+ isConcurrencySafe?: boolean;
62
+ }
63
+
64
+ export interface SkillMeta {
65
+ name: string;
66
+ description: string;
67
+ triggers?: string;
68
+ skillMdPreview?: string;
69
+ }
70
+
71
+ export interface Manifest {
72
+ version: string;
73
+ generatedAt: string;
74
+ tools: ToolMeta[];
75
+ skills: SkillMeta[];
76
+ }
77
+
78
+ /** 图模型节点 data 字段 */
79
+ export interface CanvasNodeData {
80
+ step: StepDef;
81
+ kind: StepKind;
82
+ errors?: string[];
83
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 工作流文件名安全校验。被 scripts/server.ts 和单元测试共用。
3
+ *
4
+ * 5 道防线:
5
+ * 1) 类型 / 空串
6
+ * 2) 路径分隔符(覆盖 / 和 \ 两平台)
7
+ * 3) `.` / `..` / 内含 `..`
8
+ * 4) 绝对路径(POSIX 起始 / 或 Windows 盘符)
9
+ * 5) 扩展名必须是 .yaml(大小写宽容)
10
+ * 6) resolve 后必须仍在 workflowsDir 下(防 symlink / 编码 trick)
11
+ */
12
+
13
+ import path from 'node:path';
14
+
15
+ export type SafePathResult =
16
+ | { ok: true; abs: string }
17
+ | { ok: false; reason: string };
18
+
19
+ export function safeResolveWorkflowFile(
20
+ workflowsDir: string,
21
+ rawName: unknown,
22
+ ): SafePathResult {
23
+ if (typeof rawName !== 'string' || rawName.length === 0) {
24
+ return { ok: false, reason: 'file name is required' };
25
+ }
26
+ if (rawName.includes('/') || rawName.includes('\\')) {
27
+ return { ok: false, reason: 'file name must not contain path separators' };
28
+ }
29
+ if (rawName === '..' || rawName === '.' || rawName.includes('..')) {
30
+ return { ok: false, reason: 'file name must not contain ".."' };
31
+ }
32
+ if (path.isAbsolute(rawName)) {
33
+ return { ok: false, reason: 'absolute paths are not allowed' };
34
+ }
35
+ if (!rawName.toLowerCase().endsWith('.yaml')) {
36
+ return { ok: false, reason: 'only .yaml files are allowed' };
37
+ }
38
+
39
+ const abs = path.resolve(workflowsDir, rawName);
40
+ const normalizedAbs = path.normalize(abs);
41
+ const normalizedRoot = path.normalize(workflowsDir + path.sep);
42
+ if (
43
+ !(normalizedAbs + path.sep).startsWith(normalizedRoot) &&
44
+ normalizedAbs !== path.normalize(workflowsDir)
45
+ ) {
46
+ return { ok: false, reason: 'resolved path escapes workflows directory' };
47
+ }
48
+ return { ok: true, abs };
49
+ }