From b1a3a2cb9de3eac0d9846d54791cc8c73c1269ce Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Fri, 13 Mar 2026 19:25:15 -0300 Subject: [PATCH 01/27] feat: add orchestration API proxy to NullBoiler --- src/api/orchestration.zig | 97 +++++++++++++++++++++++++++++++++++++++ src/server.zig | 25 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/api/orchestration.zig diff --git a/src/api/orchestration.zig b/src/api/orchestration.zig new file mode 100644 index 0000000..98f425d --- /dev/null +++ b/src/api/orchestration.zig @@ -0,0 +1,97 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Response = struct { + status: []const u8, + content_type: []const u8, + body: []const u8, +}; + +const prefix = "/api/orchestration"; + +/// Proxies orchestration API requests to NullBoiler. +/// Strips the /api/orchestration prefix and forwards to NullBoiler's REST API. +pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, boiler_url: []const u8, boiler_token: ?[]const u8) Response { + if (!std.mem.startsWith(u8, target, prefix)) { + return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; + } + + const boiler_path = target[prefix.len..]; + const path = if (boiler_path.len == 0) "/" else boiler_path; + + // Build full URL: boiler_url + path + const url = std.fmt.allocPrint(allocator, "{s}{s}", .{ boiler_url, path }) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + + // Resolve HTTP method string to enum + const http_method = parseMethod(method) orelse + return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" }; + + // Build auth header if token provided + var auth_header: ?[]const u8 = null; + defer if (auth_header) |value| allocator.free(value); + var header_buf: [1]std.http.Header = undefined; + const extra_headers: []const std.http.Header = if (boiler_token) |token| blk: { + auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + header_buf[0] = .{ .name = "Authorization", .value = auth_header.? }; + break :blk header_buf[0..1]; + } else &.{}; + + // Make HTTP request to NullBoiler + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + var response_body: std.io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = http_method, + .payload = if (body.len > 0) body else null, + .response_writer = &response_body.writer, + .extra_headers = extra_headers, + }) catch { + return .{ .status = "502 Bad Gateway", .content_type = "application/json", .body = "{\"error\":\"NullBoiler unreachable\"}" }; + }; + + const status_code: u10 = @intFromEnum(result.status); + const resp_body = response_body.toOwnedSlice() catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + + const status = mapStatus(status_code); + + return .{ + .status = status, + .content_type = "application/json", + .body = resp_body, + }; +} + +fn parseMethod(method: []const u8) ?std.http.Method { + if (std.mem.eql(u8, method, "GET")) return .GET; + if (std.mem.eql(u8, method, "POST")) return .POST; + if (std.mem.eql(u8, method, "PUT")) return .PUT; + if (std.mem.eql(u8, method, "DELETE")) return .DELETE; + if (std.mem.eql(u8, method, "PATCH")) return .PATCH; + return null; +} + +fn mapStatus(code: u10) []const u8 { + return switch (code) { + 200 => "200 OK", + 201 => "201 Created", + 204 => "204 No Content", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 409 => "409 Conflict", + 422 => "422 Unprocessable Entity", + 500 => "500 Internal Server Error", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + else => if (code >= 200 and code < 300) "200 OK" else if (code >= 400 and code < 500) "400 Bad Request" else "500 Internal Server Error", + }; +} diff --git a/src/server.zig b/src/server.zig index cc2dc9c..c9a463d 100644 --- a/src/server.zig +++ b/src/server.zig @@ -17,6 +17,7 @@ const wizard_api = @import("api/wizard.zig"); const providers_api = @import("api/providers.zig"); const channels_api = @import("api/channels.zig"); const usage_api = @import("api/usage.zig"); +const orchestration_api = @import("api/orchestration.zig"); const ui_modules = @import("installer/ui_modules.zig"); const orchestrator = @import("installer/orchestrator.zig"); const registry = @import("installer/registry.zig"); @@ -403,6 +404,16 @@ pub const Server = struct { }); } + fn getBoilerUrl(self: *Server) ?[]const u8 { + _ = self; + return std.posix.getenv("NULLBOILER_URL"); + } + + fn getBoilerToken(self: *Server) ?[]const u8 { + _ = self; + return std.posix.getenv("NULLBOILER_TOKEN"); + } + fn route(self: *Server, allocator: std.mem.Allocator, method: []const u8, target: []const u8, body: []const u8) Response { if (std.mem.eql(u8, method, "GET")) { if (std.mem.eql(u8, target, "/health")) { @@ -932,6 +943,20 @@ pub const Server = struct { } } + // Orchestration API — proxy to NullBoiler + if (std.mem.startsWith(u8, target, "/api/orchestration/") or std.mem.eql(u8, target, "/api/orchestration")) { + const boiler_url = self.getBoilerUrl(); + if (boiler_url) |url| { + const resp = orchestration_api.handle(allocator, method, target, body, url, self.getBoilerToken()); + return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; + } + return .{ + .status = "503 Service Unavailable", + .content_type = "application/json", + .body = "{\"error\":\"NullBoiler not configured\"}", + }; + } + // Serve UI module files from data directory (~/.nullhub/ui/{name}@{version}/...) if (!std.mem.startsWith(u8, target, "/api/") and std.mem.startsWith(u8, target, "/ui/")) { // Check if this looks like a module path: /ui/{name}@{version}/... From 233a88a8b1ac19bc7846fdf00bc7973a6066f292 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Fri, 13 Mar 2026 19:26:40 -0300 Subject: [PATCH 02/27] feat: add orchestration API client and sidebar navigation --- ui/src/lib/api/client.ts | 31 ++++++++++++++++++++++++++++ ui/src/lib/components/Sidebar.svelte | 7 +++++++ 2 files changed, 38 insertions(+) diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 9b027b5..4324d61 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -174,4 +174,35 @@ export const api = { request(`/channels/${id.replace('sc_', '')}`, { method: 'DELETE' }), revalidateSavedChannel: (id: string) => request(`/channels/${id.replace('sc_', '')}/validate`, { method: 'POST' }), + + // Orchestration - Workflows + listWorkflows: () => request('/api/orchestration/workflows'), + getWorkflow: (id: string) => request(`/api/orchestration/workflows/${id}`), + createWorkflow: (data: any) => request('/api/orchestration/workflows', { method: 'POST', body: JSON.stringify(data) }), + updateWorkflow: (id: string, data: any) => request(`/api/orchestration/workflows/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteWorkflow: (id: string) => request(`/api/orchestration/workflows/${id}`, { method: 'DELETE' }), + validateWorkflow: (id: string) => request(`/api/orchestration/workflows/${id}/validate`, { method: 'POST' }), + runWorkflow: (id: string, input: any) => request(`/api/orchestration/workflows/${id}/run`, { method: 'POST', body: JSON.stringify(input) }), + + // Orchestration - Runs + listRuns: (params?: { status?: string; workflow_id?: string }) => request(withQuery('/api/orchestration/runs', params ?? {})), + getRun: (id: string) => request(`/api/orchestration/runs/${id}`), + cancelRun: (id: string) => request(`/api/orchestration/runs/${id}/cancel`, { method: 'POST' }), + resumeRun: (id: string, updates: any) => request(`/api/orchestration/runs/${id}/resume`, { method: 'POST', body: JSON.stringify({ state_updates: updates }) }), + forkRun: (checkpointId: string, overrides?: any) => request('/api/orchestration/runs/fork', { method: 'POST', body: JSON.stringify({ checkpoint_id: checkpointId, state_overrides: overrides }) }), + injectState: (id: string, updates: any, afterStep?: string) => request(`/api/orchestration/runs/${id}/state`, { method: 'POST', body: JSON.stringify({ updates, apply_after_step: afterStep }) }), + + // Orchestration - Checkpoints + listCheckpoints: (runId: string) => request(`/api/orchestration/runs/${runId}/checkpoints`), + getCheckpoint: (runId: string, cpId: string) => request(`/api/orchestration/runs/${runId}/checkpoints/${cpId}`), + + // Orchestration - SSE + streamRun: (runId: string, onEvent: (event: { type: string; data: any }) => void) => { + const source = new EventSource(`/api/orchestration/runs/${runId}/stream`); + source.onmessage = (e) => onEvent({ type: 'message', data: JSON.parse(e.data) }); + ['state_update', 'step_started', 'step_completed', 'step_failed', 'agent_event', 'interrupted', 'run_completed', 'run_failed', 'send_progress'].forEach(type => { + source.addEventListener(type, (e: any) => onEvent({ type, data: JSON.parse(e.data) })); + }); + return source; + }, }; diff --git a/ui/src/lib/components/Sidebar.svelte b/ui/src/lib/components/Sidebar.svelte index e5c0225..7823160 100644 --- a/ui/src/lib/components/Sidebar.svelte +++ b/ui/src/lib/components/Sidebar.svelte @@ -52,6 +52,13 @@ {/each} + + From fa97b0c6bf4e61bec41c6fbe45d66b7c97293ac7 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Fri, 13 Mar 2026 19:34:03 -0300 Subject: [PATCH 03/27] feat: orchestration UI pages and components --- .../orchestration/CheckpointTimeline.svelte | 122 +++++++ .../orchestration/GraphViewer.svelte | 254 +++++++++++++ .../orchestration/InterruptPanel.svelte | 209 +++++++++++ .../components/orchestration/NodeCard.svelte | 65 ++++ .../orchestration/RunEventLog.svelte | 183 ++++++++++ .../orchestration/SendProgressBar.svelte | 51 +++ .../orchestration/StateInspector.svelte | 198 ++++++++++ .../orchestration/WorkflowJsonEditor.svelte | 77 ++++ ui/src/routes/orchestration/+page.svelte | 340 ++++++++++++++++++ ui/src/routes/orchestration/runs/+page.svelte | 282 +++++++++++++++ .../orchestration/runs/[id]/+page.svelte | 296 +++++++++++++++ .../orchestration/runs/[id]/fork/+page.svelte | 296 +++++++++++++++ .../orchestration/workflows/+page.svelte | 289 +++++++++++++++ .../orchestration/workflows/[id]/+page.svelte | 296 +++++++++++++++ 14 files changed, 2958 insertions(+) create mode 100644 ui/src/lib/components/orchestration/CheckpointTimeline.svelte create mode 100644 ui/src/lib/components/orchestration/GraphViewer.svelte create mode 100644 ui/src/lib/components/orchestration/InterruptPanel.svelte create mode 100644 ui/src/lib/components/orchestration/NodeCard.svelte create mode 100644 ui/src/lib/components/orchestration/RunEventLog.svelte create mode 100644 ui/src/lib/components/orchestration/SendProgressBar.svelte create mode 100644 ui/src/lib/components/orchestration/StateInspector.svelte create mode 100644 ui/src/lib/components/orchestration/WorkflowJsonEditor.svelte create mode 100644 ui/src/routes/orchestration/+page.svelte create mode 100644 ui/src/routes/orchestration/runs/+page.svelte create mode 100644 ui/src/routes/orchestration/runs/[id]/+page.svelte create mode 100644 ui/src/routes/orchestration/runs/[id]/fork/+page.svelte create mode 100644 ui/src/routes/orchestration/workflows/+page.svelte create mode 100644 ui/src/routes/orchestration/workflows/[id]/+page.svelte diff --git a/ui/src/lib/components/orchestration/CheckpointTimeline.svelte b/ui/src/lib/components/orchestration/CheckpointTimeline.svelte new file mode 100644 index 0000000..9b50f33 --- /dev/null +++ b/ui/src/lib/components/orchestration/CheckpointTimeline.svelte @@ -0,0 +1,122 @@ + + +
+ {#if checkpoints.length === 0} +
No checkpoints
+ {/if} + {#each checkpoints as cp, i} + + {/each} +
+ + diff --git a/ui/src/lib/components/orchestration/GraphViewer.svelte b/ui/src/lib/components/orchestration/GraphViewer.svelte new file mode 100644 index 0000000..97af222 --- /dev/null +++ b/ui/src/lib/components/orchestration/GraphViewer.svelte @@ -0,0 +1,254 @@ + + +
+ + + + + + + + {#each graph.ledges as edge} + + {/each} + + {#each graph.lnodes as node} + {#if node.isTerminal} + + {node.label} + {:else} + {@const color = statusColors[nodeStatus[node.id] || 'pending']} + + {typeLabels[node.type] || '?'} + {node.label.length > 12 ? node.label.slice(0, 11) + '...' : node.label} + {/if} + {/each} + +
+ + diff --git a/ui/src/lib/components/orchestration/InterruptPanel.svelte b/ui/src/lib/components/orchestration/InterruptPanel.svelte new file mode 100644 index 0000000..950c5ba --- /dev/null +++ b/ui/src/lib/components/orchestration/InterruptPanel.svelte @@ -0,0 +1,209 @@ + + + + +
+ + +
e.stopPropagation()}> +
+ Run Interrupted +
+ +
+
+ Message: +

{message || 'This run requires approval to continue.'}

+
+ +
+ + + {#if !jsonValid} + Invalid JSON + {/if} +
+
+ +
+ + +
+
+
+ + diff --git a/ui/src/lib/components/orchestration/NodeCard.svelte b/ui/src/lib/components/orchestration/NodeCard.svelte new file mode 100644 index 0000000..c3debba --- /dev/null +++ b/ui/src/lib/components/orchestration/NodeCard.svelte @@ -0,0 +1,65 @@ + + +
+ {typeLabels[type] || '?'} + {name} +
+ + diff --git a/ui/src/lib/components/orchestration/RunEventLog.svelte b/ui/src/lib/components/orchestration/RunEventLog.svelte new file mode 100644 index 0000000..ead4105 --- /dev/null +++ b/ui/src/lib/components/orchestration/RunEventLog.svelte @@ -0,0 +1,183 @@ + + +
+
+ Event Log + +
+
+ {#if events.length === 0} +
No events yet
+ {/if} + {#each events as ev} +
+ {formatTime(ev.timestamp)} + {ev.type} + {summarize(ev.data)} +
+ {/each} +
+
+ + diff --git a/ui/src/lib/components/orchestration/SendProgressBar.svelte b/ui/src/lib/components/orchestration/SendProgressBar.svelte new file mode 100644 index 0000000..d4d24a4 --- /dev/null +++ b/ui/src/lib/components/orchestration/SendProgressBar.svelte @@ -0,0 +1,51 @@ + + +
+ {#if label} + {label} + {/if} +
+
+
+ {current}/{total} +
+ + diff --git a/ui/src/lib/components/orchestration/StateInspector.svelte b/ui/src/lib/components/orchestration/StateInspector.svelte new file mode 100644 index 0000000..e58babe --- /dev/null +++ b/ui/src/lib/components/orchestration/StateInspector.svelte @@ -0,0 +1,198 @@ + + +
+
+ State + {#if previousState} + + {/if} +
+
+ {#if diffMode && previousState} +
+ {#if diff.added.size > 0} +
+ Added + {#each [...diff.added] as key} +
+ {key}: {JSON.stringify(currentState[key])}
+ {/each} +
+ {/if} + {#if diff.changed.size > 0} +
+ Changed + {#each [...diff.changed] as key} +
- {key}: {JSON.stringify(previousState[key])}
+
+ {key}: {JSON.stringify(currentState[key])}
+ {/each} +
+ {/if} + {#if diff.removed.size > 0} +
+ Removed + {#each [...diff.removed] as key} +
- {key}: {JSON.stringify(previousState[key])}
+ {/each} +
+ {/if} + {#if diff.added.size === 0 && diff.changed.size === 0 && diff.removed.size === 0} +
No changes
+ {/if} +
+ {:else} +
{@html highlighted}
+ {/if} +
+
+ + diff --git a/ui/src/lib/components/orchestration/WorkflowJsonEditor.svelte b/ui/src/lib/components/orchestration/WorkflowJsonEditor.svelte new file mode 100644 index 0000000..be3f86a --- /dev/null +++ b/ui/src/lib/components/orchestration/WorkflowJsonEditor.svelte @@ -0,0 +1,77 @@ + + +
+ + {#if !valid} +
{errorMsg}
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/+page.svelte b/ui/src/routes/orchestration/+page.svelte new file mode 100644 index 0000000..37edbb6 --- /dev/null +++ b/ui/src/routes/orchestration/+page.svelte @@ -0,0 +1,340 @@ + + +
+
+

Orchestration

+ New Run +
+ + {#if error} +
ERR: {error}
+ {/if} + +
+
+
Active
+
{stats.active}
+
+
+
Completed
+
{stats.completed}
+
+
+
Failed
+
{stats.failed}
+
+
+
Interrupted
+
{stats.interrupted}
+
+
+ + {#if loading && runs.length === 0} +
Loading runs...
+ {:else if runs.length === 0} +
+

> No orchestration runs yet.

+ Create a Workflow +
+ {:else} +
+

Recent Runs

+
+ + + + + + + + + + + + {#each runs.slice(0, 20) as run} + location.href = `/orchestration/runs/${run.id}`} class="clickable"> + + + + + + + {/each} + +
IDWorkflowStatusDurationCreated
{(run.id || '').slice(0, 8)}{run.workflow_name || run.workflow_id || '-'} + {run.status} + {formatDuration(run)}{formatTime(run.created_at)}
+
+ {#if runs.length > 20} + + {/if} +
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/runs/+page.svelte b/ui/src/routes/orchestration/runs/+page.svelte new file mode 100644 index 0000000..c39100c --- /dev/null +++ b/ui/src/routes/orchestration/runs/+page.svelte @@ -0,0 +1,282 @@ + + +
+
+

Runs

+
+ + {#if error} +
ERR: {error}
+ {/if} + +
+
+ + +
+
+ + +
+
+ + {#if loading} +
Loading runs...
+ {:else if runs.length === 0} +
+

> No runs match the current filter.

+
+ {:else} +
+
+ + + + + + + + + + + + {#each runs as run} + location.href = `/orchestration/runs/${run.id}`} class="clickable"> + + + + + + + {/each} + +
IDWorkflowStatusDurationCreated
{(run.id || '').slice(0, 8)}{run.workflow_name || run.workflow_id || '-'} + {run.status} + {formatDuration(run)}{formatTime(run.created_at)}
+
+
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/runs/[id]/+page.svelte b/ui/src/routes/orchestration/runs/[id]/+page.svelte new file mode 100644 index 0000000..ce66c13 --- /dev/null +++ b/ui/src/routes/orchestration/runs/[id]/+page.svelte @@ -0,0 +1,296 @@ + + +
+
+
+ Runs + / + {(id || '').slice(0, 8)} + {#if run} + {run.status} + {/if} +
+
+ {#if isActive} + + {/if} + Fork +
+
+ + {#if error} +
ERR: {error}
+ {/if} + + {#if loading} +
Loading run...
+ {:else if run} +
+
+ +
+
+ +
+
+
+ +
+ + {#if isInterrupted} + + {/if} + {/if} +
+ + diff --git a/ui/src/routes/orchestration/runs/[id]/fork/+page.svelte b/ui/src/routes/orchestration/runs/[id]/fork/+page.svelte new file mode 100644 index 0000000..140d675 --- /dev/null +++ b/ui/src/routes/orchestration/runs/[id]/fork/+page.svelte @@ -0,0 +1,296 @@ + + +
+
+ +
+ +
+
+ + {#if error} +
ERR: {error}
+ {/if} + + {#if loading} +
Loading checkpoints...
+ {:else} +
+
+
Checkpoints
+ +
+
+
+ +
+
+ + + {#if !overridesValid} + Invalid JSON + {/if} +
+
+
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/workflows/+page.svelte b/ui/src/routes/orchestration/workflows/+page.svelte new file mode 100644 index 0000000..6aaf348 --- /dev/null +++ b/ui/src/routes/orchestration/workflows/+page.svelte @@ -0,0 +1,289 @@ + + +
+
+

Workflows

+ + New Workflow +
+ + {#if error} +
ERR: {error}
+ {/if} + + {#if loading} +
Loading workflows...
+ {:else if workflows.length === 0} +
+

> No workflows defined yet.

+ Create Workflow +
+ {:else} +
+ {#each workflows as wf} +
+
+ {wf.name || wf.id} + {nodeCount(wf)} nodes +
+ {#if wf.id} +
{wf.id}
+ {/if} +
+ Edit + + {#if deleteConfirm === wf.id} + + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/workflows/[id]/+page.svelte b/ui/src/routes/orchestration/workflows/[id]/+page.svelte new file mode 100644 index 0000000..2f060f4 --- /dev/null +++ b/ui/src/routes/orchestration/workflows/[id]/+page.svelte @@ -0,0 +1,296 @@ + + +
+
+
+ Workflows + / + {isNew ? 'New Workflow' : (parsedWorkflow?.name || id)} +
+
+ {#if !isNew} + + {/if} + + {#if !isNew} + + {/if} +
+
+ + {#if error} +
ERR: {error}
+ {/if} + + {#if validationResult} +
+ {#if validationResult.valid} + Workflow is valid. + {:else} + Validation errors: + {#each validationResult.errors || [] as err} +
{err}
+ {/each} + {/if} +
+ {/if} + + {#if loading} +
Loading workflow...
+ {:else} +
+
+ +
+
+ parseError = msg} /> +
+
+ {/if} +
+ + From 98167b10c45eb452cf15580abe5fe08c8bb7e3a3 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Fri, 13 Mar 2026 20:40:26 -0300 Subject: [PATCH 04/27] feat: add Store viewer page to orchestration UI --- ui/src/lib/api/client.ts | 6 + ui/src/lib/components/Sidebar.svelte | 1 + .../routes/orchestration/store/+page.svelte | 567 ++++++++++++++++++ 3 files changed, 574 insertions(+) create mode 100644 ui/src/routes/orchestration/store/+page.svelte diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 4324d61..5201ebb 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -196,6 +196,12 @@ export const api = { listCheckpoints: (runId: string) => request(`/api/orchestration/runs/${runId}/checkpoints`), getCheckpoint: (runId: string, cpId: string) => request(`/api/orchestration/runs/${runId}/checkpoints/${cpId}`), + // Store API (proxied through NullBoiler or direct to NullTickets) + storeList: (namespace: string) => request(`/api/orchestration/store/${namespace}`), + storeGet: (namespace: string, key: string) => request(`/api/orchestration/store/${namespace}/${key}`), + storePut: (namespace: string, key: string, value: any) => request(`/api/orchestration/store/${namespace}/${key}`, { method: 'PUT', body: JSON.stringify({ value }) }), + storeDelete: (namespace: string, key: string) => request(`/api/orchestration/store/${namespace}/${key}`, { method: 'DELETE' }), + // Orchestration - SSE streamRun: (runId: string, onEvent: (event: { type: string; data: any }) => void) => { const source = new EventSource(`/api/orchestration/runs/${runId}/stream`); diff --git a/ui/src/lib/components/Sidebar.svelte b/ui/src/lib/components/Sidebar.svelte index 7823160..753e5ef 100644 --- a/ui/src/lib/components/Sidebar.svelte +++ b/ui/src/lib/components/Sidebar.svelte @@ -57,6 +57,7 @@ Dashboard Workflows Runs + Store