Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions HACKATHON_SUBMISSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# WorkflowDef Preflight for NullBoiler

## Problem discovered

NullBoiler consumes file-based tracker/pull-mode workflow definitions through
`workflow_loader.loadWorkflows`. That loader intentionally keeps permissive
runtime behavior: missing directories return an empty map, invalid files are
skipped, and files with empty `pipeline_id` are ignored.

That behavior is useful at runtime, but it makes workflow authoring harder.
Developers can make a typo in JSON, forget `pipeline_id`, or accidentally reuse a
pipeline mapping and only discover it after the server starts.

## Chosen solution

Add `nullboiler validate-workflows [PATH]`, a local CLI preflight command for the
same file-based `WorkflowDef` JSON files consumed by `loadWorkflows`.

The command reports actionable diagnostics before the server starts while
preserving the existing runtime loader semantics.

## Why this idea was chosen

This idea had the best developer-impact-to-complexity ratio among the options
found during repository exploration. NullClaw is broad and central, NullHub
changes often span backend and UI, and NullWatch already has a strong CLI. In
NullBoiler, workflow files are a core developer touchpoint, and a focused
preflight command is easy to demo, review, and merge without a large refactor.

## What was implemented

- Added a structured workflow-file validation helper in `src/workflow_loader.zig`.
- Added `validate-workflows [PATH]` CLI routing in `src/main.zig`.
- Added help output for the new command.
- Added human-readable diagnostics with separate errors and warnings.
- Added unit tests for valid files, malformed JSON, missing or empty
`pipeline_id`, duplicate `pipeline_id`, missing directories, and warning-only
shapes.
- Documented the new command in `README.md`.

## Files changed

- `src/main.zig`
- `src/workflow_loader.zig`
- `README.md`
- `HACKATHON_SUBMISSION.md`

## How to test or demo it

```bash
zig build test --summary all
zig build run -- validate-workflows
zig build run -- validate-workflows workflows
```

To demo errors, create a temporary workflow directory with malformed JSON or two
files that use the same `pipeline_id`, then run:

```bash
zig build run -- validate-workflows /path/to/temp/workflows
```

Expected behavior:

- no errors exits with status `0`
- one or more errors exits with status `1`
- warnings are printed but do not fail the command

## Limitations and future improvements

- The command validates only file-based tracker/pull-mode `WorkflowDef` files,
not every workflow format exposed by NullBoiler's HTTP graph workflow API.
- The validator scans direct `*.json` children of the target directory, matching
`loadWorkflows`; it does not recurse into nested example directories.
- Future work could add machine-readable JSON output for CI integrations.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,27 @@ state as `__config`.
When `NULLBOILER_HOME` is set, `nullboiler` reads `config.json` from that directory and
resolves relative paths like `db`, `strategies_dir`, `tracker.workflows_dir`, and
`tracker.workspace.root` relative to that config file.

## Workflow Preflight

File-based tracker/pull-mode workflows are loaded from JSON files using the
`WorkflowDef` shape in `src/workflow_loader.zig`. Before starting the server, you
can check those files locally:

```bash
zig build run -- validate-workflows
zig build run -- validate-workflows workflows
```

The command defaults to `workflows` and scans direct `*.json` files in the
directory. It reports:

- errors for missing or unreadable directories, unreadable files, malformed JSON,
JSON that cannot be parsed as `WorkflowDef`, missing or empty `pipeline_id`,
and duplicate `pipeline_id` values
- warnings for suspicious but currently allowed shapes, including empty `id`,
empty `claim_roles`, dispatch workflows without `dispatch.worker_tags`, and
directories with no JSON workflow files

Validation errors exit with status `1`. Warnings are shown but do not fail the
command, matching the existing runtime loader's permissive behavior.
75 changes: 75 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ pub fn main(init: std.process.Init) !void {
try @import("from_json.zig").run(allocator, all_args[1..]);
return;
}
if (std.mem.eql(u8, all_args[0], "help") or
std.mem.eql(u8, all_args[0], "--help") or
std.mem.eql(u8, all_args[0], "-h"))
{
printUsage();
return;
}
if (std.mem.eql(u8, all_args[0], "validate-workflows")) {
if (all_args.len > 2) {
std.debug.print("error: validate-workflows accepts at most one PATH argument\n\n", .{});
printUsage();
std.process.exit(2);
}
const workflow_dir = if (all_args.len == 2) all_args[1] else "workflows";
try runValidateWorkflows(allocator, workflow_dir);
return;
}
}

var host_override: ?[]const u8 = null;
Expand Down Expand Up @@ -427,6 +444,64 @@ pub fn main(init: std.process.Init) !void {
}
}

fn printUsage() void {
std.debug.print(
\\nullboiler v{s}
\\
\\Usage:
\\ nullboiler [--host HOST] [--port N] [--db PATH] [--config PATH] [--token TOKEN]
\\ nullboiler validate-workflows [PATH]
\\ nullboiler --export-manifest
\\ nullboiler --from-json '<wizard answers json>'
\\ nullboiler --version
\\
\\Commands:
\\ validate-workflows [PATH] Preflight file-based tracker/pull-mode WorkflowDef JSON files.
\\
, .{version});
}

fn runValidateWorkflows(allocator: std.mem.Allocator, workflow_dir: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

const result = try workflow_loader.validateWorkflowFiles(arena.allocator(), workflow_dir);

for (result.diagnostics) |diag| {
const level = switch (diag.severity) {
.@"error" => "ERROR",
.warning => "WARNING",
};
if (diag.field) |field| {
std.debug.print("{s} {s}: {s} ({s})\n", .{ level, diag.file_path, diag.message, field });
} else {
std.debug.print("{s} {s}: {s}\n", .{ level, diag.file_path, diag.message });
}
}

for (result.files) |file| {
if (!file.has_error and file.pipeline_id.len > 0) {
std.debug.print("OK {s} -> {s}\n", .{ file.file_path, file.pipeline_id });
}
}

std.debug.print(
"Checked {d} workflow files: {d} valid, {d} warning{s}, {d} error{s}\n",
.{
result.checked_files,
result.valid_files,
result.warning_count,
if (result.warning_count == 1) "" else "s",
result.error_count,
if (result.error_count == 1) "" else "s",
},
);

if (result.error_count > 0) {
std.process.exit(1);
}
}

fn ensureParentDirForFile(path: []const u8) !void {
if (path.len == 0 or std.mem.eql(u8, path, ":memory:") or std.mem.startsWith(u8, path, "file:")) return;

Expand Down
Loading
Loading