orkestr 1.0.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 (152) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +832 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/orkestr/orkestr-editor.js +72 -0
  6. data/app/assets/stylesheets/orkestr/application.css +15 -0
  7. data/app/assets/stylesheets/orkestr/theme.css +62 -0
  8. data/app/controllers/orkestr/api/base_controller.rb +45 -0
  9. data/app/controllers/orkestr/api/executions_controller.rb +50 -0
  10. data/app/controllers/orkestr/api/human_tasks_controller.rb +48 -0
  11. data/app/controllers/orkestr/api/registry_controller.rb +40 -0
  12. data/app/controllers/orkestr/api/workflows_controller.rb +53 -0
  13. data/app/controllers/orkestr/application_controller.rb +4 -0
  14. data/app/controllers/orkestr/human_tasks_controller.rb +30 -0
  15. data/app/controllers/orkestr/ui_controller.rb +8 -0
  16. data/app/controllers/orkestr/webhooks_controller.rb +35 -0
  17. data/app/helpers/orkestr/application_helper.rb +4 -0
  18. data/app/helpers/orkestr/ui_helper.rb +34 -0
  19. data/app/javascript/orkestr-ui/index.html +19 -0
  20. data/app/javascript/orkestr-ui/package-lock.json +2050 -0
  21. data/app/javascript/orkestr-ui/package.json +23 -0
  22. data/app/javascript/orkestr-ui/src/OrkestrApp.tsx +152 -0
  23. data/app/javascript/orkestr-ui/src/api/client.ts +59 -0
  24. data/app/javascript/orkestr-ui/src/api/executions.ts +23 -0
  25. data/app/javascript/orkestr-ui/src/api/humanTasks.ts +32 -0
  26. data/app/javascript/orkestr-ui/src/api/index.ts +5 -0
  27. data/app/javascript/orkestr-ui/src/api/registry.ts +10 -0
  28. data/app/javascript/orkestr-ui/src/api/workflows.ts +33 -0
  29. data/app/javascript/orkestr-ui/src/components/Editor/ActionsBuilder.tsx +213 -0
  30. data/app/javascript/orkestr-ui/src/components/Editor/CustomNode.tsx +31 -0
  31. data/app/javascript/orkestr-ui/src/components/Editor/EditorToolbar.tsx +153 -0
  32. data/app/javascript/orkestr-ui/src/components/Editor/FormSchemaBuilder.tsx +390 -0
  33. data/app/javascript/orkestr-ui/src/components/Editor/NodeConfigPanel.tsx +274 -0
  34. data/app/javascript/orkestr-ui/src/components/Editor/NodePalette.tsx +43 -0
  35. data/app/javascript/orkestr-ui/src/components/Editor/RunDialog.tsx +52 -0
  36. data/app/javascript/orkestr-ui/src/components/Editor/WorkflowEditor.tsx +299 -0
  37. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionDetail.tsx +155 -0
  38. data/app/javascript/orkestr-ui/src/components/Executions/ExecutionList.tsx +74 -0
  39. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskForm.tsx +216 -0
  40. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskFormEmbed.tsx +117 -0
  41. data/app/javascript/orkestr-ui/src/components/HumanTasks/TaskList.tsx +110 -0
  42. data/app/javascript/orkestr-ui/src/components/Workflows/NewWorkflowDialog.tsx +64 -0
  43. data/app/javascript/orkestr-ui/src/components/Workflows/WorkflowList.tsx +94 -0
  44. data/app/javascript/orkestr-ui/src/components/shared/EntryConditionsEditor.tsx +138 -0
  45. data/app/javascript/orkestr-ui/src/components/shared/ExpressionEditor.tsx +206 -0
  46. data/app/javascript/orkestr-ui/src/components/shared/HumanTaskFormRenderer.tsx +321 -0
  47. data/app/javascript/orkestr-ui/src/components/shared/JsonSchemaForm.tsx +376 -0
  48. data/app/javascript/orkestr-ui/src/components/shared/Loading.tsx +5 -0
  49. data/app/javascript/orkestr-ui/src/components/shared/StatusBadge.tsx +9 -0
  50. data/app/javascript/orkestr-ui/src/fieldRegistry.ts +74 -0
  51. data/app/javascript/orkestr-ui/src/hooks/useApi.ts +30 -0
  52. data/app/javascript/orkestr-ui/src/hooks/useRegistry.ts +35 -0
  53. data/app/javascript/orkestr-ui/src/main.tsx +75 -0
  54. data/app/javascript/orkestr-ui/src/styles/editor.css +445 -0
  55. data/app/javascript/orkestr-ui/src/styles/index.css +478 -0
  56. data/app/javascript/orkestr-ui/src/types/execution.ts +37 -0
  57. data/app/javascript/orkestr-ui/src/types/humanTask.ts +30 -0
  58. data/app/javascript/orkestr-ui/src/types/index.ts +4 -0
  59. data/app/javascript/orkestr-ui/src/types/registry.ts +22 -0
  60. data/app/javascript/orkestr-ui/src/types/workflow.ts +64 -0
  61. data/app/javascript/orkestr-ui/src/vite-env.d.ts +6 -0
  62. data/app/javascript/orkestr-ui/tsconfig.json +21 -0
  63. data/app/javascript/orkestr-ui/tsconfig.tsbuildinfo +1 -0
  64. data/app/javascript/orkestr-ui/vite.config.ts +30 -0
  65. data/app/jobs/orkestr/application_job.rb +4 -0
  66. data/app/jobs/orkestr/execute_workflow_job.rb +10 -0
  67. data/app/jobs/orkestr/resume_execution_job.rb +15 -0
  68. data/app/mailers/orkestr/application_mailer.rb +6 -0
  69. data/app/models/concerns/orkestr/assignable.rb +28 -0
  70. data/app/models/concerns/orkestr/contextualizable.rb +9 -0
  71. data/app/models/orkestr/application_record.rb +6 -0
  72. data/app/models/orkestr/assignee.rb +40 -0
  73. data/app/models/orkestr/context.rb +42 -0
  74. data/app/models/orkestr/edge.rb +58 -0
  75. data/app/models/orkestr/execution.rb +45 -0
  76. data/app/models/orkestr/execution_log.rb +38 -0
  77. data/app/models/orkestr/human_task.rb +63 -0
  78. data/app/models/orkestr/node.rb +48 -0
  79. data/app/models/orkestr/node_execution.rb +59 -0
  80. data/app/models/orkestr/workflow.rb +39 -0
  81. data/app/orkestr_nodes/action/node.rb +77 -0
  82. data/app/orkestr_nodes/condition/node.rb +67 -0
  83. data/app/orkestr_nodes/http_request/node.rb +88 -0
  84. data/app/orkestr_nodes/human_action/node.rb +103 -0
  85. data/app/orkestr_nodes/transform/node.rb +48 -0
  86. data/app/orkestr_nodes/wait/node.rb +18 -0
  87. data/app/orkestr_triggers/manual/trigger.rb +12 -0
  88. data/app/orkestr_triggers/scheduled/trigger.rb +26 -0
  89. data/app/orkestr_triggers/webhook/trigger.rb +23 -0
  90. data/app/serializers/orkestr/assignee_serializer.rb +30 -0
  91. data/app/serializers/orkestr/context_serializer.rb +30 -0
  92. data/app/serializers/orkestr/edge_serializer.rb +33 -0
  93. data/app/serializers/orkestr/execution_collection_serializer.rb +7 -0
  94. data/app/serializers/orkestr/execution_log_serializer.rb +31 -0
  95. data/app/serializers/orkestr/execution_serializer.rb +37 -0
  96. data/app/serializers/orkestr/human_task_serializer.rb +46 -0
  97. data/app/serializers/orkestr/node_execution_serializer.rb +43 -0
  98. data/app/serializers/orkestr/node_serializer.rb +29 -0
  99. data/app/serializers/orkestr/workflow_collection_serializer.rb +11 -0
  100. data/app/serializers/orkestr/workflow_serializer.rb +30 -0
  101. data/app/services/orkestr/entry_condition_evaluator.rb +68 -0
  102. data/app/services/orkestr/execution_service/complete.rb +64 -0
  103. data/app/services/orkestr/execution_service/join_resolver.rb +56 -0
  104. data/app/services/orkestr/execution_service/node_runner.rb +56 -0
  105. data/app/services/orkestr/execution_service/runner.rb +162 -0
  106. data/app/services/orkestr/execution_service/start.rb +90 -0
  107. data/app/services/orkestr/expression_resolver.rb +72 -0
  108. data/app/services/orkestr/human_task_service/complete.rb +62 -0
  109. data/app/services/orkestr/workflow_service/duplicate.rb +30 -0
  110. data/app/services/orkestr/workflow_service/export.rb +26 -0
  111. data/app/services/orkestr/workflow_service/import.rb +29 -0
  112. data/app/services/orkestr/workflow_synchronizer.rb +102 -0
  113. data/app/views/layouts/orkestr/application.html.erb +17 -0
  114. data/app/views/layouts/orkestr/ui.html.erb +18 -0
  115. data/app/views/orkestr/human_tasks/show.html.erb +17 -0
  116. data/app/views/orkestr/ui/index.html.erb +8 -0
  117. data/config/routes.rb +27 -0
  118. data/db/migrate/20260308204133_enable_pgcrypto_extension.rb +5 -0
  119. data/db/migrate/20260308204558_create_orkestr_workflows.rb +12 -0
  120. data/db/migrate/20260308204703_create_orkestr_nodes.rb +12 -0
  121. data/db/migrate/20260308204807_create_orkestr_edges.rb +12 -0
  122. data/db/migrate/20260308204931_create_orkestr_executions.rb +13 -0
  123. data/db/migrate/20260308205023_create_orkestr_node_executions.rb +16 -0
  124. data/db/migrate/20260308205119_add_react_flow_id_to_nodes.rb +6 -0
  125. data/db/migrate/20260308205123_add_react_flow_id_to_edges.rb +6 -0
  126. data/db/migrate/20260308205745_add_workflow_to_executions.rb +5 -0
  127. data/db/migrate/20260308205940_make_executable_nullable_on_executions.rb +6 -0
  128. data/db/migrate/20260308220730_create_orkestr_human_tasks.rb +15 -0
  129. data/db/migrate/20260308220900_create_orkestr_assignees.rb +13 -0
  130. data/db/migrate/20260308234115_add_unique_index_to_node_executions.rb +7 -0
  131. data/db/migrate/20260309075336_create_orkestr_contexts.rb +10 -0
  132. data/db/migrate/20260309075343_replace_executable_with_context_on_executions.rb +11 -0
  133. data/db/migrate/20260309080416_add_status_key_deadline_to_orkestr_assignees.rb +7 -0
  134. data/db/migrate/20260309082815_add_status_and_workflow_to_orkestr_contexts.rb +7 -0
  135. data/db/migrate/20260309082816_add_status_default_context_and_reuse_context_to_orkestr_workflows.rb +7 -0
  136. data/db/migrate/20260309083328_create_orkestr_execution_logs.rb +14 -0
  137. data/db/migrate/20260310223204_replace_node_executions_unique_index_for_cycle_support.rb +18 -0
  138. data/lib/orkestr/configuration.rb +16 -0
  139. data/lib/orkestr/engine.rb +48 -0
  140. data/lib/orkestr/nodes/base.rb +74 -0
  141. data/lib/orkestr/nodes/loader.rb +32 -0
  142. data/lib/orkestr/nodes/registry.rb +34 -0
  143. data/lib/orkestr/nodes/schema_dsl.rb +73 -0
  144. data/lib/orkestr/triggers/base.rb +49 -0
  145. data/lib/orkestr/triggers/loader.rb +29 -0
  146. data/lib/orkestr/triggers/registry.rb +34 -0
  147. data/lib/orkestr/triggers/schema_dsl.rb +45 -0
  148. data/lib/orkestr/version.rb +3 -0
  149. data/lib/orkestr.rb +27 -0
  150. data/lib/tasks/annotate_rb.rake +10 -0
  151. data/lib/tasks/orkestr_tasks.rake +19 -0
  152. metadata +251 -0
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "orkestr-ui",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@xyflow/react": "^12.6.0",
13
+ "react": "^19.1.0",
14
+ "react-dom": "^19.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^19.1.0",
18
+ "@types/react-dom": "^19.1.0",
19
+ "@vitejs/plugin-react": "^4.4.0",
20
+ "typescript": "~5.8.0",
21
+ "vite": "^6.3.0"
22
+ }
23
+ }
@@ -0,0 +1,152 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { ApiClient } from "./api/client";
3
+ import { useRegistry } from "./hooks/useRegistry";
4
+ import { WorkflowList } from "./components/Workflows/WorkflowList";
5
+ import { WorkflowEditor } from "./components/Editor/WorkflowEditor";
6
+ import { ExecutionList } from "./components/Executions/ExecutionList";
7
+ import { ExecutionDetail } from "./components/Executions/ExecutionDetail";
8
+ import { TaskList } from "./components/HumanTasks/TaskList";
9
+ import { TaskForm } from "./components/HumanTasks/TaskForm";
10
+ import { TaskFormEmbed } from "./components/HumanTasks/TaskFormEmbed";
11
+ import { Loading } from "./components/shared/Loading";
12
+
13
+ type View =
14
+ | { kind: "workflows" }
15
+ | { kind: "editor"; workflowId: string }
16
+ | { kind: "executions"; workflowId: string }
17
+ | { kind: "execution-detail"; executionId: string; workflowId: string }
18
+ | { kind: "tasks" }
19
+ | { kind: "task-detail"; taskId: string; prefill?: Record<string, unknown> };
20
+
21
+ export interface OrkestrAppProps {
22
+ apiBaseUrl: string;
23
+ authToken?: string;
24
+ workflowId?: string;
25
+ mode?: "dashboard" | "editor" | "task";
26
+ taskId?: string;
27
+ prefill?: Record<string, unknown>;
28
+ onTaskCompleted?: (taskId: string) => void;
29
+ onTaskCancelled?: (taskId: string) => void;
30
+ }
31
+
32
+ export function OrkestrApp({ apiBaseUrl, authToken, workflowId, mode, taskId, prefill, onTaskCompleted, onTaskCancelled }: OrkestrAppProps) {
33
+ const client = useMemo(() => new ApiClient(apiBaseUrl, authToken), [apiBaseUrl, authToken]);
34
+
35
+ // Task-only mode: render just the form, no chrome
36
+ if (mode === "task" && taskId) {
37
+ return (
38
+ <TaskFormEmbed
39
+ client={client}
40
+ taskId={taskId}
41
+ prefill={prefill}
42
+ onCompleted={onTaskCompleted}
43
+ onCancelled={onTaskCancelled}
44
+ />
45
+ );
46
+ }
47
+
48
+ const registry = useRegistry(client);
49
+
50
+ const initialView: View =
51
+ mode === "editor" && workflowId
52
+ ? { kind: "editor", workflowId }
53
+ : { kind: "workflows" };
54
+
55
+ const [view, setView] = useState<View>(initialView);
56
+
57
+ const activeTab =
58
+ view.kind === "workflows" || view.kind === "editor"
59
+ ? "workflows"
60
+ : view.kind === "executions" || view.kind === "execution-detail"
61
+ ? "executions"
62
+ : "tasks";
63
+
64
+ if (registry.loading) return <div className="ork-app"><Loading /></div>;
65
+
66
+ return (
67
+ <div className="ork-app">
68
+ {view.kind !== "editor" && view.kind !== "execution-detail" && (
69
+ <div className="ork-header">
70
+ <h1>Orkestr</h1>
71
+ <div className="ork-nav">
72
+ <button
73
+ className={`ork-nav-btn ${activeTab === "workflows" ? "active" : ""}`}
74
+ onClick={() => setView({ kind: "workflows" })}
75
+ >
76
+ Workflows
77
+ </button>
78
+ <button
79
+ className={`ork-nav-btn ${activeTab === "tasks" ? "active" : ""}`}
80
+ onClick={() => setView({ kind: "tasks" })}
81
+ >
82
+ Tasks
83
+ </button>
84
+ </div>
85
+ </div>
86
+ )}
87
+
88
+ {view.kind === "workflows" && (
89
+ <div className="ork-content">
90
+ <WorkflowList
91
+ client={client}
92
+ onSelect={(id) => setView({ kind: "editor", workflowId: id })}
93
+ />
94
+ </div>
95
+ )}
96
+
97
+ {view.kind === "editor" && (
98
+ <WorkflowEditor
99
+ client={client}
100
+ workflowId={view.workflowId}
101
+ nodeDefinitions={registry.nodes}
102
+ triggerDefinitions={registry.triggers}
103
+ onBack={() => setView({ kind: "workflows" })}
104
+ onExecutionCreated={(executionId) =>
105
+ setView({ kind: "execution-detail", executionId, workflowId: view.workflowId })
106
+ }
107
+ />
108
+ )}
109
+
110
+ {view.kind === "executions" && (
111
+ <div className="ork-content">
112
+ <ExecutionList
113
+ client={client}
114
+ workflowId={view.workflowId}
115
+ onSelect={(id) =>
116
+ setView({ kind: "execution-detail", executionId: id, workflowId: view.workflowId })
117
+ }
118
+ />
119
+ </div>
120
+ )}
121
+
122
+ {view.kind === "execution-detail" && (
123
+ <ExecutionDetail
124
+ client={client}
125
+ executionId={view.executionId}
126
+ onBack={() => setView({ kind: "executions", workflowId: view.workflowId })}
127
+ />
128
+ )}
129
+
130
+ {view.kind === "tasks" && (
131
+ <div className="ork-content">
132
+ <TaskList
133
+ client={client}
134
+ onSelect={(id, prefill) => setView({ kind: "task-detail", taskId: id, prefill })}
135
+ />
136
+ </div>
137
+ )}
138
+
139
+ {view.kind === "task-detail" && (
140
+ <div className="ork-content">
141
+ <TaskForm
142
+ client={client}
143
+ taskId={view.taskId}
144
+ prefill={view.prefill}
145
+ onBack={() => setView({ kind: "tasks" })}
146
+ onCompleted={() => setView({ kind: "tasks" })}
147
+ />
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,59 @@
1
+ export class ApiClient {
2
+ constructor(
3
+ private baseUrl: string,
4
+ private authToken?: string
5
+ ) {}
6
+
7
+ private headers(): HeadersInit {
8
+ const h: HeadersInit = { "Content-Type": "application/json", Accept: "application/json" };
9
+ if (this.authToken) h["Authorization"] = `Bearer ${this.authToken}`;
10
+ return h;
11
+ }
12
+
13
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
14
+ const url = `${this.baseUrl}/api${path}`;
15
+ const options: RequestInit = { method, headers: this.headers() };
16
+ if (body !== undefined) options.body = JSON.stringify(body);
17
+
18
+ const response = await fetch(url, options);
19
+
20
+ if (response.status === 204) return undefined as T;
21
+
22
+ if (!response.ok) {
23
+ const error = await response.json().catch(() => ({ error: response.statusText }));
24
+ throw new ApiError(response.status, error.error || "Request failed");
25
+ }
26
+
27
+ return response.json();
28
+ }
29
+
30
+ getBaseUrl(): string {
31
+ return this.baseUrl;
32
+ }
33
+
34
+ get<T>(path: string): Promise<T> {
35
+ return this.request("GET", path);
36
+ }
37
+
38
+ post<T>(path: string, body?: unknown): Promise<T> {
39
+ return this.request("POST", path, body);
40
+ }
41
+
42
+ put<T>(path: string, body?: unknown): Promise<T> {
43
+ return this.request("PUT", path, body);
44
+ }
45
+
46
+ delete(path: string): Promise<void> {
47
+ return this.request("DELETE", path);
48
+ }
49
+ }
50
+
51
+ export class ApiError extends Error {
52
+ constructor(
53
+ public status: number,
54
+ message: string
55
+ ) {
56
+ super(message);
57
+ this.name = "ApiError";
58
+ }
59
+ }
@@ -0,0 +1,23 @@
1
+ import type { ApiClient } from "./client";
2
+ import type { Execution, ExecutionSummary } from "../types";
3
+
4
+ export function listExecutions(
5
+ client: ApiClient,
6
+ workflowId: string,
7
+ status?: string
8
+ ): Promise<ExecutionSummary[]> {
9
+ const query = status ? `?status=${encodeURIComponent(status)}` : "";
10
+ return client.get(`/workflows/${workflowId}/executions${query}`);
11
+ }
12
+
13
+ export function getExecution(client: ApiClient, id: string): Promise<Execution> {
14
+ return client.get(`/executions/${id}`);
15
+ }
16
+
17
+ export function createExecution(
18
+ client: ApiClient,
19
+ workflowId: string,
20
+ context?: Record<string, unknown>
21
+ ): Promise<Execution> {
22
+ return client.post(`/workflows/${workflowId}/executions`, context ? { context } : {});
23
+ }
@@ -0,0 +1,32 @@
1
+ import type { ApiClient } from "./client";
2
+ import type { HumanTask } from "../types";
3
+
4
+ export interface ListHumanTasksParams {
5
+ status?: string;
6
+ assignable_type?: string;
7
+ assignable_id?: string;
8
+ }
9
+
10
+ export function listHumanTasks(
11
+ client: ApiClient,
12
+ params?: ListHumanTasksParams
13
+ ): Promise<HumanTask[]> {
14
+ const query = new URLSearchParams();
15
+ if (params?.status) query.set("status", params.status);
16
+ if (params?.assignable_type) query.set("assignable_type", params.assignable_type);
17
+ if (params?.assignable_id) query.set("assignable_id", params.assignable_id);
18
+ const qs = query.toString();
19
+ return client.get(`/human_tasks${qs ? `?${qs}` : ""}`);
20
+ }
21
+
22
+ export function getHumanTask(client: ApiClient, id: string): Promise<HumanTask> {
23
+ return client.get(`/human_tasks/${id}`);
24
+ }
25
+
26
+ export function completeHumanTask(
27
+ client: ApiClient,
28
+ id: string,
29
+ response: Record<string, unknown>
30
+ ): Promise<HumanTask> {
31
+ return client.put(`/human_tasks/${id}/complete`, { response });
32
+ }
@@ -0,0 +1,5 @@
1
+ export { ApiClient, ApiError } from "./client";
2
+ export * from "./workflows";
3
+ export * from "./executions";
4
+ export * from "./humanTasks";
5
+ export * from "./registry";
@@ -0,0 +1,10 @@
1
+ import type { ApiClient } from "./client";
2
+ import type { NodeDefinition, TriggerDefinition } from "../types";
3
+
4
+ export function listNodeDefinitions(client: ApiClient): Promise<NodeDefinition[]> {
5
+ return client.get("/registry/nodes");
6
+ }
7
+
8
+ export function listTriggerDefinitions(client: ApiClient): Promise<TriggerDefinition[]> {
9
+ return client.get("/registry/triggers");
10
+ }
@@ -0,0 +1,33 @@
1
+ import type { ApiClient } from "./client";
2
+ import type { Workflow, WorkflowSummary, GraphJson } from "../types";
3
+
4
+ export function listWorkflows(client: ApiClient): Promise<WorkflowSummary[]> {
5
+ return client.get("/workflows");
6
+ }
7
+
8
+ export function getWorkflow(client: ApiClient, id: string): Promise<Workflow> {
9
+ return client.get(`/workflows/${id}`);
10
+ }
11
+
12
+ export interface CreateWorkflowParams {
13
+ name: string;
14
+ trigger_type?: string;
15
+ trigger_config?: Record<string, unknown>;
16
+ graph_json?: GraphJson;
17
+ }
18
+
19
+ export function createWorkflow(client: ApiClient, params: CreateWorkflowParams): Promise<Workflow> {
20
+ return client.post("/workflows", params);
21
+ }
22
+
23
+ export function updateWorkflow(
24
+ client: ApiClient,
25
+ id: string,
26
+ params: Partial<CreateWorkflowParams>
27
+ ): Promise<Workflow> {
28
+ return client.put(`/workflows/${id}`, params);
29
+ }
30
+
31
+ export function deleteWorkflow(client: ApiClient, id: string): Promise<void> {
32
+ return client.delete(`/workflows/${id}`);
33
+ }
@@ -0,0 +1,213 @@
1
+ import React, { useState } from "react";
2
+ import { FormSchemaBuilder, type FormSchema } from "./FormSchemaBuilder";
3
+
4
+ export interface ActionConfig {
5
+ label: string;
6
+ icon?: string;
7
+ color?: string;
8
+ prefill?: Record<string, string>;
9
+ schema?: FormSchema;
10
+ }
11
+
12
+ interface Props {
13
+ value: ActionConfig[] | undefined;
14
+ onChange: (actions: ActionConfig[]) => void;
15
+ }
16
+
17
+ export function ActionsBuilder({ value, onChange }: Props) {
18
+ const actions = value || [];
19
+
20
+ const addAction = () => {
21
+ onChange([...actions, { label: "" }]);
22
+ };
23
+
24
+ const removeAction = (index: number) => {
25
+ onChange(actions.filter((_, i) => i !== index));
26
+ };
27
+
28
+ const updateAction = (index: number, patch: Partial<ActionConfig>) => {
29
+ const updated = [...actions];
30
+ updated[index] = { ...updated[index], ...patch };
31
+ onChange(updated);
32
+ };
33
+
34
+ return (
35
+ <div className="ork-form-group">
36
+ <label className="ork-label">Actions</label>
37
+ <div className="ork-field-description">
38
+ Define action buttons (e.g. Approve/Reject). Each action can prefill form fields and optionally use its own form schema.
39
+ </div>
40
+ <div className="ork-array-items">
41
+ {actions.map((action, index) => (
42
+ <ActionItem
43
+ key={index}
44
+ index={index}
45
+ action={action}
46
+ onUpdate={(patch) => updateAction(index, patch)}
47
+ onRemove={() => removeAction(index)}
48
+ />
49
+ ))}
50
+ </div>
51
+ <button type="button" className="ork-btn ork-btn-sm" onClick={addAction}>
52
+ + Add Action
53
+ </button>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function ActionItem({ index, action, onUpdate, onRemove }: {
59
+ index: number;
60
+ action: ActionConfig;
61
+ onUpdate: (patch: Partial<ActionConfig>) => void;
62
+ onRemove: () => void;
63
+ }) {
64
+ const [showSchema, setShowSchema] = useState(!!action.schema);
65
+ const prefillEntries = Object.entries(action.prefill || {});
66
+
67
+ const addPrefill = () => {
68
+ onUpdate({ prefill: { ...action.prefill, "": "" } });
69
+ };
70
+
71
+ const updatePrefillKey = (oldKey: string, newKey: string) => {
72
+ const entries = Object.entries(action.prefill || {});
73
+ const updated: Record<string, string> = {};
74
+ for (const [k, v] of entries) {
75
+ updated[k === oldKey ? newKey : k] = v;
76
+ }
77
+ onUpdate({ prefill: updated });
78
+ };
79
+
80
+ const updatePrefillValue = (key: string, val: string) => {
81
+ onUpdate({ prefill: { ...action.prefill, [key]: val } });
82
+ };
83
+
84
+ const removePrefill = (key: string) => {
85
+ const updated = { ...action.prefill };
86
+ delete updated[key];
87
+ onUpdate({ prefill: Object.keys(updated).length > 0 ? updated : undefined });
88
+ };
89
+
90
+ const handleSchemaToggle = (enabled: boolean) => {
91
+ setShowSchema(enabled);
92
+ if (!enabled) {
93
+ onUpdate({ schema: undefined });
94
+ }
95
+ };
96
+
97
+ return (
98
+ <div className="ork-array-item">
99
+ <div className="ork-array-item-header">
100
+ <span className="ork-array-item-index">#{index + 1}</span>
101
+ <span style={{ flex: 1, fontWeight: 600, fontSize: 13 }}>
102
+ {action.label || "(unnamed)"}
103
+ </span>
104
+ <button type="button" className="ork-btn ork-btn-danger ork-btn-sm" onClick={onRemove}>
105
+ &times;
106
+ </button>
107
+ </div>
108
+
109
+ {/* Label */}
110
+ <div className="ork-form-group">
111
+ <label className="ork-label">Label *</label>
112
+ <input
113
+ className="ork-input"
114
+ type="text"
115
+ value={action.label}
116
+ placeholder="e.g. Approve"
117
+ onChange={(e) => onUpdate({ label: e.target.value })}
118
+ />
119
+ </div>
120
+
121
+ <div className="ork-form-builder-row">
122
+ {/* Icon */}
123
+ <div className="ork-form-group" style={{ flex: 1 }}>
124
+ <label className="ork-label">Icon</label>
125
+ <input
126
+ className="ork-input"
127
+ type="text"
128
+ value={action.icon || ""}
129
+ placeholder="e.g. check-circle"
130
+ onChange={(e) => onUpdate({ icon: e.target.value || undefined })}
131
+ />
132
+ </div>
133
+
134
+ {/* Color */}
135
+ <div className="ork-form-group" style={{ flex: 0, minWidth: 80 }}>
136
+ <label className="ork-label">Color</label>
137
+ <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
138
+ <input
139
+ type="color"
140
+ value={action.color || "#3b82f6"}
141
+ onChange={(e) => onUpdate({ color: e.target.value })}
142
+ style={{ width: 32, height: 32, padding: 0, border: "1px solid var(--ork-border)", borderRadius: 4, cursor: "pointer" }}
143
+ />
144
+ {action.color && (
145
+ <button
146
+ type="button"
147
+ className="ork-btn ork-btn-sm"
148
+ style={{ fontSize: 10, padding: "2px 4px" }}
149
+ onClick={() => onUpdate({ color: undefined })}
150
+ >
151
+ &times;
152
+ </button>
153
+ )}
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ {/* Prefill */}
159
+ <div className="ork-form-group">
160
+ <label className="ork-label">Prefill Values</label>
161
+ <div className="ork-field-description">
162
+ Fields to pre-fill when this action is selected
163
+ </div>
164
+ {prefillEntries.map(([key, val], i) => (
165
+ <div key={i} className="ork-form-builder-row" style={{ marginBottom: 4 }}>
166
+ <input
167
+ className="ork-input"
168
+ type="text"
169
+ placeholder="Field name"
170
+ value={key}
171
+ onChange={(e) => updatePrefillKey(key, e.target.value)}
172
+ style={{ flex: 1 }}
173
+ />
174
+ <input
175
+ className="ork-input"
176
+ type="text"
177
+ placeholder="Value"
178
+ value={val}
179
+ onChange={(e) => updatePrefillValue(key, e.target.value)}
180
+ style={{ flex: 1 }}
181
+ />
182
+ <button type="button" className="ork-btn ork-btn-danger ork-btn-sm" onClick={() => removePrefill(key)}>
183
+ &times;
184
+ </button>
185
+ </div>
186
+ ))}
187
+ <button type="button" className="ork-btn ork-btn-sm" onClick={addPrefill}>
188
+ + Add Prefill
189
+ </button>
190
+ </div>
191
+
192
+ {/* Per-action schema toggle + builder */}
193
+ <div className="ork-form-group">
194
+ <label className="ork-checkbox-label" style={{ fontSize: 13 }}>
195
+ <input
196
+ type="checkbox"
197
+ checked={showSchema}
198
+ onChange={(e) => handleSchemaToggle(e.target.checked)}
199
+ />
200
+ Custom form schema for this action
201
+ </label>
202
+ {showSchema && (
203
+ <div style={{ marginTop: 8, paddingLeft: 8, borderLeft: "2px solid var(--ork-border)" }}>
204
+ <FormSchemaBuilder
205
+ value={action.schema}
206
+ onChange={(schema) => onUpdate({ schema })}
207
+ />
208
+ </div>
209
+ )}
210
+ </div>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,31 @@
1
+ import React, { memo } from "react";
2
+ import { Handle, Position } from "@xyflow/react";
3
+ import type { NodeProps } from "@xyflow/react";
4
+
5
+ function CustomNodeComponent({ data, selected }: NodeProps) {
6
+ const nodeData = data as Record<string, unknown>;
7
+ const label = (nodeData.label as string) || (nodeData.type as string) || "Node";
8
+ const nodeType = (nodeData.type as string) || "default";
9
+ const status = nodeData.executionStatus as string | undefined;
10
+
11
+ const classes = ["ork-flow-node"];
12
+ if (selected) classes.push("selected");
13
+ if (status) classes.push(`status-${status}`);
14
+
15
+ return (
16
+ <div className={classes.join(" ")}>
17
+ <Handle type="target" position={Position.Top} />
18
+ <div className="ork-flow-node-label">{label}</div>
19
+ <div className="ork-flow-node-type">{nodeType}</div>
20
+ {nodeType === "condition" && (
21
+ <>
22
+ <Handle type="source" position={Position.Bottom} id="true" style={{ left: "30%" }} />
23
+ <Handle type="source" position={Position.Bottom} id="false" style={{ left: "70%" }} />
24
+ </>
25
+ )}
26
+ {nodeType !== "condition" && <Handle type="source" position={Position.Bottom} />}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ export const CustomNode = memo(CustomNodeComponent);