Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b1a3a2c
feat: add orchestration API proxy to NullBoiler
DonPrus Mar 13, 2026
233a88a
feat: add orchestration API client and sidebar navigation
DonPrus Mar 13, 2026
fa97b0c
feat: orchestration UI pages and components
DonPrus Mar 13, 2026
98167b1
feat: add Store viewer page to orchestration UI
DonPrus Mar 13, 2026
6fb7072
refactor: fix orchestration API paths, improve UI quality, update docs
DonPrus Mar 14, 2026
073d030
Replace hardcoded colors with design system CSS variables
DonPrus Mar 14, 2026
a3a4725
Guard GraphViewer BFS layout against cyclic graphs
DonPrus Mar 14, 2026
d977cda
fix: handle 204 No Content and empty responses in API client
DonPrus Mar 14, 2026
0eb487e
Add missing subgraph node type and replayRun API method
DonPrus Mar 14, 2026
5548b33
Fix API response format mismatches with NullBoiler
DonPrus Mar 14, 2026
2225f63
Fix Store page to extract value from StoreEntry response
DonPrus Mar 14, 2026
854c252
Fix workflow, run, and stream format mismatches with NullBoiler
DonPrus Mar 14, 2026
d1051e2
Route store API requests to NullTickets instead of NullBoiler
DonPrus Mar 14, 2026
99a7871
Fix Windows build: replace std.posix.getenv with cross-platform wrapper
DonPrus Mar 14, 2026
160b343
Fix PR review issues: msToIso falsy zero, document Windows limitation…
DonPrus Mar 14, 2026
dc45980
Encode orchestration ids in UI routes
DonPrus Mar 14, 2026
89377f3
Avoid duplicate run events during polling
DonPrus Mar 14, 2026
7f2aba0
Use canonical replay checkpoint field
DonPrus Mar 14, 2026
5409c24
Correct orchestration proxy and stream docs
DonPrus Mar 14, 2026
1338f18
Harden orchestration UI and local API boundary
DonPrus Mar 14, 2026
3796a43
Avoid serializing orchestration proxy requests
DonPrus Mar 14, 2026
0b49b44
Centralize orchestration proxy routing
DonPrus Mar 14, 2026
d4fc76e
Clarify orchestration proxy backend routing
DonPrus Mar 14, 2026
f3df712
Centralize orchestration route builders
DonPrus Mar 14, 2026
b63969a
Extract orchestration API module
DonPrus Mar 14, 2026
4a7784e
Normalize orchestration stream events
DonPrus Mar 14, 2026
f00b1c4
Use cursor-based run stream polling
DonPrus Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ NullTickets).
- **One-click updates** -- download, migrate config, rollback on failure
- **Multi-instance** -- run multiple instances of the same component side by side
- **Web UI + CLI** -- browser dashboard for humans, CLI for automation
- **Orchestration UI** -- workflow editor, poll-based run monitoring, checkpoint forking, encoded workflow/run/store links, and key-value store browser (proxied to NullTickets through NullHub)

## Quick Start

Expand Down Expand Up @@ -103,6 +104,12 @@ UI modules. NullHub is a generic engine that interprets manifests.
**Storage** -- all state lives under `~/.nullhub/` (config, instances, binaries,
logs, cached manifests).

**Orchestration proxy** -- requests to `/api/orchestration/*` are reverse-proxied
to the local orchestration stack. Most routes go to NullBoiler's REST API via
`NULLBOILER_URL` (e.g. `http://localhost:8080`) and optional `NULLBOILER_TOKEN`.
`/api/orchestration/store/*` is proxied to NullTickets via `NULLTICKETS_URL` and
optional `NULLTICKETS_TOKEN`.

## Development

Backend:
Expand All @@ -127,7 +134,9 @@ End-to-end:

- Zig 0.15.2
- Svelte 5 + SvelteKit (static adapter)
- JSON over HTTP/1.1, SSE for streaming
- JSON over HTTP/1.1
- SSE for instance log streaming
- Poll-based orchestration run updates over the `/orchestration/runs/{id}/stream` API

## Project Layout

Expand All @@ -138,15 +147,17 @@ src/
server.zig # HTTP server (API + static UI)
auth.zig # Optional bearer token auth
api/ # REST endpoints (components, instances, wizard, ...)
orchestration.zig # Reverse proxy to NullBoiler orchestration API
core/ # Manifest parser, state, platform, paths
installer/ # Download, build, UI module fetching
supervisor/ # Process spawn, health checks, manager
wizard/ # Manifest wizard engine, config writer
ui/src/
routes/ # SvelteKit pages (dashboard, install, instances, settings)
routes/ # SvelteKit pages
orchestration/ # Orchestration pages (dashboard, workflows, runs, store)
lib/components/ # Reusable Svelte components
orchestration/ # GraphViewer, StateInspector, RunEventLog, InterruptPanel,
# CheckpointTimeline, WorkflowJsonEditor, NodeCard, SendProgressBar
lib/api/ # Typed API client
lib/stores/ # Reactive state (instances, hub config)
tests/
test_e2e.sh # End-to-end test script
```
194 changes: 194 additions & 0 deletions src/api/orchestration.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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";
const store_prefix = "/api/orchestration/store";

pub const Config = struct {
boiler_url: ?[]const u8 = null,
boiler_token: ?[]const u8 = null,
tickets_url: ?[]const u8 = null,
tickets_token: ?[]const u8 = null,
};

const Backend = enum {
boiler,
tickets,

fn notConfiguredBody(self: Backend) []const u8 {
return switch (self) {
.boiler => "{\"error\":\"NullBoiler not configured\"}",
.tickets => "{\"error\":\"NullTickets not configured\"}",
};
}

fn unreachableBody(self: Backend) []const u8 {
return switch (self) {
.boiler => "{\"error\":\"NullBoiler unreachable\"}",
.tickets => "{\"error\":\"NullTickets unreachable\"}",
};
}
};

pub fn isProxyPath(target: []const u8) bool {
return std.mem.eql(u8, target, prefix) or std.mem.startsWith(u8, target, prefix ++ "/");
}

fn isStorePath(target: []const u8) bool {
return std.mem.eql(u8, target, store_prefix) or std.mem.startsWith(u8, target, store_prefix ++ "/");
}

const ProxyTarget = struct {
backend: Backend,
base_url: []const u8,
token: ?[]const u8,
};

fn backendForPath(target: []const u8) ?Backend {
if (!isProxyPath(target)) return null;
return if (isStorePath(target)) .tickets else .boiler;
}

fn resolveProxyTarget(target: []const u8, cfg: Config) ?ProxyTarget {
const backend = backendForPath(target) orelse return null;
return switch (backend) {
.tickets => blk: {
const base_url = cfg.tickets_url orelse return null;
break :blk .{
.backend = .tickets,
.base_url = base_url,
.token = cfg.tickets_token,
};
},
.boiler => blk: {
const base_url = cfg.boiler_url orelse return null;
break :blk .{
.backend = .boiler,
.base_url = base_url,
.token = cfg.boiler_token,
};
},
};
}

/// Proxies orchestration API requests to the local orchestration stack.
/// `/api/orchestration/store/*` goes to NullTickets; all other orchestration
/// routes go to NullBoiler. The shared prefix is stripped before forwarding.
pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response {
if (!isProxyPath(target)) {
return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" };
}
const backend = backendForPath(target) orelse
return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" };
const resolved = resolveProxyTarget(target, cfg) orelse
return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = backend.notConfiguredBody() };

const proxied_path = target[prefix.len..];
const path = if (proxied_path.len == 0) "/" else proxied_path;

const url = std.fmt.allocPrint(allocator, "{s}{s}", .{ resolved.base_url, path }) catch
return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" };

const http_method = parseMethod(method) orelse
return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" };

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 (resolved.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 &.{};

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 = resolved.backend.unreachableBody() };
};

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",
};
}

test "isProxyPath matches orchestration namespace" {
try std.testing.expect(isProxyPath("/api/orchestration"));
try std.testing.expect(isProxyPath("/api/orchestration/runs"));
try std.testing.expect(isProxyPath("/api/orchestration/store/search"));
try std.testing.expect(!isProxyPath("/api/instances"));
}

test "backendForPath routes store requests to tickets backend" {
try std.testing.expectEqual(Backend.tickets, backendForPath("/api/orchestration/store/search").?);
try std.testing.expectEqual(Backend.boiler, backendForPath("/api/orchestration/runs").?);
}

test "handle routes store paths to NullTickets config" {
const resp = handle(std.testing.allocator, "GET", "/api/orchestration/store/search", "", .{
.boiler_url = "http://127.0.0.1:8080",
});
try std.testing.expectEqualStrings("503 Service Unavailable", resp.status);
try std.testing.expectEqualStrings("{\"error\":\"NullTickets not configured\"}", resp.body);
}

test "handle routes non-store paths to NullBoiler config" {
const resp = handle(std.testing.allocator, "GET", "/api/orchestration/runs", "", .{
.tickets_url = "http://127.0.0.1:7711",
});
try std.testing.expectEqualStrings("503 Service Unavailable", resp.status);
try std.testing.expectEqualStrings("{\"error\":\"NullBoiler not configured\"}", resp.body);
}
Loading
Loading