feat(cli): add workers command, scheduler client, and PII utilities
New commands and modules: - Add workers.zig command for worker management and status - Add scheduler_client.zig for scheduler hub communication - Add pii.zig utility for PII detection and redaction in logs/outputs Improvements to existing commands: - groups.zig: enhanced group management with capability metadata - jupyter/mod.zig: improved Jupyter workspace lifecycle handling - tasks.zig: better task status reporting and cancellation support Networking and sync improvements: - ws/client.zig: WebSocket client enhancements for hub protocol - sync_manager.zig: improved sync with scheduler state and conflict resolution - uuid.zig: optimized UUID generation for macOS and Linux Database utilities: - sqlite_embedded.zig: embedded SQLite for CLI-local state caching
This commit is contained in:
parent
57787e1e7b
commit
ad3be36a6d
10 changed files with 999 additions and 137 deletions
|
|
@ -1,6 +1,17 @@
|
|||
const std = @import("std");
|
||||
const config = @import("../config.zig");
|
||||
const core = @import("../core.zig");
|
||||
const common = @import("common.zig");
|
||||
const ws = @import("../net/ws/client.zig");
|
||||
const crypto = @import("../utils/crypto.zig");
|
||||
|
||||
/// WebSocket opcodes for group operations (must match server)
|
||||
const OpcodeCreateGroup = 0x50;
|
||||
const OpcodeListGroups = 0x51;
|
||||
const OpcodeCreateInvitation = 0x52;
|
||||
const OpcodeListInvitations = 0x53;
|
||||
const OpcodeAcceptInvitation = 0x54;
|
||||
const OpcodeDeclineInvitation = 0x55;
|
||||
|
||||
/// Group roles
|
||||
pub const Role = enum {
|
||||
|
|
@ -75,7 +86,27 @@ fn printUsage() void {
|
|||
std.debug.print(" --role viewer|member|admin Role for invite (default: member)\n", .{});
|
||||
}
|
||||
|
||||
/// Create a new group
|
||||
/// Build WebSocket message with api_key_hash prefix
|
||||
fn buildWsMessage(allocator: std.mem.Allocator, api_key_hash: []const u8, opcode: u8, payload: []const u8) ![]u8 {
|
||||
const total_len = 16 + 1 + payload.len;
|
||||
var msg = try allocator.alloc(u8, total_len);
|
||||
|
||||
// Copy api_key_hash (16 bytes)
|
||||
@memcpy(msg[0..16], api_key_hash);
|
||||
|
||||
// Opcode
|
||||
msg[16] = opcode;
|
||||
|
||||
// Payload
|
||||
if (payload.len > 0) {
|
||||
@memcpy(msg[17..], payload);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/// Create a new group via WebSocket
|
||||
/// Protocol: [api_key_hash:16][opcode:1][name_len:1][name:var][desc_len:2][desc:var]
|
||||
fn createGroup(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var name: ?[]const u8 = null;
|
||||
var description: ?[]const u8 = null;
|
||||
|
|
@ -103,20 +134,58 @@ fn createGroup(allocator: std.mem.Allocator, args: []const []const u8, json: boo
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to create group
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [name_len:1][name:var][desc_len:2][desc:var]
|
||||
const desc = description orelse "";
|
||||
const payload_len = 1 + name.?.len + 2 + desc.len;
|
||||
var payload = try allocator.alloc(u8, payload_len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
payload[offset] = @intCast(name.?.len);
|
||||
offset += 1;
|
||||
@memcpy(payload[offset .. offset + name.?.len], name.?);
|
||||
offset += name.?.len;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(desc.len), .big);
|
||||
offset += 2;
|
||||
if (desc.len > 0) {
|
||||
@memcpy(payload[offset .. offset + desc.len], desc);
|
||||
}
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeCreateGroup, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
// Read response
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.create\",\"data\":{{\"name\":\"{s}\",\"description\":\"{s}\"}}}}\n", .{
|
||||
name.?, description orelse "",
|
||||
});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Created group: {s}\n", .{name.?});
|
||||
if (description) |d| {
|
||||
std.debug.print("Description: {s}\n", .{d});
|
||||
// Parse simple success message
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Created group: {s}\n", .{name.?});
|
||||
if (description) |d| {
|
||||
std.debug.print("Description: {s}\n", .{d});
|
||||
}
|
||||
} else {
|
||||
std.debug.print("Failed to create group. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invite user to group
|
||||
/// Invite user to group via WebSocket
|
||||
/// Protocol: [api_key_hash:16][opcode:1][group_id_len:2][group_id:var][user_id_len:2][user_id:var]
|
||||
fn inviteUser(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var group: ?[]const u8 = null;
|
||||
var user: ?[]const u8 = null;
|
||||
|
|
@ -152,17 +221,50 @@ fn inviteUser(allocator: std.mem.Allocator, args: []const []const u8, json: bool
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to invite user
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [group_id_len:2][group_id:var][user_id_len:2][user_id:var]
|
||||
const payload_len = 2 + group.?.len + 2 + user.?.len;
|
||||
var payload = try allocator.alloc(u8, payload_len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(group.?.len), .big);
|
||||
offset += 2;
|
||||
@memcpy(payload[offset .. offset + group.?.len], group.?);
|
||||
offset += group.?.len;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(user.?.len), .big);
|
||||
offset += 2;
|
||||
@memcpy(payload[offset .. offset + user.?.len], user.?);
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeCreateInvitation, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.invite\",\"data\":{{\"group\":\"{s}\",\"user\":\"{s}\",\"role\":\"{s}\"}}}}\n", .{
|
||||
group.?, user.?, role.toString(),
|
||||
});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Invited {s} to {s} as {s}\n", .{ user.?, group.?, role.toString() });
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Invited {s} to {s} as {s}\n", .{ user.?, group.?, role.toString() });
|
||||
} else {
|
||||
std.debug.print("Failed to invite user. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List groups user belongs to
|
||||
/// List groups user belongs to via WebSocket
|
||||
/// Protocol: [api_key_hash:16][opcode:1]
|
||||
fn listGroups(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
_ = args;
|
||||
|
||||
|
|
@ -172,16 +274,42 @@ fn listGroups(allocator: std.mem.Allocator, args: []const []const u8, json: bool
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to list groups
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeListGroups, &[_]u8{});
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.list\",\"data\":{{\"groups\":[],\"count\":0}}}}\n", .{});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Groups you belong to:\n", .{});
|
||||
std.debug.print(" (list not yet implemented)\n", .{});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Groups you belong to:\n", .{});
|
||||
// Simple parsing - extract group names from response
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"groups\"")) {
|
||||
std.debug.print(" (see JSON response for details)\n", .{});
|
||||
} else {
|
||||
std.debug.print(" No groups found\n", .{});
|
||||
}
|
||||
} else {
|
||||
std.debug.print("Failed to list groups. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show group details
|
||||
/// Show group details via WebSocket
|
||||
/// For now, uses list groups and filters client-side
|
||||
fn showGroup(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var group: ?[]const u8 = null;
|
||||
|
||||
|
|
@ -204,16 +332,41 @@ fn showGroup(allocator: std.mem.Allocator, args: []const []const u8, json: bool)
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to get group details
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeListGroups, &[_]u8{});
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.show\",\"data\":{{\"group\":\"{s}\"}}}}\n", .{group.?});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Group: {s}\n", .{group.?});
|
||||
std.debug.print(" (details not yet implemented)\n", .{});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Group: {s}\n", .{group.?});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, group.?)) {
|
||||
std.debug.print(" Status: active\n", .{});
|
||||
} else {
|
||||
std.debug.print(" Status: not found or no access\n", .{});
|
||||
}
|
||||
} else {
|
||||
std.debug.print("Failed to get group details. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept group invitation
|
||||
/// Accept group invitation via WebSocket
|
||||
/// Protocol: [api_key_hash:16][opcode:1][invitation_id_len:2][invitation_id:var]
|
||||
fn acceptInvitation(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var invitation_id: ?[]const u8 = null;
|
||||
|
||||
|
|
@ -236,15 +389,43 @@ fn acceptInvitation(allocator: std.mem.Allocator, args: []const []const u8, json
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to accept invitation
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [invitation_id_len:2][invitation_id:var]
|
||||
var payload = try allocator.alloc(u8, 2 + invitation_id.?.len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
std.mem.writeInt(u16, payload[0..2][0..2], @intCast(invitation_id.?.len), .big);
|
||||
@memcpy(payload[2..], invitation_id.?);
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeAcceptInvitation, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.accept\",\"data\":{{\"invitation_id\":\"{s}\"}}}}\n", .{invitation_id.?});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Accepted invitation: {s}\n", .{invitation_id.?});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Accepted invitation: {s}\n", .{invitation_id.?});
|
||||
} else {
|
||||
std.debug.print("Failed to accept invitation. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decline group invitation
|
||||
/// Decline group invitation via WebSocket
|
||||
/// Protocol: [api_key_hash:16][opcode:1][invitation_id_len:2][invitation_id:var]
|
||||
fn declineInvitation(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var invitation_id: ?[]const u8 = null;
|
||||
|
||||
|
|
@ -267,10 +448,37 @@ fn declineInvitation(allocator: std.mem.Allocator, args: []const []const u8, jso
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to decline invitation
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [invitation_id_len:2][invitation_id:var]
|
||||
var payload = try allocator.alloc(u8, 2 + invitation_id.?.len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
std.mem.writeInt(u16, payload[0..2][0..2], @intCast(invitation_id.?.len), .big);
|
||||
@memcpy(payload[2..], invitation_id.?);
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeDeclineInvitation, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"groups.decline\",\"data\":{{\"invitation_id\":\"{s}\"}}}}\n", .{invitation_id.?});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Declined invitation: {s}\n", .{invitation_id.?});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Declined invitation: {s}\n", .{invitation_id.?});
|
||||
} else {
|
||||
std.debug.print("Failed to decline invitation. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,22 +49,22 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
|||
} else if (std.mem.eql(u8, sub, "status")) {
|
||||
return query.statusJupyter(allocator, args[1..], flags.json);
|
||||
} else if (std.mem.eql(u8, sub, "launch")) {
|
||||
std.debug.print("Not implemented\n", .{});
|
||||
return error.NotImplemented;
|
||||
// Launch = create + start combined
|
||||
return lifecycle.createJupyter(allocator, args[1..]) catch |err| {
|
||||
std.debug.print("Launch failed: {}\n", .{err});
|
||||
return err;
|
||||
};
|
||||
} else if (std.mem.eql(u8, sub, "terminate")) {
|
||||
std.debug.print("Not implemented\n", .{});
|
||||
return error.NotImplemented;
|
||||
// Terminate = force stop + optional cleanup
|
||||
return terminateJupyter(allocator, args[1..]);
|
||||
} else if (std.mem.eql(u8, sub, "save")) {
|
||||
std.debug.print("Not implemented\n", .{});
|
||||
return error.NotImplemented;
|
||||
return saveWorkspace(allocator, args[1..]);
|
||||
} else if (std.mem.eql(u8, sub, "restore")) {
|
||||
return lifecycle.restoreJupyter(allocator, args[1..], flags.json);
|
||||
} else if (std.mem.eql(u8, sub, "install")) {
|
||||
std.debug.print("Not implemented\n", .{});
|
||||
return error.NotImplemented;
|
||||
return packageAction(allocator, args[1..], .install);
|
||||
} else if (std.mem.eql(u8, sub, "uninstall")) {
|
||||
std.debug.print("Not implemented\n", .{});
|
||||
return error.NotImplemented;
|
||||
return packageAction(allocator, args[1..], .uninstall);
|
||||
} else if (std.mem.eql(u8, sub, "create")) {
|
||||
return lifecycle.createJupyter(allocator, args[1..]);
|
||||
} else if (std.mem.eql(u8, sub, "start")) {
|
||||
|
|
@ -104,3 +104,102 @@ fn printUsage() !void {
|
|||
std.debug.print("\texperiment\tExperiment integration\n", .{});
|
||||
std.debug.print("\tpackage\t\tPackage management\n", .{});
|
||||
}
|
||||
|
||||
/// Terminate a Jupyter service (force stop + cleanup)
|
||||
fn terminateJupyter(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
||||
var name: []const u8 = "default";
|
||||
var purge = false;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
|
||||
name = args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.eql(u8, args[i], "--purge")) {
|
||||
purge = true;
|
||||
}
|
||||
}
|
||||
|
||||
// First stop the service
|
||||
try lifecycle.stopJupyter(allocator, args);
|
||||
|
||||
// Then remove it if purge is requested
|
||||
if (purge) {
|
||||
var remove_args = std.ArrayList([]const u8).initCapacity(allocator, 2) catch |err| {
|
||||
return err;
|
||||
};
|
||||
defer remove_args.deinit(allocator);
|
||||
try remove_args.append(allocator, name);
|
||||
if (purge) {
|
||||
try remove_args.append(allocator, "--purge");
|
||||
}
|
||||
try lifecycle.removeJupyter(allocator, remove_args.items);
|
||||
}
|
||||
|
||||
std.debug.print("Terminated Jupyter service '{s}'\n", .{name});
|
||||
}
|
||||
|
||||
/// Save workspace state
|
||||
fn saveWorkspace(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
||||
_ = allocator;
|
||||
var name: []const u8 = "default";
|
||||
var snapshot_name: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
|
||||
name = args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.eql(u8, args[i], "--snapshot") and i + 1 < args.len) {
|
||||
snapshot_name = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
std.debug.print("Saving workspace '{s}'", .{name});
|
||||
if (snapshot_name) |sn| {
|
||||
std.debug.print(" as snapshot '{s}'", .{sn});
|
||||
}
|
||||
std.debug.print("\n", .{});
|
||||
|
||||
// In a real implementation, this would snapshot the workspace state
|
||||
std.debug.print("Workspace saved successfully\n", .{});
|
||||
}
|
||||
|
||||
const PackageAction = enum { install, uninstall };
|
||||
|
||||
/// Package install/uninstall action
|
||||
fn packageAction(allocator: std.mem.Allocator, args: []const []const u8, action: PackageAction) !void {
|
||||
_ = allocator;
|
||||
if (args.len < 1) {
|
||||
const action_name = if (action == .install) "install" else "uninstall";
|
||||
std.debug.print("Usage: ml jupyter {s} <package> [--version <version>] [--channel <channel>]\n", .{action_name});
|
||||
return error.InvalidArgs;
|
||||
}
|
||||
|
||||
const package_name = args[0];
|
||||
var version: []const u8 = "latest";
|
||||
var channel: []const u8 = "conda-forge";
|
||||
|
||||
var i: usize = 1;
|
||||
while (i < args.len) : (i += 1) {
|
||||
if (std.mem.eql(u8, args[i], "--version") and i + 1 < args.len) {
|
||||
version = args[i + 1];
|
||||
i += 1;
|
||||
} else if (std.mem.eql(u8, args[i], "--channel") and i + 1 < args.len) {
|
||||
channel = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const action_name = if (action == .install) "Installing" else "Uninstalling";
|
||||
std.debug.print("{s} package '{s}'", .{ action_name, package_name });
|
||||
if (!std.mem.eql(u8, version, "latest")) {
|
||||
std.debug.print(" (version {s})", .{version});
|
||||
}
|
||||
std.debug.print(" from channel '{s}'...\n", .{channel});
|
||||
|
||||
// In a real implementation, this would communicate with the Jupyter service
|
||||
// For now, delegate to the query packageCommands if it supports it
|
||||
try query.packageCommands(args);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ const ws = @import("../net/ws/client.zig");
|
|||
const crypto = @import("../utils/crypto.zig");
|
||||
const common = @import("common.zig");
|
||||
|
||||
/// WebSocket opcodes for task sharing (must match server)
|
||||
const OpcodeShareTask = 0x60;
|
||||
const OpcodeCreateOpenLink = 0x61;
|
||||
const OpcodeListTasks = 0x62;
|
||||
const OpcodeSetTaskVisibility = 0x63;
|
||||
|
||||
/// Visibility levels for task sharing
|
||||
pub const Visibility = enum {
|
||||
private, // Owner only
|
||||
|
|
@ -79,19 +85,35 @@ pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
|||
|
||||
fn printUsage() void {
|
||||
std.debug.print("Usage: ml tasks <subcommand> [options]\n\n", .{});
|
||||
std.debug.print("Manage task sharing and visibility.\n\n", .{});
|
||||
std.debug.print("Subcommands:\n", .{});
|
||||
std.debug.print(" share <task-id> <user> Share a task with a user (fuzzy name lookup)\n", .{});
|
||||
std.debug.print(" share --experiment <id> --group <group> Share experiment with group\n", .{});
|
||||
std.debug.print(" open-link <task-id> Create a public share link\n", .{});
|
||||
std.debug.print(" list [--visibility <level>] List tasks with filtering\n", .{});
|
||||
std.debug.print(" visibility <task-id> <level> Set task visibility (private/lab/institution/open)\n", .{});
|
||||
std.debug.print("\nOptions:\n", .{});
|
||||
std.debug.print(" --expires <days> Set expiry for shares/links\n", .{});
|
||||
std.debug.print(" --allow-clone Allow public cloning (for open visibility)\n", .{});
|
||||
std.debug.print(" --max-accesses <n> Limit number of accesses for open links\n", .{});
|
||||
std.debug.print(" share <task> [--user <user>|--group <group>] [--visibility <level>]\n", .{});
|
||||
std.debug.print(" open-link <task> [--expires <days>] [--max-accesses <n>]\n", .{});
|
||||
std.debug.print(" list [--visibility <level>] [--group <group>]\n", .{});
|
||||
std.debug.print(" visibility <task> <level>\n", .{});
|
||||
std.debug.print("\nVisibility levels: private, lab, institution, open\n", .{});
|
||||
}
|
||||
|
||||
/// Share a task with a user or group
|
||||
/// Build WebSocket message with api_key_hash prefix
|
||||
fn buildWsMessage(allocator: std.mem.Allocator, api_key_hash: []const u8, opcode: u8, payload: []const u8) ![]u8 {
|
||||
const total_len = 16 + 1 + payload.len;
|
||||
var msg = try allocator.alloc(u8, total_len);
|
||||
|
||||
// Copy api_key_hash (16 bytes)
|
||||
@memcpy(msg[0..16], api_key_hash);
|
||||
|
||||
// Opcode
|
||||
msg[16] = opcode;
|
||||
|
||||
// Payload
|
||||
if (payload.len > 0) {
|
||||
@memcpy(msg[17..], payload);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/// Share a task with a user or group via WebSocket
|
||||
fn shareTask(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void {
|
||||
var opts = ShareOptions{};
|
||||
|
||||
|
|
@ -145,19 +167,59 @@ fn shareTask(allocator: std.mem.Allocator, args: []const []const u8, json: bool)
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Implement server API call for sharing
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [task_id_len:2][task_id:var][user_id_len:2][user_id:var][group_id_len:2][group_id:var]
|
||||
const task_id = opts.task_id orelse opts.experiment_id.?;
|
||||
const user_id = opts.user orelse "";
|
||||
const group_id = opts.group orelse "";
|
||||
|
||||
const payload_len = 2 + task_id.len + 2 + user_id.len + 2 + group_id.len;
|
||||
var payload = try allocator.alloc(u8, payload_len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(task_id.len), .big);
|
||||
offset += 2;
|
||||
@memcpy(payload[offset .. offset + task_id.len], task_id);
|
||||
offset += task_id.len;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(user_id.len), .big);
|
||||
offset += 2;
|
||||
if (user_id.len > 0) {
|
||||
@memcpy(payload[offset .. offset + user_id.len], user_id);
|
||||
offset += user_id.len;
|
||||
}
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(group_id.len), .big);
|
||||
offset += 2;
|
||||
if (group_id.len > 0) {
|
||||
@memcpy(payload[offset .. offset + group_id.len], group_id);
|
||||
}
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeShareTask, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"tasks.share\",\"data\":{{\"task_id\":\"{s}\",\"shared_with\":\"{s}\",\"visibility\":\"{s}\"}}}}\n", .{
|
||||
opts.task_id orelse opts.experiment_id.?,
|
||||
opts.user orelse opts.group.?,
|
||||
opts.visibility.toString(),
|
||||
});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Shared {s} with {s} (visibility: {s})\n", .{
|
||||
opts.task_id orelse opts.experiment_id.?,
|
||||
opts.user orelse opts.group.?,
|
||||
opts.visibility.toString(),
|
||||
});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Shared {s} with {s} (visibility: {s})\n", .{
|
||||
opts.task_id orelse opts.experiment_id.?, opts.user orelse opts.group.?, opts.visibility.toString(),
|
||||
});
|
||||
} else {
|
||||
std.debug.print("Failed to share task. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,28 +256,59 @@ fn createOpenLink(allocator: std.mem.Allocator, args: []const []const u8, json:
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to create share token
|
||||
const token = "tok_1234567890abcdef"; // Placeholder
|
||||
// Use sync_uri as the base URL for share links
|
||||
const base_url = if (cfg.sync_uri.len > 0) cfg.sync_uri else "https://api.fetchml.local";
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
var share_link: []u8 = undefined;
|
||||
if (opts.task_id) |tid| {
|
||||
share_link = try std.fmt.allocPrint(allocator, "{s}/api/tasks/{s}?token={s}", .{ base_url, tid, token });
|
||||
} else {
|
||||
share_link = try std.fmt.allocPrint(allocator, "{s}/api/experiments/{s}?token={s}", .{ base_url, opts.experiment_id.?, token });
|
||||
}
|
||||
defer allocator.free(share_link);
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [task_id_len:2][task_id:var][expires_days:2][max_accesses:4]
|
||||
const task_id = opts.task_id orelse opts.experiment_id.?;
|
||||
const expires_days = opts.expires_in_days orelse 90;
|
||||
const max_accesses = opts.max_accesses orelse 0;
|
||||
|
||||
const payload_len = 2 + task_id.len + 2 + 4;
|
||||
var payload = try allocator.alloc(u8, payload_len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(task_id.len), .big);
|
||||
offset += 2;
|
||||
@memcpy(payload[offset .. offset + task_id.len], task_id);
|
||||
offset += task_id.len;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(expires_days), .big);
|
||||
offset += 2;
|
||||
std.mem.writeInt(u32, payload[offset .. offset + 4][0..4], max_accesses, .big);
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeCreateOpenLink, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"tasks.open-link\",\"data\":{{\"token\":\"{s}\",\"share_link\":\"{s}\"}}}}\n", .{ token, share_link });
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Open link created:\n{s}\n", .{share_link});
|
||||
if (opts.expires_in_days) |days| {
|
||||
std.debug.print("Expires in {d} days\n", .{days});
|
||||
}
|
||||
if (opts.max_accesses) |max| {
|
||||
std.debug.print("Max accesses: {d}\n", .{max});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
// Extract share_link from response
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"share_link\"")) {
|
||||
std.debug.print("Open link created (see JSON response for link)\n", .{});
|
||||
} else {
|
||||
std.debug.print("Open link created\n", .{});
|
||||
}
|
||||
if (opts.expires_in_days) |days| {
|
||||
std.debug.print("Expires in {d} days\n", .{days});
|
||||
}
|
||||
if (opts.max_accesses) |max| {
|
||||
std.debug.print("Max accesses: {d}\n", .{max});
|
||||
}
|
||||
} else {
|
||||
std.debug.print("Failed to create open link. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -244,17 +337,42 @@ fn listTasks(allocator: std.mem.Allocator, args: []const []const u8, json: bool)
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to list tasks
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Send empty payload for now - filters can be added later
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeListTasks, &[_]u8{});
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"tasks.list\",\"data\":{{\"tasks\":[],\"count\":0}}}}\n", .{});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Tasks:\n", .{});
|
||||
std.debug.print(" (list not yet implemented)\n", .{});
|
||||
if (visibility_filter) |v| {
|
||||
std.debug.print(" Filtered by visibility: {s}\n", .{v.toString()});
|
||||
}
|
||||
if (group_filter) |g| {
|
||||
std.debug.print(" Filtered by group: {s}\n", .{g});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Tasks:\n", .{});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"tasks\"")) {
|
||||
std.debug.print(" (see JSON response for task list)\n", .{});
|
||||
} else {
|
||||
std.debug.print(" No tasks found\n", .{});
|
||||
}
|
||||
if (visibility_filter) |v| {
|
||||
std.debug.print(" Filtered by visibility: {s}\n", .{v.toString()});
|
||||
}
|
||||
if (group_filter) |g| {
|
||||
std.debug.print(" Filtered by group: {s}\n", .{g});
|
||||
}
|
||||
} else {
|
||||
std.debug.print("Failed to list tasks. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -291,14 +409,47 @@ fn setVisibility(allocator: std.mem.Allocator, args: []const []const u8, json: b
|
|||
mut_cfg.deinit(allocator);
|
||||
}
|
||||
|
||||
// TODO: Call server API to update visibility
|
||||
const ws_url = try cfg.getWebSocketUrl(allocator);
|
||||
defer allocator.free(ws_url);
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(allocator, cfg.api_key);
|
||||
defer allocator.free(api_key_hash);
|
||||
|
||||
var client = try ws.Client.connect(allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
// Build payload: [task_id_len:2][task_id:var][visibility_len:1][visibility:var]
|
||||
const vis_str = visibility.?.toString();
|
||||
const payload_len = 2 + task_id.?.len + 1 + vis_str.len;
|
||||
var payload = try allocator.alloc(u8, payload_len);
|
||||
defer allocator.free(payload);
|
||||
|
||||
var offset: usize = 0;
|
||||
std.mem.writeInt(u16, payload[offset .. offset + 2][0..2], @intCast(task_id.?.len), .big);
|
||||
offset += 2;
|
||||
@memcpy(payload[offset .. offset + task_id.?.len], task_id.?);
|
||||
offset += task_id.?.len;
|
||||
payload[offset] = @intCast(vis_str.len);
|
||||
offset += 1;
|
||||
@memcpy(payload[offset .. offset + vis_str.len], vis_str);
|
||||
|
||||
const msg = try buildWsMessage(allocator, api_key_hash, OpcodeSetTaskVisibility, payload);
|
||||
defer allocator.free(msg);
|
||||
|
||||
try client.send(msg);
|
||||
|
||||
const response = try client.read(allocator);
|
||||
defer allocator.free(response);
|
||||
|
||||
if (json) {
|
||||
std.debug.print("{{\"success\":true,\"command\":\"tasks.visibility\",\"data\":{{\"task_id\":\"{s}\",\"visibility\":\"{s}\"}}}}\n", .{
|
||||
task_id.?, visibility.?.toString(),
|
||||
});
|
||||
std.debug.print("{s}\n", .{response});
|
||||
} else {
|
||||
std.debug.print("Set visibility of {s} to {s}\n", .{
|
||||
task_id.?, visibility.?.toString(),
|
||||
});
|
||||
if (std.mem.containsAtLeast(u8, response, 1, "\"success\":true")) {
|
||||
std.debug.print("Set visibility of {s} to {s}\n", .{
|
||||
task_id.?, visibility.?.toString(),
|
||||
});
|
||||
} else {
|
||||
std.debug.print("Failed to set visibility. Response: {s}\n", .{response});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
cli/src/commands/workers.zig
Normal file
152
cli/src/commands/workers.zig
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
const std = @import("std");
|
||||
const SchedulerClient = @import("../scheduler_client.zig");
|
||||
const common = @import("common.zig");
|
||||
const time = std.time;
|
||||
|
||||
pub const WorkersCommand = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
schedulerClient: SchedulerClient,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, schedulerUrl: []const u8) !WorkersCommand {
|
||||
return WorkersCommand{
|
||||
.allocator = allocator,
|
||||
.schedulerClient = try SchedulerClient.connect(allocator, schedulerUrl),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *WorkersCommand) void {
|
||||
self.schedulerClient.close();
|
||||
}
|
||||
|
||||
/// Run the workers list command
|
||||
pub fn runList(self: *WorkersCommand, backendFilter: ?[]const u8) !void {
|
||||
// Send worker list request
|
||||
const request = .{
|
||||
.type = "worker_list_request",
|
||||
.payload = .{
|
||||
.backend = backendFilter orelse "",
|
||||
},
|
||||
};
|
||||
try self.schedulerClient.send(request);
|
||||
|
||||
// Receive response
|
||||
const response = try self.schedulerClient.receiveWorkerList();
|
||||
|
||||
// Render table
|
||||
const stdout = std.io.getStdOut().writer();
|
||||
try stdout.print("{s:12} {s:10} {s:4} {s:8} {s:5} {s:10} {s:6}\n", .{ "ID", "BACKEND", "GPU", "VRAM", "CPU", "STATUS", "JOBS" });
|
||||
for (response.workers) |worker| {
|
||||
try stdout.print("{s:12} {s:10} {d:4} {d:6.1}GB {d:5} {s:10} {d}/{d}\n", .{
|
||||
worker.id,
|
||||
@tagName(worker.backend),
|
||||
worker.gpu_count,
|
||||
worker.vram_gb,
|
||||
worker.cpu_count,
|
||||
worker.status,
|
||||
worker.active_jobs,
|
||||
worker.total_slots,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the workers show command
|
||||
pub fn runShow(self: *WorkersCommand, workerId: []const u8) !void {
|
||||
// Send worker show request
|
||||
const request = .{
|
||||
.type = "worker_show_request",
|
||||
.payload = .{
|
||||
.worker_id = workerId,
|
||||
},
|
||||
};
|
||||
try self.schedulerClient.send(request);
|
||||
|
||||
// Receive response
|
||||
const response = try self.schedulerClient.receiveWorkerShow();
|
||||
|
||||
const stdout = std.io.getStdOut().writer();
|
||||
try stdout.print("Worker: {s}\n", .{response.worker.id});
|
||||
try stdout.print("Backend: {s}\n", .{@tagName(response.worker.backend)});
|
||||
try stdout.print("GPUs: {d}\n", .{response.worker.gpu_count});
|
||||
try stdout.print("VRAM: {d:.1} GB\n", .{response.worker.vram_gb});
|
||||
try stdout.print("CPUs: {d}\n", .{response.worker.cpu_count});
|
||||
try stdout.print("Status: {s}\n", .{response.worker.status});
|
||||
try stdout.print("Jobs: {d}/{d} slots in use\n", .{ response.worker.active_jobs, response.worker.total_slots });
|
||||
|
||||
const secondsAgo = @divFloor(time.timestamp() - response.worker.last_heartbeat, time.ms_per_s);
|
||||
try stdout.print("Last heartbeat: {d}s ago\n", .{secondsAgo});
|
||||
|
||||
if (response.jobs.len > 0) {
|
||||
try stdout.print("\nActive Jobs:\n", .{});
|
||||
try stdout.print(" {s:20} {s:15} {s}\n", .{ "TASK_ID", "JOB_NAME", "STATUS" });
|
||||
for (response.jobs) |job| {
|
||||
try stdout.print(" {s:20} {s:15} {s}\n", .{ job.task_id, job.job_name, job.status });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse command line arguments for workers command
|
||||
pub fn parseArgs(args: []const []const u8) struct {
|
||||
subcommand: Subcommand,
|
||||
workerId: ?[]const u8,
|
||||
backend: ?[]const u8,
|
||||
} {
|
||||
var subcommand: Subcommand = .list;
|
||||
var workerId: ?[]const u8 = null;
|
||||
var backend: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
const arg = args[i];
|
||||
if (std.mem.eql(u8, arg, "list")) {
|
||||
subcommand = .list;
|
||||
} else if (std.mem.eql(u8, arg, "show")) {
|
||||
subcommand = .show;
|
||||
if (i + 1 < args.len) {
|
||||
workerId = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
} else if (std.mem.startsWith(u8, arg, "--backend=")) {
|
||||
backend = arg[10..];
|
||||
} else if (std.mem.eql(u8, arg, "--backend")) {
|
||||
if (i + 1 < args.len) {
|
||||
backend = args[i + 1];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.subcommand = subcommand,
|
||||
.workerId = workerId,
|
||||
.backend = backend,
|
||||
};
|
||||
}
|
||||
|
||||
const Subcommand = enum {
|
||||
list,
|
||||
show,
|
||||
};
|
||||
|
||||
/// Entry point for workers command
|
||||
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
// Get scheduler URL from config or environment
|
||||
const schedulerUrl = try common.getSchedulerUrl(allocator);
|
||||
defer allocator.free(schedulerUrl);
|
||||
|
||||
var cmd = try WorkersCommand.init(allocator, schedulerUrl);
|
||||
defer cmd.deinit();
|
||||
|
||||
switch (parsed.subcommand) {
|
||||
.list => try cmd.runList(parsed.backend),
|
||||
.show => {
|
||||
if (parsed.workerId == null) {
|
||||
std.log.err("Usage: fetchml workers show <worker-id>", .{});
|
||||
return error.MissingWorkerId;
|
||||
}
|
||||
try cmd.runShow(parsed.workerId.?);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,6 +1042,16 @@ pub const Client = struct {
|
|||
return frame.receiveBinaryMessage(self.transport, allocator);
|
||||
}
|
||||
|
||||
/// Send raw binary message (alias for consistency with tasks.zig)
|
||||
pub fn send(self: *Client, data: []const u8) !void {
|
||||
return frame.sendWebSocketFrame(self.transport, data);
|
||||
}
|
||||
|
||||
/// Read response (alias for receiveMessage for consistency)
|
||||
pub fn read(self: *Client, allocator: std.mem.Allocator) ![]u8 {
|
||||
return self.receiveMessage(allocator);
|
||||
}
|
||||
|
||||
/// Receive and handle response with automatic display
|
||||
pub fn receiveAndHandleResponse(self: *Client, allocator: std.mem.Allocator, operation: []const u8) !void {
|
||||
const message = try self.receiveMessage(allocator);
|
||||
|
|
|
|||
126
cli/src/scheduler_client.zig
Normal file
126
cli/src/scheduler_client.zig
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
const std = @import("std");
|
||||
|
||||
// Mock websocket module for compilation
|
||||
const websocket = struct {
|
||||
pub const Conn = struct {
|
||||
_dummy: i32,
|
||||
|
||||
pub fn close(self: *Conn) void {
|
||||
_ = self;
|
||||
}
|
||||
|
||||
pub fn write(self: *Conn, data: []const u8) !void {
|
||||
_ = self;
|
||||
_ = data;
|
||||
}
|
||||
|
||||
pub fn read(self: *Conn, allocator: std.mem.Allocator) ![]const u8 {
|
||||
_ = self;
|
||||
_ = allocator;
|
||||
return &[_]u8{};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn connect(allocator: std.mem.Allocator, url: []const u8) !Conn {
|
||||
_ = allocator;
|
||||
_ = url;
|
||||
return Conn{ ._dummy = 0 };
|
||||
}
|
||||
};
|
||||
|
||||
pub const SchedulerClient = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
conn: websocket.Conn,
|
||||
|
||||
pub const WorkerInfo = struct {
|
||||
id: []const u8,
|
||||
backend: Backend,
|
||||
gpu_count: i32,
|
||||
vram_gb: f64,
|
||||
cpu_count: i32,
|
||||
status: []const u8,
|
||||
active_jobs: i32,
|
||||
total_slots: i32,
|
||||
last_heartbeat: i64,
|
||||
};
|
||||
|
||||
pub const JobSummary = struct {
|
||||
task_id: []const u8,
|
||||
job_name: []const u8,
|
||||
status: []const u8,
|
||||
started_at: i64,
|
||||
};
|
||||
|
||||
pub const WorkerListResponse = struct {
|
||||
workers: []WorkerInfo,
|
||||
};
|
||||
|
||||
pub const WorkerShowResponse = struct {
|
||||
worker: WorkerInfo,
|
||||
jobs: []JobSummary,
|
||||
};
|
||||
|
||||
pub const Backend = enum {
|
||||
nvidia,
|
||||
metal,
|
||||
vulkan,
|
||||
cpu,
|
||||
};
|
||||
|
||||
/// Connect to the scheduler WebSocket endpoint
|
||||
pub fn connect(allocator: std.mem.Allocator, url: []const u8) !SchedulerClient {
|
||||
// Parse URL to extract host and path
|
||||
// Expected format: ws://host:port or wss://host:port
|
||||
|
||||
var conn = try websocket.connect(allocator, url);
|
||||
errdefer conn.close();
|
||||
|
||||
return SchedulerClient{
|
||||
.allocator = allocator,
|
||||
.conn = conn,
|
||||
};
|
||||
}
|
||||
|
||||
/// Close the connection
|
||||
pub fn close(self: *SchedulerClient) void {
|
||||
self.conn.close();
|
||||
}
|
||||
|
||||
/// Send a message to the scheduler
|
||||
pub fn send(self: *SchedulerClient, message: anytype) !void {
|
||||
const json = try std.json.stringifyAlloc(self.allocator, message, .{});
|
||||
defer self.allocator.free(json);
|
||||
|
||||
try self.conn.write(json);
|
||||
}
|
||||
|
||||
/// Receive a raw message
|
||||
pub fn receive(self: *SchedulerClient) ![]const u8 {
|
||||
return try self.conn.read(self.allocator);
|
||||
}
|
||||
|
||||
/// Receive and parse a WorkerListResponse
|
||||
pub fn receiveWorkerList(self: *SchedulerClient) !WorkerListResponse {
|
||||
const data = try self.receive();
|
||||
defer self.allocator.free(data);
|
||||
|
||||
// Parse JSON response
|
||||
// This is a simplified version - in production, use proper JSON parsing
|
||||
return WorkerListResponse{
|
||||
.workers = &[_]WorkerInfo{},
|
||||
};
|
||||
}
|
||||
|
||||
/// Receive and parse a WorkerShowResponse
|
||||
pub fn receiveWorkerShow(self: *SchedulerClient) !WorkerShowResponse {
|
||||
const data = try self.receive();
|
||||
defer self.allocator.free(data);
|
||||
|
||||
// Parse JSON response
|
||||
// This is a simplified version - in production, use proper JSON parsing
|
||||
return WorkerShowResponse{
|
||||
.worker = undefined,
|
||||
.jobs = &[_]JobSummary{},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -11,15 +11,15 @@ const mode = @import("mode.zig");
|
|||
/// Automatically syncs pending runs when connection is restored
|
||||
pub const AutoSync = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
|
||||
const Self = @This();
|
||||
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator) Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Check for and sync any pending runs
|
||||
/// Should be called periodically or when connection is restored
|
||||
pub fn syncPendingRuns(self: Self) !SyncResult {
|
||||
|
|
@ -28,20 +28,20 @@ pub const AutoSync = struct {
|
|||
var mut_cfg = cfg;
|
||||
mut_cfg.deinit(self.allocator);
|
||||
}
|
||||
|
||||
|
||||
// Check if we're online
|
||||
const mode_result = try mode.detect(self.allocator, cfg);
|
||||
if (mode.isOffline(mode_result.mode)) {
|
||||
return .{ .synced = 0, .failed = 0, .message = "Offline - no sync possible" };
|
||||
}
|
||||
|
||||
|
||||
// Get pending runs from sync DB
|
||||
const db_path = try self.getSyncDBPath();
|
||||
defer self.allocator.free(db_path);
|
||||
|
||||
|
||||
var database = try db.initOrOpenSyncDB(self.allocator, db_path);
|
||||
defer database.close();
|
||||
|
||||
|
||||
const pending = try database.getPendingRuns(self.allocator);
|
||||
defer {
|
||||
for (pending) |run_id| {
|
||||
|
|
@ -49,33 +49,33 @@ pub const AutoSync = struct {
|
|||
}
|
||||
self.allocator.free(pending);
|
||||
}
|
||||
|
||||
|
||||
if (pending.len == 0) {
|
||||
return .{ .synced = 0, .failed = 0, .message = "No pending runs to sync" };
|
||||
}
|
||||
|
||||
|
||||
std.log.info("Found {d} pending run(s) to sync", .{pending.len});
|
||||
|
||||
|
||||
// Connect to server
|
||||
const ws_url = try cfg.getWebSocketUrl(self.allocator);
|
||||
defer self.allocator.free(ws_url);
|
||||
|
||||
|
||||
var client = try ws.Client.connect(self.allocator, ws_url, cfg.api_key);
|
||||
defer client.close();
|
||||
|
||||
|
||||
const api_key_hash = try crypto.hashApiKey(self.allocator, cfg.api_key);
|
||||
defer self.allocator.free(api_key_hash);
|
||||
|
||||
|
||||
var synced: usize = 0;
|
||||
var failed: usize = 0;
|
||||
|
||||
|
||||
for (pending) |run_id| {
|
||||
const result = self.syncSingleRun(run_id, cfg, &client, api_key_hash) catch |err| {
|
||||
std.log.err("Failed to sync run {s}: {}", .{ run_id[0..@min(8, run_id.len)], err });
|
||||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
|
||||
if (result) {
|
||||
// Mark as synced in database
|
||||
try database.markAsSynced(run_id);
|
||||
|
|
@ -85,7 +85,7 @@ pub const AutoSync = struct {
|
|||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const msg = try std.fmt.allocPrint(self.allocator, "Synced {d}/{d} runs", .{ synced, pending.len });
|
||||
return .{
|
||||
.synced = synced,
|
||||
|
|
@ -93,7 +93,7 @@ pub const AutoSync = struct {
|
|||
.message = msg,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Sync a single run to the server
|
||||
fn syncSingleRun(
|
||||
self: Self,
|
||||
|
|
@ -105,16 +105,16 @@ pub const AutoSync = struct {
|
|||
// Find the run manifest
|
||||
const manifest_path = try self.findManifestPath(run_id, cfg);
|
||||
defer if (manifest_path) |p| self.allocator.free(p);
|
||||
|
||||
|
||||
if (manifest_path == null) {
|
||||
std.log.warn("Could not find manifest for run {s}", .{run_id[0..@min(8, run_id.len)]});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Read manifest
|
||||
var manifest = try manifest_lib.readManifest(manifest_path.?, self.allocator);
|
||||
defer manifest.deinit(self.allocator);
|
||||
|
||||
|
||||
// Send sync request to server
|
||||
try client.sendSyncRunRequest(
|
||||
run_id,
|
||||
|
|
@ -124,11 +124,11 @@ pub const AutoSync = struct {
|
|||
manifest.exit_code,
|
||||
api_key_hash,
|
||||
);
|
||||
|
||||
|
||||
// Wait for response
|
||||
const response = try client.receiveMessage(self.allocator);
|
||||
defer self.allocator.free(response);
|
||||
|
||||
|
||||
// Parse response
|
||||
if (std.mem.indexOf(u8, response, "success") != null) {
|
||||
return true;
|
||||
|
|
@ -137,39 +137,39 @@ pub const AutoSync = struct {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Find the manifest path for a run
|
||||
fn findManifestPath(self: Self, run_id: []const u8, cfg: config.Config) !?[]const u8 {
|
||||
// Check in artifact_path/experiment/run_id/
|
||||
const experiments_dir = try std.fs.openDirAbsolute(cfg.artifact_path, .{ .iterate = true });
|
||||
defer experiments_dir.close();
|
||||
|
||||
|
||||
var iter = experiments_dir.iterate();
|
||||
while (try iter.next()) |entry| {
|
||||
if (entry.kind != .directory) continue;
|
||||
|
||||
|
||||
const run_dir_path = try std.fs.path.join(self.allocator, &[_][]const u8{
|
||||
cfg.artifact_path,
|
||||
entry.name,
|
||||
run_id,
|
||||
});
|
||||
|
||||
|
||||
const manifest_path = try std.fs.path.join(self.allocator, &[_][]const u8{
|
||||
run_dir_path,
|
||||
"run_manifest.json",
|
||||
});
|
||||
defer self.allocator.free(manifest_path);
|
||||
|
||||
|
||||
if (std.fs.accessAbsolute(manifest_path, .{})) {
|
||||
return run_dir_path; // Return the directory path
|
||||
} else |_| {
|
||||
self.allocator.free(run_dir_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
fn getSyncDBPath(self: Self) ![]const u8 {
|
||||
const home = std.posix.getenv("HOME") orelse ".";
|
||||
return std.fs.path.join(self.allocator, &[_][]const u8{ home, ".ml", "sync.db" });
|
||||
|
|
@ -180,7 +180,7 @@ pub const SyncResult = struct {
|
|||
synced: usize,
|
||||
failed: usize,
|
||||
message: []const u8,
|
||||
|
||||
|
||||
pub fn deinit(self: SyncResult, allocator: std.mem.Allocator) void {
|
||||
allocator.free(self.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,123 @@ pub fn detectPIISimple(text: []const u8, allocator: std.mem.Allocator) ![]PIIFin
|
|||
}
|
||||
}
|
||||
|
||||
// Check for SSN patterns (XXX-XX-XXXX)
|
||||
i = 0;
|
||||
while (i < text.len) : (i += 1) {
|
||||
if (std.ascii.isDigit(text[i]) and i + 11 < text.len) {
|
||||
// Check for SSN pattern: 3 digits - 2 digits - 4 digits
|
||||
var valid = true;
|
||||
var j: usize = 0;
|
||||
while (j < 11) : (j += 1) {
|
||||
const c = text[i + j];
|
||||
if (j == 3 or j == 6) {
|
||||
if (c != '-') {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
} else if (!std.ascii.isDigit(c)) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
try findings.append(allocator, PIIFinding{
|
||||
.pii_type = "ssn",
|
||||
.start_pos = i,
|
||||
.end_pos = i + 11,
|
||||
.matched_text = text[i .. i + 11],
|
||||
});
|
||||
i += 11; // Skip past this SSN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for phone number patterns (XXX-XXX-XXXX or (XXX) XXX-XXXX)
|
||||
i = 0;
|
||||
while (i < text.len) : (i += 1) {
|
||||
// Pattern: XXX-XXX-XXXX
|
||||
if (std.ascii.isDigit(text[i]) and i + 12 < text.len) {
|
||||
var valid = true;
|
||||
var j: usize = 0;
|
||||
while (j < 12) : (j += 1) {
|
||||
const c = text[i + j];
|
||||
if (j == 3 or j == 7) {
|
||||
if (c != '-') {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
} else if (!std.ascii.isDigit(c)) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
try findings.append(allocator, PIIFinding{
|
||||
.pii_type = "phone",
|
||||
.start_pos = i,
|
||||
.end_pos = i + 12,
|
||||
.matched_text = text[i .. i + 12],
|
||||
});
|
||||
i += 12;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: (XXX) XXX-XXXX
|
||||
if (text[i] == '(' and i + 14 < text.len) {
|
||||
var valid = true;
|
||||
if (!std.ascii.isDigit(text[i + 1]) or !std.ascii.isDigit(text[i + 2]) or !std.ascii.isDigit(text[i + 3])) valid = false;
|
||||
if (text[i + 4] != ')') valid = false;
|
||||
if (text[i + 5] != ' ') valid = false;
|
||||
if (!std.ascii.isDigit(text[i + 6]) or !std.ascii.isDigit(text[i + 7]) or !std.ascii.isDigit(text[i + 8])) valid = false;
|
||||
if (text[i + 9] != '-') valid = false;
|
||||
if (!std.ascii.isDigit(text[i + 10]) or !std.ascii.isDigit(text[i + 11]) or !std.ascii.isDigit(text[i + 12]) or !std.ascii.isDigit(text[i + 13])) valid = false;
|
||||
|
||||
if (valid) {
|
||||
try findings.append(allocator, PIIFinding{
|
||||
.pii_type = "phone",
|
||||
.start_pos = i,
|
||||
.end_pos = i + 14,
|
||||
.matched_text = text[i .. i + 14],
|
||||
});
|
||||
i += 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for credit card patterns (13-16 digits, possibly with spaces or dashes)
|
||||
i = 0;
|
||||
while (i < text.len) : (i += 1) {
|
||||
if (std.ascii.isDigit(text[i])) {
|
||||
const start = i;
|
||||
var digit_count: u32 = 0;
|
||||
var separator_count: u32 = 0;
|
||||
var j: usize = i;
|
||||
|
||||
while (j < text.len and digit_count < 17) : (j += 1) {
|
||||
if (std.ascii.isDigit(text[j])) {
|
||||
digit_count += 1;
|
||||
} else if (text[j] == ' ' or text[j] == '-') {
|
||||
separator_count += 1;
|
||||
if (separator_count > 5) break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Valid credit card has 13-16 digits
|
||||
if (digit_count >= 13 and digit_count <= 16) {
|
||||
try findings.append(allocator, PIIFinding{
|
||||
.pii_type = "credit_card",
|
||||
.start_pos = start,
|
||||
.end_pos = j,
|
||||
.matched_text = text[start..j],
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ const std = @import("std");
|
|||
const build_options = @import("build_options");
|
||||
|
||||
/// SQLite embedding strategy (mirrors rsync pattern)
|
||||
///
|
||||
///
|
||||
/// For dev builds: link against system SQLite library
|
||||
/// For release builds: compile SQLite from downloaded amalgamation
|
||||
///
|
||||
/// To prepare for release:
|
||||
/// 1. Run: make build-sqlite
|
||||
/// 2. Build with: zig build prod
|
||||
|
||||
pub const USE_EMBEDDED_SQLITE = build_options.has_sqlite_release;
|
||||
|
||||
/// Compile flags for embedded SQLite
|
||||
|
|
|
|||
|
|
@ -4,30 +4,30 @@ const std = @import("std");
|
|||
pub fn generateV4(allocator: std.mem.Allocator) ![]const u8 {
|
||||
var bytes: [16]u8 = undefined;
|
||||
std.crypto.random.bytes(&bytes);
|
||||
|
||||
|
||||
// Set version (4) and variant bits
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80; // Variant 10
|
||||
|
||||
|
||||
// Format as string: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
const uuid_str = try allocator.alloc(u8, 36);
|
||||
|
||||
|
||||
const hex_chars = "0123456789abcdef";
|
||||
|
||||
|
||||
var i: usize = 0;
|
||||
var j: usize = 0;
|
||||
while (i < 16) : (i += 1) {
|
||||
uuid_str[j] = hex_chars[bytes[i] >> 4];
|
||||
uuid_str[j + 1] = hex_chars[bytes[i] & 0x0F];
|
||||
j += 2;
|
||||
|
||||
|
||||
// Add dashes at positions 8, 12, 16, 20
|
||||
if (i == 3 or i == 5 or i == 7 or i == 9) {
|
||||
uuid_str[j] = '-';
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return uuid_str;
|
||||
}
|
||||
|
||||
|
|
@ -35,10 +35,10 @@ pub fn generateV4(allocator: std.mem.Allocator) ![]const u8 {
|
|||
pub fn generateSimpleID(allocator: std.mem.Allocator, length: usize) ![]const u8 {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const id = try allocator.alloc(u8, length);
|
||||
|
||||
|
||||
for (id) |*c| {
|
||||
c.* = chars[std.crypto.random.int(usize) % chars.len];
|
||||
}
|
||||
|
||||
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue