From 80be27a50ed9fc05b781263c700afccefe6dba2d Mon Sep 17 00:00:00 2001 From: venzeles <17001808+venzeles@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:30 +0300 Subject: [PATCH 01/14] Add NullWatch flight recorder cockpit --- HACKATHON_SUBMISSION.md | 84 +++ README.md | 21 +- .../nullhub-observability-failure.png | Bin 0 -> 103716 bytes .../nullhub-observability-overview.png | Bin 0 -> 96935 bytes src/api/components.zig | 10 +- src/api/meta.zig | 10 + src/api/observability.zig | 126 ++++ src/installer/registry.zig | 17 + src/root.zig | 2 + src/server.zig | 28 +- ui/src/lib/api/client.ts | 45 ++ ui/src/lib/components/Sidebar.svelte | 4 + ui/src/routes/observability/+page.svelte | 607 ++++++++++++++++++ 13 files changed, 949 insertions(+), 5 deletions(-) create mode 100644 HACKATHON_SUBMISSION.md create mode 100644 docs/screenshots/nullhub-observability-failure.png create mode 100644 docs/screenshots/nullhub-observability-overview.png create mode 100644 src/api/observability.zig create mode 100644 ui/src/routes/observability/+page.svelte diff --git a/HACKATHON_SUBMISSION.md b/HACKATHON_SUBMISSION.md new file mode 100644 index 0000000..c08284c --- /dev/null +++ b/HACKATHON_SUBMISSION.md @@ -0,0 +1,84 @@ +# Agent Flight Recorder + +## Problem Discovered + +NullWatch already provides the observability layer for the nullclaw ecosystem: +run summaries, spans, evals, OTLP ingest, cost, token usage, and failure context. +It also exports a NullHub-compatible manifest. NullHub already provides the +operator UI and orchestration pages, but it did not register NullWatch or expose +its tracing/eval data in the UI. + +## Chosen Solution + +Add a local-first Observability cockpit to NullHub: + +- register `nullwatch` as a known component +- proxy `/api/observability/*` to a local NullWatch instance +- add a Flight Recorder page for runs, spans, evals, cost, tokens, and errors +- document the local demo flow with `NULLWATCH_URL` + +## Why This Idea Was Chosen + +This is stronger than a single CLI preflight because it connects multiple parts +of the ecosystem into a visible agent platform story: execution, orchestration, +task tracking, observability, and operations. It is still hackathon-sized because +it uses existing NullWatch APIs and NullHub UI patterns instead of changing core +agent runtime behavior. + +## What Was Implemented + +- NullWatch component registration in the NullHub registry. +- Observability reverse proxy with optional bearer token forwarding. +- Sidebar entry and `/observability` UI page. +- API client methods for NullWatch summary, runs, spans, evals, and health. +- README documentation for the proxy and local demo setup. + +## Files Changed + +- `src/installer/registry.zig` +- `src/api/observability.zig` +- `src/api/components.zig` +- `src/api/meta.zig` +- `src/root.zig` +- `src/server.zig` +- `ui/src/lib/api/client.ts` +- `ui/src/lib/components/Sidebar.svelte` +- `ui/src/routes/observability/+page.svelte` +- `README.md` +- `HACKATHON_SUBMISSION.md` + +## How To Test Or Demo + +Start NullWatch with seeded data from the sibling repository: + +```bash +cd ../nullwatch +zig build run -- demo-seed +zig build run -- serve --port 7710 +``` + +Start NullHub with the observability proxy configured: + +```bash +NULLWATCH_URL=http://127.0.0.1:7710 zig build run -- serve --no-open +``` + +Open `/observability` in NullHub and inspect the seeded runs. + +## Screenshots + +Flight Recorder overview: + +![NullHub Observability overview](docs/screenshots/nullhub-observability-overview.png) + +Failure detail with tool-call error context: + +![NullHub Observability failure detail](docs/screenshots/nullhub-observability-failure.png) + +## Limitations And Future Improvements + +- The MVP reads from a configured `NULLWATCH_URL`; automatic discovery of managed + NullWatch instances can be added later. +- The first UI version renders a compact timeline, not a full waterfall chart. +- Run correlation with NullBoiler orchestration pages can be added as a follow-up + when both systems share stable run ids. diff --git a/README.md b/README.md index 73f7c48..9acfe00 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Management hub for the nullclaw ecosystem. `NullHub` is a single Zig binary with an embedded Svelte web UI for installing, configuring, monitoring, and updating ecosystem components (NullClaw, NullBoiler, -NullTickets). +NullTickets, NullWatch). ## Features @@ -22,6 +22,7 @@ NullTickets). - **Web UI + CLI** -- browser dashboard for humans, CLI for automation - **Managed instance admin API** -- instance-scoped status, config, models, cron, channels, and skills routes for managed NullClaw installs - **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) +- **Observability cockpit** -- local NullWatch run summaries, span timelines, eval results, token usage, cost, and error context through a NullHub proxy ## Quick Start @@ -119,6 +120,22 @@ to the local orchestration stack. Most routes go to NullBoiler's REST API via `/api/orchestration/store/*` is proxied to NullTickets via `NULLTICKETS_URL` and optional `NULLTICKETS_TOKEN`. +**Observability proxy** -- requests to `/api/observability/*` are reverse-proxied +to a local NullWatch instance via `NULLWATCH_URL` (for example +`http://localhost:7710`) and optional `NULLWATCH_TOKEN`. The built-in +Observability page uses this proxy to display run summaries, spans, evals, +latency, cost, and failure context without sending data to hosted services. + +### Observability Screenshots + +Flight Recorder overview: + +![NullHub Observability overview](docs/screenshots/nullhub-observability-overview.png) + +Failure detail with tool-call error context: + +![NullHub Observability failure detail](docs/screenshots/nullhub-observability-failure.png) + ## Development Backend: @@ -157,12 +174,14 @@ src/ auth.zig # Optional bearer token auth api/ # REST endpoints (components, instances, wizard, ...) orchestration.zig # Reverse proxy to NullBoiler orchestration API + observability.zig # Reverse proxy to NullWatch tracing/eval API core/ # Manifest parser, state, platform, paths installer/ # Download, build, UI module fetching supervisor/ # Process spawn, health checks, manager ui/src/ routes/ # SvelteKit pages orchestration/ # Orchestration pages (dashboard, workflows, runs, store) + observability/ # NullWatch flight recorder page lib/components/ # Reusable Svelte components orchestration/ # GraphViewer, StateInspector, RunEventLog, InterruptPanel, # CheckpointTimeline, WorkflowJsonEditor, NodeCard, SendProgressBar diff --git a/docs/screenshots/nullhub-observability-failure.png b/docs/screenshots/nullhub-observability-failure.png new file mode 100644 index 0000000000000000000000000000000000000000..19f38d484d26dacd449999d07281b3f4c33976e3 GIT binary patch literal 103716 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

Icp*8?M*$4K2rJ2yZt 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/nullhub-observability-overview.png b/docs/screenshots/nullhub-observability-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..31058e038f535bcbdcd34b70c4c769dd8e2edf82 GIT binary patch literal 96935 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%0AvD6pnaqrDKFinA|LZQJPy4hRZGVn=503H7=hM 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%0A+lIC0QhilQ*;apayHWG!r2G8z`PhSJK_cE} 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 literal 0 HcmV?d00001 diff --git a/src/api/components.zig b/src/api/components.zig index ecbae86..f1b0947 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -175,7 +175,7 @@ test "deriveDisplayName capitalizes first letter" { try std.testing.expectEqualStrings("", name3); } -test "handleList returns valid JSON with all 3 known components" { +test "handleList returns valid JSON with all known components" { const allocator = std.testing.allocator; var s = state_mod.State.init(allocator, "/tmp/nullhub-test-components.json"); defer s.deinit(); @@ -187,29 +187,33 @@ test "handleList returns valid JSON with all 3 known components" { try std.testing.expect(std.mem.startsWith(u8, json, "{\"components\":[")); try std.testing.expect(std.mem.endsWith(u8, json, "]}")); - // Verify all 3 components are present + // Verify all components are present try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nullboiler\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nulltickets\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"nullwatch\"") != null); // Verify display names try std.testing.expect(std.mem.indexOf(u8, json, "\"NullClaw\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"NullBoiler\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"NullTickets\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"NullWatch\"") != null); // Verify descriptions are present try std.testing.expect(std.mem.indexOf(u8, json, "Autonomous AI agent runtime") != null); try std.testing.expect(std.mem.indexOf(u8, json, "DAG-based workflow orchestrator") != null); try std.testing.expect(std.mem.indexOf(u8, json, "Task and issue tracker") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "Headless observability") != null); // Verify repo fields try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullclaw\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/NullBoiler\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nulltickets\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullwatch\"") != null); // Verify structural fields try std.testing.expect(std.mem.indexOf(u8, json, "\"alpha\"") != null); - try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"alpha\":true")); + try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, json, "\"alpha\":true")); try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "\"alpha\":false")); try std.testing.expect(std.mem.indexOf(u8, json, "\"installed\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"instance_count\"") != null); diff --git a/src/api/meta.zig b/src/api/meta.zig index 56bb876..64e20db 100644 --- a/src/api/meta.zig +++ b/src/api/meta.zig @@ -1333,6 +1333,16 @@ const routes = [_]RouteSpec{ .body = "Forwarded as-is to the orchestration backend.", .response = "Forwarded upstream JSON response.", }, + .{ + .id = "observability.proxy", + .method = "ANY", + .path_template = "/api/observability/{...}", + .category = "observability", + .summary = "Proxy observability requests to a local NullWatch instance.", + .auth_mode = "optional_bearer", + .body = "Forwarded as-is to NullWatch.", + .response = "Forwarded upstream JSON response.", + }, }; pub fn allRoutes() []const RouteSpec { diff --git a/src/api/observability.zig b/src/api/observability.zig new file mode 100644 index 0000000..4fe8d92 --- /dev/null +++ b/src/api/observability.zig @@ -0,0 +1,126 @@ +const std = @import("std"); +const std_compat = @import("compat"); + +const Allocator = std.mem.Allocator; + +const Response = struct { + status: []const u8, + content_type: []const u8, + body: []const u8, +}; + +const prefix = "/api/observability"; + +pub const Config = struct { + watch_url: ?[]const u8 = null, + watch_token: ?[]const u8 = null, +}; + +pub fn isProxyPath(target: []const u8) bool { + return std.mem.eql(u8, target, prefix) or std.mem.startsWith(u8, target, prefix ++ "/"); +} + +/// Proxies observability API requests to a local NullWatch instance. +/// The shared `/api/observability` prefix is stripped before forwarding, so +/// `/api/observability/v1/runs` becomes `/v1/runs` on NullWatch. +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 base_url = cfg.watch_url orelse + return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullWatch not configured\"}" }; + + const proxied_path = target[prefix.len..]; + const path = if (proxied_path.len == 0) "/v1/summary" else proxied_path; + const url = std.fmt.allocPrint(allocator, "{s}{s}", .{ 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 (cfg.watch_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, .io = std_compat.io() }; + 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\":\"NullWatch 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\"}" }; + + return .{ + .status = mapStatus(status_code), + .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", + 415 => "415 Unsupported Media Type", + 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 observability namespace" { + try std.testing.expect(isProxyPath("/api/observability")); + try std.testing.expect(isProxyPath("/api/observability/v1/runs")); + try std.testing.expect(isProxyPath("/api/observability/health")); + try std.testing.expect(!isProxyPath("/api/orchestration/v1/runs")); +} + +test "handle returns not configured without NullWatch URL" { + const resp = handle(std.testing.allocator, "GET", "/api/observability/v1/summary", "", .{}); + try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"NullWatch not configured\"}", resp.body); +} + +test "handle rejects non-observability paths" { + const resp = handle(std.testing.allocator, "GET", "/api/status", "", .{ + .watch_url = "http://127.0.0.1:7710", + }); + try std.testing.expectEqualStrings("404 Not Found", resp.status); +} diff --git a/src/installer/registry.zig b/src/installer/registry.zig index fd124b6..b55dd14 100644 --- a/src/installer/registry.zig +++ b/src/installer/registry.zig @@ -48,6 +48,15 @@ pub const known_components = [_]KnownComponent{ .repo = "nullclaw/nulltickets", .is_alpha = true, }, + .{ + .name = "nullwatch", + .display_name = "NullWatch", + .description = "Headless observability, tracing, evals, and run intelligence for lightweight agent infrastructure.", + .repo = "nullclaw/nullwatch", + .is_alpha = true, + .default_launch_command = "serve", + .default_port = 7710, + }, }; /// Look up a component by name in the known_components list. @@ -238,6 +247,14 @@ test "findKnownComponent returns nullboiler" { try std.testing.expectEqualStrings("nullclaw/NullBoiler", comp.?.repo); } +test "findKnownComponent returns nullwatch" { + const comp = findKnownComponent("nullwatch"); + try std.testing.expect(comp != null); + try std.testing.expectEqualStrings("nullclaw/nullwatch", comp.?.repo); + try std.testing.expectEqualStrings("serve", comp.?.default_launch_command); + try std.testing.expectEqual(@as(u16, 7710), comp.?.default_port); +} + test "findKnownComponent returns null for unknown" { try std.testing.expect(findKnownComponent("nonexistent") == null); } diff --git a/src/root.zig b/src/root.zig index b98fa0a..aeb7966 100644 --- a/src/root.zig +++ b/src/root.zig @@ -17,6 +17,7 @@ pub const manager = @import("supervisor/manager.zig"); pub const managed_skills = @import("managed_skills.zig"); pub const meta_api = @import("api/meta.zig"); pub const mdns = @import("mdns.zig"); +pub const observability_api = @import("api/observability.zig"); pub const orchestrator = @import("installer/orchestrator.zig"); pub const manifest = @import("core/manifest.zig"); pub const paths = @import("core/paths.zig"); @@ -64,6 +65,7 @@ test { _ = managed_skills; _ = meta_api; _ = mdns; + _ = observability_api; _ = orchestrator; _ = manifest; _ = paths; diff --git a/src/server.zig b/src/server.zig index 9ab8785..2518994 100644 --- a/src/server.zig +++ b/src/server.zig @@ -24,6 +24,7 @@ const channels_api = @import("api/channels.zig"); const usage_api = @import("api/usage.zig"); const report_api = @import("api/report.zig"); const orchestration_api = @import("api/orchestration.zig"); +const observability_api = @import("api/observability.zig"); const launch_args_mod = @import("core/launch_args.zig"); const ui_modules = @import("installer/ui_modules.zig"); const orchestrator = @import("installer/orchestrator.zig"); @@ -519,6 +520,10 @@ pub const Server = struct { "NULLTICKETS_URL" else if (std.mem.eql(u8, name, "NULLTICKETS_TOKEN")) "NULLTICKETS_TOKEN" + else if (std.mem.eql(u8, name, "NULLWATCH_URL")) + "NULLWATCH_URL" + else if (std.mem.eql(u8, name, "NULLWATCH_TOKEN")) + "NULLWATCH_TOKEN" else return null; return if (std.c.getenv(name_z)) |value| std.mem.span(value) else null; @@ -544,8 +549,20 @@ pub const Server = struct { return getEnv("NULLTICKETS_TOKEN"); } + fn getWatchUrl(self: *Server) ?[]const u8 { + _ = self; + return getEnv("NULLWATCH_URL"); + } + + fn getWatchToken(self: *Server) ?[]const u8 { + _ = self; + return getEnv("NULLWATCH_TOKEN"); + } + fn routeWithoutServerMutex(target: []const u8) bool { - return instances_api.isIntegrationPath(target) or orchestration_api.isProxyPath(target); + return instances_api.isIntegrationPath(target) or + orchestration_api.isProxyPath(target) or + observability_api.isProxyPath(target); } fn route(self: *Server, allocator: std.mem.Allocator, method: []const u8, target: []const u8, body: []const u8) Response { @@ -1123,6 +1140,14 @@ pub const Server = struct { return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; } + if (observability_api.isProxyPath(target)) { + const resp = observability_api.handle(allocator, method, target, body, .{ + .watch_url = self.getWatchUrl(), + .watch_token = self.getWatchToken(), + }); + return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; + } + // 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}/... @@ -1747,6 +1772,7 @@ test "routeWithoutServerMutex keeps orchestration proxy requests off global lock try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration")); try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration/runs")); try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration/store/search")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/observability/v1/runs")); try std.testing.expect(Server.routeWithoutServerMutex("/api/instances/nullclaw/demo/logs")); try std.testing.expect(!Server.routeWithoutServerMutex("/api/components")); } diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 2a2ef84..5abb656 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -159,6 +159,51 @@ export const api = { refreshComponents: () => request('/components/refresh', { method: 'POST' }), + getObservabilityHealth: () => request('/observability/health'), + getObservabilitySummary: () => request('/observability/v1/summary'), + getObservabilityRuns: (params?: { run_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; verdict?: string; dataset?: string; limit?: number }) => + request( + withQuery('/observability/v1/runs', { + run_id: params?.run_id, + source: params?.source, + operation: params?.operation, + status: params?.status, + model: params?.model, + tool_name: params?.tool_name, + verdict: params?.verdict, + dataset: params?.dataset, + limit: params?.limit, + }), + ), + getObservabilityRun: (runId: string) => request(`/observability/v1/runs/${encodeURIComponent(runId)}`), + getObservabilitySpans: (params?: { run_id?: string; trace_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; task_id?: string; session_id?: string; agent_id?: string; limit?: number }) => + request( + withQuery('/observability/v1/spans', { + run_id: params?.run_id, + trace_id: params?.trace_id, + source: params?.source, + operation: params?.operation, + status: params?.status, + model: params?.model, + tool_name: params?.tool_name, + task_id: params?.task_id, + session_id: params?.session_id, + agent_id: params?.agent_id, + limit: params?.limit, + }), + ), + getObservabilityEvals: (params?: { run_id?: string; verdict?: string; eval_key?: string; scorer?: string; dataset?: string; limit?: number }) => + request( + withQuery('/observability/v1/evals', { + run_id: params?.run_id, + verdict: params?.verdict, + eval_key: params?.eval_key, + scorer: params?.scorer, + dataset: params?.dataset, + limit: params?.limit, + }), + ), + applyUpdate: (c: string, n: string) => request(`/instances/${c}/${n}/update`, { method: 'POST' }), diff --git a/ui/src/lib/components/Sidebar.svelte b/ui/src/lib/components/Sidebar.svelte index 71a72eb..a9beadc 100644 --- a/ui/src/lib/components/Sidebar.svelte +++ b/ui/src/lib/components/Sidebar.svelte @@ -83,6 +83,10 @@ Channels +

+
- + {#if supportsAgentData} + + {/if} - + {#if supportsChat} + + {/if} {#if supportsAgentData}
+ {/if} {:else if activeTab === "history"} {#key instanceRouteKey} From d78dcf5103aaae372dcf3cba27f2783e09716fdd Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 22:18:24 -0300 Subject: [PATCH 08/14] fix nullwatch management edge cases --- src/api/components.zig | 8 +- src/api/instance_runtime.zig | 85 +++++++++++++++++- src/api/instances.zig | 155 +++++++++++++++++++++++++++++--- src/api/updates.zig | 22 ++++- src/installer/orchestrator.zig | 156 +++++++++++++++++++++++++++------ src/server.zig | 69 +++++++++++++-- src/supervisor/manager.zig | 131 +++++++++++++++++++++++++-- 7 files changed, 570 insertions(+), 56 deletions(-) diff --git a/src/api/components.zig b/src/api/components.zig index fb7b931..048bd83 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -1,5 +1,6 @@ const std = @import("std"); const std_compat = @import("compat"); +const builtin = @import("builtin"); const registry = @import("../installer/registry.zig"); const paths_mod = @import("../core/paths.zig"); const state_mod = @import("../core/state.zig"); @@ -21,7 +22,12 @@ pub fn deriveDisplayName(allocator: std.mem.Allocator, name: []const u8) ![]cons /// Check if a component has a standalone installation at ~/.{component}/config.json fn hasStandaloneInstall(allocator: std.mem.Allocator, component: []const u8) bool { - const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch return false; + const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { + if (builtin.os.tag == .windows) { + break :blk std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return false; + } + return false; + }; defer allocator.free(home); const dot_name = std.fmt.allocPrint(allocator, ".{s}", .{component}) catch return false; defer allocator.free(dot_name); diff --git a/src/api/instance_runtime.zig b/src/api/instance_runtime.zig index 7a782c5..054179c 100644 --- a/src/api/instance_runtime.zig +++ b/src/api/instance_runtime.zig @@ -50,12 +50,57 @@ pub fn readPortFromConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, } } - return switch (current) { - .integer => |value| if (value >= 0 and value <= 65535) @intCast(value) else null, + return parsePortValue(current); +} + +fn parsePortValue(value: std.json.Value) ?u16 { + return switch (value) { + .integer => |raw| if (raw >= 0 and raw <= 65535) @intCast(raw) else null, + .number_string => |raw| std.fmt.parseInt(u16, raw, 10) catch null, + .string => |raw| std.fmt.parseInt(u16, raw, 10) catch null, else => null, }; } +fn readStringFromConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, name: []const u8, dot_key: []const u8) ?[]u8 { + const config_path = paths.instanceConfig(allocator, component, name) catch return null; + defer allocator.free(config_path); + + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return null; + defer file.close(); + const contents = file.readToEndAlloc(allocator, 4 * 1024 * 1024) catch return null; + defer allocator.free(contents); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + + var current = parsed.value; + var it = std.mem.splitScalar(u8, dot_key, '.'); + while (it.next()) |segment| { + switch (current) { + .object => |obj| current = obj.get(segment) orelse return null, + else => return null, + } + } + + if (current != .string) return null; + return allocator.dupe(u8, current.string) catch null; +} + +fn normalizeHealthHost(allocator: std.mem.Allocator, host: []const u8) ![]u8 { + if (host.len == 0 or + std.mem.eql(u8, host, "0.0.0.0") or + std.mem.eql(u8, host, "::") or + std.mem.eql(u8, host, "localhost")) + { + return allocator.dupe(u8, "127.0.0.1"); + } + return allocator.dupe(u8, host); +} + fn isImportedStandalone( allocator: std.mem.Allocator, paths: paths_mod.Paths, @@ -125,7 +170,13 @@ fn deriveImportedStandaloneSnapshot( const port = readPortFromConfig(allocator, paths, component, name, port_key) orelse known.default_port; if (port == 0) return null; - const health = health_mod.check(allocator, "127.0.0.1", port, known.default_health_endpoint); + const configured_host = readStringFromConfig(allocator, paths, component, name, "host") orelse + allocator.dupe(u8, "127.0.0.1") catch return null; + defer allocator.free(configured_host); + const health_host = normalizeHealthHost(allocator, configured_host) catch return null; + defer allocator.free(health_host); + + const health = health_mod.check(allocator, health_host, port, known.default_health_endpoint); const status = standaloneStatus(manager_snapshot, health.ok); var snapshot = manager_snapshot orelse Snapshot{ .status = status }; snapshot.status = status; @@ -161,3 +212,31 @@ test "standalone runtime metadata covers nullclaw and nullwatch" { try std.testing.expect(isStandaloneLaunchMode("nullwatch", "nullwatch", "serve")); try std.testing.expect(!isStandaloneLaunchMode("nullboiler", "gateway", "gateway")); } + +test "readPortFromConfig accepts string ports" { + const allocator = std.testing.allocator; + var fixture = try @import("../test_helpers.zig").TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullwatch", "watch"); + defer allocator.free(inst_dir); + try std_compat.fs.makeDirAbsolute(std.fs.path.dirname(inst_dir).?); + try std_compat.fs.makeDirAbsolute(inst_dir); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullwatch", "watch"); + defer allocator.free(config_path); + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":\"7711\",\"host\":\"::1\"}"); + + try std.testing.expectEqual(@as(u16, 7711), readPortFromConfig(allocator, fixture.paths, "nullwatch", "watch", "port").?); + + const host = readStringFromConfig(allocator, fixture.paths, "nullwatch", "watch", "host").?; + defer allocator.free(host); + try std.testing.expectEqualStrings("::1", host); + + const normalized = try normalizeHealthHost(allocator, "::"); + defer allocator.free(normalized); + try std.testing.expectEqualStrings("127.0.0.1", normalized); +} diff --git a/src/api/instances.zig b/src/api/instances.zig index 62783a9..52d9de9 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -17,6 +17,8 @@ const query_api = @import("query.zig"); const test_helpers = @import("../test_helpers.zig"); const instance_runtime = @import("instance_runtime.zig"); const registry = @import("../installer/registry.zig"); +const downloader = @import("../installer/downloader.zig"); +const platform = @import("../core/platform.zig"); const ApiResponse = helpers.ApiResponse; const appendEscaped = helpers.appendEscaped; @@ -40,6 +42,129 @@ fn isLegacyDefaultLaunchMode(component: []const u8, launch_mode: []const u8) boo return !std.mem.eql(u8, default_launch, "gateway") and std.mem.eql(u8, launch_mode, "gateway"); } +const StartBinary = struct { + path: []const u8, + version: []const u8, + version_owned: bool = false, + + fn deinit(self: StartBinary, allocator: std.mem.Allocator) void { + allocator.free(self.path); + if (self.version_owned) allocator.free(self.version); + } +}; + +fn persistStartVersion( + s: *state_mod.State, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, + version: []const u8, +) !void { + const updated = try s.updateInstance(component, name, .{ + .version = version, + .auto_start = entry.auto_start, + .launch_mode = entry.launch_mode, + .verbose = entry.verbose, + }); + if (!updated) return error.StateError; + s.save() catch return error.StateError; +} + +fn resolveStandaloneStartBinary( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) !StartBinary { + if (local_binary.stageDevLocal(allocator, paths, component)) |dest_bin| { + persistStartVersion(s, component, name, entry, local_binary.dev_local_version) catch |err| { + allocator.free(dest_bin); + return err; + }; + return .{ .path = dest_bin, .version = local_binary.dev_local_version }; + } + + const known = registry.findKnownComponent(component) orelse return error.NoPlatformAsset; + var release = registry.fetchLatestRelease(allocator, known.repo) catch return error.FetchFailed; + defer release.deinit(); + + const platform_key = comptime platform.detect().toString(); + const asset = registry.findAssetForComponentPlatform(allocator, release.value, component, platform_key) orelse return error.NoPlatformAsset; + + const version = try allocator.dupe(u8, release.value.tag_name); + errdefer allocator.free(version); + const bin_path = try paths.binary(allocator, component, version); + errdefer allocator.free(bin_path); + + downloader.downloadIfMissing(allocator, asset.browser_download_url, bin_path) catch return error.DownloadFailed; + persistStartVersion(s, component, name, entry, version) catch |err| return err; + + return .{ .path = bin_path, .version = version, .version_owned = true }; +} + +fn resolveStartBinary( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) !StartBinary { + if (std.mem.eql(u8, entry.version, "standalone")) { + return resolveStandaloneStartBinary(allocator, s, paths, component, name, entry); + } + + local_binary.refreshStagedDevLocal(allocator, paths, component, entry.version); + return .{ + .path = try paths.binary(allocator, component, entry.version), + .version = entry.version, + }; +} + +fn startBinaryError(err: anyerror) ApiResponse { + return switch (err) { + error.FetchFailed => .{ + .status = "502 Bad Gateway", + .content_type = "application/json", + .body = "{\"error\":\"failed to fetch latest release\"}", + }, + error.NoPlatformAsset => .{ + .status = "502 Bad Gateway", + .content_type = "application/json", + .body = "{\"error\":\"no platform asset for latest version\"}", + }, + error.DownloadFailed => .{ + .status = "502 Bad Gateway", + .content_type = "application/json", + .body = "{\"error\":\"failed to download latest binary\"}", + }, + else => helpers.serverError(), + }; +} + +fn isExternalStandaloneRunning( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + manager: *manager_mod.Manager, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) bool { + if (manager.getStatus(component, name) != null) return false; + const snapshot = instance_runtime.resolve(allocator, paths, manager, component, name, entry); + return snapshot.status == .running; +} + +fn externalStandaloneConflict() ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = "{\"error\":\"instance is running outside nullhub supervision\"}", + }; +} + const FetchedJsonValue = struct { bytes: []u8, parsed: std.json.Parsed(std.json.Value), @@ -1859,6 +1984,9 @@ pub fn handleGet(allocator: std.mem.Allocator, s: *state_mod.State, manager: *ma /// POST /api/instances/{component}/{name}/start pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8, body: []const u8) ApiResponse { const entry = s.getInstance(component, name) orelse return notFound(); + if (isExternalStandaloneRunning(allocator, paths, manager, component, name, entry)) { + return externalStandaloneConflict(); + } _ = nullclaw_web_channel.ensureNullclawWebChannelConfig( allocator, @@ -1902,11 +2030,10 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * } } - local_binary.refreshStagedDevLocal(allocator, paths, component, entry.version); - - // Resolve binary path - const bin_path = paths.binary(allocator, component, entry.version) catch return helpers.serverError(); - defer allocator.free(bin_path); + const start_binary = resolveStartBinary(allocator, s, paths, component, name, entry) catch |err| return startBinaryError(err); + defer start_binary.deinit(allocator); + const bin_path = start_binary.path; + const current_version = start_binary.version; // Read manifest from binary to get health endpoint and port var health_endpoint: []const u8 = "/health"; @@ -1943,7 +2070,7 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * if (should_normalize_launch) { launch_cmd = mode; _ = s.updateInstance(component, name, .{ - .version = entry.version, + .version = current_version, .auto_start = entry.auto_start, .launch_mode = launch_cmd, .verbose = entry.verbose, @@ -1975,15 +2102,21 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * } /// POST /api/instances/{component}/{name}/stop -pub fn handleStop(s: *state_mod.State, manager: *manager_mod.Manager, component: []const u8, name: []const u8) ApiResponse { - _ = s.getInstance(component, name) orelse return notFound(); +pub fn handleStop(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8) ApiResponse { + const entry = s.getInstance(component, name) orelse return notFound(); + if (isExternalStandaloneRunning(allocator, paths, manager, component, name, entry)) { + return externalStandaloneConflict(); + } manager.stopInstance(component, name) catch return helpers.serverError(); return jsonOk("{\"status\":\"stopped\"}"); } /// POST /api/instances/{component}/{name}/restart pub fn handleRestart(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8, body: []const u8) ApiResponse { - _ = s.getInstance(component, name) orelse return notFound(); + const entry = s.getInstance(component, name) orelse return notFound(); + if (isExternalStandaloneRunning(allocator, paths, manager, component, name, entry)) { + return externalStandaloneConflict(); + } manager.stopInstance(component, name) catch {}; return handleStart(allocator, s, manager, paths, component, name, body); } @@ -3824,7 +3957,7 @@ pub fn dispatch( if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed(); if (std.mem.eql(u8, action, "start")) return handleStart(allocator, s, manager, paths, parsed.component, parsed.name, body); - if (std.mem.eql(u8, action, "stop")) return handleStop(s, manager, parsed.component, parsed.name); + if (std.mem.eql(u8, action, "stop")) return handleStop(allocator, s, manager, paths, parsed.component, parsed.name); if (std.mem.eql(u8, action, "restart")) return handleRestart(allocator, s, manager, paths, parsed.component, parsed.name, body); return notFound(); @@ -4441,7 +4574,7 @@ test "handleStop returns 200 for existing instance" { try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); - const resp = handleStop(&s, &mctx.manager, "nullclaw", "my-agent"); + const resp = handleStop(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent"); try std.testing.expectEqualStrings("200 OK", resp.status); try std.testing.expectEqualStrings("{\"status\":\"stopped\"}", resp.body); } diff --git a/src/api/updates.zig b/src/api/updates.zig index 7434c5c..66f0750 100644 --- a/src/api/updates.zig +++ b/src/api/updates.zig @@ -57,6 +57,16 @@ fn versionsEqual(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, stripV(a), stripV(b)); } +fn normalizedLaunchModeForUpdate(component: []const u8, launch_mode: []const u8, known: registry.KnownComponent) []const u8 { + if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, launch_mode, "gateway")) { + return known.default_launch_command; + } + if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, launch_mode, "nullwatch")) { + return known.default_launch_command; + } + return launch_mode; +} + fn fetchLatestTagForComponent(allocator: std.mem.Allocator, component: []const u8) ?[]u8 { if (builtin.is_test) return null; @@ -197,7 +207,8 @@ pub fn handleApplyUpdateRuntime( const inst_dir = paths.instanceDir(allocator, component, name) catch return serverError(); defer allocator.free(inst_dir); - var launch = launch_args_mod.resolve(allocator, entry.launch_mode, entry.verbose) catch return serverError(); + const launch_mode = normalizedLaunchModeForUpdate(component, entry.launch_mode, known); + var launch = launch_args_mod.resolve(allocator, launch_mode, entry.verbose) catch return serverError(); defer launch.deinit(); const effective_port = launch.effectiveHealthPort(port); @@ -253,7 +264,7 @@ pub fn handleApplyUpdateRuntime( const updated = s.updateInstance(component, name, .{ .version = latest_tag, .auto_start = entry.auto_start, - .launch_mode = entry.launch_mode, + .launch_mode = launch_mode, .verbose = entry.verbose, }) catch return serverError(); if (!updated) return notFound(); @@ -394,6 +405,13 @@ test "handleApplyUpdate returns success for existing instance" { try std.testing.expectEqualStrings("Update initiated", parsed.value.message); } +test "normalizedLaunchModeForUpdate maps legacy nullwatch launch modes to serve" { + const known = registry.findKnownComponent("nullwatch").?; + try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "gateway", known)); + try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "nullwatch", known)); + try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "serve", known)); +} + test "parseUpdatePath extracts component and name correctly" { const p = parseUpdatePath("/api/instances/nullclaw/my-agent/update").?; try std.testing.expectEqualStrings("nullclaw", p.component); diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index 2f12335..fd6b9d6 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -375,28 +375,49 @@ fn resolveConfiguredPort( paths: paths_mod.Paths, state: *state_mod.State, ) u16 { - const parsed = std.json.parseFromSlice( - struct { - port: ?u16 = null, - gateway_port: ?u16 = null, - answers: ?struct { - port: ?u16 = null, - gateway_port: ?u16 = null, - } = null, - }, - allocator, - answers_json, - .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true }, - ) catch return findNextAvailablePort(allocator, default_port, paths, state); + const requested = resolveRequestedPort(allocator, answers_json) orelse + return findNextAvailablePort(allocator, default_port, paths, state); + + if (requested == default_port) { + return findNextAvailablePort(allocator, default_port, paths, state); + } + return requested; +} + +fn resolveRequestedPort(allocator: std.mem.Allocator, answers_json: []const u8) ?u16 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, answers_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return null; defer parsed.deinit(); + if (parsed.value != .object) return null; - if (parsed.value.port) |v| return v; - if (parsed.value.gateway_port) |v| return v; - if (parsed.value.answers) |a| { - if (a.port) |v| return v; - if (a.gateway_port) |v| return v; + if (parsed.value.object.get("port")) |value| { + if (parsePortValue(value)) |port| return port; } - return findNextAvailablePort(allocator, default_port, paths, state); + if (parsed.value.object.get("gateway_port")) |value| { + if (parsePortValue(value)) |port| return port; + } + if (parsed.value.object.get("answers")) |answers| { + if (answers == .object) { + if (answers.object.get("port")) |value| { + if (parsePortValue(value)) |port| return port; + } + if (answers.object.get("gateway_port")) |value| { + if (parsePortValue(value)) |port| return port; + } + } + } + return null; +} + +fn parsePortValue(value: std.json.Value) ?u16 { + return switch (value) { + .integer => |raw| if (raw >= 0 and raw <= 65535) @intCast(raw) else null, + .number_string => |raw| std.fmt.parseInt(u16, raw, 10) catch null, + .string => |raw| std.fmt.parseInt(u16, raw, 10) catch null, + else => null, + }; } fn persistAndStartInstance( @@ -577,10 +598,14 @@ fn readPortFromConfigPath(allocator: std.mem.Allocator, config_path: []const u8, } } - return switch (current) { - .integer => |value| if (value >= 0 and value <= 65535) @intCast(value) else null, - else => null, - }; + return parsePortValue(current); +} + +fn shouldWritePortField(value: ?std.json.Value, port: u16, overwrite: bool) bool { + if (overwrite) return true; + const existing = value orelse return true; + const existing_port = parsePortValue(existing) orelse return true; + return existing_port != port; } fn injectPortFields( @@ -597,15 +622,21 @@ fn injectPortFields( if (parsed.value != .object) return error.InvalidJson; var root = &parsed.value.object; - if (overwrite or root.get("port") == null) { + if (shouldWritePortField(root.get("port"), port, overwrite)) { try root.put(allocator, "port", .{ .integer = @as(i64, port) }); } - if (overwrite or root.get("gateway_port") == null) { + if (shouldWritePortField(root.get("gateway_port"), port, overwrite)) { try root.put(allocator, "gateway_port", .{ .integer = @as(i64, port) }); } if (root.getPtr("gateway")) |gateway_value| { - if (gateway_value.* == .object and (overwrite or gateway_value.object.get("port") == null)) { - try gateway_value.object.put(allocator, "port", .{ .integer = @as(i64, port) }); + if (gateway_value.* == .object) { + if (shouldWritePortField(gateway_value.object.get("port"), port, overwrite)) { + try gateway_value.object.put(allocator, "port", .{ .integer = @as(i64, port) }); + } + } else if (overwrite) { + var gateway_obj: std.json.ObjectMap = .empty; + try gateway_obj.put(allocator, "port", .{ .integer = @as(i64, port) }); + gateway_value.* = .{ .object = gateway_obj }; } } else { var gateway_obj: std.json.ObjectMap = .empty; @@ -1213,6 +1244,19 @@ test "resolveConfiguredPort reads top-level port" { try std.testing.expectEqual(@as(u16, 9001), port); } +test "resolveConfiguredPort reads string port" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + + const port = resolveConfiguredPort(allocator, "{\"port\":\"9002\"}", 8080, fixture.paths, &state); + try std.testing.expectEqual(@as(u16, 9002), port); +} + test "resolveConfiguredPort reads nested answers port" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); @@ -1263,6 +1307,43 @@ test "resolveConfiguredPort skips configured instance ports" { try std.testing.expect(port > 43000); } +test "resolveConfiguredPort skips configured string default port" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nullwatch", "watch-1", .{ + .version = "v2026.3.8", + .auto_start = true, + .launch_mode = "serve", + }); + + const comp_dir = try std.fs.path.join(allocator, &.{ fixture.paths.root, "instances", "nullwatch" }); + defer allocator.free(comp_dir); + std_compat.fs.makeDirAbsolute(comp_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const inst_dir = try fixture.paths.instanceDir(allocator, "nullwatch", "watch-1"); + defer allocator.free(inst_dir); + std_compat.fs.makeDirAbsolute(inst_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const config_path = try fixture.paths.instanceConfig(allocator, "nullwatch", "watch-1"); + defer allocator.free(config_path); + try writeFile(config_path, "{\"port\":\"43010\"}"); + + const port = resolveConfiguredPort(allocator, "{\"port\":\"43010\"}", 43010, fixture.paths, &state); + try std.testing.expect(port > 43010); +} + test "injectPortFields fills missing port fields" { const allocator = std.testing.allocator; const rendered = try injectPortFields(allocator, "{\"instance_name\":\"instance-2\"}", 3002, false); @@ -1279,6 +1360,27 @@ test "injectPortFields fills missing port fields" { try std.testing.expectEqual(@as(i64, 3002), parsed.value.object.get("gateway").?.object.get("port").?.integer); } +test "injectPortFields overwrites stale string port fields" { + const allocator = std.testing.allocator; + const rendered = try injectPortFields( + allocator, + "{\"instance_name\":\"instance-2\",\"port\":\"3000\",\"gateway_port\":\"3000\",\"gateway\":{\"port\":\"3000\"}}", + 3002, + false, + ); + defer allocator.free(rendered); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, rendered, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(i64, 3002), parsed.value.object.get("port").?.integer); + try std.testing.expectEqual(@as(i64, 3002), parsed.value.object.get("gateway_port").?.integer); + try std.testing.expectEqual(@as(i64, 3002), parsed.value.object.get("gateway").?.object.get("port").?.integer); +} + test "injectPortFields overwrites existing port fields when requested" { const allocator = std.testing.allocator; const rendered = try injectPortFields( diff --git a/src/server.zig b/src/server.zig index 25804d5..4adeb0c 100644 --- a/src/server.zig +++ b/src/server.zig @@ -18,6 +18,7 @@ const paths_mod = @import("core/paths.zig"); const manager_mod = @import("supervisor/manager.zig"); const process_mod = @import("supervisor/process.zig"); const runtime_state_mod = @import("supervisor/runtime_state.zig"); +const instance_runtime = @import("api/instance_runtime.zig"); const wizard_api = @import("api/wizard.zig"); const providers_api = @import("api/providers.zig"); const channels_api = @import("api/channels.zig"); @@ -588,24 +589,55 @@ pub const Server = struct { const Candidate = struct { name: []const u8, port: u16, + + fn prefer(current: ?@This(), next: @This()) @This() { + const existing = current orelse return next; + return if (std.mem.order(u8, next.name, existing.name) == .lt) next else existing; + } }; + var running: ?Candidate = null; var starting: ?Candidate = null; - var it = self.manager.instances.iterator(); - while (it.next()) |entry| { + + if (self.state.instances.getPtr("nullwatch")) |watch_instances| { + var state_it = watch_instances.iterator(); + while (state_it.next()) |entry| { + const snapshot = instance_runtime.resolve( + allocator, + self.paths, + self.manager, + "nullwatch", + entry.key_ptr.*, + entry.value_ptr.*, + ); + if (snapshot.port == 0) continue; + + const candidate = Candidate{ .name = entry.key_ptr.*, .port = snapshot.port }; + switch (snapshot.status) { + .running => running = Candidate.prefer(running, candidate), + .starting, .restarting => starting = Candidate.prefer(starting, candidate), + .stopped, .stopping, .failed => {}, + } + } + } + + var manager_it = self.manager.instances.iterator(); + while (manager_it.next()) |entry| { const inst = entry.value_ptr.*; if (!std.mem.eql(u8, inst.component, "nullwatch")) continue; if (inst.port == 0) continue; + const candidate = Candidate{ .name = inst.name, .port = inst.port }; switch (inst.status) { - .running => return try self.buildManagedWatchTarget(allocator, inst.name, inst.port, token_override), - .starting, .restarting => { - if (starting == null) starting = .{ .name = inst.name, .port = inst.port }; - }, + .running => running = Candidate.prefer(running, candidate), + .starting, .restarting => starting = Candidate.prefer(starting, candidate), .stopped, .stopping, .failed => {}, } } + if (running) |candidate| { + return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); + } if (starting) |candidate| { return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); } @@ -2016,6 +2048,31 @@ test "managed NullWatch target is discovered from supervisor state" { try std.testing.expect(target.token == null); } +test "managed NullWatch target prefers first running instance by name" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + + const key_z = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ "nullwatch", "zulu" }); + try ctx.manager.instances.put(key_z, .{ + .component = "nullwatch", + .name = "zulu", + .status = .running, + .port = 7712, + }); + const key_a = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ "nullwatch", "alpha" }); + try ctx.manager.instances.put(key_a, .{ + .component = "nullwatch", + .name = "alpha", + .status = .running, + .port = 7711, + }); + + const target = try ctx.server.getManagedWatchTarget(allocator, null); + defer target.deinit(allocator); + try std.testing.expectEqualStrings("http://127.0.0.1:7711", target.url.?); +} + test "managed NullWatch target reads host and token from config" { const allocator = std.testing.allocator; var ctx = TestContext.init(allocator); diff --git a/src/supervisor/manager.zig b/src/supervisor/manager.zig index 09efa77..6c63ce4 100644 --- a/src/supervisor/manager.zig +++ b/src/supervisor/manager.zig @@ -336,6 +336,68 @@ pub const Manager = struct { self.clearPid(inst); } + const HealthHost = struct { + value: []const u8 = "127.0.0.1", + owned: bool = false, + + fn deinit(self: HealthHost, allocator: std.mem.Allocator) void { + if (self.owned) allocator.free(self.value); + } + }; + + fn normalizeHealthHost(allocator: std.mem.Allocator, host: []const u8) ![]const u8 { + if (host.len == 0 or + std.mem.eql(u8, host, "0.0.0.0") or + std.mem.eql(u8, host, "::") or + std.mem.eql(u8, host, "localhost")) + { + return allocator.dupe(u8, "127.0.0.1"); + } + return allocator.dupe(u8, host); + } + + fn readHealthHostFromConfigPath(self: *Manager, config_path: []const u8) ?[]const u8 { + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return null; + defer file.close(); + + const contents = file.readToEndAlloc(self.allocator, 1024 * 1024) catch return null; + defer self.allocator.free(contents); + + const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + if (parsed.value != .object) return null; + + const host_value = parsed.value.object.get("host") orelse return null; + if (host_value != .string) return null; + return normalizeHealthHost(self.allocator, host_value.string) catch null; + } + + fn instanceConfigPathForHealth(self: *Manager, inst: *const ManagedInstance) ?[]const u8 { + if (inst.config_path.len > 0) { + return self.allocator.dupe(u8, inst.config_path) catch null; + } + if (inst.working_dir.len > 0) { + return std.fs.path.join(self.allocator, &.{ inst.working_dir, "config.json" }) catch null; + } + return null; + } + + fn resolveInstanceHealthHost(self: *Manager, inst: *const ManagedInstance) HealthHost { + const config_path = self.instanceConfigPathForHealth(inst) orelse return .{}; + defer self.allocator.free(config_path); + const host = self.readHealthHostFromConfigPath(config_path) orelse return .{}; + return .{ .value = host, .owned = true }; + } + + fn checkInstanceHealth(self: *Manager, inst: *const ManagedInstance) health.HealthCheckResult { + const host = self.resolveInstanceHealthHost(inst); + defer host.deinit(self.allocator); + return health.check(self.allocator, host.value, inst.port, inst.health_endpoint); + } + /// Start an instance. binary_path is the path to the component binary. pub fn startInstance( self: *Manager, @@ -464,10 +526,17 @@ pub const Manager = struct { errdefer owned.deinit(self.allocator); const now = std_compat.time.milliTimestamp(); - const probe = if (runtime.port > 0) - health.check(self.allocator, "127.0.0.1", runtime.port, runtime.health_endpoint) - else - health.HealthCheckResult{ .ok = true }; + const probe = if (runtime.port > 0) blk: { + const probe_inst = ManagedInstance{ + .component = owned.component, + .name = owned.name, + .port = runtime.port, + .health_endpoint = owned.health_endpoint, + .working_dir = owned.working_dir, + .config_path = owned.config_path, + }; + break :blk self.checkInstanceHealth(&probe_inst); + } else health.HealthCheckResult{ .ok = true }; const status: Status = if (runtime.port == 0 or probe.ok) .running else .starting; const starting_since = if (status == .starting) @@ -634,7 +703,7 @@ pub const Manager = struct { } // Check health endpoint - const result = health.check(self.allocator, "127.0.0.1", inst.port, inst.health_endpoint); + const result = self.checkInstanceHealth(inst); if (result.ok) { inst.status = .running; inst.last_health_ok = now; @@ -699,7 +768,7 @@ pub const Manager = struct { } inst.last_health_check = now; - const result = health.check(self.allocator, "127.0.0.1", inst.port, inst.health_endpoint); + const result = self.checkInstanceHealth(inst); if (result.ok) { if (inst.health_consecutive_failures > 0) { self.logSupervisor(inst.component, inst.name, "health check recovered after {d} consecutive failures", .{inst.health_consecutive_failures}); @@ -892,6 +961,56 @@ test "logSupervisor appends diagnostics to nullhub.log" { try std.testing.expect(std.mem.indexOf(u8, contents, "second diagnostic") != null); } +test "normalizeHealthHost maps wildcard and localhost to loopback" { + const allocator = std.testing.allocator; + + const empty = try Manager.normalizeHealthHost(allocator, ""); + defer allocator.free(empty); + try std.testing.expectEqualStrings("127.0.0.1", empty); + + const wildcard = try Manager.normalizeHealthHost(allocator, "::"); + defer allocator.free(wildcard); + try std.testing.expectEqualStrings("127.0.0.1", wildcard); + + const localhost = try Manager.normalizeHealthHost(allocator, "localhost"); + defer allocator.free(localhost); + try std.testing.expectEqualStrings("127.0.0.1", localhost); + + const ipv6 = try Manager.normalizeHealthHost(allocator, "::1"); + defer allocator.free(ipv6); + try std.testing.expectEqualStrings("::1", ipv6); +} + +test "resolveInstanceHealthHost reads host from working dir config" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + var mgr = Manager.init(allocator, fixture.paths); + defer mgr.deinit(); + + const inst_dir = try fixture.path(allocator, "watch"); + defer allocator.free(inst_dir); + try std_compat.fs.makeDirAbsolute(inst_dir); + + const config_path = try std.fs.path.join(allocator, &.{ inst_dir, "config.json" }); + defer allocator.free(config_path); + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"host\":\"::1\"}"); + + const inst = ManagedInstance{ + .component = "nullwatch", + .name = "watch", + .working_dir = inst_dir, + }; + const host = mgr.resolveInstanceHealthHost(&inst); + defer host.deinit(allocator); + + try std.testing.expect(host.owned); + try std.testing.expectEqualStrings("::1", host.value); +} + test "status reporting for manually-added instance" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); From 9ec08fd444f59dd9716e1ebe4008ff6f8d6cff8e Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 22:26:31 -0300 Subject: [PATCH 09/14] fix nullwatch parity gaps --- src/server.zig | 72 ++++++++++++++++++- .../instances/[component]/[name]/+page.svelte | 20 +++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/server.zig b/src/server.zig index 4adeb0c..5a41e6d 100644 --- a/src/server.zig +++ b/src/server.zig @@ -158,12 +158,23 @@ pub const Server = struct { }; defer self.allocator.free(desired_binary); - var desired_launch = launch_args_mod.resolve(self.allocator, entry.launch_mode, entry.verbose) catch { + const launch_mode = normalizedLaunchModeForRestore(component, entry.launch_mode); + var desired_launch = launch_args_mod.resolve(self.allocator, launch_mode, entry.verbose) catch { self.terminatePersistedRuntime(&runtime, component, name); return false; }; defer desired_launch.deinit(); + if (!std.mem.eql(u8, launch_mode, entry.launch_mode)) { + _ = self.state.updateInstance(component, name, .{ + .version = entry.version, + .auto_start = entry.auto_start, + .launch_mode = launch_mode, + .verbose = entry.verbose, + }) catch {}; + self.state.save() catch {}; + } + if (!persistedMatchesDesired(runtime, desired_binary, desired_launch.primary_command, desired_launch.argv)) { self.terminatePersistedRuntime(&runtime, component, name); return false; @@ -174,6 +185,17 @@ pub const Server = struct { return restored; } + fn normalizedLaunchModeForRestore(component: []const u8, launch_mode: []const u8) []const u8 { + const known = registry.findKnownComponent(component) orelse return launch_mode; + if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, launch_mode, "gateway")) { + return known.default_launch_command; + } + if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, launch_mode, "nullwatch")) { + return known.default_launch_command; + } + return launch_mode; + } + fn terminatePersistedRuntime( self: *Server, runtime: *runtime_state_mod.PersistedRuntime, @@ -1798,6 +1820,54 @@ test "reconcileInstancesOnBoot terminates mismatched persisted runtime without r try std.testing.expectEqualStrings("started\n", contents); } +test "reconcileInstancesOnBoot adopts legacy nullwatch launch mode as serve" { + const builtin = @import("builtin"); + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + try ctx.paths.ensureDirs(); + + const binary_path = try ctx.paths.binary(allocator, "nullwatch", "1.0.0"); + defer allocator.free(binary_path); + + try ctx.state.addInstance("nullwatch", "watch", .{ + .version = "1.0.0", + .auto_start = false, + .launch_mode = "gateway", + }); + + var launch = try launch_args_mod.resolve(allocator, "serve", false); + defer launch.deinit(); + + const spawned = try process_mod.spawn(allocator, .{ + .binary = "/bin/sleep", + .argv = &.{"60"}, + }); + + try runtime_state_mod.write(allocator, ctx.paths, "nullwatch", "watch", .{ + .pid = process_mod.persistedPidValue(spawned.pid).?, + .port = 0, + .health_endpoint = "/health", + .binary_path = binary_path, + .launch_command = launch.primary_command, + .launch_args = launch.argv, + .started_at = std_compat.time.milliTimestamp(), + .starting_since = std_compat.time.milliTimestamp(), + }); + + ctx.reconcileInstancesOnBoot(); + + const status = ctx.manager.getStatus("nullwatch", "watch").?; + try std.testing.expectEqual(manager_mod.Status.running, status.status); + try std.testing.expect(process_mod.isAlive(spawned.pid)); + try std.testing.expectEqualStrings("serve", ctx.state.getInstance("nullwatch", "watch").?.launch_mode); + + ctx.manager.stopInstance("nullwatch", "watch") catch {}; + _ = spawned.child.wait() catch {}; +} + test "route GET /api/status returns version and platform" { var ctx = TestContext.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); diff --git a/ui/src/routes/instances/[component]/[name]/+page.svelte b/ui/src/routes/instances/[component]/[name]/+page.svelte index 4c9f72e..c1710f2 100644 --- a/ui/src/routes/instances/[component]/[name]/+page.svelte +++ b/ui/src/routes/instances/[component]/[name]/+page.svelte @@ -121,8 +121,11 @@ let standaloneHomePath = $derived(`$NULLHUB_HOME/instances/${component}/${name}`); let standaloneConfigPath = $derived(`${standaloneHomePath}/config.json`); let hasStandaloneBinary = $derived(Boolean(instance?.version && instance.version !== "standalone")); + let standaloneBinaryName = $derived( + hasStandaloneBinary ? managedBinaryName(component, instance.version) : "", + ); let standaloneBinaryPath = $derived( - hasStandaloneBinary ? `$NULLHUB_HOME/bin/${component}-${instance.version}` : "", + standaloneBinaryName ? `$NULLHUB_HOME/bin/${standaloneBinaryName}` : "", ); let standaloneLaunchScript = $derived( hasStandaloneBinary @@ -292,6 +295,12 @@ return "gateway"; } + function managedBinaryName(componentName: string, version: string | undefined): string { + if (!version) return ""; + if (version === "dev-local") return componentName; + return `${componentName}-${version}`; + } + function normalizedLaunchArgs(componentName: string, launchMode: string | undefined): string[] { const args = tokenizeLaunchMode(launchMode || defaultLaunchMode(componentName)); if (args.length === 0) args.push(defaultLaunchMode(componentName)); @@ -324,7 +333,7 @@ if (verbose) args.push("--verbose"); const command = [ - `"$NULLHUB_HOME/bin/${componentName}-${version}"`, + `"$NULLHUB_HOME/bin/${managedBinaryName(componentName, version)}"`, ...args.map(shellQuote), ].join(" "); @@ -730,6 +739,9 @@ {/if} + {#if component === "nullwatch"} + Observability + {/if} @@ -1297,6 +1309,9 @@ gap: 0.75rem; } .btn { + display: inline-flex; + align-items: center; + justify-content: center; padding: 0.5rem 1rem; border: 1px solid var(--accent-dim); border-radius: 2px; @@ -1309,6 +1324,7 @@ cursor: pointer; transition: all 0.2s ease; text-shadow: var(--text-glow); + text-decoration: none; } .btn:hover { background: var(--bg-hover); From a403a2333d50df75068597cbb95e436dcd34917a Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 22:31:01 -0300 Subject: [PATCH 10/14] add agent instructions pointer --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11894db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Documentation + +Project documentation has moved to [README.md](README.md) and `docs/`. + +- Read first: `README.md` +- Design docs: `docs/plans/` From 261f65426e807aeda2d645477af1e628ddd6a65b Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 22:36:49 -0300 Subject: [PATCH 11/14] support selecting nullwatch observers --- src/api/observability.zig | 63 +++++- src/server.zig | 83 ++++++-- ui/src/lib/api/client.ts | 25 ++- .../instances/[component]/[name]/+page.svelte | 2 +- ui/src/routes/observability/+page.svelte | 179 +++++++++++++++++- 5 files changed, 325 insertions(+), 27 deletions(-) diff --git a/src/api/observability.zig b/src/api/observability.zig index 1a032d3..ff75777 100644 --- a/src/api/observability.zig +++ b/src/api/observability.zig @@ -1,5 +1,6 @@ const std = @import("std"); const http_proxy = @import("proxy.zig"); +const query = @import("query.zig"); const Allocator = std.mem.Allocator; @@ -13,7 +14,42 @@ pub const Config = struct { }; pub fn isProxyPath(target: []const u8) bool { - return http_proxy.isPathInNamespace(target, prefix); + return http_proxy.isPathInNamespace(target, prefix) or + (target.len > prefix.len and + std.mem.startsWith(u8, target, prefix) and + target[prefix.len] == '?'); +} + +pub fn selectedWatchNameAlloc(allocator: Allocator, target: []const u8) !?[]u8 { + if (try query.valueAlloc(allocator, target, "nullhub_watch")) |value| return value; + if (try query.valueAlloc(allocator, target, "watch")) |value| return value; + return try query.valueAlloc(allocator, target, "instance"); +} + +fn isSelectorParam(param: []const u8) bool { + const key = if (std.mem.indexOfScalar(u8, param, '=')) |idx| param[0..idx] else param; + return std.mem.eql(u8, key, "nullhub_watch") or + std.mem.eql(u8, key, "watch") or + std.mem.eql(u8, key, "instance"); +} + +fn stripSelectorParamsAlloc(allocator: Allocator, target: []const u8) ![]u8 { + const qmark = std.mem.indexOfScalar(u8, target, '?') orelse return allocator.dupe(u8, target); + + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice(target[0..qmark]); + + var wrote_query = false; + var params = std.mem.splitScalar(u8, target[qmark + 1 ..], '&'); + while (params.next()) |param| { + if (param.len == 0 or isSelectorParam(param)) continue; + try buf.append(if (wrote_query) '&' else '?'); + wrote_query = true; + try buf.appendSlice(param); + } + + return buf.toOwnedSlice(); } /// Proxies observability API requests to a managed or configured NullWatch instance. @@ -27,7 +63,11 @@ pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body const base_url = cfg.watch_url orelse return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullWatch not configured\"}" }; - const proxied_path = target[prefix.len..]; + const forward_target = stripSelectorParamsAlloc(allocator, target) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer allocator.free(forward_target); + + const proxied_path = forward_target[prefix.len..]; const path = if (proxied_path.len == 0) "/v1/summary" else proxied_path; return http_proxy.forward(allocator, .{ .method = method, @@ -41,6 +81,7 @@ pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body test "isProxyPath matches observability namespace" { try std.testing.expect(isProxyPath("/api/observability")); + try std.testing.expect(isProxyPath("/api/observability?watch=default")); try std.testing.expect(isProxyPath("/api/observability/v1/runs")); try std.testing.expect(isProxyPath("/api/observability/health")); try std.testing.expect(!isProxyPath("/api/orchestration/v1/runs")); @@ -58,3 +99,21 @@ test "handle rejects non-observability paths" { }); try std.testing.expectEqualStrings("404 Not Found", resp.status); } + +test "selectedWatchNameAlloc reads hub selector query params" { + const allocator = std.testing.allocator; + const selected = (try selectedWatchNameAlloc(allocator, "/api/observability/v1/runs?limit=1&nullhub_watch=watch+one")).?; + defer allocator.free(selected); + try std.testing.expectEqualStrings("watch one", selected); +} + +test "stripSelectorParamsAlloc removes only NullHub watch selector" { + const allocator = std.testing.allocator; + const stripped = try stripSelectorParamsAlloc(allocator, "/api/observability/v1/runs?limit=50&nullhub_watch=alpha&status=ok"); + defer allocator.free(stripped); + try std.testing.expectEqualStrings("/api/observability/v1/runs?limit=50&status=ok", stripped); + + const root = try stripSelectorParamsAlloc(allocator, "/api/observability?watch=alpha"); + defer allocator.free(root); + try std.testing.expectEqualStrings("/api/observability", root); +} diff --git a/src/server.zig b/src/server.zig index 5a41e6d..83dea30 100644 --- a/src/server.zig +++ b/src/server.zig @@ -601,13 +601,15 @@ pub const Server = struct { } }; - fn getWatchTarget(self: *Server, allocator: std.mem.Allocator) WatchTarget { + fn getWatchTarget(self: *Server, allocator: std.mem.Allocator, selected_name: ?[]const u8) WatchTarget { const env_token = getEnv("NULLWATCH_TOKEN"); - if (getEnv("NULLWATCH_URL")) |url| return .{ .url = url, .token = env_token }; - return self.getManagedWatchTarget(allocator, env_token) catch .{ .token = env_token }; + if (selected_name == null) { + if (getEnv("NULLWATCH_URL")) |url| return .{ .url = url, .token = env_token }; + } + return self.getManagedWatchTarget(allocator, env_token, selected_name) catch .{ .token = env_token }; } - fn getManagedWatchTarget(self: *Server, allocator: std.mem.Allocator, token_override: ?[]const u8) !WatchTarget { + fn getManagedWatchTarget(self: *Server, allocator: std.mem.Allocator, token_override: ?[]const u8, selected_name: ?[]const u8) !WatchTarget { const Candidate = struct { name: []const u8, port: u16, @@ -620,6 +622,7 @@ pub const Server = struct { var running: ?Candidate = null; var starting: ?Candidate = null; + var selected: ?Candidate = null; if (self.state.instances.getPtr("nullwatch")) |watch_instances| { var state_it = watch_instances.iterator(); @@ -636,8 +639,18 @@ pub const Server = struct { const candidate = Candidate{ .name = entry.key_ptr.*, .port = snapshot.port }; switch (snapshot.status) { - .running => running = Candidate.prefer(running, candidate), - .starting, .restarting => starting = Candidate.prefer(starting, candidate), + .running => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) selected = candidate; + } + running = Candidate.prefer(running, candidate); + }, + .starting, .restarting => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) selected = candidate; + } + starting = Candidate.prefer(starting, candidate); + }, .stopped, .stopping, .failed => {}, } } @@ -651,12 +664,28 @@ pub const Server = struct { const candidate = Candidate{ .name = inst.name, .port = inst.port }; switch (inst.status) { - .running => running = Candidate.prefer(running, candidate), - .starting, .restarting => starting = Candidate.prefer(starting, candidate), + .running => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) selected = candidate; + } + running = Candidate.prefer(running, candidate); + }, + .starting, .restarting => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) selected = candidate; + } + starting = Candidate.prefer(starting, candidate); + }, .stopped, .stopping, .failed => {}, } } + if (selected_name != null) { + if (selected) |candidate| { + return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); + } + return .{ .token = token_override }; + } if (running) |candidate| { return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); } @@ -1311,7 +1340,10 @@ pub const Server = struct { } if (observability_api.isProxyPath(target)) { - const watch_target = self.getWatchTarget(allocator); + const selected_watch = observability_api.selectedWatchNameAlloc(allocator, target) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer if (selected_watch) |value| allocator.free(value); + const watch_target = self.getWatchTarget(allocator, selected_watch); defer watch_target.deinit(allocator); const resp = observability_api.handle(allocator, method, target, body, .{ .watch_url = watch_target.url, @@ -2111,7 +2143,7 @@ test "managed NullWatch target is discovered from supervisor state" { .port = 7710, }); - const target = try ctx.server.getManagedWatchTarget(allocator, null); + const target = try ctx.server.getManagedWatchTarget(allocator, null, null); defer target.deinit(allocator); try std.testing.expect(target.url != null); try std.testing.expectEqualStrings("http://127.0.0.1:7710", target.url.?); @@ -2138,11 +2170,36 @@ test "managed NullWatch target prefers first running instance by name" { .port = 7711, }); - const target = try ctx.server.getManagedWatchTarget(allocator, null); + const target = try ctx.server.getManagedWatchTarget(allocator, null, null); defer target.deinit(allocator); try std.testing.expectEqualStrings("http://127.0.0.1:7711", target.url.?); } +test "managed NullWatch target can select a specific running instance" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + + const key_alpha = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ "nullwatch", "alpha" }); + try ctx.manager.instances.put(key_alpha, .{ + .component = "nullwatch", + .name = "alpha", + .status = .running, + .port = 7711, + }); + const key_zulu = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ "nullwatch", "zulu" }); + try ctx.manager.instances.put(key_zulu, .{ + .component = "nullwatch", + .name = "zulu", + .status = .running, + .port = 7712, + }); + + const target = try ctx.server.getManagedWatchTarget(allocator, null, "zulu"); + defer target.deinit(allocator); + try std.testing.expectEqualStrings("http://127.0.0.1:7712", target.url.?); +} + test "managed NullWatch target reads host and token from config" { const allocator = std.testing.allocator; var ctx = TestContext.init(allocator); @@ -2167,7 +2224,7 @@ test "managed NullWatch target reads host and token from config" { .port = 7710, }); - const target = try ctx.server.getManagedWatchTarget(allocator, null); + const target = try ctx.server.getManagedWatchTarget(allocator, null, null); defer target.deinit(allocator); try std.testing.expectEqualStrings("http://127.0.0.1:7710", target.url.?); try std.testing.expectEqualStrings("managed-secret", target.token.?); @@ -2197,7 +2254,7 @@ test "managed NullWatch target brackets IPv6 host and lets env token override co .port = 7710, }); - const target = try ctx.server.getManagedWatchTarget(allocator, "env-secret"); + const target = try ctx.server.getManagedWatchTarget(allocator, "env-secret", null); defer target.deinit(allocator); try std.testing.expectEqualStrings("http://[::1]:7710", target.url.?); try std.testing.expectEqualStrings("env-secret", target.token.?); diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 5abb656..47d9b3e 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -22,6 +22,9 @@ type InstanceStartOptions = { launch_mode?: string; verbose?: boolean; }; +type ObservabilityTarget = { + watch?: string; +}; async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { @@ -159,11 +162,14 @@ export const api = { refreshComponents: () => request('/components/refresh', { method: 'POST' }), - getObservabilityHealth: () => request('/observability/health'), - getObservabilitySummary: () => request('/observability/v1/summary'), - getObservabilityRuns: (params?: { run_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; verdict?: string; dataset?: string; limit?: number }) => + getObservabilityHealth: (params?: ObservabilityTarget) => + request(withQuery('/observability/health', { nullhub_watch: params?.watch })), + getObservabilitySummary: (params?: ObservabilityTarget) => + request(withQuery('/observability/v1/summary', { nullhub_watch: params?.watch })), + getObservabilityRuns: (params?: ObservabilityTarget & { run_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; verdict?: string; dataset?: string; limit?: number }) => request( withQuery('/observability/v1/runs', { + nullhub_watch: params?.watch, run_id: params?.run_id, source: params?.source, operation: params?.operation, @@ -175,10 +181,16 @@ export const api = { limit: params?.limit, }), ), - getObservabilityRun: (runId: string) => request(`/observability/v1/runs/${encodeURIComponent(runId)}`), - getObservabilitySpans: (params?: { run_id?: string; trace_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; task_id?: string; session_id?: string; agent_id?: string; limit?: number }) => + getObservabilityRun: (runId: string, params?: ObservabilityTarget) => + request( + withQuery(`/observability/v1/runs/${encodeURIComponent(runId)}`, { + nullhub_watch: params?.watch, + }), + ), + getObservabilitySpans: (params?: ObservabilityTarget & { run_id?: string; trace_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; task_id?: string; session_id?: string; agent_id?: string; limit?: number }) => request( withQuery('/observability/v1/spans', { + nullhub_watch: params?.watch, run_id: params?.run_id, trace_id: params?.trace_id, source: params?.source, @@ -192,9 +204,10 @@ export const api = { limit: params?.limit, }), ), - getObservabilityEvals: (params?: { run_id?: string; verdict?: string; eval_key?: string; scorer?: string; dataset?: string; limit?: number }) => + getObservabilityEvals: (params?: ObservabilityTarget & { run_id?: string; verdict?: string; eval_key?: string; scorer?: string; dataset?: string; limit?: number }) => request( withQuery('/observability/v1/evals', { + nullhub_watch: params?.watch, run_id: params?.run_id, verdict: params?.verdict, eval_key: params?.eval_key, diff --git a/ui/src/routes/instances/[component]/[name]/+page.svelte b/ui/src/routes/instances/[component]/[name]/+page.svelte index c1710f2..5b8d35e 100644 --- a/ui/src/routes/instances/[component]/[name]/+page.svelte +++ b/ui/src/routes/instances/[component]/[name]/+page.svelte @@ -740,7 +740,7 @@ {#if component === "nullwatch"} - Observability + Observability {/if} ([]); let selectedRunId = $state(''); let selectedRun = $state(null); + let status = $state(null); + let selectedWatchName = $state(''); + let watchSelectionInitialized = $state(false); let loading = $state(true); let loadingRun = $state(false); let error = $state(null); let pollInterval: ReturnType | null = null; + type WatchOption = { + name: string; + status: string; + port: number; + }; + + let watchOptions = $derived(extractWatchOptions(status)); + let selectedWatch = $derived( + watchOptions.find((watch) => watch.name === selectedWatchName) || null, + ); const selectedSummary = $derived(selectedRun?.summary || null); const sortedSpans = $derived( (selectedRun?.spans || []).slice().sort((a: any, b: any) => (a.started_at_ms || 0) - (b.started_at_ms || 0)), @@ -19,16 +32,78 @@ (selectedRun?.evals || []).slice().sort((a: any, b: any) => (a.recorded_at_ms || 0) - (b.recorded_at_ms || 0)), ); + function extractWatchOptions(value: any): WatchOption[] { + const instances = value?.instances?.nullwatch || {}; + return Object.entries(instances) + .map(([name, info]: [string, any]) => ({ + name, + status: info?.status || 'stopped', + port: info?.port || 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + function preferredWatch(options: WatchOption[], requested: string): string { + if (requested && options.some((watch) => watch.name === requested)) return requested; + const running = options.find((watch) => watch.status === 'running'); + if (running) return running.name; + const starting = options.find((watch) => watch.status === 'starting' || watch.status === 'restarting'); + if (starting) return starting.name; + return options[0]?.name || ''; + } + + function urlWatchName(): string { + try { + return new URLSearchParams(window.location.search).get('watch') || ''; + } catch { + return ''; + } + } + + function setUrlWatchName(name: string) { + try { + const url = new URL(window.location.href); + if (name) { + url.searchParams.set('watch', name); + } else { + url.searchParams.delete('watch'); + } + window.history.replaceState(null, '', url); + } catch { + /* ignore */ + } + } + + async function refreshWatchSelection(): Promise { + try { + const statusResult = await api.getStatus(); + status = statusResult; + const options = extractWatchOptions(statusResult); + const requested = watchSelectionInitialized ? selectedWatchName : urlWatchName(); + selectedWatchName = preferredWatch(options, requested); + watchSelectionInitialized = true; + } catch { + watchSelectionInitialized = true; + } + return selectedWatchName || undefined; + } + async function loadOverview() { try { + const watch = await refreshWatchSelection(); const [summaryResult, runsResult] = await Promise.all([ - api.getObservabilitySummary(), - api.getObservabilityRuns({ limit: 50 }), + api.getObservabilitySummary({ watch }), + api.getObservabilityRuns({ limit: 50, watch }), ]); summary = summaryResult; runs = runsResult?.items || []; error = null; + if (selectedRunId && !runs.some((run) => run.run_id === selectedRunId)) { + selectedRunId = ''; + selectedRun = null; + } + if (!selectedRunId && runs.length > 0) { await selectRun(runs[0].run_id); } else if (selectedRunId) { @@ -44,7 +119,7 @@ async function loadRun(runId: string, showSpinner = true) { if (showSpinner) loadingRun = true; try { - selectedRun = await api.getObservabilityRun(runId); + selectedRun = await api.getObservabilityRun(runId, { watch: selectedWatchName || undefined }); error = null; } catch (e) { error = (e as Error).message; @@ -58,6 +133,15 @@ await loadRun(runId); } + async function handleWatchChange(event: Event) { + selectedWatchName = (event.currentTarget as HTMLSelectElement).value; + setUrlWatchName(selectedWatchName); + selectedRunId = ''; + selectedRun = null; + loading = true; + await loadOverview(); + } + onMount(() => { void loadOverview(); pollInterval = setInterval(loadOverview, 5000); @@ -108,7 +192,27 @@

Flight Recorder

NullWatch traces, evals, cost, and failure context

- +
+ {#if watchOptions.length > 1} + + {:else if watchOptions.length === 1} +
+ NullWatch + {watchOptions[0].name} + {watchOptions[0].status} +
+ {/if} + +
{#if error} @@ -149,7 +253,7 @@

Runs

- {runs.length} + {selectedWatch ? `${selectedWatch.name} / ${runs.length}` : runs.length}
{#if runs.length === 0}
No NullWatch runs found.
@@ -299,6 +403,57 @@ margin: 0.25rem 0 0; } + .header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; + } + + .watch-picker, + .watch-chip { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.1rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-surface); + } + + .watch-picker span, + .watch-chip span { + color: var(--fg-muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + } + + .watch-picker select { + min-width: 12rem; + max-width: 22rem; + border: 0; + background: transparent; + color: var(--fg); + font-family: var(--font-mono); + font-size: 0.8125rem; + } + + .watch-chip strong { + color: var(--fg); + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 700; + } + + .watch-chip em { + color: var(--fg-muted); + font-size: 0.75rem; + font-style: normal; + } + .action-btn { padding: 0.5rem 0.85rem; background: var(--bg-surface); @@ -603,5 +758,19 @@ align-items: flex-start; flex-direction: column; } + + .header-actions { + justify-content: flex-start; + width: 100%; + } + + .watch-picker { + width: 100%; + } + + .watch-picker select { + min-width: 0; + width: 100%; + } } From 0bb0a2c24769e5ca62c451b9a07274236d33fe77 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 22:43:49 -0300 Subject: [PATCH 12/14] fix nullwatch observer management issues --- src/api/observability.zig | 15 +++++------ src/installer/orchestrator.zig | 46 ++++++++++++++++++++++++++++++++++ src/server.zig | 8 +++++- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/api/observability.zig b/src/api/observability.zig index ff75777..bec5071 100644 --- a/src/api/observability.zig +++ b/src/api/observability.zig @@ -21,16 +21,12 @@ pub fn isProxyPath(target: []const u8) bool { } pub fn selectedWatchNameAlloc(allocator: Allocator, target: []const u8) !?[]u8 { - if (try query.valueAlloc(allocator, target, "nullhub_watch")) |value| return value; - if (try query.valueAlloc(allocator, target, "watch")) |value| return value; - return try query.valueAlloc(allocator, target, "instance"); + return try query.valueAlloc(allocator, target, "nullhub_watch"); } fn isSelectorParam(param: []const u8) bool { const key = if (std.mem.indexOfScalar(u8, param, '=')) |idx| param[0..idx] else param; - return std.mem.eql(u8, key, "nullhub_watch") or - std.mem.eql(u8, key, "watch") or - std.mem.eql(u8, key, "instance"); + return std.mem.eql(u8, key, "nullhub_watch"); } fn stripSelectorParamsAlloc(allocator: Allocator, target: []const u8) ![]u8 { @@ -105,6 +101,7 @@ test "selectedWatchNameAlloc reads hub selector query params" { const selected = (try selectedWatchNameAlloc(allocator, "/api/observability/v1/runs?limit=1&nullhub_watch=watch+one")).?; defer allocator.free(selected); try std.testing.expectEqualStrings("watch one", selected); + try std.testing.expect((try selectedWatchNameAlloc(allocator, "/api/observability/v1/runs?watch=upstream")) == null); } test "stripSelectorParamsAlloc removes only NullHub watch selector" { @@ -113,7 +110,11 @@ test "stripSelectorParamsAlloc removes only NullHub watch selector" { defer allocator.free(stripped); try std.testing.expectEqualStrings("/api/observability/v1/runs?limit=50&status=ok", stripped); - const root = try stripSelectorParamsAlloc(allocator, "/api/observability?watch=alpha"); + const root = try stripSelectorParamsAlloc(allocator, "/api/observability?nullhub_watch=alpha"); defer allocator.free(root); try std.testing.expectEqualStrings("/api/observability", root); + + const upstream_filter = try stripSelectorParamsAlloc(allocator, "/api/observability/v1/runs?watch=alpha&instance=demo"); + defer allocator.free(upstream_filter); + try std.testing.expectEqualStrings("/api/observability/v1/runs?watch=alpha&instance=demo", upstream_filter); } diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index fd6b9d6..d9b276f 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -381,6 +381,15 @@ fn resolveConfiguredPort( if (requested == default_port) { return findNextAvailablePort(allocator, default_port, paths, state); } + + var used_ports = collectConfiguredPorts(allocator, paths, state) catch return if (isPortFree(requested)) + requested + else + findFreePort(requested); + defer used_ports.deinit(); + if (used_ports.contains(requested) or !isPortFree(requested)) { + return findNextAvailablePort(allocator, requested, paths, state); + } return requested; } @@ -1344,6 +1353,43 @@ test "resolveConfiguredPort skips configured string default port" { try std.testing.expect(port > 43010); } +test "resolveConfiguredPort skips configured explicit non-default port" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nullwatch", "watch-1", .{ + .version = "v2026.3.8", + .auto_start = true, + .launch_mode = "serve", + }); + + const comp_dir = try std.fs.path.join(allocator, &.{ fixture.paths.root, "instances", "nullwatch" }); + defer allocator.free(comp_dir); + std_compat.fs.makeDirAbsolute(comp_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const inst_dir = try fixture.paths.instanceDir(allocator, "nullwatch", "watch-1"); + defer allocator.free(inst_dir); + std_compat.fs.makeDirAbsolute(inst_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const config_path = try fixture.paths.instanceConfig(allocator, "nullwatch", "watch-1"); + defer allocator.free(config_path); + try writeFile(config_path, "{\"port\":43020}"); + + const port = resolveConfiguredPort(allocator, "{\"port\":43020}", 7710, fixture.paths, &state); + try std.testing.expect(port > 43020); +} + test "injectPortFields fills missing port fields" { const allocator = std.testing.allocator; const rendered = try injectPortFields(allocator, "{\"instance_name\":\"instance-2\"}", 3002, false); diff --git a/src/server.zig b/src/server.zig index 83dea30..8faa777 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1343,8 +1343,14 @@ pub const Server = struct { const selected_watch = observability_api.selectedWatchNameAlloc(allocator, target) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; defer if (selected_watch) |value| allocator.free(value); - const watch_target = self.getWatchTarget(allocator, selected_watch); + + const watch_target = blk: { + self.mutex.lock(); + defer self.mutex.unlock(); + break :blk self.getWatchTarget(allocator, selected_watch); + }; defer watch_target.deinit(allocator); + const resp = observability_api.handle(allocator, method, target, body, .{ .watch_url = watch_target.url, .watch_token = watch_target.token, From c8ffd663d300fa45df6ade58087e1d86f2f20b48 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Wed, 6 May 2026 23:02:18 -0300 Subject: [PATCH 13/14] add nullwatch telemetry linking --- src/api/instances.zig | 525 +++++++++++++++++- src/api/meta.zig | 4 +- src/core/integration.zig | 68 +++ .../instances/[component]/[name]/+page.svelte | 187 ++++++- 4 files changed, 778 insertions(+), 6 deletions(-) diff --git a/src/api/instances.zig b/src/api/instances.zig index 52d9de9..c80098b 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -398,6 +398,17 @@ fn listNullBoilersLocked( return integration_mod.listNullBoilers(allocator, state, paths); } +fn listNullWatchLocked( + allocator: std.mem.Allocator, + mutex: *std_compat.sync.Mutex, + state: *state_mod.State, + paths: paths_mod.Paths, +) ![]integration_mod.NullWatchConfig { + mutex.lock(); + defer mutex.unlock(); + return integration_mod.listNullWatch(allocator, state, paths); +} + const PipelineSummary = struct { id: []const u8, name: []const u8, @@ -412,6 +423,19 @@ const TrackerIntegrationOption = struct { pipelines: []const PipelineSummary = &.{}, }; +const WatchIntegrationOption = struct { + name: []const u8, + host: []const u8, + port: u16, + running: bool, +}; + +const ClawIntegrationOption = struct { + name: []const u8, + running: bool, + linked: bool, +}; + fn fetchPipelineSummaries(allocator: std.mem.Allocator, url: []const u8, bearer_token: ?[]const u8) ?[]PipelineSummary { var client: std.http.Client = .{ .allocator = allocator, .io = std_compat.io() }; defer client.deinit(); @@ -545,6 +569,93 @@ fn pipelineContainsString(values: []const []const u8, candidate: []const u8) boo return false; } +const NullClawTelemetryLink = struct { + configured: bool = false, + endpoint: ?[]const u8 = null, + service_name: ?[]const u8 = null, + auth_configured: bool = false, + source_header_configured: bool = false, +}; + +fn objectField(obj: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap { + const value = obj.get(key) orelse return null; + return if (value == .object) value.object else null; +} + +fn diagnosticsObject(config: std.json.Value) ?std.json.ObjectMap { + if (config != .object) return null; + return objectField(config.object, "diagnostics"); +} + +fn telemetryHeadersObject(diagnostics: std.json.ObjectMap) ?std.json.ObjectMap { + if (objectField(diagnostics, "otel")) |otel| { + if (objectField(otel, "headers")) |headers| return headers; + } + return objectField(diagnostics, "otel_headers"); +} + +fn parseNullClawTelemetryLink(config: std.json.Value) NullClawTelemetryLink { + const diagnostics = diagnosticsObject(config) orelse return .{}; + const backend = jsonStringOrEmpty(diagnostics, "backend"); + const backend_configured = std.mem.eql(u8, backend, "otel") or std.mem.eql(u8, backend, "otlp"); + + var endpoint: ?[]const u8 = null; + var service_name: ?[]const u8 = null; + if (objectField(diagnostics, "otel")) |otel| { + endpoint = jsonString(otel, "endpoint"); + service_name = jsonString(otel, "service_name"); + } + if (endpoint == null) endpoint = jsonString(diagnostics, "otel_endpoint"); + if (service_name == null) service_name = jsonString(diagnostics, "otel_service_name"); + + const headers = telemetryHeadersObject(diagnostics); + const auth_configured = if (headers) |map| jsonString(map, "Authorization") != null else false; + const source_header_configured = if (headers) |map| jsonString(map, "x-nullwatch-source") != null else false; + + return .{ + .configured = backend_configured and endpoint != null, + .endpoint = endpoint, + .service_name = service_name, + .auth_configured = auth_configured, + .source_header_configured = source_header_configured, + }; +} + +fn findNullWatchByEndpoint(watches: []const integration_mod.NullWatchConfig, endpoint: ?[]const u8) ?integration_mod.NullWatchConfig { + const value = endpoint orelse return null; + const port = nullWatchEndpointPort(value) orelse return null; + for (watches) |watch| { + if (watch.port == port) return watch; + } + return null; +} + +fn nullWatchEndpointPort(endpoint: []const u8) ?u16 { + if (integration_mod.extractLocalPort(endpoint)) |port| return port; + const uri = std.Uri.parse(endpoint) catch return null; + return uri.port; +} + +fn normalizedConnectHost(host: []const u8) []const u8 { + if (host.len == 0 or + std.mem.eql(u8, host, "0.0.0.0") or + std.mem.eql(u8, host, "::") or + std.mem.eql(u8, host, "[::]") or + std.mem.eql(u8, host, "localhost")) + { + return "127.0.0.1"; + } + return host; +} + +fn buildNullWatchEndpoint(allocator: std.mem.Allocator, watch: integration_mod.NullWatchConfig) ?[]const u8 { + const host = normalizedConnectHost(watch.host); + if (std.mem.indexOfScalar(u8, host, ':') != null and !std.mem.startsWith(u8, host, "[")) { + return std.fmt.allocPrint(allocator, "http://[{s}]:{d}", .{ host, watch.port }) catch null; + } + return std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ host, watch.port }) catch null; +} + fn ensurePath(path: []const u8) !void { try std_compat.fs.cwd().makePath(path); } @@ -3389,6 +3500,113 @@ fn handleIntegrationGet( component: []const u8, name: []const u8, ) ApiResponse { + if (std.mem.eql(u8, component, "nullclaw")) { + const config_path = paths.instanceConfig(allocator, "nullclaw", name) catch return helpers.serverError(); + defer allocator.free(config_path); + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return notFound(); + defer file.close(); + const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return helpers.serverError(); + defer allocator.free(config_bytes); + + var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return helpers.serverError(); + defer parsed_config.deinit(); + + const link = parseNullClawTelemetryLink(parsed_config.value); + const watches = listNullWatchLocked(allocator, mutex, s, paths) catch return helpers.serverError(); + defer integration_mod.deinitNullWatchConfigs(allocator, watches); + const linked = findNullWatchByEndpoint(watches, link.endpoint); + + var watch_options: std.ArrayListUnmanaged(WatchIntegrationOption) = .empty; + defer watch_options.deinit(allocator); + for (watches) |watch| { + const is_running = blk: { + const status = getStatusLocked(mutex, manager, "nullwatch", watch.name) orelse break :blk false; + break :blk status.status == .running; + }; + watch_options.append(allocator, .{ + .name = watch.name, + .host = watch.host, + .port = watch.port, + .running = is_running, + }) catch return helpers.serverError(); + } + + const body = std.json.Stringify.valueAlloc(allocator, .{ + .kind = "nullclaw", + .configured = link.configured, + .linked_watch = if (linked) |watch| .{ + .name = watch.name, + .host = watch.host, + .port = watch.port, + } else null, + .available_watches = watch_options.items, + .current_link = if (link.endpoint) |endpoint| .{ + .endpoint = endpoint, + .service_name = link.service_name orelse "", + .auth_header = link.auth_configured, + .source_header = link.source_header_configured, + } else null, + }, .{ .emit_null_optional_fields = false }) catch return helpers.serverError(); + return jsonOk(body); + } + + if (std.mem.eql(u8, component, "nullwatch")) { + var watch_cfg = integration_mod.loadNullWatchConfig(allocator, paths, name) catch null orelse return notFound(); + defer integration_mod.deinitNullWatchConfig(allocator, &watch_cfg); + + const claw_names_opt = blk: { + mutex.lock(); + defer mutex.unlock(); + break :blk s.instanceNames("nullclaw") catch return helpers.serverError(); + }; + defer if (claw_names_opt) |claw_names| s.allocator.free(claw_names); + + var claw_options: std.ArrayListUnmanaged(ClawIntegrationOption) = .empty; + defer claw_options.deinit(allocator); + if (claw_names_opt) |claw_names| { + for (claw_names) |claw_name| { + const is_running = blk: { + const status = getStatusLocked(mutex, manager, "nullclaw", claw_name) orelse break :blk false; + break :blk status.status == .running; + }; + const is_linked = blk: { + const config_path = paths.instanceConfig(allocator, "nullclaw", claw_name) catch break :blk false; + defer allocator.free(config_path); + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch break :blk false; + defer file.close(); + const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch break :blk false; + defer allocator.free(config_bytes); + var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch break :blk false; + defer parsed_config.deinit(); + const link = parseNullClawTelemetryLink(parsed_config.value); + break :blk findNullWatchByEndpoint(&.{watch_cfg}, link.endpoint) != null; + }; + claw_options.append(allocator, .{ + .name = claw_name, + .running = is_running, + .linked = is_linked, + }) catch return helpers.serverError(); + } + } + + const body = std.json.Stringify.valueAlloc(allocator, .{ + .kind = "nullwatch", + .watch = .{ + .name = watch_cfg.name, + .host = watch_cfg.host, + .port = watch_cfg.port, + }, + .available_claws = claw_options.items, + }, .{ .emit_null_optional_fields = false }) catch return helpers.serverError(); + return jsonOk(body); + } + if (std.mem.eql(u8, component, "nullboiler")) { var boiler_cfg = integration_mod.loadNullBoilerConfig(allocator, paths, name) catch null orelse return notFound(); defer integration_mod.deinitNullBoilerConfig(allocator, &boiler_cfg); @@ -3540,6 +3758,74 @@ fn handleIntegrationGet( return notFound(); } +fn linkNullClawTelemetry( + allocator: std.mem.Allocator, + s: *state_mod.State, + manager: *manager_mod.Manager, + mutex: *std_compat.sync.Mutex, + paths: paths_mod.Paths, + claw_name: []const u8, + watch_cfg: integration_mod.NullWatchConfig, +) ApiResponse { + const config_path = paths.instanceConfig(allocator, "nullclaw", claw_name) catch return helpers.serverError(); + defer allocator.free(config_path); + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return notFound(); + defer file.close(); + const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return helpers.serverError(); + defer allocator.free(config_bytes); + + var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return helpers.serverError(); + defer parsed_config.deinit(); + if (parsed_config.value != .object) return helpers.serverError(); + + const diagnostics_map = ensureObjectField(allocator, &parsed_config.value.object, "diagnostics") catch return helpers.serverError(); + diagnostics_map.put(allocator, "backend", .{ .string = "otel" }) catch return helpers.serverError(); + + const otel_map = ensureObjectField(allocator, diagnostics_map, "otel") catch return helpers.serverError(); + const endpoint = buildNullWatchEndpoint(allocator, watch_cfg) orelse return helpers.serverError(); + otel_map.put(allocator, "endpoint", .{ .string = endpoint }) catch return helpers.serverError(); + + const existing_service_name = jsonString(otel_map.*, "service_name") orelse jsonString(diagnostics_map.*, "otel_service_name"); + const service_name = if (existing_service_name) |value| blk: { + if (value.len > 0 and !std.mem.eql(u8, value, "nullclaw")) break :blk value; + break :blk std.fmt.allocPrint(allocator, "nullclaw/{s}", .{claw_name}) catch return helpers.serverError(); + } else std.fmt.allocPrint(allocator, "nullclaw/{s}", .{claw_name}) catch return helpers.serverError(); + otel_map.put(allocator, "service_name", .{ .string = service_name }) catch return helpers.serverError(); + + const headers_map = ensureObjectField(allocator, otel_map, "headers") catch return helpers.serverError(); + headers_map.put(allocator, "x-nullwatch-source", .{ .string = "nullclaw" }) catch return helpers.serverError(); + if (watch_cfg.api_token) |token| { + const auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch return helpers.serverError(); + headers_map.put(allocator, "Authorization", .{ .string = auth_header }) catch return helpers.serverError(); + } else { + _ = headers_map.swapRemove("Authorization"); + } + + const rendered = std.json.Stringify.valueAlloc(allocator, parsed_config.value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }) catch return helpers.serverError(); + defer allocator.free(rendered); + + const out = std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }) catch return helpers.serverError(); + defer out.close(); + out.writeAll(rendered) catch return helpers.serverError(); + out.writeAll("\n") catch return helpers.serverError(); + + if (getStatusLocked(mutex, manager, "nullclaw", claw_name)) |status| { + if (status.status == .running) { + mutex.lock(); + defer mutex.unlock(); + return handleRestart(allocator, s, manager, paths, "nullclaw", claw_name, ""); + } + } + + return jsonOk("{\"status\":\"linked\"}"); +} + fn handleIntegrationPost( allocator: std.mem.Allocator, s: *state_mod.State, @@ -3550,7 +3836,52 @@ fn handleIntegrationPost( name: []const u8, body: []const u8, ) ApiResponse { - if (!std.mem.eql(u8, component, "nullboiler")) return badRequest("{\"error\":\"integration updates are only supported for nullboiler\"}"); + if (std.mem.eql(u8, component, "nullclaw")) { + const watch_cfg = blk: { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return badRequest("{\"error\":\"invalid JSON body\"}"); + defer parsed.deinit(); + if (parsed.value != .object) return badRequest("{\"error\":\"invalid JSON body\"}"); + const watch_name = if (parsed.value.object.get("watch_instance")) |value| + if (value == .string and value.string.len > 0) value.string else null + else + null; + if (watch_name == null) return badRequest("{\"error\":\"watch_instance is required\"}"); + break :blk integration_mod.loadNullWatchConfig(allocator, paths, watch_name.?) catch null orelse return notFound(); + }; + defer { + var owned_cfg = watch_cfg; + integration_mod.deinitNullWatchConfig(allocator, &owned_cfg); + } + + return linkNullClawTelemetry(allocator, s, manager, mutex, paths, name, watch_cfg); + } + + if (std.mem.eql(u8, component, "nullwatch")) { + const claw_name = blk: { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return badRequest("{\"error\":\"invalid JSON body\"}"); + defer parsed.deinit(); + if (parsed.value != .object) return badRequest("{\"error\":\"invalid JSON body\"}"); + const value = if (parsed.value.object.get("claw_instance")) |item| + if (item == .string and item.string.len > 0) item.string else null + else + null; + if (value == null) return badRequest("{\"error\":\"claw_instance is required\"}"); + break :blk allocator.dupe(u8, value.?) catch return helpers.serverError(); + }; + defer allocator.free(claw_name); + + var watch_cfg = integration_mod.loadNullWatchConfig(allocator, paths, name) catch null orelse return notFound(); + defer integration_mod.deinitNullWatchConfig(allocator, &watch_cfg); + return linkNullClawTelemetry(allocator, s, manager, mutex, paths, claw_name, watch_cfg); + } + + if (!std.mem.eql(u8, component, "nullboiler")) return badRequest("{\"error\":\"integration updates are only supported for nullclaw, nullwatch, and nullboiler\"}"); const tracker_cfg = blk: { const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ @@ -5355,6 +5686,198 @@ test "dispatch routes GET integration action for linked nullboiler" { try std.testing.expectEqual(@as(i64, 2), current_link.get("max_concurrent_tasks").?.integer); } +test "dispatch routes GET integration action for nullclaw nullwatch telemetry" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); + + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"host\":\"127.0.0.1\",\"port\":7711,\"api_token\":\"watch-token\"}"); + try writeTestInstanceConfig( + allocator, + mctx.paths, + "nullclaw", + "my-agent", + "{\"diagnostics\":{\"backend\":\"otel\",\"otel\":{\"endpoint\":\"http://127.0.0.1:7711\",\"service_name\":\"nullclaw/my-agent\",\"headers\":{\"Authorization\":\"Bearer watch-token\",\"x-nullwatch-source\":\"nullclaw\"}}}}", + ); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/my-agent/integration", "").?; + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, resp.body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("nullclaw", parsed.value.object.get("kind").?.string); + try std.testing.expect(parsed.value.object.get("configured").?.bool); + const linked = parsed.value.object.get("linked_watch").?.object; + try std.testing.expectEqualStrings("observer-a", linked.get("name").?.string); + try std.testing.expectEqual(@as(i64, 7711), linked.get("port").?.integer); + const current_link = parsed.value.object.get("current_link").?.object; + try std.testing.expectEqualStrings("http://127.0.0.1:7711", current_link.get("endpoint").?.string); + try std.testing.expectEqualStrings("nullclaw/my-agent", current_link.get("service_name").?.string); + try std.testing.expect(current_link.get("auth_header").?.bool); + try std.testing.expect(current_link.get("source_header").?.bool); + try std.testing.expectEqual(@as(usize, 1), parsed.value.object.get("available_watches").?.array.items.len); +} + +test "dispatch routes POST integration action for nullclaw links nullwatch" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); + + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"host\":\"0.0.0.0\",\"port\":7712,\"api_token\":\"watch-token\"}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "my-agent", "{\"diagnostics\":{\"backend\":\"jsonl\",\"log_tool_calls\":true,\"otel\":{\"service_name\":\"nullclaw\"}}}"); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nullclaw/my-agent/integration", + "{\"watch_instance\":\"observer-a\"}", + ).?; + try std.testing.expectEqualStrings("200 OK", resp.status); + + const config_path = try mctx.paths.instanceConfig(allocator, "nullclaw", "my-agent"); + defer allocator.free(config_path); + const config_bytes = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(config_bytes); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + const diagnostics = parsed.value.object.get("diagnostics").?.object; + try std.testing.expectEqualStrings("otel", diagnostics.get("backend").?.string); + try std.testing.expect(diagnostics.get("log_tool_calls").?.bool); + const otel = diagnostics.get("otel").?.object; + try std.testing.expectEqualStrings("http://127.0.0.1:7712", otel.get("endpoint").?.string); + try std.testing.expectEqualStrings("nullclaw/my-agent", otel.get("service_name").?.string); + const headers = otel.get("headers").?.object; + try std.testing.expectEqualStrings("Bearer watch-token", headers.get("Authorization").?.string); + try std.testing.expectEqualStrings("nullclaw", headers.get("x-nullwatch-source").?.string); +} + +test "dispatch routes GET integration action for nullwatch lists linked nullclaws" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "linked-agent", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "plain-agent", .{ .version = "1.0.0" }); + + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"port\":7711}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "linked-agent", "{\"diagnostics\":{\"backend\":\"otel\",\"otel\":{\"endpoint\":\"http://127.0.0.1:7711\",\"service_name\":\"nullclaw/linked-agent\"}}}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "plain-agent", "{\"diagnostics\":{\"backend\":\"jsonl\"}}"); + + const resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullwatch/observer-a/integration", "").?; + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, resp.body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("nullwatch", parsed.value.object.get("kind").?.string); + const claws = parsed.value.object.get("available_claws").?.array.items; + try std.testing.expectEqual(@as(usize, 2), claws.len); + + var linked_found = false; + var plain_found = false; + for (claws) |claw| { + const obj = claw.object; + if (std.mem.eql(u8, obj.get("name").?.string, "linked-agent")) { + linked_found = obj.get("linked").?.bool; + } + if (std.mem.eql(u8, obj.get("name").?.string, "plain-agent")) { + plain_found = !obj.get("linked").?.bool; + } + } + try std.testing.expect(linked_found); + try std.testing.expect(plain_found); +} + +test "dispatch routes POST integration action for nullwatch links selected nullclaw" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); + + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"port\":7713}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "my-agent", "{\"diagnostics\":{\"backend\":\"jsonl\"}}"); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nullwatch/observer-a/integration", + "{\"claw_instance\":\"my-agent\"}", + ).?; + try std.testing.expectEqualStrings("200 OK", resp.status); + + const config_path = try mctx.paths.instanceConfig(allocator, "nullclaw", "my-agent"); + defer allocator.free(config_path); + const config_bytes = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(config_bytes); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + const diagnostics = parsed.value.object.get("diagnostics").?.object; + try std.testing.expectEqualStrings("otel", diagnostics.get("backend").?.string); + const otel = diagnostics.get("otel").?.object; + try std.testing.expectEqualStrings("http://127.0.0.1:7713", otel.get("endpoint").?.string); + try std.testing.expectEqualStrings("nullclaw/my-agent", otel.get("service_name").?.string); +} + test "dispatch routes POST integration action for nullboiler" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); diff --git a/src/api/meta.zig b/src/api/meta.zig index 4222679..0313e21 100644 --- a/src/api/meta.zig +++ b/src/api/meta.zig @@ -1061,7 +1061,7 @@ const routes = [_]RouteSpec{ .method = "GET", .path_template = "/api/instances/{component}/{name}/integration", .category = "instances", - .summary = "Read integration status for linked orchestration and tracker components.", + .summary = "Read integration status for linked telemetry, orchestration, and tracker components.", .auth_mode = "optional_bearer", .path_params = common_instance_params[0..], .response = "Integration status and linkage payload.", @@ -1071,7 +1071,7 @@ const routes = [_]RouteSpec{ .method = "POST", .path_template = "/api/instances/{component}/{name}/integration", .category = "instances", - .summary = "Link or relink supported components such as nullboiler and nulltickets.", + .summary = "Link or relink supported components such as nullclaw, nullwatch, nullboiler, and nulltickets.", .auth_mode = "optional_bearer", .path_params = common_instance_params[0..], .body = "Integration update payload.", diff --git a/src/core/integration.zig b/src/core/integration.zig index a51e827..38e7182 100644 --- a/src/core/integration.zig +++ b/src/core/integration.zig @@ -9,6 +9,13 @@ pub const NullTicketsConfig = struct { api_token: ?[]const u8 = null, }; +pub const NullWatchConfig = struct { + name: []const u8, + host: []const u8 = "127.0.0.1", + port: u16 = 7710, + api_token: ?[]const u8 = null, +}; + pub const NullBoilerWorkflowConfig = struct { file_name: []const u8, pipeline_id: []const u8, @@ -37,6 +44,7 @@ pub const NullBoilerConfig = struct { pub fn listNullTickets(allocator: std.mem.Allocator, state: *state_mod.State, paths: paths_mod.Paths) ![]NullTicketsConfig { const names = try state.instanceNames("nulltickets") orelse return allocator.alloc(NullTicketsConfig, 0); + defer state.allocator.free(names); var list: std.ArrayListUnmanaged(NullTicketsConfig) = .empty; errdefer deinitNullTicketsConfigs(allocator, list.items); defer list.deinit(allocator); @@ -52,8 +60,27 @@ pub fn listNullTickets(allocator: std.mem.Allocator, state: *state_mod.State, pa return list.toOwnedSlice(allocator); } +pub fn listNullWatch(allocator: std.mem.Allocator, state: *state_mod.State, paths: paths_mod.Paths) ![]NullWatchConfig { + const names = try state.instanceNames("nullwatch") orelse return allocator.alloc(NullWatchConfig, 0); + defer state.allocator.free(names); + var list: std.ArrayListUnmanaged(NullWatchConfig) = .empty; + errdefer deinitNullWatchConfigs(allocator, list.items); + defer list.deinit(allocator); + + for (names) |name| { + if (try loadNullWatchConfig(allocator, paths, name)) |cfg| { + var owned = cfg; + errdefer deinitNullWatchConfig(allocator, &owned); + try list.append(allocator, owned); + } + } + + return list.toOwnedSlice(allocator); +} + pub fn listNullBoilers(allocator: std.mem.Allocator, state: *state_mod.State, paths: paths_mod.Paths) ![]NullBoilerConfig { const names = try state.instanceNames("nullboiler") orelse return allocator.alloc(NullBoilerConfig, 0); + defer state.allocator.free(names); var list: std.ArrayListUnmanaged(NullBoilerConfig) = .empty; errdefer deinitNullBoilerConfigs(allocator, list.items); defer list.deinit(allocator); @@ -91,6 +118,29 @@ pub fn loadNullTicketsConfig(allocator: std.mem.Allocator, paths: paths_mod.Path }; } +pub fn loadNullWatchConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, name: []const u8) !?NullWatchConfig { + const config_path = paths.instanceConfig(allocator, "nullwatch", name) catch return null; + defer allocator.free(config_path); + + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return null; + defer file.close(); + + const bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(bytes); + const parsed = std.json.parseFromSlice(NullWatchConfigFile, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return null; + defer parsed.deinit(); + + return .{ + .name = try allocator.dupe(u8, name), + .host = try allocator.dupe(u8, parsed.value.host), + .port = parsed.value.port, + .api_token = if (parsed.value.api_token) |token| try allocator.dupe(u8, token) else null, + }; +} + pub fn loadNullBoilerConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, name: []const u8) !?NullBoilerConfig { const config_path = paths.instanceConfig(allocator, "nullboiler", name) catch return null; defer allocator.free(config_path); @@ -138,6 +188,18 @@ pub fn deinitNullTicketsConfigs(allocator: std.mem.Allocator, configs: []NullTic allocator.free(configs); } +pub fn deinitNullWatchConfig(allocator: std.mem.Allocator, cfg: *NullWatchConfig) void { + allocator.free(cfg.name); + allocator.free(cfg.host); + if (cfg.api_token) |token| allocator.free(token); + cfg.* = undefined; +} + +pub fn deinitNullWatchConfigs(allocator: std.mem.Allocator, configs: []NullWatchConfig) void { + for (configs) |*cfg| deinitNullWatchConfig(allocator, cfg); + allocator.free(configs); +} + pub fn deinitNullBoilerConfig(allocator: std.mem.Allocator, cfg: *NullBoilerConfig) void { allocator.free(cfg.name); if (cfg.api_token) |token| allocator.free(token); @@ -259,6 +321,12 @@ const NullTicketsConfigFile = struct { api_token: ?[]const u8 = null, }; +const NullWatchConfigFile = struct { + host: []const u8 = "127.0.0.1", + port: u16 = 7710, + api_token: ?[]const u8 = null, +}; + const NullBoilerConfigFile = struct { port: u16 = 8080, api_token: ?[]const u8 = null, diff --git a/ui/src/routes/instances/[component]/[name]/+page.svelte b/ui/src/routes/instances/[component]/[name]/+page.svelte index 5b8d35e..5285b52 100644 --- a/ui/src/routes/instances/[component]/[name]/+page.svelte +++ b/ui/src/routes/instances/[component]/[name]/+page.svelte @@ -32,6 +32,8 @@ let integrationLoading = $state(false); let integrationError = $state(null); let linkingIntegration = $state(false); + let selectedWatch = $state(""); + let selectedClaw = $state(""); let selectedTracker = $state(""); let selectedPipeline = $state(""); let trackerClaimRole = $state("coder"); @@ -92,7 +94,10 @@ : "", ); let supportsIntegration = $derived( - component === "nullboiler" || component === "nulltickets", + component === "nullclaw" || + component === "nullwatch" || + component === "nullboiler" || + component === "nulltickets", ); let supportsAgentData = $derived(component === "nullclaw"); let supportsChat = $derived(component === "nullclaw"); @@ -102,6 +107,11 @@ let queueSummary = $derived(summarizeQueue(integration?.queue)); let linkedBoilers = $derived(integration?.linked_boilers || []); let trackerOptions = $derived(integration?.available_trackers || []); + let watchOptions = $derived(integration?.available_watches || []); + let linkedWatch = $derived(integration?.linked_watch || null); + let currentTelemetryLink = $derived(integration?.current_link || null); + let clawOptions = $derived(integration?.available_claws || []); + let linkedClaws = $derived(clawOptions.filter((claw: any) => claw?.linked)); let selectedTrackerOption = $derived( trackerOptions.find((tracker: any) => tracker?.name === selectedTracker) || null, ); @@ -451,6 +461,19 @@ currentLink?.success_trigger || trackerSuccessTrigger || "complete"; trackerConcurrency = String(currentLink?.max_concurrent_tasks || trackerConcurrency || "1"); + } else if (component === "nullclaw") { + selectedWatch = + integration?.linked_watch?.name || + selectedWatch || + integration?.available_watches?.[0]?.name || + ""; + } else if (component === "nullwatch") { + const unlinked = integration?.available_claws?.find((claw: any) => !claw?.linked); + selectedClaw = + selectedClaw || + unlinked?.name || + integration?.available_claws?.[0]?.name || + ""; } } catch (e) { integration = null; @@ -479,6 +502,30 @@ } } + async function linkNullWatch() { + if (component !== "nullclaw" || !selectedWatch) return; + + linkingIntegration = true; + try { + await api.linkIntegration(component, name, { watch_instance: selectedWatch }); + await refresh(); + } finally { + linkingIntegration = false; + } + } + + async function linkNullClawToWatch() { + if (component !== "nullwatch" || !selectedClaw) return; + + linkingIntegration = true; + try { + await api.linkIntegration(component, name, { claw_instance: selectedClaw }); + await refresh(); + } finally { + linkingIntegration = false; + } + } + async function refreshProviderHealth(cfgOverride: any = config) { const status = extractProviderStatus(cfgOverride); if (!status.provider) { @@ -634,6 +681,10 @@ usageData = null; integration = null; integrationError = null; + selectedWatch = ""; + selectedClaw = ""; + selectedTracker = ""; + selectedPipeline = ""; onboardingStatus = null; bootstrapChatAutoOpenedFor = ""; lastUsageRefreshAt = 0; @@ -885,9 +936,15 @@
{component === "nullboiler" ? "NullTickets Link" : "Linked NullBoilers"}{component === "nullclaw" + ? "NullWatch Telemetry" + : component === "nullwatch" + ? "Observed NullClaws" + : component === "nullboiler" + ? "NullTickets Link" + : "Linked NullBoilers"} - {#if component === "nullboiler" && integration?.linked_tracker} + {#if (component === "nullboiler" && integration?.linked_tracker) || (component === "nullclaw" && linkedWatch) || (component === "nullwatch" && linkedClaws.length > 0)} Linked {/if}
@@ -896,6 +953,130 @@ Loading integration status... {:else if integrationError} {integrationError} + {:else if component === "nullclaw"} +
+ Observer + {#if linkedWatch} + {linkedWatch.name}:{linkedWatch.port} + {:else if currentTelemetryLink} + {currentTelemetryLink.endpoint} + {:else} + No NullWatch linked yet. + {/if} +
+ + {#if currentTelemetryLink} +
+ OTLP +
+
+ Endpoint + {currentTelemetryLink.endpoint} +
+
+ Service + {currentTelemetryLink.service_name || "-"} +
+
+ Auth + {currentTelemetryLink.auth_header ? "Bearer" : "None"} +
+
+ Source Header + {currentTelemetryLink.source_header ? "On" : "Off"} +
+
+
+ {/if} + + {#if watchOptions.length > 0} +
+ + + {#if linkedWatch} + Open Observability + {/if} +
+ {:else} + Install NullWatch to link telemetry. + {/if} + {:else if component === "nullwatch"} +
+ Linked NullClaws + {#if linkedClaws.length > 0} +
+ {#each linkedClaws as claw} +
+
+ {claw.name} + {claw.running ? "running" : "stopped"} +
+ Open +
+ {/each} +
+ {:else} + No NullClaw instances linked yet. + {/if} +
+ + {#if clawOptions.length > 0} +
+ + + Open Observability +
+ {:else} + Install NullClaw to send telemetry here. + {/if} {:else if component === "nullboiler"}
Tracker From bb915484411989e3e308e02e01c89c135e149df0 Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Thu, 7 May 2026 09:15:41 -0300 Subject: [PATCH 14/14] refactor nullwatch integration management --- src/api/instances.zig | 183 ++++-------------------------------- src/core/integration.zig | 196 +++++++++++++++++++++++++++++++++++++++ src/server.zig | 160 ++++++++++---------------------- 3 files changed, 267 insertions(+), 272 deletions(-) diff --git a/src/api/instances.zig b/src/api/instances.zig index c80098b..b93ed95 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -569,93 +569,6 @@ fn pipelineContainsString(values: []const []const u8, candidate: []const u8) boo return false; } -const NullClawTelemetryLink = struct { - configured: bool = false, - endpoint: ?[]const u8 = null, - service_name: ?[]const u8 = null, - auth_configured: bool = false, - source_header_configured: bool = false, -}; - -fn objectField(obj: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap { - const value = obj.get(key) orelse return null; - return if (value == .object) value.object else null; -} - -fn diagnosticsObject(config: std.json.Value) ?std.json.ObjectMap { - if (config != .object) return null; - return objectField(config.object, "diagnostics"); -} - -fn telemetryHeadersObject(diagnostics: std.json.ObjectMap) ?std.json.ObjectMap { - if (objectField(diagnostics, "otel")) |otel| { - if (objectField(otel, "headers")) |headers| return headers; - } - return objectField(diagnostics, "otel_headers"); -} - -fn parseNullClawTelemetryLink(config: std.json.Value) NullClawTelemetryLink { - const diagnostics = diagnosticsObject(config) orelse return .{}; - const backend = jsonStringOrEmpty(diagnostics, "backend"); - const backend_configured = std.mem.eql(u8, backend, "otel") or std.mem.eql(u8, backend, "otlp"); - - var endpoint: ?[]const u8 = null; - var service_name: ?[]const u8 = null; - if (objectField(diagnostics, "otel")) |otel| { - endpoint = jsonString(otel, "endpoint"); - service_name = jsonString(otel, "service_name"); - } - if (endpoint == null) endpoint = jsonString(diagnostics, "otel_endpoint"); - if (service_name == null) service_name = jsonString(diagnostics, "otel_service_name"); - - const headers = telemetryHeadersObject(diagnostics); - const auth_configured = if (headers) |map| jsonString(map, "Authorization") != null else false; - const source_header_configured = if (headers) |map| jsonString(map, "x-nullwatch-source") != null else false; - - return .{ - .configured = backend_configured and endpoint != null, - .endpoint = endpoint, - .service_name = service_name, - .auth_configured = auth_configured, - .source_header_configured = source_header_configured, - }; -} - -fn findNullWatchByEndpoint(watches: []const integration_mod.NullWatchConfig, endpoint: ?[]const u8) ?integration_mod.NullWatchConfig { - const value = endpoint orelse return null; - const port = nullWatchEndpointPort(value) orelse return null; - for (watches) |watch| { - if (watch.port == port) return watch; - } - return null; -} - -fn nullWatchEndpointPort(endpoint: []const u8) ?u16 { - if (integration_mod.extractLocalPort(endpoint)) |port| return port; - const uri = std.Uri.parse(endpoint) catch return null; - return uri.port; -} - -fn normalizedConnectHost(host: []const u8) []const u8 { - if (host.len == 0 or - std.mem.eql(u8, host, "0.0.0.0") or - std.mem.eql(u8, host, "::") or - std.mem.eql(u8, host, "[::]") or - std.mem.eql(u8, host, "localhost")) - { - return "127.0.0.1"; - } - return host; -} - -fn buildNullWatchEndpoint(allocator: std.mem.Allocator, watch: integration_mod.NullWatchConfig) ?[]const u8 { - const host = normalizedConnectHost(watch.host); - if (std.mem.indexOfScalar(u8, host, ':') != null and !std.mem.startsWith(u8, host, "[")) { - return std.fmt.allocPrint(allocator, "http://[{s}]:{d}", .{ host, watch.port }) catch null; - } - return std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ host, watch.port }) catch null; -} - fn ensurePath(path: []const u8) !void { try std_compat.fs.cwd().makePath(path); } @@ -3501,23 +3414,14 @@ fn handleIntegrationGet( name: []const u8, ) ApiResponse { if (std.mem.eql(u8, component, "nullclaw")) { - const config_path = paths.instanceConfig(allocator, "nullclaw", name) catch return helpers.serverError(); - defer allocator.free(config_path); - const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return notFound(); - defer file.close(); - const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return helpers.serverError(); - defer allocator.free(config_bytes); - - var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch return helpers.serverError(); - defer parsed_config.deinit(); - - const link = parseNullClawTelemetryLink(parsed_config.value); + var link = integration_mod.loadNullClawTelemetryLink(allocator, paths, name) catch |err| switch (err) { + error.NotFound => return notFound(), + else => return helpers.serverError(), + }; + defer link.deinit(allocator); const watches = listNullWatchLocked(allocator, mutex, s, paths) catch return helpers.serverError(); defer integration_mod.deinitNullWatchConfigs(allocator, watches); - const linked = findNullWatchByEndpoint(watches, link.endpoint); + const linked = integration_mod.findNullWatchByEndpoint(watches, link.endpoint); var watch_options: std.ArrayListUnmanaged(WatchIntegrationOption) = .empty; defer watch_options.deinit(allocator); @@ -3573,19 +3477,9 @@ fn handleIntegrationGet( break :blk status.status == .running; }; const is_linked = blk: { - const config_path = paths.instanceConfig(allocator, "nullclaw", claw_name) catch break :blk false; - defer allocator.free(config_path); - const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch break :blk false; - defer file.close(); - const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch break :blk false; - defer allocator.free(config_bytes); - var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch break :blk false; - defer parsed_config.deinit(); - const link = parseNullClawTelemetryLink(parsed_config.value); - break :blk findNullWatchByEndpoint(&.{watch_cfg}, link.endpoint) != null; + var link = integration_mod.loadNullClawTelemetryLink(allocator, paths, claw_name) catch break :blk false; + defer link.deinit(allocator); + break :blk integration_mod.findNullWatchByEndpoint(&.{watch_cfg}, link.endpoint) != null; }; claw_options.append(allocator, .{ .name = claw_name, @@ -3767,53 +3661,10 @@ fn linkNullClawTelemetry( claw_name: []const u8, watch_cfg: integration_mod.NullWatchConfig, ) ApiResponse { - const config_path = paths.instanceConfig(allocator, "nullclaw", claw_name) catch return helpers.serverError(); - defer allocator.free(config_path); - const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return notFound(); - defer file.close(); - const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return helpers.serverError(); - defer allocator.free(config_bytes); - - var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch return helpers.serverError(); - defer parsed_config.deinit(); - if (parsed_config.value != .object) return helpers.serverError(); - - const diagnostics_map = ensureObjectField(allocator, &parsed_config.value.object, "diagnostics") catch return helpers.serverError(); - diagnostics_map.put(allocator, "backend", .{ .string = "otel" }) catch return helpers.serverError(); - - const otel_map = ensureObjectField(allocator, diagnostics_map, "otel") catch return helpers.serverError(); - const endpoint = buildNullWatchEndpoint(allocator, watch_cfg) orelse return helpers.serverError(); - otel_map.put(allocator, "endpoint", .{ .string = endpoint }) catch return helpers.serverError(); - - const existing_service_name = jsonString(otel_map.*, "service_name") orelse jsonString(diagnostics_map.*, "otel_service_name"); - const service_name = if (existing_service_name) |value| blk: { - if (value.len > 0 and !std.mem.eql(u8, value, "nullclaw")) break :blk value; - break :blk std.fmt.allocPrint(allocator, "nullclaw/{s}", .{claw_name}) catch return helpers.serverError(); - } else std.fmt.allocPrint(allocator, "nullclaw/{s}", .{claw_name}) catch return helpers.serverError(); - otel_map.put(allocator, "service_name", .{ .string = service_name }) catch return helpers.serverError(); - - const headers_map = ensureObjectField(allocator, otel_map, "headers") catch return helpers.serverError(); - headers_map.put(allocator, "x-nullwatch-source", .{ .string = "nullclaw" }) catch return helpers.serverError(); - if (watch_cfg.api_token) |token| { - const auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch return helpers.serverError(); - headers_map.put(allocator, "Authorization", .{ .string = auth_header }) catch return helpers.serverError(); - } else { - _ = headers_map.swapRemove("Authorization"); - } - - const rendered = std.json.Stringify.valueAlloc(allocator, parsed_config.value, .{ - .whitespace = .indent_2, - .emit_null_optional_fields = false, - }) catch return helpers.serverError(); - defer allocator.free(rendered); - - const out = std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }) catch return helpers.serverError(); - defer out.close(); - out.writeAll(rendered) catch return helpers.serverError(); - out.writeAll("\n") catch return helpers.serverError(); + integration_mod.linkNullClawToNullWatch(allocator, paths, claw_name, watch_cfg) catch |err| switch (err) { + error.NotFound => return notFound(), + else => return helpers.serverError(), + }; if (getStatusLocked(mutex, manager, "nullclaw", claw_name)) |status| { if (status.status == .running) { @@ -5747,7 +5598,13 @@ test "dispatch routes POST integration action for nullclaw links nullwatch" { try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"host\":\"0.0.0.0\",\"port\":7712,\"api_token\":\"watch-token\"}"); - try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "my-agent", "{\"diagnostics\":{\"backend\":\"jsonl\",\"log_tool_calls\":true,\"otel\":{\"service_name\":\"nullclaw\"}}}"); + try writeTestInstanceConfig( + allocator, + mctx.paths, + "nullclaw", + "my-agent", + "{\"diagnostics\":{\"backend\":\"jsonl\",\"log_tool_calls\":true,\"otel\":{\"service_name\":\"nullclaw\",\"headers\":{\"Authorization\":\"Bearer old\"}}}}", + ); const resp = dispatch( allocator, diff --git a/src/core/integration.zig b/src/core/integration.zig index 38e7182..3ca96de 100644 --- a/src/core/integration.zig +++ b/src/core/integration.zig @@ -42,6 +42,20 @@ pub const NullBoilerConfig = struct { tracker: ?NullBoilerTrackerConfig = null, }; +pub const NullClawTelemetryLink = struct { + configured: bool = false, + endpoint: ?[]u8 = null, + service_name: ?[]u8 = null, + auth_configured: bool = false, + source_header_configured: bool = false, + + pub fn deinit(self: *NullClawTelemetryLink, allocator: std.mem.Allocator) void { + if (self.endpoint) |value| allocator.free(value); + if (self.service_name) |value| allocator.free(value); + self.* = .{}; + } +}; + pub fn listNullTickets(allocator: std.mem.Allocator, state: *state_mod.State, paths: paths_mod.Paths) ![]NullTicketsConfig { const names = try state.instanceNames("nulltickets") orelse return allocator.alloc(NullTicketsConfig, 0); defer state.allocator.free(names); @@ -243,6 +257,107 @@ pub fn countLinkedBoilersForTickets(tickets_cfg: NullTicketsConfig, boilers: []c return count; } +pub fn loadNullClawTelemetryLink(allocator: std.mem.Allocator, paths: paths_mod.Paths, name: []const u8) !NullClawTelemetryLink { + const config_path = try paths.instanceConfig(allocator, "nullclaw", name); + defer allocator.free(config_path); + + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return error.NotFound, + else => return err, + }; + defer file.close(); + + const bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(bytes); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + return try parseNullClawTelemetryLink(allocator, parsed.value); +} + +pub fn linkNullClawToNullWatch( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + claw_name: []const u8, + watch_cfg: NullWatchConfig, +) !void { + const config_path = try paths.instanceConfig(allocator, "nullclaw", claw_name); + defer allocator.free(config_path); + + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return error.NotFound, + else => return err, + }; + defer file.close(); + + const config_bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(config_bytes); + + var parsed_config = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed_config.deinit(); + if (parsed_config.value != .object) return error.InvalidConfig; + + const diagnostics_map = try ensureObjectField(allocator, &parsed_config.value.object, "diagnostics"); + try diagnostics_map.put(allocator, "backend", .{ .string = "otel" }); + + const otel_map = try ensureObjectField(allocator, diagnostics_map, "otel"); + const endpoint = try buildNullWatchEndpoint(allocator, watch_cfg); + try otel_map.put(allocator, "endpoint", .{ .string = endpoint }); + + const should_default_service = blk: { + const service_name = jsonString(otel_map.*, "service_name") orelse break :blk true; + break :blk service_name.len == 0 or std.mem.eql(u8, service_name, "nullclaw"); + }; + if (should_default_service) { + const service_name = try std.fmt.allocPrint(allocator, "nullclaw/{s}", .{claw_name}); + try otel_map.put(allocator, "service_name", .{ .string = service_name }); + } + + const headers_map = try ensureObjectField(allocator, otel_map, "headers"); + try headers_map.put(allocator, "x-nullwatch-source", .{ .string = "nullclaw" }); + if (watch_cfg.api_token) |token| { + const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); + try headers_map.put(allocator, "Authorization", .{ .string = auth_header }); + } else { + _ = headers_map.swapRemove("Authorization"); + } + + const rendered = try std.json.Stringify.valueAlloc(allocator, parsed_config.value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const out = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(rendered); + try out.writeAll("\n"); +} + +pub fn findNullWatchByEndpoint(watches: []const NullWatchConfig, endpoint: ?[]const u8) ?NullWatchConfig { + const value = endpoint orelse return null; + const port = nullWatchEndpointPort(value) orelse return null; + for (watches) |watch| { + if (watch.port == port) return watch; + } + return null; +} + +pub fn buildNullWatchEndpoint(allocator: std.mem.Allocator, watch: NullWatchConfig) ![]u8 { + const host = normalizedConnectHost(watch.host); + if (std.mem.indexOfScalar(u8, host, ':') != null and !std.mem.startsWith(u8, host, "[")) { + return std.fmt.allocPrint(allocator, "http://[{s}]:{d}", .{ host, watch.port }); + } + return std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ host, watch.port }); +} + pub fn extractLocalPort(url: []const u8) ?u16 { const uri = std.Uri.parse(url) catch return null; const host = uri.host orelse return null; @@ -254,6 +369,66 @@ pub fn extractLocalPort(url: []const u8) ?u16 { }; } +fn parseNullClawTelemetryLink(allocator: std.mem.Allocator, config: std.json.Value) !NullClawTelemetryLink { + const diagnostics = diagnosticsObject(config) orelse return .{}; + const backend = jsonString(diagnostics, "backend") orelse ""; + const backend_configured = std.mem.eql(u8, backend, "otel") or std.mem.eql(u8, backend, "otlp"); + + var endpoint: ?[]const u8 = null; + var service_name: ?[]const u8 = null; + if (objectField(diagnostics, "otel")) |otel| { + endpoint = jsonString(otel, "endpoint"); + service_name = jsonString(otel, "service_name"); + } + + const headers = telemetryHeadersObject(diagnostics); + const auth_configured = if (headers) |map| jsonString(map, "Authorization") != null else false; + const source_header_configured = if (headers) |map| jsonString(map, "x-nullwatch-source") != null else false; + + return .{ + .configured = backend_configured and endpoint != null, + .endpoint = if (endpoint) |value| try allocator.dupe(u8, value) else null, + .service_name = if (service_name) |value| try allocator.dupe(u8, value) else null, + .auth_configured = auth_configured, + .source_header_configured = source_header_configured, + }; +} + +fn objectField(obj: std.json.ObjectMap, key: []const u8) ?std.json.ObjectMap { + const value = obj.get(key) orelse return null; + return if (value == .object) value.object else null; +} + +fn diagnosticsObject(config: std.json.Value) ?std.json.ObjectMap { + if (config != .object) return null; + return objectField(config.object, "diagnostics"); +} + +fn telemetryHeadersObject(diagnostics: std.json.ObjectMap) ?std.json.ObjectMap { + if (objectField(diagnostics, "otel")) |otel| { + if (objectField(otel, "headers")) |headers| return headers; + } + return null; +} + +fn nullWatchEndpointPort(endpoint: []const u8) ?u16 { + if (extractLocalPort(endpoint)) |port| return port; + const uri = std.Uri.parse(endpoint) catch return null; + return uri.port; +} + +fn normalizedConnectHost(host: []const u8) []const u8 { + if (host.len == 0 or + std.mem.eql(u8, host, "0.0.0.0") or + std.mem.eql(u8, host, "::") or + std.mem.eql(u8, host, "[::]") or + std.mem.eql(u8, host, "localhost")) + { + return "127.0.0.1"; + } + return host; +} + fn isLocalHost(host: []const u8) bool { return std.mem.eql(u8, host, "127.0.0.1") or std.mem.eql(u8, host, "localhost") or @@ -261,6 +436,27 @@ fn isLocalHost(host: []const u8) bool { std.mem.eql(u8, host, "::1"); } +fn jsonString(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const value = obj.get(key) orelse return null; + return if (value == .string) value.string else null; +} + +fn ensureObjectField( + allocator: std.mem.Allocator, + parent: *std.json.ObjectMap, + key: []const u8, +) !*std.json.ObjectMap { + if (parent.getPtr(key)) |value_ptr| { + if (value_ptr.* != .object) { + value_ptr.* = .{ .object = .empty }; + } + return &value_ptr.object; + } + + try parent.put(allocator, key, .{ .object = .empty }); + return &parent.getPtr(key).?.object; +} + fn loadPrimaryWorkflowConfig(allocator: std.mem.Allocator, workflows_dir: []const u8) !?NullBoilerWorkflowConfig { var dir = std_compat.fs.openDirAbsolute(workflows_dir, .{ .iterate = true }) catch return null; defer dir.close(); diff --git a/src/server.zig b/src/server.zig index 8faa777..97ce8e6 100644 --- a/src/server.zig +++ b/src/server.zig @@ -14,6 +14,7 @@ const updates_api = @import("api/updates.zig"); const access = @import("access.zig"); const mdns_mod = @import("mdns.zig"); const state_mod = @import("core/state.zig"); +const integration_mod = @import("core/integration.zig"); const paths_mod = @import("core/paths.zig"); const manager_mod = @import("supervisor/manager.zig"); const process_mod = @import("supervisor/process.zig"); @@ -589,15 +590,39 @@ pub const Server = struct { } }; - const ManagedWatchConfig = struct { - host: []const u8 = "127.0.0.1", - host_owned: bool = false, - api_token: ?[]const u8 = null, - api_token_owned: bool = false, + const WatchCandidate = struct { + name: []const u8, + port: u16, + }; - fn deinit(self: ManagedWatchConfig, allocator: std.mem.Allocator) void { - if (self.host_owned) allocator.free(self.host); - if (self.api_token_owned) if (self.api_token) |value| allocator.free(value); + const WatchCandidateSelection = struct { + running: ?WatchCandidate = null, + starting: ?WatchCandidate = null, + selected: ?WatchCandidate = null, + + fn prefer(current: ?WatchCandidate, next: WatchCandidate) WatchCandidate { + const existing = current orelse return next; + return if (std.mem.order(u8, next.name, existing.name) == .lt) next else existing; + } + + fn add(self: *@This(), selected_name: ?[]const u8, candidate: WatchCandidate, status: manager_mod.Status) void { + if (candidate.port == 0) return; + + switch (status) { + .running => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) self.selected = candidate; + } + self.running = prefer(self.running, candidate); + }, + .starting, .restarting => { + if (selected_name) |name| { + if (std.mem.eql(u8, name, candidate.name)) self.selected = candidate; + } + self.starting = prefer(self.starting, candidate); + }, + .stopped, .stopping, .failed => {}, + } } }; @@ -610,19 +635,7 @@ pub const Server = struct { } fn getManagedWatchTarget(self: *Server, allocator: std.mem.Allocator, token_override: ?[]const u8, selected_name: ?[]const u8) !WatchTarget { - const Candidate = struct { - name: []const u8, - port: u16, - - fn prefer(current: ?@This(), next: @This()) @This() { - const existing = current orelse return next; - return if (std.mem.order(u8, next.name, existing.name) == .lt) next else existing; - } - }; - - var running: ?Candidate = null; - var starting: ?Candidate = null; - var selected: ?Candidate = null; + var candidates = WatchCandidateSelection{}; if (self.state.instances.getPtr("nullwatch")) |watch_instances| { var state_it = watch_instances.iterator(); @@ -635,24 +648,7 @@ pub const Server = struct { entry.key_ptr.*, entry.value_ptr.*, ); - if (snapshot.port == 0) continue; - - const candidate = Candidate{ .name = entry.key_ptr.*, .port = snapshot.port }; - switch (snapshot.status) { - .running => { - if (selected_name) |name| { - if (std.mem.eql(u8, name, candidate.name)) selected = candidate; - } - running = Candidate.prefer(running, candidate); - }, - .starting, .restarting => { - if (selected_name) |name| { - if (std.mem.eql(u8, name, candidate.name)) selected = candidate; - } - starting = Candidate.prefer(starting, candidate); - }, - .stopped, .stopping, .failed => {}, - } + candidates.add(selected_name, .{ .name = entry.key_ptr.*, .port = snapshot.port }, snapshot.status); } } @@ -660,51 +656,40 @@ pub const Server = struct { while (manager_it.next()) |entry| { const inst = entry.value_ptr.*; if (!std.mem.eql(u8, inst.component, "nullwatch")) continue; - if (inst.port == 0) continue; - - const candidate = Candidate{ .name = inst.name, .port = inst.port }; - switch (inst.status) { - .running => { - if (selected_name) |name| { - if (std.mem.eql(u8, name, candidate.name)) selected = candidate; - } - running = Candidate.prefer(running, candidate); - }, - .starting, .restarting => { - if (selected_name) |name| { - if (std.mem.eql(u8, name, candidate.name)) selected = candidate; - } - starting = Candidate.prefer(starting, candidate); - }, - .stopped, .stopping, .failed => {}, - } + candidates.add(selected_name, .{ .name = inst.name, .port = inst.port }, inst.status); } if (selected_name != null) { - if (selected) |candidate| { + if (candidates.selected) |candidate| { return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); } return .{ .token = token_override }; } - if (running) |candidate| { + if (candidates.running) |candidate| { return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); } - if (starting) |candidate| { + if (candidates.starting) |candidate| { return try self.buildManagedWatchTarget(allocator, candidate.name, candidate.port, token_override); } return .{ .token = token_override }; } fn buildManagedWatchTarget(self: *Server, allocator: std.mem.Allocator, name: []const u8, port: u16, token_override: ?[]const u8) !WatchTarget { - var cfg = try self.loadManagedWatchConfig(allocator, name); - defer cfg.deinit(allocator); - - const url_host = try normalizeWatchUrlHost(allocator, cfg.host); - defer allocator.free(url_host); + var cfg = (try integration_mod.loadNullWatchConfig(allocator, self.paths, name)) orelse blk: { + const cfg_name = try allocator.dupe(u8, name); + errdefer allocator.free(cfg_name); + const cfg_host = try allocator.dupe(u8, "127.0.0.1"); + break :blk integration_mod.NullWatchConfig{ + .name = cfg_name, + .host = cfg_host, + }; + }; + defer integration_mod.deinitNullWatchConfig(allocator, &cfg); + cfg.port = port; var target = WatchTarget{}; errdefer target.deinit(allocator); - target.url = try std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ url_host, port }); + target.url = try integration_mod.buildNullWatchEndpoint(allocator, cfg); target.url_owned = true; if (token_override) |token| { target.token = token; @@ -715,49 +700,6 @@ pub const Server = struct { return target; } - fn loadManagedWatchConfig(self: *Server, allocator: std.mem.Allocator, name: []const u8) !ManagedWatchConfig { - const config_path = self.paths.instanceConfig(allocator, "nullwatch", name) catch return .{}; - defer allocator.free(config_path); - - const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return .{}; - defer file.close(); - - const contents = file.readToEndAlloc(allocator, 1024 * 1024) catch return .{}; - defer allocator.free(contents); - - const parsed = std.json.parseFromSlice( - struct { - host: []const u8 = "127.0.0.1", - api_token: ?[]const u8 = null, - }, - allocator, - contents, - .{ .allocate = .alloc_always, .ignore_unknown_fields = true }, - ) catch return .{}; - defer parsed.deinit(); - - return .{ - .host = try allocator.dupe(u8, parsed.value.host), - .host_owned = true, - .api_token = if (parsed.value.api_token) |token| try allocator.dupe(u8, token) else null, - .api_token_owned = parsed.value.api_token != null, - }; - } - - fn normalizeWatchUrlHost(allocator: std.mem.Allocator, host: []const u8) ![]const u8 { - if (host.len == 0 or - std.mem.eql(u8, host, "0.0.0.0") or - std.mem.eql(u8, host, "::")) - { - return allocator.dupe(u8, "127.0.0.1"); - } - - if (std.mem.indexOfScalar(u8, host, ':') != null and !std.mem.startsWith(u8, host, "[")) { - return std.fmt.allocPrint(allocator, "[{s}]", .{host}); - } - return allocator.dupe(u8, host); - } - fn routeWithoutServerMutex(target: []const u8) bool { return instances_api.isIntegrationPath(target) or orchestration_api.isProxyPath(target) or