refactor(cli): Update main.zig and remove deprecated commands

- main.zig: Update command dispatch and usage text
  - Wire up new commands: note, logs, sync, cancel, watch
  - Remove deprecated command references
  - Updated usage reflects unified command structure
- Delete deprecated command files:
  - annotate.zig (replaced by note.zig)
  - experiment.zig (functionality in run/note/logs)
  - logs.zig (old version, replaced)
  - monitor.zig (unused)
  - narrative.zig (replaced by note --hypothesis/context)
  - outcome.zig (replaced by note --outcome)
  - privacy.zig (replaced by note --privacy)
  - requeue.zig (functionality merged into queue --rerun)
This commit is contained in:
Jeremie Fraeys 2026-02-20 21:28:42 -05:00
parent d3461cd07f
commit adf4c2a834
No known key found for this signature in database
9 changed files with 38 additions and 2665 deletions

View file

@ -1,187 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const io = @import("../utils/io.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const manifest = @import("../utils/manifest.zig");
const pii = @import("../utils/pii.zig");
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
if (args.len == 0) {
try printUsage();
return error.InvalidArgs;
}
if (std.mem.eql(u8, args[0], "--help") or std.mem.eql(u8, args[0], "-h")) {
try printUsage();
return;
}
const target = args[0];
var author: []const u8 = "";
var note: ?[]const u8 = null;
var base_override: ?[]const u8 = null;
var json_mode: bool = false;
var privacy_scan: bool = false;
var force: bool = false;
var i: usize = 1;
while (i < args.len) : (i += 1) {
const a = args[i];
if (std.mem.eql(u8, a, "--author")) {
if (i + 1 >= args.len) {
colors.printError("Missing value for --author\n", .{});
return error.InvalidArgs;
}
author = args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--note")) {
if (i + 1 >= args.len) {
colors.printError("Missing value for --note\n", .{});
return error.InvalidArgs;
}
note = args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--base")) {
if (i + 1 >= args.len) {
colors.printError("Missing value for --base\n", .{});
return error.InvalidArgs;
}
base_override = args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--json")) {
json_mode = true;
} else if (std.mem.eql(u8, a, "--privacy-scan")) {
privacy_scan = true;
} else if (std.mem.eql(u8, a, "--force")) {
force = true;
} else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
try printUsage();
return;
} else if (std.mem.startsWith(u8, a, "--")) {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
} else {
colors.printError("Unexpected argument: {s}\n", .{a});
return error.InvalidArgs;
}
}
if (note == null or std.mem.trim(u8, note.?, " \t\r\n").len == 0) {
colors.printError("--note is required\n", .{});
try printUsage();
return error.InvalidArgs;
}
// PII detection if requested
if (privacy_scan) {
if (pii.scanForPII(note.?, allocator)) |warning| {
colors.printWarning("{s}\n", .{warning.?});
if (!force) {
colors.printInfo("Use --force to store anyway, or edit your note.\n", .{});
return error.PIIDetected;
}
} else |_| {
// PII scan failed, continue anyway
}
}
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const resolved_base = base_override orelse cfg.worker_base;
const manifest_path = manifest.resolvePathWithBase(allocator, target, resolved_base) catch |err| {
if (err == error.FileNotFound) {
colors.printError(
"Could not locate run_manifest.json for '{s}'. Provide a path, or use --base <path> to scan finished/failed/running/pending.\n",
.{target},
);
}
return err;
};
defer allocator.free(manifest_path);
const job_name = try manifest.readJobNameFromManifest(allocator, manifest_path);
defer allocator.free(job_name);
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendAnnotateRun(job_name, author, note.?, api_key_hash);
if (json_mode) {
const msg = try client.receiveMessage(allocator);
defer allocator.free(msg);
const packet = protocol.ResponsePacket.deserialize(msg, allocator) catch {
var out = io.stdoutWriter();
try out.print("{s}\n", .{msg});
return error.InvalidPacket;
};
defer packet.deinit(allocator);
const Result = struct {
ok: bool,
job_name: []const u8,
message: []const u8,
error_code: ?u8 = null,
error_message: ?[]const u8 = null,
details: ?[]const u8 = null,
};
var out = io.stdoutWriter();
if (packet.packet_type == .error_packet) {
const res = Result{
.ok = false,
.job_name = job_name,
.message = "",
.error_code = @intFromEnum(packet.error_code.?),
.error_message = packet.error_message orelse "",
.details = packet.error_details orelse "",
};
try out.print("{f}\n", .{std.json.fmt(res, .{})});
return error.CommandFailed;
}
const res = Result{
.ok = true,
.job_name = job_name,
.message = packet.success_message orelse "",
};
try out.print("{f}\n", .{std.json.fmt(res, .{})});
return;
}
try client.receiveAndHandleResponse(allocator, "Annotate");
colors.printSuccess("Annotation added\n", .{});
colors.printInfo("Job: {s}\n", .{job_name});
}
fn printUsage() !void {
colors.printInfo("Usage: ml annotate <path|run_id|task_id> --note <text> [--author <name>] [--base <path>] [--json] [--privacy-scan] [--force]\n", .{});
colors.printInfo("\nOptions:\n", .{});
colors.printInfo(" --note <text> Annotation text (required)\n", .{});
colors.printInfo(" --author <name> Author of the annotation\n", .{});
colors.printInfo(" --base <path> Base path to search for run_manifest.json\n", .{});
colors.printInfo(" --privacy-scan Scan note for PII before storing\n", .{});
colors.printInfo(" --force Store even if PII detected\n", .{});
colors.printInfo(" --json Output JSON response\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml annotate 8b3f... --note \"Try lr=3e-4 next\"\n", .{});
colors.printInfo(" ml annotate ./finished/job-123 --note \"Baseline looks stable\" --author alice\n", .{});
colors.printInfo(" ml annotate run_123 --note \"Contact at user@example.com\" --privacy-scan\n", .{});
}

View file

@ -1,882 +0,0 @@
const std = @import("std");
const config = @import("../config.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const history = @import("../utils/history.zig");
const colors = @import("../utils/colors.zig");
const cancel_cmd = @import("cancel.zig");
const crypto = @import("../utils/crypto.zig");
const db = @import("../db.zig");
fn jsonError(command: []const u8, message: []const u8) void {
std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"{s}\"}}\n",
.{ command, message },
);
}
fn jsonErrorWithDetails(command: []const u8, message: []const u8, details: []const u8) void {
std.debug.print(
"{{\"success\":false,\"command\":\"{s}\",\"error\":\"{s}\",\"details\":\"{s}\"}}\n",
.{ command, message, details },
);
}
const ExperimentOptions = struct {
json: bool = false,
help: bool = false,
};
pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
var options = ExperimentOptions{};
var command_args = std.ArrayList([]const u8).initCapacity(allocator, 10) catch |err| {
return err;
};
defer command_args.deinit(allocator);
// Parse flags
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--json")) {
options.json = true;
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
options.help = true;
} else {
try command_args.append(allocator, arg);
}
}
if (command_args.items.len < 1 or options.help) {
try printUsage();
return;
}
const command = command_args.items[0];
if (std.mem.eql(u8, command, "init")) {
try executeInit(allocator, command_args.items[1..], &options);
} else if (std.mem.eql(u8, command, "create")) {
try executeCreate(allocator, command_args.items[1..], &options);
} else if (std.mem.eql(u8, command, "log")) {
// Route to local or server mode based on config
const cfg = try config.Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
if (cfg.isLocalMode()) {
try executeLogLocal(allocator, command_args.items[1..], &options);
} else {
try executeLog(allocator, command_args.items[1..], &options);
}
} else if (std.mem.eql(u8, command, "show")) {
try executeShow(allocator, command_args.items[1..], &options);
} else if (std.mem.eql(u8, command, "list")) {
// Route to local or server mode based on config
const cfg = try config.Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
if (cfg.isLocalMode()) {
try executeListLocal(allocator, &options);
} else {
try executeList(allocator, &options);
}
} else if (std.mem.eql(u8, command, "delete")) {
if (command_args.items.len < 2) {
if (options.json) {
jsonError("experiment.delete", "Usage: ml experiment delete <alias|commit>");
} else {
colors.printError("Usage: ml experiment delete <alias|commit>\n", .{});
}
return;
}
try executeDelete(allocator, command_args.items[1], &options);
} else {
if (options.json) {
const msg = try std.fmt.allocPrint(allocator, "Unknown command: {s}", .{command});
defer allocator.free(msg);
jsonError("experiment", msg);
} else {
colors.printError("Unknown command: {s}\n", .{command});
try printUsage();
}
}
}
fn executeInit(allocator: std.mem.Allocator, args: []const []const u8, options: *const ExperimentOptions) !void {
var name: ?[]const u8 = null;
var description: ?[]const u8 = null;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--name")) {
if (i + 1 < args.len) {
name = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--description")) {
if (i + 1 < args.len) {
description = args[i + 1];
i += 1;
}
}
}
// Generate experiment ID and commit ID
const stdcrypto = std.crypto;
var exp_id_bytes: [16]u8 = undefined;
stdcrypto.random.bytes(&exp_id_bytes);
var commit_id_bytes: [20]u8 = undefined;
stdcrypto.random.bytes(&commit_id_bytes);
const exp_id = try crypto.encodeHexLower(allocator, &exp_id_bytes);
defer allocator.free(exp_id);
const commit_id = try crypto.encodeHexLower(allocator, &commit_id_bytes);
defer allocator.free(commit_id);
const exp_name = name orelse "unnamed-experiment";
const exp_desc = description orelse "No description provided";
if (options.json) {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.init\",\"data\":{{\"experiment_id\":\"{s}\",\"commit_id\":\"{s}\",\"name\":\"{s}\",\"description\":\"{s}\",\"status\":\"initialized\"}}}}\n",
.{ exp_id, commit_id, exp_name, exp_desc },
);
} else {
colors.printInfo("Experiment initialized successfully!\n", .{});
colors.printInfo("Experiment ID: {s}\n", .{exp_id});
colors.printInfo("Commit ID: {s}\n", .{commit_id});
colors.printInfo("Name: {s}\n", .{exp_name});
colors.printInfo("Description: {s}\n", .{exp_desc});
colors.printInfo("Status: initialized\n", .{});
colors.printInfo("Use this commit ID when queuing jobs: --commit-id {s}\n", .{commit_id});
}
}
fn printUsage() !void {
colors.printInfo("Usage: ml experiment [options] <command> [args]\n", .{});
colors.printInfo("\nOptions:\n", .{});
colors.printInfo(" --json Output structured JSON\n", .{});
colors.printInfo(" --help, -h Show this help message\n", .{});
colors.printInfo("\nCommands:\n", .{});
colors.printInfo(" init Initialize a new experiment (server mode)\n", .{});
colors.printInfo(" create --name <name> Create experiment in local mode\n", .{});
colors.printInfo(" log Log a metric (auto-detects mode)\n", .{});
colors.printInfo(" show <commit_id> Show experiment details\n", .{});
colors.printInfo(" list List experiments (auto-detects mode)\n", .{});
colors.printInfo(" delete <alias|commit> Cancel/delete an experiment\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml experiment create --name \"my-experiment\"\n", .{});
colors.printInfo(" ml experiment show abc123 --json\n", .{});
colors.printInfo(" ml experiment list --json\n", .{});
}
// Local mode implementations
fn executeCreate(allocator: std.mem.Allocator, args: []const []const u8, options: *const ExperimentOptions) !void {
var name: ?[]const u8 = null;
var artifact_path: ?[]const u8 = null;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--name")) {
if (i + 1 < args.len) {
name = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--artifact-path")) {
if (i + 1 < args.len) {
artifact_path = args[i + 1];
i += 1;
}
}
}
if (name == null) {
if (options.json) {
jsonError("experiment.create", "--name is required");
} else {
colors.printError("Error: --name is required\n", .{});
}
return error.MissingArgument;
}
// Load config
const cfg = try config.Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
if (!cfg.isLocalMode()) {
if (options.json) {
jsonError("experiment.create", "create only works in local mode (sqlite://)");
} else {
colors.printError("Error: experiment create only works in local mode (sqlite://)\n", .{});
}
return error.NotLocalMode;
}
// Get DB path
const db_path = try cfg.getDBPath(allocator);
defer allocator.free(db_path);
// Initialize DB
var database = try db.DB.init(allocator, db_path);
defer database.close();
// Generate experiment ID
const exp_id = try db.generateUUID(allocator);
defer allocator.free(exp_id);
// Insert experiment
const sql = "INSERT INTO ml_experiments (experiment_id, name, artifact_path) VALUES (?, ?, ?);";
const stmt = try database.prepare(sql);
defer db.DB.finalize(stmt);
try db.DB.bindText(stmt, 1, exp_id);
try db.DB.bindText(stmt, 2, name.?);
try db.DB.bindText(stmt, 3, artifact_path orelse "");
_ = try db.DB.step(stmt);
database.checkpointOnExit();
if (options.json) {
std.debug.print("{{\"success\":true,\"command\":\"experiment.create\",\"data\":{{\"experiment_id\":\"{s}\",\"name\":\"{s}\"}}}}\n", .{ exp_id, name.? });
} else {
colors.printSuccess("✓ Created experiment: {s}\n", .{name.?});
colors.printInfo(" experiment_id: {s}\n", .{exp_id});
}
}
fn executeLogLocal(allocator: std.mem.Allocator, args: []const []const u8, options: *const ExperimentOptions) !void {
var run_id: ?[]const u8 = null;
var name: ?[]const u8 = null;
var value: ?f64 = null;
var step: i64 = 0;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--run")) {
if (i + 1 < args.len) {
run_id = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--name")) {
if (i + 1 < args.len) {
name = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--value")) {
if (i + 1 < args.len) {
value = std.fmt.parseFloat(f64, args[i + 1]) catch null;
i += 1;
}
} else if (std.mem.eql(u8, arg, "--step")) {
if (i + 1 < args.len) {
step = std.fmt.parseInt(i64, args[i + 1], 10) catch 0;
i += 1;
}
}
}
if (run_id == null or name == null or value == null) {
if (options.json) {
jsonError("experiment.log", "Usage: ml experiment log --run <id> --name <name> --value <value> [--step <step>]");
} else {
colors.printError("Usage: ml experiment log --run <id> --name <name> --value <value> [--step <step>]\n", .{});
}
return;
}
// Load config
const cfg = try config.Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
// Get DB path
const db_path = try cfg.getDBPath(allocator);
defer allocator.free(db_path);
// Initialize DB
var database = try db.DB.init(allocator, db_path);
defer database.close();
// Insert metric
const sql = "INSERT INTO ml_metrics (run_id, key, value, step) VALUES (?, ?, ?, ?);";
const stmt = try database.prepare(sql);
defer db.DB.finalize(stmt);
try db.DB.bindText(stmt, 1, run_id.?);
try db.DB.bindText(stmt, 2, name.?);
try db.DB.bindDouble(stmt, 3, value.?);
try db.DB.bindInt64(stmt, 4, step);
_ = try db.DB.step(stmt);
if (options.json) {
std.debug.print("{{\"success\":true,\"command\":\"experiment.log\",\"data\":{{\"run_id\":\"{s}\",\"metric\":{{\"name\":\"{s}\",\"value\":{d},\"step\":{d}}}}}}}\n", .{ run_id.?, name.?, value.?, step });
} else {
colors.printSuccess("✓ Logged metric: {s} = {d:.4} (step {d})\n", .{ name.?, value.?, step });
}
}
fn executeListLocal(allocator: std.mem.Allocator, options: *const ExperimentOptions) !void {
// Load config
const cfg = try config.Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
// Get DB path
const db_path = try cfg.getDBPath(allocator);
defer allocator.free(db_path);
// Initialize DB
var database = try db.DB.init(allocator, db_path);
defer database.close();
// Query experiments
const sql = "SELECT experiment_id, name, lifecycle, created_at FROM ml_experiments ORDER BY created_at DESC;";
const stmt = try database.prepare(sql);
defer db.DB.finalize(stmt);
var experiments = std.ArrayList(struct { id: []const u8, name: []const u8, lifecycle: []const u8, created: []const u8 }).init(allocator);
defer {
for (experiments.items) |exp| {
allocator.free(exp.id);
allocator.free(exp.name);
allocator.free(exp.lifecycle);
allocator.free(exp.created);
}
experiments.deinit();
}
while (try db.DB.step(stmt)) {
const id = try allocator.dupe(u8, db.DB.columnText(stmt, 0));
const name = try allocator.dupe(u8, db.DB.columnText(stmt, 1));
const lifecycle = try allocator.dupe(u8, db.DB.columnText(stmt, 2));
const created = try allocator.dupe(u8, db.DB.columnText(stmt, 3));
try experiments.append(.{ .id = id, .name = name, .lifecycle = lifecycle, .created = created });
}
if (options.json) {
std.debug.print("{{\"success\":true,\"command\":\"experiment.list\",\"data\":{{\"experiments\":[", .{});
for (experiments.items, 0..) |exp, idx| {
if (idx > 0) std.debug.print(",", .{});
std.debug.print("{{\"experiment_id\":\"{s}\",\"name\":\"{s}\",\"lifecycle\":\"{s}\",\"created_at\":\"{s}\"}}", .{ exp.id, exp.name, exp.lifecycle, exp.created });
}
std.debug.print("],\"total\":{d}}}}}\n", .{experiments.items.len});
} else {
if (experiments.items.len == 0) {
colors.printWarning("No experiments found. Create one with: ml experiment create --name <name>\n", .{});
} else {
colors.printInfo("\nExperiments:\n", .{});
colors.printInfo("{s:-<60}\n", .{""});
for (experiments.items) |exp| {
std.debug.print("{s} | {s} | {s} | {s}\n", .{ exp.id, exp.name, exp.lifecycle, exp.created });
}
std.debug.print("\nTotal: {d} experiments\n", .{experiments.items.len});
}
}
}
fn executeLog(allocator: std.mem.Allocator, args: []const []const u8, options: *const ExperimentOptions) !void {
var commit_id: ?[]const u8 = null;
var name: ?[]const u8 = null;
var value: ?f64 = null;
var step: u32 = 0;
var i: usize = 0;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--id")) {
if (i + 1 < args.len) {
commit_id = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--name")) {
if (i + 1 < args.len) {
name = args[i + 1];
i += 1;
}
} else if (std.mem.eql(u8, arg, "--value")) {
if (i + 1 < args.len) {
value = try std.fmt.parseFloat(f64, args[i + 1]);
i += 1;
}
} else if (std.mem.eql(u8, arg, "--step")) {
if (i + 1 < args.len) {
step = try std.fmt.parseInt(u32, args[i + 1], 10);
i += 1;
}
}
}
if (commit_id == null or name == null or value == null) {
if (options.json) {
jsonError("experiment.log", "Usage: ml experiment log --id <commit_id> --name <name> --value <value> [--step <step>]");
} else {
colors.printError("Usage: ml experiment log --id <commit_id> --name <name> --value <value> [--step <step>]\n", .{});
}
return;
}
const Config = @import("../config.zig").Config;
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendLogMetric(api_key_hash, commit_id.?, name.?, value.?, step);
if (options.json) {
const message = try client.receiveMessage(allocator);
defer allocator.free(message);
const packet = protocol.ResponsePacket.deserialize(message, allocator) catch {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.log\",\"data\":{{\"commit_id\":\"{s}\",\"metric\":{{\"name\":\"{s}\",\"value\":{d},\"step\":{d}}},\"message\":\"{s}\"}}}}\n",
.{ commit_id.?, name.?, value.?, step, message },
);
return;
};
defer packet.deinit(allocator);
switch (packet.packet_type) {
.success => {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.log\",\"data\":{{\"commit_id\":\"{s}\",\"metric\":{{\"name\":\"{s}\",\"value\":{d},\"step\":{d}}},\"message\":\"{s}\"}}}}\n",
.{ commit_id.?, name.?, value.?, step, message },
);
return;
},
else => {},
}
} else {
try client.receiveAndHandleResponse(allocator, "Log metric");
colors.printSuccess("Metric logged successfully!\n", .{});
colors.printInfo("Commit ID: {s}\n", .{commit_id.?});
colors.printInfo("Metric: {s} = {d:.4} (step {d})\n", .{ name.?, value.?, step });
}
}
fn executeShow(allocator: std.mem.Allocator, args: []const []const u8, options: *const ExperimentOptions) !void {
if (args.len < 1) {
if (options.json) {
jsonError("experiment.show", "Usage: ml experiment show <commit_id|alias>");
} else {
colors.printError("Usage: ml experiment show <commit_id|alias>\n", .{});
}
return;
}
const identifier = args[0];
const commit_id = try resolveCommitIdentifier(allocator, identifier);
defer allocator.free(commit_id);
const Config = @import("../config.zig").Config;
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendGetExperiment(api_key_hash, commit_id);
const message = try client.receiveMessage(allocator);
defer allocator.free(message);
const packet = try protocol.ResponsePacket.deserialize(message, allocator);
defer packet.deinit(allocator);
// For now, let's just print the result
switch (packet.packet_type) {
.success, .data => {
if (packet.data_payload) |payload| {
if (options.json) {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.show\",\"data\":{s}}}\n",
.{payload},
);
return;
} else {
// Parse JSON response
const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch |err| {
colors.printError("Failed to parse response: {}\n", .{err});
return;
};
defer parsed.deinit();
const root = parsed.value;
if (root != .object) {
colors.printError("Invalid response format\n", .{});
return;
}
const metadata = root.object.get("metadata");
const metrics = root.object.get("metrics");
if (metadata != null and metadata.? == .object) {
colors.printInfo("\nExperiment Details:\n", .{});
colors.printInfo("-------------------\n", .{});
const m = metadata.?.object;
if (m.get("JobName")) |v| colors.printInfo("Job Name: {s}\n", .{v.string});
if (m.get("CommitID")) |v| colors.printInfo("Commit ID: {s}\n", .{v.string});
if (m.get("User")) |v| colors.printInfo("User: {s}\n", .{v.string});
if (m.get("Timestamp")) |v| {
const ts = v.integer;
colors.printInfo("Timestamp: {d}\n", .{ts});
}
}
if (metrics != null and metrics.? == .array) {
colors.printInfo("\nMetrics:\n", .{});
colors.printInfo("-------------------\n", .{});
const items = metrics.?.array.items;
if (items.len == 0) {
colors.printInfo("No metrics logged.\n", .{});
} else {
for (items) |item| {
if (item == .object) {
const name = item.object.get("name").?.string;
const value = item.object.get("value").?.float;
const step = item.object.get("step").?.integer;
colors.printInfo("{s}: {d:.4} (Step: {d})\n", .{ name, value, step });
}
}
}
}
const repro = root.object.get("reproducibility");
if (repro != null and repro.? == .object) {
colors.printInfo("\nReproducibility:\n", .{});
colors.printInfo("-------------------\n", .{});
const repro_obj = repro.?.object;
if (repro_obj.get("experiment")) |exp_val| {
if (exp_val == .object) {
const e = exp_val.object;
if (e.get("id")) |v| colors.printInfo("Experiment ID: {s}\n", .{v.string});
if (e.get("name")) |v| colors.printInfo("Name: {s}\n", .{v.string});
if (e.get("status")) |v| colors.printInfo("Status: {s}\n", .{v.string});
if (e.get("user_id")) |v| colors.printInfo("User ID: {s}\n", .{v.string});
}
}
if (repro_obj.get("environment")) |env_val| {
if (env_val == .object) {
const env = env_val.object;
if (env.get("python_version")) |v| colors.printInfo("Python: {s}\n", .{v.string});
if (env.get("cuda_version")) |v| colors.printInfo("CUDA: {s}\n", .{v.string});
if (env.get("system_os")) |v| colors.printInfo("OS: {s}\n", .{v.string});
if (env.get("system_arch")) |v| colors.printInfo("Arch: {s}\n", .{v.string});
if (env.get("hostname")) |v| colors.printInfo("Hostname: {s}\n", .{v.string});
if (env.get("requirements_hash")) |v| colors.printInfo("Requirements hash: {s}\n", .{v.string});
}
}
if (repro_obj.get("git_info")) |git_val| {
if (git_val == .object) {
const g = git_val.object;
if (g.get("commit_sha")) |v| colors.printInfo("Git SHA: {s}\n", .{v.string});
if (g.get("branch")) |v| colors.printInfo("Git branch: {s}\n", .{v.string});
if (g.get("remote_url")) |v| colors.printInfo("Git remote: {s}\n", .{v.string});
if (g.get("is_dirty")) |v| colors.printInfo("Git dirty: {}\n", .{v.bool});
}
}
if (repro_obj.get("seeds")) |seeds_val| {
if (seeds_val == .object) {
const s = seeds_val.object;
if (s.get("numpy_seed")) |v| colors.printInfo("Numpy seed: {d}\n", .{v.integer});
if (s.get("torch_seed")) |v| colors.printInfo("Torch seed: {d}\n", .{v.integer});
if (s.get("tensorflow_seed")) |v| colors.printInfo("TensorFlow seed: {d}\n", .{v.integer});
if (s.get("random_seed")) |v| colors.printInfo("Random seed: {d}\n", .{v.integer});
}
}
}
colors.printInfo("\n", .{});
}
} else if (packet.success_message) |msg| {
if (options.json) {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.show\",\"data\":{{\"message\":\"{s}\"}}}}\n",
.{msg},
);
} else {
colors.printSuccess("{s}\n", .{msg});
}
}
},
.error_packet => {
const code_int: u8 = if (packet.error_code) |c| @intFromEnum(c) else 0;
const default_msg = if (packet.error_code) |c| protocol.ResponsePacket.getErrorMessage(c) else "Server error";
const err_msg = packet.error_message orelse default_msg;
const details = packet.error_details orelse "";
if (options.json) {
std.debug.print(
"{{\"success\":false,\"command\":\"experiment.show\",\"error\":{s},\"error_code\":{d},\"error_details\":{s}}}\n",
.{ err_msg, code_int, details },
);
} else {
colors.printError("Error: {s}\n", .{err_msg});
if (details.len > 0) {
colors.printError("Details: {s}\n", .{details});
}
}
},
else => {
if (options.json) {
jsonError("experiment.show", "Unexpected response type");
} else {
colors.printError("Unexpected response type\n", .{});
}
},
}
}
fn executeList(allocator: std.mem.Allocator, options: *const ExperimentOptions) !void {
const entries = history.loadEntries(allocator) catch |err| {
if (options.json) {
const details = try std.fmt.allocPrint(allocator, "{}", .{err});
defer allocator.free(details);
jsonErrorWithDetails("experiment.list", "Failed to read experiment history", details);
} else {
colors.printError("Failed to read experiment history: {}\n", .{err});
}
return err;
};
defer history.freeEntries(allocator, entries);
if (entries.len == 0) {
if (options.json) {
std.debug.print("{{\"success\":true,\"command\":\"experiment.list\",\"data\":{{\"experiments\":[],\"total\":0,\"message\":\"No experiments recorded yet. Use `ml queue` to submit one.\"}}}}\n", .{});
} else {
colors.printWarning("No experiments recorded yet. Use `ml queue` to submit one.\n", .{});
}
return;
}
if (options.json) {
std.debug.print("{{\"success\":true,\"command\":\"experiment.list\",\"data\":{{\"experiments\":[", .{});
var idx: usize = 0;
while (idx < entries.len) : (idx += 1) {
const entry = entries[entries.len - idx - 1];
if (idx > 0) {
std.debug.print(",", .{});
}
std.debug.print(
"{{\"alias\":\"{s}\",\"commit_id\":\"{s}\",\"queued_at\":{d}}}",
.{
entry.job_name, entry.commit_id,
entry.queued_at,
},
);
}
std.debug.print("],\"total\":{d}", .{entries.len});
std.debug.print("}}}}\n", .{});
} else {
colors.printInfo("\nRecent Experiments (latest first):\n", .{});
colors.printInfo("---------------------------------\n", .{});
const max_display = if (entries.len > 20) 20 else entries.len;
var idx: usize = 0;
while (idx < max_display) : (idx += 1) {
const entry = entries[entries.len - idx - 1];
std.debug.print("{d:2}) Alias: {s}\n", .{ idx + 1, entry.job_name });
std.debug.print(" Commit: {s}\n", .{entry.commit_id});
std.debug.print(" Queued: {d}\n\n", .{entry.queued_at});
}
if (entries.len > max_display) {
colors.printInfo("...and {d} more\n", .{entries.len - max_display});
}
}
}
fn executeDelete(allocator: std.mem.Allocator, identifier: []const u8, options: *const ExperimentOptions) !void {
const resolved = try resolveJobIdentifier(allocator, identifier);
defer allocator.free(resolved);
if (options.json) {
const Config = @import("../config.zig").Config;
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendCancelJob(resolved, api_key_hash);
const message = try client.receiveMessage(allocator);
defer allocator.free(message);
// Prefer parsing structured binary response packets if present.
if (message.len > 0) {
const packet = protocol.ResponsePacket.deserialize(message, allocator) catch null;
if (packet) |p| {
defer p.deinit(allocator);
switch (p.packet_type) {
.success => {
const msg = p.success_message orelse "";
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.delete\",\"data\":{{\"experiment\":\"{s}\",\"message\":\"{s}\"}}}}\n",
.{ resolved, msg },
);
return;
},
.error_packet => {
const code_int: u8 = if (p.error_code) |c| @intFromEnum(c) else 0;
const default_msg = if (p.error_code) |c| protocol.ResponsePacket.getErrorMessage(c) else "Server error";
const err_msg = p.error_message orelse default_msg;
const details = p.error_details orelse "";
std.debug.print("{{\"success\":false,\"command\":\"experiment.delete\",\"error\":\"{s}\",\"error_code\":{d},\"error_details\":\"{s}\",\"data\":{{\"experiment\":\"{s}\"}}}}\n", .{ err_msg, code_int, details, resolved });
return error.CommandFailed;
},
else => {},
}
}
}
// Next: if server returned JSON, wrap it and attempt to infer success.
if (message.len > 0 and message[0] == '{') {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, message, .{}) catch {
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.delete\",\"data\":{{\"experiment\":\"{s}\",\"response\":{s}}}}}\n",
.{ resolved, message },
);
return;
};
defer parsed.deinit();
if (parsed.value == .object) {
if (parsed.value.object.get("success")) |sval| {
if (sval == .bool and !sval.bool) {
const err_val = parsed.value.object.get("error");
const err_msg = if (err_val != null and err_val.? == .string) err_val.?.string else "Failed to cancel experiment";
std.debug.print(
"{{\"success\":false,\"command\":\"experiment.delete\",\"error\":\"{s}\",\"data\":{{\"experiment\":\"{s}\",\"response\":{s}}}}}\n",
.{ err_msg, resolved, message },
);
return error.CommandFailed;
}
}
}
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.delete\",\"data\":{{\"experiment\":\"{s}\",\"response\":{s}}}}}\n",
.{ resolved, message },
);
return;
}
// Fallback: plain string message.
std.debug.print(
"{{\"success\":true,\"command\":\"experiment.delete\",\"data\":{{\"experiment\":\"{s}\",\"message\":\"{s}\"}}}}\n",
.{ resolved, message },
);
return;
}
// Build cancel args with JSON flag if needed
var cancel_args = std.ArrayList([]const u8).initCapacity(allocator, 5) catch |err| {
return err;
};
defer cancel_args.deinit(allocator);
try cancel_args.append(allocator, resolved);
cancel_cmd.run(allocator, cancel_args.items) catch |err| {
colors.printError("Failed to cancel experiment '{s}': {}\n", .{ resolved, err });
return err;
};
}
fn resolveCommitIdentifier(allocator: std.mem.Allocator, identifier: []const u8) ![]const u8 {
const entries = history.loadEntries(allocator) catch {
if (identifier.len != 40) return error.InvalidCommitId;
const commit_bytes = try crypto.decodeHex(allocator, identifier);
if (commit_bytes.len != 20) {
allocator.free(commit_bytes);
return error.InvalidCommitId;
}
return commit_bytes;
};
defer history.freeEntries(allocator, entries);
var commit_hex: []const u8 = identifier;
for (entries) |entry| {
if (std.mem.eql(u8, identifier, entry.job_name)) {
commit_hex = entry.commit_id;
break;
}
}
if (commit_hex.len != 40) return error.InvalidCommitId;
const commit_bytes = try crypto.decodeHex(allocator, commit_hex);
if (commit_bytes.len != 20) {
allocator.free(commit_bytes);
return error.InvalidCommitId;
}
return commit_bytes;
}
fn resolveJobIdentifier(allocator: std.mem.Allocator, identifier: []const u8) ![]const u8 {
const entries = history.loadEntries(allocator) catch {
return allocator.dupe(u8, identifier);
};
defer history.freeEntries(allocator, entries);
for (entries) |entry| {
if (std.mem.eql(u8, identifier, entry.job_name) or
std.mem.eql(u8, identifier, entry.commit_id) or
(identifier.len <= entry.commit_id.len and
std.mem.eql(u8, entry.commit_id[0..identifier.len], identifier)))
{
return allocator.dupe(u8, entry.job_name);
}
}
return allocator.dupe(u8, identifier);
}

View file

@ -1,134 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
/// Logs command - fetch and display job logs via WebSocket API
pub fn run(allocator: std.mem.Allocator, argv: []const []const u8) !void {
if (argv.len == 0) {
try printUsage();
return error.InvalidArgs;
}
if (std.mem.eql(u8, argv[0], "--help") or std.mem.eql(u8, argv[0], "-h")) {
try printUsage();
return;
}
const target = argv[0];
// Parse optional flags
var follow = false;
var tail: ?usize = null;
var i: usize = 1;
while (i < argv.len) : (i += 1) {
const a = argv[i];
if (std.mem.eql(u8, a, "-f") or std.mem.eql(u8, a, "--follow")) {
follow = true;
} else if (std.mem.eql(u8, a, "-n") and i + 1 < argv.len) {
tail = try std.fmt.parseInt(usize, argv[i + 1], 10);
i += 1;
} else if (std.mem.eql(u8, a, "--tail") and i + 1 < argv.len) {
tail = try std.fmt.parseInt(usize, argv[i + 1], 10);
i += 1;
} else {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
}
}
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
colors.printInfo("Fetching logs for: {s}\n", .{target});
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
// Send appropriate request based on follow flag
if (follow) {
try client.sendStreamLogs(target, api_key_hash);
} else {
try client.sendGetLogs(target, api_key_hash);
}
// Receive and display response
const message = try client.receiveMessage(allocator);
defer allocator.free(message);
const packet = protocol.ResponsePacket.deserialize(message, allocator) catch {
// Fallback: treat as plain text response
std.debug.print("{s}\n", .{message});
return;
};
defer packet.deinit(allocator);
switch (packet.packet_type) {
.data => {
if (packet.data_payload) |payload| {
// Parse JSON response
const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch {
std.debug.print("{s}\n", .{payload});
return;
};
defer parsed.deinit();
const root = parsed.value.object;
// Display logs
if (root.get("logs")) |logs| {
if (logs == .string) {
std.debug.print("{s}\n", .{logs.string});
}
} else if (root.get("message")) |msg| {
if (msg == .string) {
colors.printInfo("{s}\n", .{msg.string});
}
}
// Show truncation warning if applicable
if (root.get("truncated")) |truncated| {
if (truncated == .bool and truncated.bool) {
if (root.get("total_lines")) |total| {
if (total == .integer) {
colors.printWarning("\n[Output truncated. Total lines: {d}]\n", .{total.integer});
}
}
}
}
}
},
.error_packet => {
const err_msg = packet.error_message orelse "Unknown error";
colors.printError("Error: {s}\n", .{err_msg});
return error.ServerError;
},
else => {
if (packet.success_message) |msg| {
colors.printSuccess("{s}\n", .{msg});
} else {
colors.printInfo("Logs retrieved successfully\n", .{});
}
},
}
}
fn printUsage() !void {
colors.printInfo("Usage:\n", .{});
colors.printInfo(" ml logs <task_id|run_id|experiment_id> [-f|--follow] [-n <count>|--tail <count>]\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml logs abc123 # Show full logs\n", .{});
colors.printInfo(" ml logs abc123 -f # Follow logs in real-time\n", .{});
colors.printInfo(" ml logs abc123 -n 100 # Show last 100 lines\n", .{});
}

View file

@ -1,69 +0,0 @@
const std = @import("std");
const Config = @import("../config.zig").Config;
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
for (args) |arg| {
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
printUsage();
return;
}
if (std.mem.eql(u8, arg, "--json")) {
std.debug.print("monitor does not support --json\n", .{});
printUsage();
return error.InvalidArgs;
}
}
const config = try Config.load(allocator);
defer {
var mut_config = config;
mut_config.deinit(allocator);
}
std.debug.print("Launching TUI via SSH...\n", .{});
// Build remote command that exports config via env vars and runs the TUI
var remote_cmd_buffer = std.ArrayList(u8){};
defer remote_cmd_buffer.deinit(allocator);
{
const writer = remote_cmd_buffer.writer(allocator);
try writer.print("cd {s} && ", .{config.worker_base});
try writer.print(
"FETCH_ML_CLI_HOST=\"{s}\" FETCH_ML_CLI_USER=\"{s}\" FETCH_ML_CLI_BASE=\"{s}\" ",
.{ config.worker_host, config.worker_user, config.worker_base },
);
try writer.print(
"FETCH_ML_CLI_PORT=\"{d}\" FETCH_ML_CLI_API_KEY=\"{s}\" ",
.{ config.worker_port, config.api_key },
);
try writer.writeAll("./bin/tui");
for (args) |arg| {
try writer.print(" {s}", .{arg});
}
}
const remote_cmd = try remote_cmd_buffer.toOwnedSlice(allocator);
defer allocator.free(remote_cmd);
const ssh_cmd = try std.fmt.allocPrint(
allocator,
"ssh -t -p {d} {s}@{s} '{s}'",
.{ config.worker_port, config.worker_user, config.worker_host, remote_cmd },
);
defer allocator.free(ssh_cmd);
var child = std.process.Child.init(&[_][]const u8{ "sh", "-c", ssh_cmd }, allocator);
child.stdin_behavior = .Inherit;
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
const term = try child.spawnAndWait();
if (term.tag == .Exited and term.Exited != 0) {
std.debug.print("TUI exited with code {d}\n", .{term.Exited});
}
}
fn printUsage() void {
std.debug.print("Usage: ml monitor [-- <tui-args...>]\n\n", .{});
std.debug.print("Launches the remote TUI over SSH using ~/.ml/config.toml\n", .{});
}

View file

@ -1,251 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const io = @import("../utils/io.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const manifest = @import("../utils/manifest.zig");
pub fn run(allocator: std.mem.Allocator, argv: []const []const u8) !void {
if (argv.len == 0) {
try printUsage();
return error.InvalidArgs;
}
const sub = argv[0];
if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h")) {
try printUsage();
return;
}
if (!std.mem.eql(u8, sub, "set")) {
colors.printError("Unknown subcommand: {s}\n", .{sub});
try printUsage();
return error.InvalidArgs;
}
if (argv.len < 2) {
try printUsage();
return error.InvalidArgs;
}
const target = argv[1];
var hypothesis: ?[]const u8 = null;
var context: ?[]const u8 = null;
var intent: ?[]const u8 = null;
var expected_outcome: ?[]const u8 = null;
var parent_run: ?[]const u8 = null;
var experiment_group: ?[]const u8 = null;
var tags_csv: ?[]const u8 = null;
var base_override: ?[]const u8 = null;
var json_mode: bool = false;
var i: usize = 2;
while (i < argv.len) : (i += 1) {
const a = argv[i];
if (std.mem.eql(u8, a, "--hypothesis")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
hypothesis = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--context")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
context = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--intent")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
intent = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--expected-outcome")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
expected_outcome = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--parent-run")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
parent_run = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--experiment-group")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
experiment_group = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--tags")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
tags_csv = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--base")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
base_override = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--json")) {
json_mode = true;
} else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
try printUsage();
return;
} else {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
}
}
if (hypothesis == null and context == null and intent == null and expected_outcome == null and parent_run == null and experiment_group == null and tags_csv == null) {
colors.printError("No narrative fields provided.\n", .{});
return error.InvalidArgs;
}
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const resolved_base = base_override orelse cfg.worker_base;
const manifest_path = manifest.resolvePathWithBase(allocator, target, resolved_base) catch |err| {
if (err == error.FileNotFound) {
colors.printError(
"Could not locate run_manifest.json for '{s}'. Provide a path, or use --base <path> to scan finished/failed/running/pending.\n",
.{target},
);
}
return err;
};
defer allocator.free(manifest_path);
const job_name = try manifest.readJobNameFromManifest(allocator, manifest_path);
defer allocator.free(job_name);
const patch_json = try buildPatchJSON(
allocator,
hypothesis,
context,
intent,
expected_outcome,
parent_run,
experiment_group,
tags_csv,
);
defer allocator.free(patch_json);
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendSetRunNarrative(job_name, patch_json, api_key_hash);
if (json_mode) {
const msg = try client.receiveMessage(allocator);
defer allocator.free(msg);
const packet = protocol.ResponsePacket.deserialize(msg, allocator) catch {
var out = io.stdoutWriter();
try out.print("{s}\n", .{msg});
return error.InvalidPacket;
};
defer packet.deinit(allocator);
const Result = struct {
ok: bool,
job_name: []const u8,
message: []const u8,
error_code: ?u8 = null,
error_message: ?[]const u8 = null,
details: ?[]const u8 = null,
};
var out = io.stdoutWriter();
if (packet.packet_type == .error_packet) {
const res = Result{
.ok = false,
.job_name = job_name,
.message = "",
.error_code = @intFromEnum(packet.error_code.?),
.error_message = packet.error_message orelse "",
.details = packet.error_details orelse "",
};
try out.print("{f}\n", .{std.json.fmt(res, .{})});
return error.CommandFailed;
}
const res = Result{
.ok = true,
.job_name = job_name,
.message = packet.success_message orelse "",
};
try out.print("{f}\n", .{std.json.fmt(res, .{})});
return;
}
try client.receiveAndHandleResponse(allocator, "Narrative");
colors.printSuccess("Narrative updated\n", .{});
colors.printInfo("Job: {s}\n", .{job_name});
}
fn buildPatchJSON(
allocator: std.mem.Allocator,
hypothesis: ?[]const u8,
context: ?[]const u8,
intent: ?[]const u8,
expected_outcome: ?[]const u8,
parent_run: ?[]const u8,
experiment_group: ?[]const u8,
tags_csv: ?[]const u8,
) ![]u8 {
var out = std.ArrayList(u8).initCapacity(allocator, 256) catch return error.OutOfMemory;
defer out.deinit(allocator);
var tags_list = std.ArrayList([]const u8).initCapacity(allocator, 8) catch return error.OutOfMemory;
defer tags_list.deinit(allocator);
if (tags_csv) |csv| {
var it = std.mem.splitScalar(u8, csv, ',');
while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, " \t\r\n");
if (trimmed.len == 0) continue;
try tags_list.append(allocator, trimmed);
}
}
const Patch = struct {
hypothesis: ?[]const u8 = null,
context: ?[]const u8 = null,
intent: ?[]const u8 = null,
expected_outcome: ?[]const u8 = null,
parent_run: ?[]const u8 = null,
experiment_group: ?[]const u8 = null,
tags: ?[]const []const u8 = null,
};
const patch = Patch{
.hypothesis = hypothesis,
.context = context,
.intent = intent,
.expected_outcome = expected_outcome,
.parent_run = parent_run,
.experiment_group = experiment_group,
.tags = if (tags_list.items.len > 0) tags_list.items else null,
};
const writer = out.writer(allocator);
try writer.print("{f}", .{std.json.fmt(patch, .{})});
return out.toOwnedSlice(allocator);
}
fn printUsage() !void {
colors.printInfo("Usage: ml narrative set <path|run_id|task_id> [fields]\n", .{});
colors.printInfo("\nFields:\n", .{});
colors.printInfo(" --hypothesis \"...\"\n", .{});
colors.printInfo(" --context \"...\"\n", .{});
colors.printInfo(" --intent \"...\"\n", .{});
colors.printInfo(" --expected-outcome \"...\"\n", .{});
colors.printInfo(" --parent-run <id>\n", .{});
colors.printInfo(" --experiment-group <name>\n", .{});
colors.printInfo(" --tags a,b,c\n", .{});
colors.printInfo(" --base <path>\n", .{});
colors.printInfo(" --json\n", .{});
}

View file

@ -1,314 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const io = @import("../utils/io.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const manifest = @import("../utils/manifest.zig");
pub fn run(allocator: std.mem.Allocator, argv: []const []const u8) !void {
if (argv.len == 0) {
try printUsage();
return error.InvalidArgs;
}
const sub = argv[0];
if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h")) {
try printUsage();
return;
}
if (!std.mem.eql(u8, sub, "set")) {
colors.printError("Unknown subcommand: {s}\n", .{sub});
try printUsage();
return error.InvalidArgs;
}
if (argv.len < 2) {
try printUsage();
return error.InvalidArgs;
}
const target = argv[1];
var outcome_status: ?[]const u8 = null;
var outcome_summary: ?[]const u8 = null;
var learnings = std.ArrayList([]const u8).empty;
defer learnings.deinit(allocator);
var next_steps = std.ArrayList([]const u8).empty;
defer next_steps.deinit(allocator);
var validation_status: ?[]const u8 = null;
var surprises = std.ArrayList([]const u8).empty;
defer surprises.deinit(allocator);
var base_override: ?[]const u8 = null;
var json_mode: bool = false;
var i: usize = 2;
while (i < argv.len) : (i += 1) {
const a = argv[i];
if (std.mem.eql(u8, a, "--outcome") or std.mem.eql(u8, a, "--status")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
outcome_status = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--summary")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
outcome_summary = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--learning")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
try learnings.append(allocator, argv[i + 1]);
i += 1;
} else if (std.mem.eql(u8, a, "--next-step")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
try next_steps.append(allocator, argv[i + 1]);
i += 1;
} else if (std.mem.eql(u8, a, "--validation-status")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
validation_status = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--surprise")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
try surprises.append(allocator, argv[i + 1]);
i += 1;
} else if (std.mem.eql(u8, a, "--base")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
base_override = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--json")) {
json_mode = true;
} else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
try printUsage();
return;
} else {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
}
}
if (outcome_status == null and outcome_summary == null and learnings.items.len == 0 and
next_steps.items.len == 0 and validation_status == null and surprises.items.len == 0)
{
colors.printError("No outcome fields provided.\n", .{});
return error.InvalidArgs;
}
// Validate outcome status if provided
if (outcome_status) |os| {
const valid = std.mem.eql(u8, os, "validates") or
std.mem.eql(u8, os, "refutes") or
std.mem.eql(u8, os, "inconclusive") or
std.mem.eql(u8, os, "partial") or
std.mem.eql(u8, os, "inconclusive-partial");
if (!valid) {
colors.printError("Invalid outcome status: {s}. Must be one of: validates, refutes, inconclusive, partial\n", .{os});
return error.InvalidArgs;
}
}
// Validate validation status if provided
if (validation_status) |vs| {
const valid = std.mem.eql(u8, vs, "validates") or
std.mem.eql(u8, vs, "refutes") or
std.mem.eql(u8, vs, "inconclusive") or
std.mem.eql(u8, vs, "");
if (!valid) {
colors.printError("Invalid validation status: {s}. Must be one of: validates, refutes, inconclusive\n", .{vs});
return error.InvalidArgs;
}
}
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const resolved_base = base_override orelse cfg.worker_base;
const manifest_path = manifest.resolvePathWithBase(allocator, target, resolved_base) catch |err| {
if (err == error.FileNotFound) {
colors.printError(
"Could not locate run_manifest.json for '{s}'. Provide a path, or use --base <path> to scan finished/failed/running/pending.\n",
.{target},
);
}
return err;
};
defer allocator.free(manifest_path);
const job_name = try manifest.readJobNameFromManifest(allocator, manifest_path);
defer allocator.free(job_name);
const patch_json = try buildOutcomePatchJSON(
allocator,
outcome_status,
outcome_summary,
learnings.items,
next_steps.items,
validation_status,
surprises.items,
);
defer allocator.free(patch_json);
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendSetRunNarrative(job_name, patch_json, api_key_hash);
if (json_mode) {
const msg = try client.receiveMessage(allocator);
defer allocator.free(msg);
const packet = protocol.ResponsePacket.deserialize(msg, allocator) catch {
var out = io.stdoutWriter();
try out.print("{s}\n", .{msg});
return error.InvalidPacket;
};
defer packet.deinit(allocator);
if (packet.packet_type == .success) {
var out = io.stdoutWriter();
try out.print("{{\"success\":true,\"job_name\":\"{s}\"}}\n", .{job_name});
} else if (packet.packet_type == .error_packet) {
var out = io.stdoutWriter();
try out.print("{{\"success\":false,\"error\":\"{s}\"}}\n", .{packet.error_message orelse "unknown"});
} else {
var out = io.stdoutWriter();
try out.print("{{\"success\":true,\"job_name\":\"{s}\",\"response\":\"{s}\"}}\n", .{ job_name, packet.success_message orelse "ok" });
}
} else {
try client.receiveAndHandleResponse(allocator, "Outcome set");
}
}
fn printUsage() !void {
colors.printInfo("Usage: ml outcome set <run-id|job-name|path> [options]\n", .{});
colors.printInfo("\nPost-Run Outcome Capture:\n", .{});
colors.printInfo(" --outcome <status> Outcome: validates|refutes|inconclusive|partial\n", .{});
colors.printInfo(" --summary <text> Summary of results\n", .{});
colors.printInfo(" --learning <text> A learning from this run (can repeat)\n", .{});
colors.printInfo(" --next-step <text> Suggested next step (can repeat)\n", .{});
colors.printInfo(" --validation-status <st> Did results validate hypothesis? validates|refutes|inconclusive\n", .{});
colors.printInfo(" --surprise <text> Unexpected finding (can repeat)\n", .{});
colors.printInfo("\nOptions:\n", .{});
colors.printInfo(" --base <path> Base path to search for run_manifest.json\n", .{});
colors.printInfo(" --json Output JSON response\n", .{});
colors.printInfo(" --help, -h Show this help\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml outcome set run_abc --outcome validates --summary \"Accuracy +0.02\"\n", .{});
colors.printInfo(" ml outcome set run_abc --learning \"LR scaling worked\" --learning \"GPU util 95%\"\n", .{});
colors.printInfo(" ml outcome set run_abc --outcome validates --next-step \"Try batch=96\"\n", .{});
}
fn buildOutcomePatchJSON(
allocator: std.mem.Allocator,
outcome_status: ?[]const u8,
outcome_summary: ?[]const u8,
learnings: [][]const u8,
next_steps: [][]const u8,
validation_status: ?[]const u8,
surprises: [][]const u8,
) ![]u8 {
var buf = std.ArrayList(u8).empty;
defer buf.deinit(allocator);
const writer = buf.writer(allocator);
try writer.writeAll("{\"narrative\":");
try writer.writeAll("{");
var first = true;
if (outcome_status) |os| {
if (!first) try writer.writeAll(",");
first = false;
try writer.print("\"outcome\":\"{s}\"", .{os});
}
if (outcome_summary) |os| {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"outcome_summary\":");
try writeJSONString(writer, os);
}
if (learnings.len > 0) {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"learnings\":[");
for (learnings, 0..) |learning, idx| {
if (idx > 0) try writer.writeAll(",");
try writeJSONString(writer, learning);
}
try writer.writeAll("]");
}
if (next_steps.len > 0) {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"next_steps\":[");
for (next_steps, 0..) |step, idx| {
if (idx > 0) try writer.writeAll(",");
try writeJSONString(writer, step);
}
try writer.writeAll("]");
}
if (validation_status) |vs| {
if (!first) try writer.writeAll(",");
first = false;
try writer.print("\"validation_status\":\"{s}\"", .{vs});
}
if (surprises.len > 0) {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"surprises\":[");
for (surprises, 0..) |surprise, idx| {
if (idx > 0) try writer.writeAll(",");
try writeJSONString(writer, surprise);
}
try writer.writeAll("]");
}
try writer.writeAll("}}");
return buf.toOwnedSlice(allocator);
}
fn writeJSONString(writer: anytype, s: []const u8) !void {
try writer.writeAll("\"");
for (s) |c| {
switch (c) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => {
if (c < 0x20) {
var buf: [6]u8 = undefined;
buf[0] = '\\';
buf[1] = 'u';
buf[2] = '0';
buf[3] = '0';
buf[4] = hexDigit(@intCast((c >> 4) & 0x0F));
buf[5] = hexDigit(@intCast(c & 0x0F));
try writer.writeAll(&buf);
} else {
try writer.writeAll(&[_]u8{c});
}
},
}
}
try writer.writeAll("\"");
}
fn hexDigit(v: u8) u8 {
return if (v < 10) ('0' + v) else ('a' + (v - 10));
}

View file

@ -1,241 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const io = @import("../utils/io.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const manifest = @import("../utils/manifest.zig");
pub fn run(allocator: std.mem.Allocator, argv: []const []const u8) !void {
if (argv.len == 0) {
try printUsage();
return error.InvalidArgs;
}
const sub = argv[0];
if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h")) {
try printUsage();
return;
}
if (!std.mem.eql(u8, sub, "set")) {
colors.printError("Unknown subcommand: {s}\n", .{sub});
try printUsage();
return error.InvalidArgs;
}
if (argv.len < 2) {
try printUsage();
return error.InvalidArgs;
}
const target = argv[1];
var privacy_level: ?[]const u8 = null;
var team: ?[]const u8 = null;
var owner: ?[]const u8 = null;
var base_override: ?[]const u8 = null;
var json_mode: bool = false;
var i: usize = 2;
while (i < argv.len) : (i += 1) {
const a = argv[i];
if (std.mem.eql(u8, a, "--level") or std.mem.eql(u8, a, "--privacy-level")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
privacy_level = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--team")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
team = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--owner")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
owner = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--base")) {
if (i + 1 >= argv.len) return error.InvalidArgs;
base_override = argv[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--json")) {
json_mode = true;
} else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
try printUsage();
return;
} else {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
}
}
if (privacy_level == null and team == null and owner == null) {
colors.printError("No privacy fields provided.\n", .{});
return error.InvalidArgs;
}
// Validate privacy level if provided
if (privacy_level) |pl| {
const valid = std.mem.eql(u8, pl, "private") or
std.mem.eql(u8, pl, "team") or
std.mem.eql(u8, pl, "public") or
std.mem.eql(u8, pl, "anonymized");
if (!valid) {
colors.printError("Invalid privacy level: {s}. Must be one of: private, team, public, anonymized\n", .{pl});
return error.InvalidArgs;
}
}
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
const resolved_base = base_override orelse cfg.worker_base;
const manifest_path = manifest.resolvePathWithBase(allocator, target, resolved_base) catch |err| {
if (err == error.FileNotFound) {
colors.printError(
"Could not locate run_manifest.json for '{s}'. Provide a path, or use --base <path> to scan finished/failed/running/pending.\n",
.{target},
);
}
return err;
};
defer allocator.free(manifest_path);
const job_name = try manifest.readJobNameFromManifest(allocator, manifest_path);
defer allocator.free(job_name);
const patch_json = try buildPrivacyPatchJSON(
allocator,
privacy_level,
team,
owner,
);
defer allocator.free(patch_json);
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
try client.sendSetRunPrivacy(job_name, patch_json, api_key_hash);
if (json_mode) {
const msg = try client.receiveMessage(allocator);
defer allocator.free(msg);
const packet = protocol.ResponsePacket.deserialize(msg, allocator) catch {
var out = io.stdoutWriter();
try out.print("{s}\n", .{msg});
return error.InvalidPacket;
};
defer packet.deinit(allocator);
if (packet.packet_type == .success) {
var out = io.stdoutWriter();
try out.print("{{\"success\":true,\"job_name\":\"{s}\"}}\n", .{job_name});
} else if (packet.packet_type == .error_packet) {
var out = io.stdoutWriter();
try out.print("{{\"success\":false,\"error\":\"{s}\"}}\n", .{packet.error_message orelse "unknown"});
}
} else {
try client.receiveAndHandleResponse(allocator, "Privacy set");
}
}
fn printUsage() !void {
colors.printInfo("Usage: ml privacy set <run-id|job-name|path> [options]\n", .{});
colors.printInfo("\nPrivacy Levels:\n", .{});
colors.printInfo(" private Owner only (default)\n", .{});
colors.printInfo(" team Same-team members can view\n", .{});
colors.printInfo(" public All authenticated users\n", .{});
colors.printInfo(" anonymized Strip PII before sharing\n", .{});
colors.printInfo("\nOptions:\n", .{});
colors.printInfo(" --level <lvl> Set privacy level\n", .{});
colors.printInfo(" --team <name> Set team name\n", .{});
colors.printInfo(" --owner <email> Set owner email\n", .{});
colors.printInfo(" --base <path> Base path to search for run_manifest.json\n", .{});
colors.printInfo(" --json Output JSON response\n", .{});
colors.printInfo(" --help, -h Show this help\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml privacy set run_abc --level team --team vision-research\n", .{});
colors.printInfo(" ml privacy set run_abc --level private\n", .{});
colors.printInfo(" ml privacy set run_abc --owner user@lab.edu --team ml-group\n", .{});
}
fn buildPrivacyPatchJSON(
allocator: std.mem.Allocator,
privacy_level: ?[]const u8,
team: ?[]const u8,
owner: ?[]const u8,
) ![]u8 {
var buf = std.ArrayList(u8).empty;
defer buf.deinit(allocator);
const writer = buf.writer(allocator);
try writer.writeAll("{\"privacy\":");
try writer.writeAll("{");
var first = true;
if (privacy_level) |pl| {
if (!first) try writer.writeAll(",");
first = false;
try writer.print("\"level\":\"{s}\"", .{pl});
}
if (team) |t| {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"team\":");
try writeJSONString(writer, t);
}
if (owner) |o| {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"owner\":");
try writeJSONString(writer, o);
}
try writer.writeAll("}}");
return buf.toOwnedSlice(allocator);
}
fn writeJSONString(writer: anytype, s: []const u8) !void {
try writer.writeAll("\"");
for (s) |c| {
switch (c) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => {
if (c < 0x20) {
var buf: [6]u8 = undefined;
buf[0] = '\\';
buf[1] = 'u';
buf[2] = '0';
buf[3] = '0';
buf[4] = hexDigit(@intCast((c >> 4) & 0x0F));
buf[5] = hexDigit(@intCast(c & 0x0F));
try writer.writeAll(&buf);
} else {
try writer.writeAll(&[_]u8{c});
}
},
}
}
try writer.writeAll("\"");
}
fn hexDigit(v: u8) u8 {
return if (v < 10) ('0' + v) else ('a' + (v - 10));
}

View file

@ -1,523 +0,0 @@
const std = @import("std");
const colors = @import("../utils/colors.zig");
const Config = @import("../config.zig").Config;
const crypto = @import("../utils/crypto.zig");
const ws = @import("../net/ws/client.zig");
const protocol = @import("../net/protocol.zig");
const manifest = @import("../utils/manifest.zig");
const json = @import("../utils/json.zig");
pub fn run(allocator: std.mem.Allocator, argv: []const []const u8) !void {
if (argv.len == 0) {
try printUsage();
return error.InvalidArgs;
}
if (std.mem.eql(u8, argv[0], "--help") or std.mem.eql(u8, argv[0], "-h")) {
try printUsage();
return;
}
const target = argv[0];
// Split args at "--".
var sep_index: ?usize = null;
for (argv, 0..) |a, i| {
if (std.mem.eql(u8, a, "--")) {
sep_index = i;
break;
}
}
const pre = argv[1..(sep_index orelse argv.len)];
const post = if (sep_index) |i| argv[(i + 1)..] else argv[0..0];
const cfg = try Config.load(allocator);
defer {
var mut_cfg = cfg;
mut_cfg.deinit(allocator);
}
// Defaults
var job_name_override: ?[]const u8 = null;
var priority: u8 = cfg.default_priority;
var cpu: u8 = cfg.default_cpu;
var memory: u8 = cfg.default_memory;
var gpu: u8 = cfg.default_gpu;
var gpu_memory: ?[]const u8 = cfg.default_gpu_memory;
var args_override: ?[]const u8 = null;
var note_override: ?[]const u8 = null;
var force: bool = false;
// New: Change tracking options
var inherit_narrative: bool = false;
var inherit_config: bool = false;
var parent_link: bool = false;
var overrides = std.ArrayList([2][]const u8).empty; // [key, value] pairs
defer overrides.deinit(allocator);
var i: usize = 0;
while (i < pre.len) : (i += 1) {
const a = pre[i];
if (std.mem.eql(u8, a, "--name") and i + 1 < pre.len) {
job_name_override = pre[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--priority") and i + 1 < pre.len) {
priority = try std.fmt.parseInt(u8, pre[i + 1], 10);
i += 1;
} else if (std.mem.eql(u8, a, "--cpu") and i + 1 < pre.len) {
cpu = try std.fmt.parseInt(u8, pre[i + 1], 10);
i += 1;
} else if (std.mem.eql(u8, a, "--memory") and i + 1 < pre.len) {
memory = try std.fmt.parseInt(u8, pre[i + 1], 10);
i += 1;
} else if (std.mem.eql(u8, a, "--gpu") and i + 1 < pre.len) {
gpu = try std.fmt.parseInt(u8, pre[i + 1], 10);
i += 1;
} else if (std.mem.eql(u8, a, "--gpu-memory") and i + 1 < pre.len) {
gpu_memory = pre[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--args") and i + 1 < pre.len) {
args_override = pre[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--note") and i + 1 < pre.len) {
note_override = pre[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--force")) {
force = true;
} else if (std.mem.eql(u8, a, "--inherit-narrative")) {
inherit_narrative = true;
} else if (std.mem.eql(u8, a, "--inherit-config")) {
inherit_config = true;
} else if (std.mem.eql(u8, a, "--parent")) {
parent_link = true;
} else if (std.mem.startsWith(u8, a, "--") and std.mem.indexOf(u8, a, "=") != null) {
// Key=value override: --lr=0.002 or --batch-size=128
const eq_idx = std.mem.indexOf(u8, a, "=").?;
const key = a[2..eq_idx];
const value = a[eq_idx + 1 ..];
try overrides.append(allocator, [2][]const u8{ key, value });
} else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) {
try printUsage();
return;
} else {
colors.printError("Unknown option: {s}\n", .{a});
return error.InvalidArgs;
}
}
var args_joined: []const u8 = "";
if (post.len > 0) {
var buf: std.ArrayList(u8) = .{};
defer buf.deinit(allocator);
for (post, 0..) |a, idx| {
if (idx > 0) try buf.append(allocator, ' ');
try buf.appendSlice(allocator, a);
}
args_joined = try buf.toOwnedSlice(allocator);
}
defer if (post.len > 0) allocator.free(args_joined);
const args_final: []const u8 = if (args_override) |a| a else args_joined;
const note_final: []const u8 = if (note_override) |n| n else "";
// Read original manifest for inheritance
var original_narrative: ?std.json.ObjectMap = null;
var original_config: ?std.json.ObjectMap = null;
var parent_run_id: ?[]const u8 = null;
// Target can be:
// - commit_id (40-hex) or commit_id prefix (>=7 hex) resolvable under worker_base
// - run_id/task_id/path (resolved to run_manifest.json to read commit_id)
var commit_hex: []const u8 = "";
var commit_hex_owned: ?[]u8 = null;
defer if (commit_hex_owned) |s| allocator.free(s);
var commit_bytes: []u8 = &[_]u8{};
var commit_bytes_allocated = false;
defer if (commit_bytes_allocated) allocator.free(commit_bytes);
// If we need to inherit narrative or config, or link parent, read the manifest first
if (inherit_narrative or inherit_config or parent_link) {
const manifest_path = try manifest.resolvePathWithBase(allocator, target, cfg.worker_base);
defer allocator.free(manifest_path);
const data = try manifest.readFileAlloc(allocator, manifest_path);
defer allocator.free(data);
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
defer parsed.deinit();
if (parsed.value == .object) {
const root = parsed.value.object;
if (inherit_narrative) {
if (root.get("narrative")) |narr| {
if (narr == .object) {
original_narrative = try narr.object.clone();
}
}
}
if (inherit_config) {
if (root.get("metadata")) |meta| {
if (meta == .object) {
original_config = try meta.object.clone();
}
}
}
if (parent_link) {
parent_run_id = json.getString(root, "run_id") orelse json.getString(root, "id");
}
}
}
if (target.len >= 7 and target.len <= 40 and isHexLowerOrUpper(target)) {
if (target.len == 40) {
commit_hex = target;
} else {
commit_hex_owned = try resolveCommitPrefix(allocator, cfg.worker_base, target);
commit_hex = commit_hex_owned.?;
}
const decoded = crypto.decodeHex(allocator, commit_hex) catch {
commit_hex = "";
commit_hex_owned = null;
return error.InvalidCommitId;
};
if (decoded.len != 20) {
allocator.free(decoded);
commit_hex = "";
commit_hex_owned = null;
} else {
commit_bytes = decoded;
commit_bytes_allocated = true;
}
}
var job_name = blk: {
if (job_name_override) |n| break :blk n;
break :blk "requeue";
};
if (commit_hex.len == 0) {
const manifest_path = try manifest.resolvePathWithBase(allocator, target, cfg.worker_base);
defer allocator.free(manifest_path);
const data = try manifest.readFileAlloc(allocator, manifest_path);
defer allocator.free(data);
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{});
defer parsed.deinit();
if (parsed.value != .object) return error.InvalidManifest;
const root = parsed.value.object;
commit_hex = json.getString(root, "commit_id") orelse "";
if (commit_hex.len != 40) {
colors.printError("run manifest missing commit_id\n", .{});
return error.InvalidManifest;
}
if (job_name_override == null) {
const j = json.getString(root, "job_name") orelse "";
if (j.len > 0) job_name = j;
}
const b = try crypto.decodeHex(allocator, commit_hex);
if (b.len != 20) {
allocator.free(b);
return error.InvalidCommitId;
}
commit_bytes = b;
commit_bytes_allocated = true;
}
// Build tracking JSON with narrative/config inheritance and overrides
const tracking_json = blk: {
if (inherit_narrative or inherit_config or parent_link or overrides.items.len > 0) {
var buf = std.ArrayList(u8).empty;
defer buf.deinit(allocator);
const writer = buf.writer(allocator);
try writer.writeAll("{");
var first = true;
// Add narrative if inherited
if (original_narrative) |narr| {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"narrative\":");
try writeJSONValue(writer, std.json.Value{ .object = narr });
}
// Add parent relationship
if (parent_run_id) |pid| {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"parent_run\":\"");
try writer.writeAll(pid);
try writer.writeAll("\"");
}
// Add overrides
if (overrides.items.len > 0) {
if (!first) try writer.writeAll(",");
first = false;
try writer.writeAll("\"overrides\":{");
for (overrides.items, 0..) |pair, idx| {
if (idx > 0) try writer.writeAll(",");
try writer.print("\"{s}\":\"{s}\"", .{ pair[0], pair[1] });
}
try writer.writeAll("}");
}
try writer.writeAll("}");
break :blk try buf.toOwnedSlice(allocator);
}
break :blk "";
};
defer if (tracking_json.len > 0) allocator.free(tracking_json);
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
defer allocator.free(api_key_hash);
const ws_url = try cfg.getWebSocketUrl(allocator);
defer allocator.free(ws_url);
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
defer client.close();
// Send with tracking JSON if we have inheritance/overrides
if (tracking_json.len > 0) {
try client.sendQueueJobWithTrackingAndResources(
job_name,
commit_bytes,
priority,
api_key_hash,
tracking_json,
cpu,
memory,
gpu,
gpu_memory,
);
} else if (note_final.len > 0) {
try client.sendQueueJobWithArgsNoteAndResources(
job_name,
commit_bytes,
priority,
api_key_hash,
args_final,
note_final,
force,
cpu,
memory,
gpu,
gpu_memory,
);
} else {
try client.sendQueueJobWithArgsAndResources(
job_name,
commit_bytes,
priority,
api_key_hash,
args_final,
force,
cpu,
memory,
gpu,
gpu_memory,
);
}
// Receive response with duplicate detection
const message = try client.receiveMessage(allocator);
defer allocator.free(message);
const packet = protocol.ResponsePacket.deserialize(message, allocator) catch {
if (message.len > 0 and message[0] == '{') {
try handleDuplicateResponse(allocator, message, job_name, commit_hex);
} else {
colors.printInfo("Server response: {s}\n", .{message});
}
return;
};
defer packet.deinit(allocator);
switch (packet.packet_type) {
.success => {
colors.printSuccess("Queued requeue\n", .{});
colors.printInfo("Job: {s}\n", .{job_name});
colors.printInfo("Commit: {s}\n", .{commit_hex});
},
.data => {
if (packet.data_payload) |payload| {
try handleDuplicateResponse(allocator, payload, job_name, commit_hex);
}
},
.error_packet => {
const err_msg = packet.error_message orelse "Unknown error";
colors.printError("Error: {s}\n", .{err_msg});
return error.ServerError;
},
else => {
try client.handleResponsePacket(packet, "Requeue");
colors.printSuccess("Queued requeue\n", .{});
colors.printInfo("Job: {s}\n", .{job_name});
colors.printInfo("Commit: {s}\n", .{commit_hex});
},
}
}
fn handleDuplicateResponse(
allocator: std.mem.Allocator,
payload: []const u8,
job_name: []const u8,
commit_hex: []const u8,
) !void {
const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch {
colors.printInfo("Server response: {s}\n", .{payload});
return;
};
defer parsed.deinit();
const root = parsed.value.object;
const is_dup = root.get("duplicate") != null and root.get("duplicate").?.bool;
if (!is_dup) {
colors.printSuccess("Queued requeue\n", .{});
colors.printInfo("Job: {s}\n", .{job_name});
colors.printInfo("Commit: {s}\n", .{commit_hex});
return;
}
const existing_id = root.get("existing_id").?.string;
const status = root.get("status").?.string;
if (std.mem.eql(u8, status, "queued") or std.mem.eql(u8, status, "running")) {
colors.printInfo("\n→ Identical job already in progress: {s}\n", .{existing_id[0..8]});
colors.printInfo("\n Watch: ml watch {s}\n", .{existing_id[0..8]});
} else if (std.mem.eql(u8, status, "completed")) {
colors.printInfo("\n→ Identical job already completed: {s}\n", .{existing_id[0..8]});
colors.printInfo("\n Inspect: ml experiment show {s}\n", .{existing_id[0..8]});
colors.printInfo(" Rerun: ml requeue {s} --force\n", .{commit_hex});
} else if (std.mem.eql(u8, status, "failed")) {
colors.printWarning("\n→ Identical job previously failed: {s}\n", .{existing_id[0..8]});
}
}
fn printUsage() !void {
colors.printInfo("Usage:\n", .{});
colors.printInfo(" ml requeue <commit_id|run_id|task_id|path> [options] -- <args...>\n\n", .{});
colors.printInfo("Resource Options:\n", .{});
colors.printInfo(" --name <job> Override job name\n", .{});
colors.printInfo(" --priority <n> Set priority (0-255)\n", .{});
colors.printInfo(" --cpu <n> CPU cores\n", .{});
colors.printInfo(" --memory <gb> Memory in GB\n", .{});
colors.printInfo(" --gpu <n> GPU count\n", .{});
colors.printInfo(" --gpu-memory <gb> GPU memory budget\n", .{});
colors.printInfo("\nInheritance Options:\n", .{});
colors.printInfo(" --inherit-narrative Copy hypothesis/context/intent from parent\n", .{});
colors.printInfo(" --inherit-config Copy metadata/config from parent\n", .{});
colors.printInfo(" --parent Link as child run\n", .{});
colors.printInfo("\nOverride Options:\n", .{});
colors.printInfo(" --key=value Override specific config (e.g., --lr=0.002)\n", .{});
colors.printInfo(" --args <string> Override runner args\n", .{});
colors.printInfo(" --note <string> Add human note\n", .{});
colors.printInfo("\nOther:\n", .{});
colors.printInfo(" --force Requeue even if duplicate exists\n", .{});
colors.printInfo(" --help, -h Show this help\n", .{});
colors.printInfo("\nExamples:\n", .{});
colors.printInfo(" ml requeue run_abc --lr=0.002 --batch-size=128\n", .{});
colors.printInfo(" ml requeue run_abc --inherit-narrative --parent\n", .{});
colors.printInfo(" ml requeue run_abc --lr=0.002 --inherit-narrative\n", .{});
}
fn writeJSONValue(writer: anytype, v: std.json.Value) !void {
switch (v) {
.null => try writer.writeAll("null"),
.bool => |b| try writer.print("{}", .{b}),
.integer => |i| try writer.print("{d}", .{i}),
.float => |f| try writer.print("{d}", .{f}),
.string => |s| {
try writer.writeAll("\"");
try writeEscapedString(writer, s);
try writer.writeAll("\"");
},
.array => |arr| {
try writer.writeAll("[");
for (arr.items, 0..) |item, idx| {
if (idx > 0) try writer.writeAll(",");
try writeJSONValue(writer, item);
}
try writer.writeAll("]");
},
.object => |obj| {
try writer.writeAll("{");
var first = true;
var it = obj.iterator();
while (it.next()) |entry| {
if (!first) try writer.writeAll(",");
first = false;
try writer.print("\"{s}\":", .{entry.key_ptr.*});
try writeJSONValue(writer, entry.value_ptr.*);
}
try writer.writeAll("}");
},
.number_string => |s| try writer.print("{s}", .{s}),
}
}
fn writeEscapedString(writer: anytype, s: []const u8) !void {
for (s) |c| {
switch (c) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => {
if (c < 0x20) {
try writer.print("\\u00{x:0>2}", .{c});
} else {
try writer.writeAll(&[_]u8{c});
}
},
}
}
}
fn isHexLowerOrUpper(s: []const u8) bool {
for (s) |c| {
if (!std.ascii.isHex(c)) return false;
}
return true;
}
fn resolveCommitPrefix(allocator: std.mem.Allocator, base_path: []const u8, prefix: []const u8) ![]u8 {
var dir = if (std.fs.path.isAbsolute(base_path))
try std.fs.openDirAbsolute(base_path, .{ .iterate = true })
else
try std.fs.cwd().openDir(base_path, .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
var found: ?[]u8 = null;
errdefer if (found) |s| allocator.free(s);
while (try it.next()) |entry| {
if (entry.kind != .directory) continue;
const name = entry.name;
if (name.len != 40) continue;
if (!std.mem.startsWith(u8, name, prefix)) continue;
if (!isHexLowerOrUpper(name)) continue;
if (found != null) {
colors.printError("Ambiguous commit prefix: {s}\n", .{prefix});
return error.InvalidCommitId;
}
found = try allocator.dupe(u8, name);
}
if (found) |s| return s;
colors.printError("No commit matches prefix: {s}\n", .{prefix});
return error.FileNotFound;
}

View file

@ -12,15 +12,14 @@ pub fn main() !void {
// Initialize colors based on environment
colors.initColors();
// Use ArenaAllocator for thread-safe memory management
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Use c_allocator for better performance on Linux
const allocator = std.heap.c_allocator;
const args = std.process.argsAlloc(allocator) catch |err| {
std.debug.print("Failed to allocate args: {}\n", .{err});
return;
};
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
printUsage();
@ -33,61 +32,50 @@ pub fn main() !void {
switch (command[0]) {
'j' => if (std.mem.eql(u8, command, "jupyter")) {
try @import("commands/jupyter.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'i' => if (std.mem.eql(u8, command, "init")) {
try @import("commands/init.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "info")) {
try @import("commands/info.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
'a' => if (std.mem.eql(u8, command, "annotate")) {
try @import("commands/annotate.zig").run(allocator, args[2..]);
},
'n' => if (std.mem.eql(u8, command, "narrative")) {
try @import("commands/narrative.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "outcome")) {
try @import("commands/outcome.zig").run(allocator, args[2..]);
},
'p' => if (std.mem.eql(u8, command, "privacy")) {
try @import("commands/privacy.zig").run(allocator, args[2..]);
},
'n' => if (std.mem.eql(u8, command, "note")) {
try @import("commands/note.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
's' => if (std.mem.eql(u8, command, "sync")) {
if (args.len < 3) {
colors.printError("Usage: ml sync <path>\n", .{});
return error.InvalidArgs;
}
colors.printInfo("Sync project to server: {s}\n", .{args[2]});
try @import("commands/sync.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "status")) {
try @import("commands/status.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
'r' => if (std.mem.eql(u8, command, "requeue")) {
try @import("commands/requeue.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "run")) {
'r' => if (std.mem.eql(u8, command, "run")) {
try @import("commands/run.zig").execute(allocator, args[2..]);
} else handleUnknownCommand(command),
'q' => if (std.mem.eql(u8, command, "queue")) {
try @import("commands/queue.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'd' => if (std.mem.eql(u8, command, "dataset")) {
try @import("commands/dataset.zig").run(allocator, args[2..]);
},
'e' => if (std.mem.eql(u8, command, "experiment")) {
try @import("commands/experiment.zig").execute(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "export")) {
} else handleUnknownCommand(command),
'e' => if (std.mem.eql(u8, command, "export")) {
try @import("commands/export_cmd.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'c' => if (std.mem.eql(u8, command, "cancel")) {
try @import("commands/cancel.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "compare")) {
try @import("commands/compare.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'f' => if (std.mem.eql(u8, command, "find")) {
try @import("commands/find.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'v' => if (std.mem.eql(u8, command, "validate")) {
try @import("commands/validate.zig").run(allocator, args[2..]);
},
} else handleUnknownCommand(command),
'l' => if (std.mem.eql(u8, command, "logs")) {
try @import("commands/logs.zig").run(allocator, args[2..]);
try @import("commands/log.zig").run(allocator, args[2..]);
} else if (std.mem.eql(u8, command, "log")) {
try @import("commands/log.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
'w' => if (std.mem.eql(u8, command, "watch")) {
try @import("commands/watch.zig").run(allocator, args[2..]);
} else handleUnknownCommand(command),
else => {
colors.printError("Unknown command: {s}\n", .{args[1]});
@ -102,44 +90,30 @@ fn printUsage() void {
colors.printInfo("ML Experiment Manager\n\n", .{});
std.debug.print("Usage: ml <command> [options]\n\n", .{});
std.debug.print("Commands:\n", .{});
std.debug.print(" jupyter Jupyter workspace management\n", .{});
std.debug.print(" init Initialize local experiment tracking database\n", .{});
std.debug.print(" experiment Manage experiments (auto-detects local/server mode)\n", .{});
std.debug.print(" - create, list, log, show, delete\n", .{});
std.debug.print(" run Manage runs (auto-detects local/server mode)\n", .{});
std.debug.print(" - start, finish, fail, list\n", .{});
std.debug.print(" annotate <path|id> Add an annotation to run_manifest.json (--note \"...\")\n", .{});
std.debug.print(" compare <a> <b> Compare two runs (show differences)\n", .{});
std.debug.print(" export <id> Export experiment bundle (--anonymize for safe sharing)\n", .{});
std.debug.print(" find [query] Search experiments by tags/outcome/dataset\n", .{});
std.debug.print(" narrative set <path|id> Set run narrative fields (hypothesis/context/...)\n", .{});
std.debug.print(" outcome set <path|id> Set post-run outcome (validates/refutes/inconclusive)\n", .{});
std.debug.print(" privacy set <path|id> Set experiment privacy level (private/team/public)\n", .{});
std.debug.print(" info <path|id> Show run info from run_manifest.json (optionally --base <path>)\n", .{});
std.debug.print(" sync <path> Sync project to server\n", .{});
std.debug.print(" requeue <id> Re-submit from run_id/task_id/path (supports -- <args>)\n", .{});
std.debug.print(" queue (q) <job> Queue job for execution\n", .{});
std.debug.print(" init Initialize project with config (use --local for SQLite)\n", .{});
std.debug.print(" run [args] Execute a run locally (forks, captures, parses metrics)\n", .{});
std.debug.print(" queue <job> Queue job on server (--rerun <id> to re-queue local run)\n", .{});
std.debug.print(" note <id> Add metadata annotation (hypothesis/outcome/confidence)\n", .{});
std.debug.print(" logs <id> Fetch or stream run logs (--follow for live tail)\n", .{});
std.debug.print(" sync [id] Push local runs to server (sync_run + sync_ack protocol)\n", .{});
std.debug.print(" cancel <id> Cancel local run (SIGTERM/SIGKILL by PID)\n", .{});
std.debug.print(" watch [--sync] Watch directory with optional auto-sync\n", .{});
std.debug.print(" status Get system status\n", .{});
std.debug.print(" monitor Launch TUI via SSH\n", .{});
std.debug.print(" logs <id> Fetch job logs (-f to follow, -n for tail)\n", .{});
std.debug.print(" cancel <job> Cancel running job\n", .{});
std.debug.print(" prune Remove old experiments\n", .{});
std.debug.print(" watch <path> Watch directory for auto-sync\n", .{});
std.debug.print(" dataset Manage datasets\n", .{});
std.debug.print(" experiment Manage experiments and metrics\n", .{});
std.debug.print(" validate Validate provenance and integrity for a commit/task\n", .{});
std.debug.print(" export <id> Export experiment bundle\n", .{});
std.debug.print(" validate Validate provenance and integrity\n", .{});
std.debug.print(" compare <a> <b> Compare two runs\n", .{});
std.debug.print(" find [query] Search experiments\n", .{});
std.debug.print(" jupyter Jupyter workspace management\n", .{});
std.debug.print(" info <id> Show run info\n", .{});
std.debug.print("\nUse 'ml <command> --help' for detailed help.\n", .{});
}
test {
_ = @import("commands/info.zig");
_ = @import("commands/requeue.zig");
_ = @import("commands/annotate.zig");
_ = @import("commands/narrative.zig");
_ = @import("commands/outcome.zig");
_ = @import("commands/privacy.zig");
_ = @import("commands/compare.zig");
_ = @import("commands/find.zig");
_ = @import("commands/export_cmd.zig");
_ = @import("commands/logs.zig");
_ = @import("commands/log.zig");
_ = @import("commands/note.zig");
}