Skip to content

Commit e1b7a69

Browse files
authored
Merge pull request #9 from nullclaw/feat/orchestration
Orchestration UI: workflows, runs, streaming, and store management
2 parents 0d07b83 + f00b1c4 commit e1b7a69

22 files changed

Lines changed: 4166 additions & 37 deletions

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ NullTickets).
2020
- **One-click updates** -- download, migrate config, rollback on failure
2121
- **Multi-instance** -- run multiple instances of the same component side by side
2222
- **Web UI + CLI** -- browser dashboard for humans, CLI for automation
23+
- **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)
2324

2425
## Quick Start
2526

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

107+
**Orchestration proxy** -- requests to `/api/orchestration/*` are reverse-proxied
108+
to the local orchestration stack. Most routes go to NullBoiler's REST API via
109+
`NULLBOILER_URL` (e.g. `http://localhost:8080`) and optional `NULLBOILER_TOKEN`.
110+
`/api/orchestration/store/*` is proxied to NullTickets via `NULLTICKETS_URL` and
111+
optional `NULLTICKETS_TOKEN`.
112+
106113
## Development
107114

108115
Backend:
@@ -127,7 +134,9 @@ End-to-end:
127134

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

132141
## Project Layout
133142

@@ -138,15 +147,17 @@ src/
138147
server.zig # HTTP server (API + static UI)
139148
auth.zig # Optional bearer token auth
140149
api/ # REST endpoints (components, instances, wizard, ...)
150+
orchestration.zig # Reverse proxy to NullBoiler orchestration API
141151
core/ # Manifest parser, state, platform, paths
142152
installer/ # Download, build, UI module fetching
143153
supervisor/ # Process spawn, health checks, manager
144-
wizard/ # Manifest wizard engine, config writer
145154
ui/src/
146-
routes/ # SvelteKit pages (dashboard, install, instances, settings)
155+
routes/ # SvelteKit pages
156+
orchestration/ # Orchestration pages (dashboard, workflows, runs, store)
147157
lib/components/ # Reusable Svelte components
158+
orchestration/ # GraphViewer, StateInspector, RunEventLog, InterruptPanel,
159+
# CheckpointTimeline, WorkflowJsonEditor, NodeCard, SendProgressBar
148160
lib/api/ # Typed API client
149-
lib/stores/ # Reactive state (instances, hub config)
150161
tests/
151162
test_e2e.sh # End-to-end test script
152163
```

src/api/orchestration.zig

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
const std = @import("std");
2+
const Allocator = std.mem.Allocator;
3+
4+
const Response = struct {
5+
status: []const u8,
6+
content_type: []const u8,
7+
body: []const u8,
8+
};
9+
10+
const prefix = "/api/orchestration";
11+
const store_prefix = "/api/orchestration/store";
12+
13+
pub const Config = struct {
14+
boiler_url: ?[]const u8 = null,
15+
boiler_token: ?[]const u8 = null,
16+
tickets_url: ?[]const u8 = null,
17+
tickets_token: ?[]const u8 = null,
18+
};
19+
20+
const Backend = enum {
21+
boiler,
22+
tickets,
23+
24+
fn notConfiguredBody(self: Backend) []const u8 {
25+
return switch (self) {
26+
.boiler => "{\"error\":\"NullBoiler not configured\"}",
27+
.tickets => "{\"error\":\"NullTickets not configured\"}",
28+
};
29+
}
30+
31+
fn unreachableBody(self: Backend) []const u8 {
32+
return switch (self) {
33+
.boiler => "{\"error\":\"NullBoiler unreachable\"}",
34+
.tickets => "{\"error\":\"NullTickets unreachable\"}",
35+
};
36+
}
37+
};
38+
39+
pub fn isProxyPath(target: []const u8) bool {
40+
return std.mem.eql(u8, target, prefix) or std.mem.startsWith(u8, target, prefix ++ "/");
41+
}
42+
43+
fn isStorePath(target: []const u8) bool {
44+
return std.mem.eql(u8, target, store_prefix) or std.mem.startsWith(u8, target, store_prefix ++ "/");
45+
}
46+
47+
const ProxyTarget = struct {
48+
backend: Backend,
49+
base_url: []const u8,
50+
token: ?[]const u8,
51+
};
52+
53+
fn backendForPath(target: []const u8) ?Backend {
54+
if (!isProxyPath(target)) return null;
55+
return if (isStorePath(target)) .tickets else .boiler;
56+
}
57+
58+
fn resolveProxyTarget(target: []const u8, cfg: Config) ?ProxyTarget {
59+
const backend = backendForPath(target) orelse return null;
60+
return switch (backend) {
61+
.tickets => blk: {
62+
const base_url = cfg.tickets_url orelse return null;
63+
break :blk .{
64+
.backend = .tickets,
65+
.base_url = base_url,
66+
.token = cfg.tickets_token,
67+
};
68+
},
69+
.boiler => blk: {
70+
const base_url = cfg.boiler_url orelse return null;
71+
break :blk .{
72+
.backend = .boiler,
73+
.base_url = base_url,
74+
.token = cfg.boiler_token,
75+
};
76+
},
77+
};
78+
}
79+
80+
/// Proxies orchestration API requests to the local orchestration stack.
81+
/// `/api/orchestration/store/*` goes to NullTickets; all other orchestration
82+
/// routes go to NullBoiler. The shared prefix is stripped before forwarding.
83+
pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response {
84+
if (!isProxyPath(target)) {
85+
return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" };
86+
}
87+
const backend = backendForPath(target) orelse
88+
return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" };
89+
const resolved = resolveProxyTarget(target, cfg) orelse
90+
return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = backend.notConfiguredBody() };
91+
92+
const proxied_path = target[prefix.len..];
93+
const path = if (proxied_path.len == 0) "/" else proxied_path;
94+
95+
const url = std.fmt.allocPrint(allocator, "{s}{s}", .{ resolved.base_url, path }) catch
96+
return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" };
97+
98+
const http_method = parseMethod(method) orelse
99+
return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" };
100+
101+
var auth_header: ?[]const u8 = null;
102+
defer if (auth_header) |value| allocator.free(value);
103+
var header_buf: [1]std.http.Header = undefined;
104+
const extra_headers: []const std.http.Header = if (resolved.token) |token| blk: {
105+
auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch
106+
return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" };
107+
header_buf[0] = .{ .name = "Authorization", .value = auth_header.? };
108+
break :blk header_buf[0..1];
109+
} else &.{};
110+
111+
var client: std.http.Client = .{ .allocator = allocator };
112+
defer client.deinit();
113+
114+
var response_body: std.io.Writer.Allocating = .init(allocator);
115+
defer response_body.deinit();
116+
117+
const result = client.fetch(.{
118+
.location = .{ .url = url },
119+
.method = http_method,
120+
.payload = if (body.len > 0) body else null,
121+
.response_writer = &response_body.writer,
122+
.extra_headers = extra_headers,
123+
}) catch {
124+
return .{ .status = "502 Bad Gateway", .content_type = "application/json", .body = resolved.backend.unreachableBody() };
125+
};
126+
127+
const status_code: u10 = @intFromEnum(result.status);
128+
const resp_body = response_body.toOwnedSlice() catch
129+
return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" };
130+
131+
const status = mapStatus(status_code);
132+
133+
return .{
134+
.status = status,
135+
.content_type = "application/json",
136+
.body = resp_body,
137+
};
138+
}
139+
140+
fn parseMethod(method: []const u8) ?std.http.Method {
141+
if (std.mem.eql(u8, method, "GET")) return .GET;
142+
if (std.mem.eql(u8, method, "POST")) return .POST;
143+
if (std.mem.eql(u8, method, "PUT")) return .PUT;
144+
if (std.mem.eql(u8, method, "DELETE")) return .DELETE;
145+
if (std.mem.eql(u8, method, "PATCH")) return .PATCH;
146+
return null;
147+
}
148+
149+
fn mapStatus(code: u10) []const u8 {
150+
return switch (code) {
151+
200 => "200 OK",
152+
201 => "201 Created",
153+
204 => "204 No Content",
154+
400 => "400 Bad Request",
155+
401 => "401 Unauthorized",
156+
403 => "403 Forbidden",
157+
404 => "404 Not Found",
158+
405 => "405 Method Not Allowed",
159+
409 => "409 Conflict",
160+
422 => "422 Unprocessable Entity",
161+
500 => "500 Internal Server Error",
162+
502 => "502 Bad Gateway",
163+
503 => "503 Service Unavailable",
164+
else => if (code >= 200 and code < 300) "200 OK" else if (code >= 400 and code < 500) "400 Bad Request" else "500 Internal Server Error",
165+
};
166+
}
167+
168+
test "isProxyPath matches orchestration namespace" {
169+
try std.testing.expect(isProxyPath("/api/orchestration"));
170+
try std.testing.expect(isProxyPath("/api/orchestration/runs"));
171+
try std.testing.expect(isProxyPath("/api/orchestration/store/search"));
172+
try std.testing.expect(!isProxyPath("/api/instances"));
173+
}
174+
175+
test "backendForPath routes store requests to tickets backend" {
176+
try std.testing.expectEqual(Backend.tickets, backendForPath("/api/orchestration/store/search").?);
177+
try std.testing.expectEqual(Backend.boiler, backendForPath("/api/orchestration/runs").?);
178+
}
179+
180+
test "handle routes store paths to NullTickets config" {
181+
const resp = handle(std.testing.allocator, "GET", "/api/orchestration/store/search", "", .{
182+
.boiler_url = "http://127.0.0.1:8080",
183+
});
184+
try std.testing.expectEqualStrings("503 Service Unavailable", resp.status);
185+
try std.testing.expectEqualStrings("{\"error\":\"NullTickets not configured\"}", resp.body);
186+
}
187+
188+
test "handle routes non-store paths to NullBoiler config" {
189+
const resp = handle(std.testing.allocator, "GET", "/api/orchestration/runs", "", .{
190+
.tickets_url = "http://127.0.0.1:7711",
191+
});
192+
try std.testing.expectEqualStrings("503 Service Unavailable", resp.status);
193+
try std.testing.expectEqualStrings("{\"error\":\"NullBoiler not configured\"}", resp.body);
194+
}

0 commit comments

Comments
 (0)