diff options
Diffstat (limited to '9harness/src/tree.zig')
| -rw-r--r-- | 9harness/src/tree.zig | 890 |
1 files changed, 879 insertions, 11 deletions
diff --git a/9harness/src/tree.zig b/9harness/src/tree.zig index 6525e58..b6ff4a4 100644 --- a/9harness/src/tree.zig +++ b/9harness/src/tree.zig @@ -41,6 +41,7 @@ const fs = cloud9.fs; const E = fs.E; const Io = std.Io; const linux = std.os.linux; +pub const active = @import("active.zig"); // ---- comptime bounds --------------------------------------------------------- @@ -84,9 +85,10 @@ pub const Top = enum(u8) { hermes, dsh, skills, + active, /// Directory entries of the root, in listing order. - pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills }; + pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active }; pub fn fileName(t: Top) []const u8 { return @tagName(t); @@ -94,7 +96,7 @@ pub const Top = enum(u8) { pub fn dir(t: Top) bool { return switch (t) { - .root, .claude, .codex, .omp, .hermes, .dsh, .skills => true, + .root, .claude, .codex, .omp, .hermes, .dsh, .skills, .active => true, .pid, .uptime => false, }; } @@ -171,7 +173,82 @@ fn mountOwner(r: Root, rel_path: []const u8) Top { // ---- node ids and the path table --------------------------------------------- -pub const Kind = enum(u8) { top = 0, path = 1 }; +pub const Kind = enum(u8) { top = 0, path = 1, active = 2 }; + +comptime { + // The derived view indexes the same pinned roots the mirror does. + for (std.enums.values(Root)) |r| { + if (!std.mem.eql(u8, @tagName(r), @tagName(@as(active.Kind, @enumFromInt(@intFromEnum(r)))))) + @compileError("tree.Root and active.Kind must agree, index by index"); + } +} + +/// A node under `/active`. Everything but a harness directory names a +/// slot of the live table and the generation it had when the id was +/// minted, so an id outlives its process only long enough to answer +/// ENOENT. +pub const AFile = enum(u8) { + /// `/active/<harness>` + harness_dir, + /// `/active/<harness>/<pid>` + entry, + pid, + ppid, + started, + cwd, + name, + title, + session, + model, + via, + zmx, + status, + transcript, + /// `/active/<harness>/<pid>/agents` + agents, + /// `/active/<harness>/<pid>/agents/<name>` + agent, + agent_model, + agent_transcript, + + /// The fields of one entry, in listing order. A field with no value + /// is not listed and does not resolve. + pub const entry_files = [_]AFile{ + .pid, .ppid, .started, .cwd, .name, .title, + .session, .model, .via, .zmx, .status, .transcript, + }; + pub const agent_files = [_]AFile{ .agent_model, .agent_transcript }; + + pub fn fileName(f: AFile) []const u8 { + return switch (f) { + .agent_model => "model", + .agent_transcript => "transcript", + else => @tagName(f), + }; + } +}; + +/// A resolved `/active` node. +pub const Act = struct { + file: AFile, + /// Only for `harness_dir`. + kind: active.Kind = .claude, + slot: u8 = 0, + gen: u8 = 0, + agent: u8 = 0, +}; + +pub fn activeNode(a: Act) u64 { + const serial: u48 = switch (a.file) { + .harness_dir => @intFromEnum(a.kind), + else => @as(u48, a.slot) | (@as(u48, a.gen) << 8) | (@as(u48, a.agent) << 16), + }; + return @bitCast(Node{ + .idx = @intFromEnum(a.file), + .kind = @intFromEnum(Kind.active), + .serial = serial, + }); +} pub const Node = packed struct(u64) { /// A Top index (kind == .top) or a Root index (kind == .path). @@ -306,15 +383,71 @@ pub const Harness = struct { list_dirs: [list_capacity]bool = undefined, list_offs: [list_capacity]u32 = undefined, + // The derived view (`/active`). An empty `proc` base turns it off, + // which is the default: a test must opt in to reading a process + // tree, and never the live one. + live: active.Table = .{}, + proc_buf: [base_capacity]u8 = @splat(0), + proc_len: u16 = 0, + home_buf: [base_capacity]u8 = @splat(0), + home_len: u16 = 0, + zmx_buf: [base_capacity]u8 = @splat(0), + zmx_len: u16 = 0, + runtime_buf: [base_capacity]u8 = @splat(0), + runtime_len: u16 = 0, + envp: ?cloud9.post.Env = null, + self_pid: u32 = 0, + btime: i64 = 0, + allow_move: bool = false, + act_text: [1024]u8 = undefined, + act_name: [64]u8 = undefined, + act_path: [active.path_capacity]u8 = undefined, + pub const InitOptions = struct { io: Io, pid: u32, /// Base path of each root, or "" when the root is not pinned. bases: [5][]const u8, + /// The proc filesystem `/active` reads. Empty — the default — + /// leaves the derived view out of the tree entirely. + proc: []const u8 = "", + /// $HOME, for the harnesses' store-slug spellings. + home: []const u8 = "", + /// The zmx binary a move execs, resolved to an absolute path by + /// the caller. Never client bytes. + zmx: []const u8 = "zmx", + /// $XDG_RUNTIME_DIR, where a move looks for the zmx session it + /// is about to create. + runtime: []const u8 = "", + /// The daemon's own environment block, handed to the harness a + /// move re-execs. Null leaves a moved agent with an empty one. + envp: ?cloud9.post.Env = null, + /// Whether writing `/active/<h>/<pid>/zmx` may move a session. + allow_move: bool = false, }; pub fn init(h: *Harness, o: InitOptions) void { h.* = .{ .io = o.io, .pid = o.pid, .started_sec = Io.Timestamp.now(o.io, .real).toSeconds() }; + if (o.proc.len > 0 and o.proc.len <= base_capacity) { + @memcpy(h.proc_buf[0..o.proc.len], o.proc); + h.proc_len = @intCast(o.proc.len); + h.self_pid = o.pid; + h.btime = active.bootTime(o.io, o.proc); + } + if (o.home.len <= base_capacity) { + @memcpy(h.home_buf[0..o.home.len], o.home); + h.home_len = @intCast(o.home.len); + } + if (o.zmx.len > 0 and o.zmx.len <= base_capacity) { + @memcpy(h.zmx_buf[0..o.zmx.len], o.zmx); + h.zmx_len = @intCast(o.zmx.len); + } + if (o.runtime.len <= base_capacity) { + @memcpy(h.runtime_buf[0..o.runtime.len], o.runtime); + h.runtime_len = @intCast(o.runtime.len); + } + h.allow_move = o.allow_move; + h.envp = o.envp; inline for (0..5) |i| { const path = o.bases[i]; h.base_set[i] = path.len > 0 and path.len <= base_capacity; @@ -388,6 +521,18 @@ pub const Harness = struct { if (serialOf(slot, e.gen) != n.serial) return null; return .{ .path = e }; }, + .active => { + const f = std.enums.fromInt(AFile, n.idx) orelse return null; + if (f == .harness_dir) { + const k = std.enums.fromInt(active.Kind, @as(u8, @truncate(n.serial))) orelse return null; + return .{ .act = .{ .file = f, .kind = k } }; + } + const slot: u8 = @truncate(n.serial); + const gen: u8 = @truncate(n.serial >> 8); + const agent: u8 = @truncate(n.serial >> 16); + const l = h.live.at(slot, gen) orelse return null; + return .{ .act = .{ .file = f, .kind = l.kind, .slot = slot, .gen = gen, .agent = agent } }; + }, } } @@ -399,6 +544,7 @@ pub const Harness = struct { pub const Target = union(enum) { top: Top, path: *Entry, + act: Act, }; // ---- attributes --------------------------------------------------------------- @@ -508,6 +654,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr { .top => |top| { switch (top) { .root => return .{ .name = "/", .node = root, .dir = true, .mode = 0o555 }, + .active => return .{ .name = "active", .node = topNode(.active), .dir = true, .mode = 0o555 }, .pid, .uptime => return .{ .name = top.fileName(), .node = topNode(top), @@ -530,6 +677,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr { } }, .path => |e| return statAttr(h, e), + .act => |a| return activeAttr(h, a), } } @@ -544,6 +692,442 @@ fn attrOfMount(h: *Harness, r: Root, rel_path: []const u8) ?fs.Attr { return statAttr(h, e); } +// ---- /active: the derived view ------------------------------------------------- + +fn sourcesOf(h: *Harness) active.Sources { + var roots: [5][]const u8 = @splat(""); + for (0..5) |i| { + if (h.base_set[i]) roots[i] = h.base_buf[i][0..h.base_len[i]]; + } + return .{ + .io = h.io, + .proc = h.proc_buf[0..h.proc_len], + .home = h.home_buf[0..h.home_len], + .roots = roots, + .self_pid = h.self_pid, + .btime = h.btime, + }; +} + +/// Rescans the process tree. Done on a listing of `/active` and on a +/// lookup that misses, which is what makes an agent visible the moment +/// it starts and gone the moment it exits. +fn rescan(h: *Harness) void { + if (h.proc_len == 0) return; + h.live.scan(sourcesOf(h)); +} + +fn hasLive(h: *Harness, k: active.Kind) bool { + for (&h.live.slots) |*sl| { + if (sl.used and sl.live.kind == k) return true; + } + return false; +} + +/// `<text>\n` staged where an answer can point at it. +fn line(h: *Harness, text: []const u8) ?[]const u8 { + return std.fmt.bufPrint(&h.act_text, "{s}\n", .{text}) catch null; +} + +/// The value of a field file, or null when this agent has none — an +/// absent field is not listed and does not resolve, so the tree never +/// answers a blank where it does not know. +fn activeText(h: *Harness, a: Act) ?[]const u8 { + const l = h.live.at(a.slot, a.gen) orelse return null; + const src = sourcesOf(h); + var scratch: [active.text_capacity]u8 = undefined; + return switch (a.file) { + .pid => std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.pid}) catch null, + .ppid => if (l.ppid == 0) null else std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.ppid}) catch null, + .started => if (l.started == 0) null else std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.started}) catch null, + .cwd => if (l.cwd.len == 0) null else line(h, l.cwd.slice()), + .session => if (l.session.len == 0) null else line(h, l.session.slice()), + .via => line(h, l.via.text()), + .name => line(h, active.registryField(src, l, "name", &scratch) orelse return null), + .status => line(h, active.registryField(src, l, "status", &scratch) orelse return null), + .title => line(h, active.headField(h.io, l.kind, l.transcript.slice(), .title, &scratch) orelse return null), + .model => line(h, active.headField(h.io, l.kind, l.transcript.slice(), .model, &scratch) orelse return null), + .agent_model => blk: { + const path = activePath(h, a) orelse break :blk null; + break :blk line(h, active.headField(h.io, l.kind, path, .model, &scratch) orelse return null); + }, + // `zmx` is always there, so it can always be written to; empty + // means the agent runs outside zmx. + .zmx => if (active.zmxOf(src, l.pid, &scratch)) |v| line(h, v) else h.act_text[0..0], + else => null, + }; +} + +/// The absolute path behind a transcript-shaped file. +fn activePath(h: *Harness, a: Act) ?[]const u8 { + const l = h.live.at(a.slot, a.gen) orelse return null; + switch (a.file) { + .transcript => return if (l.transcript.len == 0) null else l.transcript.slice(), + .agent_transcript, .agent_model => { + var ag: active.Agents = .{}; + active.agentsOf(h.io, l, &ag); + return active.agentPath(l, &ag, a.agent, &h.act_path); + }, + else => return null, + } +} + +/// Opens an absolute path without following a symlink at the last +/// component. These paths are derived from a pinned root or from the +/// fd the harness itself holds, never from client bytes, but a name +/// swapped underneath must still fail rather than redirect. +fn openAbsNoFollow(path: []const u8) ?i32 { + if (path.len == 0 or path.len >= active.path_capacity) return null; + var z: [active.path_capacity]u8 = @splat(0); + @memcpy(z[0..path.len], path); + const rc = linux.open(@ptrCast(&z), .{ + .ACCMODE = .RDONLY, + .NOFOLLOW = true, + .CLOEXEC = true, + .NONBLOCK = true, + }, 0); + if (linux.errno(rc) != .SUCCESS) return null; + return @intCast(rc); +} + +fn activeAttr(h: *Harness, a: Act) ?fs.Attr { + switch (a.file) { + .harness_dir => { + if (!hasLive(h, a.kind)) return null; + return .{ .name = a.kind.text(), .node = activeNode(a), .dir = true, .mode = 0o555 }; + }, + .entry => { + const l = h.live.at(a.slot, a.gen) orelse return null; + const nm = std.fmt.bufPrint(&h.act_name, "{d}", .{l.pid}) catch return null; + return .{ .name = nm, .node = activeNode(a), .dir = true, .mode = 0o555 }; + }, + .agents => { + const l = h.live.at(a.slot, a.gen) orelse return null; + if (l.agent_dir.len == 0) return null; + return .{ .name = "agents", .node = activeNode(a), .dir = true, .mode = 0o555 }; + }, + .agent => { + const l = h.live.at(a.slot, a.gen) orelse return null; + var ag: active.Agents = .{}; + active.agentsOf(h.io, l, &ag); + if (a.agent >= ag.count) return null; + const nm = ag.name(a.agent); + @memcpy(h.act_name[0..nm.len], nm); + return .{ .name = h.act_name[0..nm.len], .node = activeNode(a), .dir = true, .mode = 0o555 }; + }, + .transcript, .agent_transcript => { + const path = activePath(h, a) orelse return null; + const st = Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = false }) catch return null; + if (st.kind != .file) return null; + return .{ + .name = a.file.fileName(), + .node = activeNode(a), + .size = st.size, + .mode = 0o444, + .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))), + }; + }, + else => { + const text = activeText(h, a) orelse return null; + // Only `zmx` is ever writable, and only when a move is allowed. + const mode: u16 = if (a.file == .zmx and h.allow_move) 0o644 else 0o444; + return .{ .name = a.file.fileName(), .node = activeNode(a), .size = text.len, .mode = mode }; + }, + } +} + +/// The slot holding `pid` for this harness, if any. +fn slotOfPid(h: *Harness, k: active.Kind, pid: u32) ?Act { + for (&h.live.slots, 0..) |*sl, i| { + if (!sl.used or sl.live.kind != k or sl.live.pid != pid) continue; + return .{ .file = .entry, .kind = k, .slot = @intCast(i), .gen = sl.gen }; + } + return null; +} + +fn activeLookup(h: *Harness, req: fs.Req, a: Act, name: []const u8) Answer { + switch (a.file) { + .harness_dir => { + const pid = std.fmt.parseInt(u32, name, 10) catch return fail(req.tag, E.NOENT); + var found = slotOfPid(h, a.kind, pid); + if (found == null) { + rescan(h); + found = slotOfPid(h, a.kind, pid); + } + const act = found orelse return fail(req.tag, E.NOENT); + return activeReply(h, req, act, name); + }, + .entry => { + if (std.mem.eql(u8, name, "agents")) { + return activeReply(h, req, .{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen }, name); + } + for (AFile.entry_files) |f| { + if (!std.mem.eql(u8, f.fileName(), name)) continue; + return activeReply(h, req, .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen }, name); + } + return fail(req.tag, E.NOENT); + }, + .agents => { + const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT); + var ag: active.Agents = .{}; + active.agentsOf(h.io, l, &ag); + const i = ag.indexOf(name) orelse return fail(req.tag, E.NOENT); + return activeReply(h, req, .{ + .file = .agent, + .kind = a.kind, + .slot = a.slot, + .gen = a.gen, + .agent = @intCast(i), + }, name); + }, + .agent => { + for (AFile.agent_files) |f| { + if (!std.mem.eql(u8, f.fileName(), name)) continue; + return activeReply(h, req, .{ + .file = f, + .kind = a.kind, + .slot = a.slot, + .gen = a.gen, + .agent = a.agent, + }, name); + } + return fail(req.tag, E.NOENT); + }, + else => return fail(req.tag, E.NOTDIR), + } +} + +fn activeReply(h: *Harness, req: fs.Req, a: Act, name: []const u8) Answer { + const attr = activeAttr(h, a) orelse return fail(req.tag, E.NOENT); + var with_name = attr; + with_name.name = name; + return .{ .reply = .{ .tag = req.tag, .attr = with_name } }; +} + +fn activeReaddir(h: *Harness, req: fs.Req, a: Act) Answer { + var st: Staging = .{ .buf = &h.stage_buf, .skip = req.off }; + switch (a.file) { + .harness_dir => { + for (&h.live.slots, 0..) |*sl, i| { + if (!sl.used or sl.live.kind != a.kind) continue; + var num: [24]u8 = undefined; + const nm = std.fmt.bufPrint(&num, "{d}", .{sl.live.pid}) catch continue; + st.add(activeNode(.{ + .file = .entry, + .kind = a.kind, + .slot = @intCast(i), + .gen = sl.gen, + }), true, nm); + } + }, + .entry => { + const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT); + for (AFile.entry_files) |f| { + const child: Act = .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen }; + if (activeAttr(h, child) == null) continue; // no value: not listed + st.add(activeNode(child), false, f.fileName()); + } + if (l.agent_dir.len > 0) { + st.add(activeNode(.{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen }), true, "agents"); + } + }, + .agents => { + const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT); + var ag: active.Agents = .{}; + active.agentsOf(h.io, l, &ag); + for (0..ag.count) |i| { + st.add(activeNode(.{ + .file = .agent, + .kind = a.kind, + .slot = a.slot, + .gen = a.gen, + .agent = @intCast(i), + }), true, ag.name(i)); + } + }, + .agent => { + for (AFile.agent_files) |f| { + const child: Act = .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen, .agent = a.agent }; + if (activeAttr(h, child) == null) continue; + st.add(activeNode(child), false, f.fileName()); + } + }, + else => return fail(req.tag, E.NOTDIR), + } + return .{ .reply = .{ .tag = req.tag }, .bytes = h.stage_buf[0..st.len] }; +} + +fn activeRead(h: *Harness, req: fs.Req, a: Act) Answer { + switch (a.file) { + .harness_dir, .entry, .agents, .agent => return fail(req.tag, E.ISDIR), + .transcript, .agent_transcript => { + const path = activePath(h, a) orelse return fail(req.tag, E.NOENT); + const fd = openAbsNoFollow(path) orelse return fail(req.tag, E.NOENT); + const file: Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + defer file.close(h.io); + const st = file.stat(h.io) catch return fail(req.tag, E.IO); + if (st.kind == .directory) return fail(req.tag, E.ISDIR); + if (st.kind != .file) return fail(req.tag, E.PERM); + _ = linux.fcntl(fd, linux.F.SETFL, 0); + const want = @min(req.size, h.data_buf.len); + const n = file.readPositionalAll(h.io, h.data_buf[0..want], req.off) catch + return fail(req.tag, E.IO); + return .{ .reply = .{ .tag = req.tag }, .bytes = h.data_buf[0..n] }; + }, + else => { + const text = activeText(h, a) orelse return fail(req.tag, E.NOENT); + return window(req, text); + }, + } +} + +// ---- the move: writing a zmx session name into an agent's `zmx` ---------------- + +/// How long a move waits, in 100ms ticks: for the agent to take the +/// hangup, then to die outright, then for the new zmx session to post. +const term_ticks: usize = 50; +const kill_ticks: usize = 30; +const post_ticks: usize = 100; + +fn napOneTick(h: *Harness) void { + h.io.sleep(.fromMilliseconds(100), .awake) catch {}; +} + +/// Is this name already a live zmx session? zmx posts each session into +/// the registry, so the registry is the answer — no process scanning. +fn zmxPosted(h: *Harness, name: []const u8) bool { + if (h.runtime_len == 0) return false; + var buf: [active.path_capacity]u8 = undefined; + const path = std.fmt.bufPrint(&buf, "{s}/9p/zmx/{s}", .{ h.runtime_buf[0..h.runtime_len], name }) catch return true; + return Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = true }) != error.FileNotFound; +} + +fn procGone(h: *Harness, pid: u32) bool { + var buf: [active.path_capacity]u8 = undefined; + const path = std.fmt.bufPrint(&buf, "{s}/{d}", .{ h.proc_buf[0..h.proc_len], pid }) catch return false; + _ = Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = true }) catch return true; + return false; +} + +/// SIGTERM, then SIGKILL, then give up. The agent's session was +/// resolved before this ran, so whatever happens it has somewhere to +/// come back to. +fn killAndWait(h: *Harness, pid: u32) bool { + _ = linux.kill(@intCast(pid), .TERM); + for (0..term_ticks) |_| { + if (procGone(h, pid)) return true; + napOneTick(h); + } + _ = linux.kill(@intCast(pid), .KILL); + for (0..kill_ticks) |_| { + if (procGone(h, pid)) return true; + napOneTick(h); + } + return false; +} + +/// `zmx run <name> -d <harness> <resume flag> <resume value>`, in the +/// agent's own directory. Every word but `<name>` is fixed by the +/// harness; `<name>` was checked against zmx's label charset before +/// anything was killed. No byte a client wrote reaches `exec` as a +/// command. +fn spawnZmx(h: *Harness, l: *const active.Live, name: []const u8, resume_value: []const u8) bool { + if (h.zmx_len == 0) return false; + const harness_argv0: []const u8 = @tagName(l.kind); + const resume_flag: []const u8 = switch (l.kind) { + .codex => "resume", + else => "--resume", + }; + + // One buffer holds every NUL-terminated word; `argv` points into it. + var words: [8 * active.path_capacity]u8 = undefined; + var used: usize = 0; + var argv: [9]?[*:0]const u8 = @splat(null); + var n: usize = 0; + const parts = [_][]const u8{ + h.zmx_buf[0..h.zmx_len], "run", name, "-d", + harness_argv0, resume_flag, resume_value, + }; + for (parts) |w| { + if (used + w.len + 1 > words.len or n + 1 >= argv.len) return false; + @memcpy(words[used..][0..w.len], w); + words[used + w.len] = 0; + argv[n] = @ptrCast(&words[used]); + n += 1; + used += w.len + 1; + } + + var cwd_z: [active.cwd_capacity + 1]u8 = @splat(0); + if (l.cwd.len >= cwd_z.len) return false; + @memcpy(cwd_z[0..l.cwd.len], l.cwd.slice()); + + const rc = linux.fork(); + if (linux.errno(rc) != .SUCCESS) return false; + if (rc == 0) { + // The child: only async-signal-safe calls from here. + _ = linux.chdir(@ptrCast(&cwd_z)); + const empty = [_:null]?[*:0]const u8{}; + const envp: cloud9.post.Env = h.envp orelse ∅ + _ = linux.execve(argv[0].?, @ptrCast(&argv), envp); + linux.exit(127); + } + // Reap the forked `zmx`, which returns as soon as the session is up. + const child: i32 = @intCast(rc); + var status: u32 = 0; + for (0..post_ticks) |_| { + const w = linux.waitpid(child, &status, 1); // WNOHANG + if (w == @as(usize, @intCast(child))) break; + napOneTick(h); + } + return true; +} + +/// A move: kill the agent and bring it back inside a zmx session of the +/// name written. Refusals come before anything is destroyed. +fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer { + if (!h.allow_move) return fail(req.tag, E.PERM); + const name = std.mem.trim(u8, req.data, " \t\r\n"); + if (!active.legalZmxName(name)) return fail(req.tag, E.INVAL); + + const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT); + // Nothing is killed that has nowhere to come back to. + if (l.via == .none or l.session.len == 0) return fail(req.tag, E.PERM); + if (l.cwd.len == 0) return fail(req.tag, E.PERM); + const resume_value: []const u8 = switch (l.kind) { + // omp resumes by the transcript it wrote, the others by id. + .omp => l.transcript.slice(), + .claude, .codex, .hermes => l.session.slice(), + // dsh has no resume form worth guessing at. + .dsh => return fail(req.tag, E.PERM), + }; + if (resume_value.len == 0) return fail(req.tag, E.PERM); + + // Already there: setting a value it already has changes nothing. + var have: [active.text_capacity]u8 = undefined; + if (active.zmxOf(sourcesOf(h), l.pid, &have)) |current| { + if (std.mem.eql(u8, current, name)) { + return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } }; + } + } + if (zmxPosted(h, name)) return fail(req.tag, E.EXIST); + // The slot could have gone stale between the scan and this write. + if (!active.stillAlive(sourcesOf(h), l)) return fail(req.tag, E.NOENT); + + // Everything below this line destroys something. + var snapshot = l.*; + if (!killAndWait(h, snapshot.pid)) return fail(req.tag, E.IO); + if (!spawnZmx(h, &snapshot, name, resume_value)) return fail(req.tag, E.IO); + for (0..post_ticks) |_| { + if (zmxPosted(h, name)) { + rescan(h); + return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } }; + } + napOneTick(h); + } + rescan(h); + return fail(req.tag, E.IO); +} + // ---- dispatch ------------------------------------------------------------------ /// Answers one engine request. The caller holds `h.mutex` and replies @@ -553,15 +1137,39 @@ pub fn handle(h: *Harness, req: fs.Req) Answer { return switch (req.op) { .lookup => lookup(h, req, t), .getattr => attrReply(h, req.tag, t), - .setattr => fail(req.tag, E.PERM), + .setattr => setattrReq(h, req, t), .open => open(h, req, t), .release => release(h, req, t), .readdir => readdir(h, req, t), .read => read(h, req, t), - .write => fail(req.tag, E.PERM), + .write => writeReq(h, req, t), }; } +/// Truncation is how a shell's `>` opens a file before writing it. The +/// `zmx` file has no length of its own — a write replaces the value — +/// so on the one writable file a zero-length truncate is a no-op +/// rather than a refusal. Everything else still answers EPERM. +fn setattrReq(h: *Harness, req: fs.Req, t: Target) Answer { + switch (t) { + .act => |a| if (a.file == .zmx and h.allow_move and req.truncate) { + return .{ .reply = .{ .tag = req.tag } }; + }, + else => {}, + } + return fail(req.tag, E.PERM); +} + +/// The tree answers EPERM to every write but one: a zmx session name +/// into a live agent's `zmx`, which moves it there. +fn writeReq(h: *Harness, req: fs.Req, t: Target) Answer { + switch (t) { + .act => |a| if (a.file == .zmx) return moveToZmx(h, req, a), + else => {}, + } + return fail(req.tag, E.PERM); +} + fn attrReply(h: *Harness, tag: u64, t: Target) Answer { const a = attrFor(h, t) orelse return fail(tag, E.NOENT); return .{ .reply = .{ .tag = tag, .attr = a } }; @@ -586,7 +1194,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer { // The engine asks for "." when cloning a fid; the reference is // paid for path targets here like any other lookup result. switch (t) { - .top => return attrReply(h, req.tag, t), + .top, .act => return attrReply(h, req.tag, t), .path => |e| { e.refs += 1; return lookupAttr(h, req, t, name); @@ -618,6 +1226,16 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer { const m = top.mirror().?; return lookupBelow(h, req, m.root, m.rel, name); }, + .active => { + const k = blk: for (std.enums.values(active.Kind)) |k| { + if (std.mem.eql(u8, k.text(), name)) break :blk k; + } else return fail(req.tag, E.NOENT); + if (!hasLive(h, k)) { + rescan(h); + if (!hasLive(h, k)) return fail(req.tag, E.NOENT); + } + return activeReply(h, req, .{ .file = .harness_dir, .kind = k }, name); + }, .pid, .uptime => return fail(req.tag, E.NOTDIR), }, .path => |e| { @@ -627,6 +1245,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer { } else return fail(req.tag, E.NOENT); return lookupBelow(h, req, e.root, rel_path, name); }, + .act => |a| return activeLookup(h, req, a, name), } } @@ -673,6 +1292,21 @@ fn lookupParent(h: *Harness, req: fs.Req, t: Target) Answer { } break :blk .{ .top = mountOwner(e.root, rel_path) }; }, + .act => |a| switch (a.file) { + .harness_dir => .{ .top = .active }, + .entry => .{ .act = .{ .file = .harness_dir, .kind = a.kind } }, + .agents => .{ .act = .{ .file = .entry, .kind = a.kind, .slot = a.slot, .gen = a.gen } }, + .agent => .{ .act = .{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen } }, + .agent_model, .agent_transcript => .{ .act = .{ + .file = .agent, + .kind = a.kind, + .slot = a.slot, + .gen = a.gen, + .agent = a.agent, + } }, + // Every other file hangs directly off its entry. + else => .{ .act = .{ .file = .entry, .kind = a.kind, .slot = a.slot, .gen = a.gen } }, + }, }; return attrReply(h, req.tag, parent); } @@ -687,17 +1321,26 @@ fn unref(e: *Entry) void { fn open(h: *Harness, req: fs.Req, t: Target) Answer { _ = attrFor(h, t) orelse return fail(req.tag, E.NOENT); // still there? - // Read-only tree: any open that would write or truncate is refused. + // Read-only tree, with exactly one exception: the `zmx` file of a + // live agent, and only when the daemon was started to allow moves. + // Truncation is meaningless there (a write replaces the value), so + // it is accepted rather than refused, which is what `>` needs. + const writable = switch (t) { + .act => |a| a.file == .zmx and h.allow_move, + else => false, + }; const rw = req.omode & 3; - if (rw == cloud9.owrite or rw == cloud9.ordwr) return fail(req.tag, E.PERM); - if (req.omode & cloud9.otrunc != 0) return fail(req.tag, E.PERM); + if (!writable) { + if (rw == cloud9.owrite or rw == cloud9.ordwr) return fail(req.tag, E.PERM); + if (req.omode & cloud9.otrunc != 0) return fail(req.tag, E.PERM); + } return .{ .reply = .{ .tag = req.tag, .handle = 1 } }; } fn release(h: *Harness, req: fs.Req, t: Target) Answer { _ = h; switch (t) { - .top => {}, + .top, .act => {}, .path => |e| unref(e), } return .{ .reply = .{ .tag = req.tag } }; @@ -714,6 +1357,7 @@ fn read(h: *Harness, req: fs.Req, t: Target) Answer { }, else => return fail(req.tag, E.ISDIR), }, + .act => |a| return activeRead(h, req, a), .path => |e| { // `O_NONBLOCK` so a fifo left in a harness root cannot park the // daemon in `open`; the kind check below refuses it anyway. @@ -792,6 +1436,13 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer { } }, .pid, .uptime => return fail(req.tag, E.NOTDIR), + .active => { + rescan(h); + for (std.enums.values(active.Kind)) |k| { + if (!hasLive(h, k)) continue; + st.add(activeNode(.{ .file = .harness_dir, .kind = k }), true, k.text()); + } + }, .omp, .hermes, .dsh => { const m = top.mirror().?; if (h.base(m.root) == null) return fail(req.tag, E.NOENT); @@ -814,6 +1465,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer { .overflow => return fail(req.tag, E.NFILE), } }, + .act => |a| return activeReaddir(h, req, a), } return .{ .reply = .{ .tag = req.tag }, .bytes = h.stage_buf[0..st.len] }; } @@ -904,6 +1556,14 @@ const testing = std.testing; /// ~/.claude or any other live harness root. const Rig = struct { dir: testing.TmpDir, + /// Build a fixture process tree under <home>/proc and point + /// `/active` at it. Off by default: a unit test must opt in to + /// reading a process tree, and it is never the live one. + with_proc: bool = false, + /// The pid the daemon believes it is, for the ancestry exclusion. + self_pid: u32 = 4242, + zmx: []const u8 = "zmx", + allow_move: bool = false, path_buf: [std.fs.max_path_bytes]u8 = undefined, home: []const u8 = undefined, h: *Harness = undefined, @@ -931,7 +1591,24 @@ const Rig = struct { bases[i] = try std.fmt.bufPrint(&base_buf[i], "{s}/{s}", .{ rig.home, home_dirs[i] }); } rig.harness_mem = undefined; - rig.harness_mem.init(.{ .io = io, .pid = 4242, .bases = bases }); + var proc_buf: [std.fs.max_path_bytes]u8 = undefined; + const proc_root: []const u8 = if (rig.with_proc) + try std.fmt.bufPrint(&proc_buf, "{s}/proc", .{rig.home}) + else + ""; + if (rig.with_proc) { + try Io.Dir.cwd().createDirPath(io, proc_root); + try rig.put("proc/stat", "cpu 1 2 3\nbtime 1000000\nprocesses 7\n"); + } + rig.harness_mem.init(.{ + .io = io, + .pid = rig.self_pid, + .bases = bases, + .proc = proc_root, + .home = rig.home, + .zmx = rig.zmx, + .allow_move = rig.allow_move, + }); rig.h = &rig.harness_mem; } @@ -964,6 +1641,58 @@ const Rig = struct { fn lookupName(rig: *Rig, tag: u64, dir_node: u64, name: []const u8) Answer { return handle(rig.h, .{ .tag = tag, .op = .lookup, .node = dir_node, .data = name }); } + + /// Writes one fake process into the fixture `/proc`: the `stat` + /// line (with the start time in field 22, after a name that holds + /// the spaces and parentheses a real one can), the NUL-separated + /// `cmdline`, and a `cwd` symlink. + fn fakeProc(rig: *Rig, o: struct { + pid: u32, + ppid: u32 = 1, + comm: []const u8 = "x", + starttime: u64 = 5000, + argv: []const u8, + cwd: []const u8 = "", + }) !void { + var rel: [128]u8 = undefined; + var stat_text: [512]u8 = undefined; + var w = Io.Writer.fixed(&stat_text); + try w.print("{d} ({s}) S {d}", .{ o.pid, o.comm, o.ppid }); + for (5..22) |i| try w.print(" {d}", .{i}); + try w.print(" {d} 0 0\n", .{o.starttime}); + try rig.put(try std.fmt.bufPrint(&rel, "proc/{d}/stat", .{o.pid}), w.buffered()); + try rig.put(try std.fmt.bufPrint(&rel, "proc/{d}/cmdline", .{o.pid}), o.argv); + + const target = if (o.cwd.len > 0) o.cwd else rig.home; + var link_buf: [std.fs.max_path_bytes]u8 = undefined; + const link = try std.fmt.bufPrintZ(&link_buf, "{s}/proc/{d}/cwd", .{ rig.home, o.pid }); + Io.Dir.cwd().symLink(testing.io, target, link, .{}) catch {}; + } + + /// Removes a fake process, as an exit would. + fn reapProc(rig: *Rig, pid: u32) !void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = try std.fmt.bufPrint(&buf, "{s}/proc/{d}", .{ rig.home, pid }); + const d = try Io.Dir.openDirAbsolute(testing.io, dir_path, .{ .iterate = true }); + var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var it = Io.Dir.Reader.init(d, &rb); + var names: [8][64]u8 = undefined; + var lens: [8]usize = @splat(0); + var n: usize = 0; + while (n < names.len) { + const e = (it.next(testing.io) catch break) orelse break; + @memcpy(names[n][0..e.name.len], e.name); + lens[n] = e.name.len; + n += 1; + } + Io.Dir.close(d, testing.io); + for (0..n) |i| { + var one: [std.fs.max_path_bytes]u8 = undefined; + const path = try std.fmt.bufPrint(&one, "{s}/{s}", .{ dir_path, names[i][0..lens[i]] }); + Io.Dir.deleteFileAbsolute(testing.io, path) catch {}; + } + try Io.Dir.cwd().deleteDir(testing.io, dir_path); + } }; const home_dirs = [5][]const u8{ ".claude", ".codex", ".omp", ".hermes", ".dsh" }; @@ -1327,3 +2056,142 @@ test "tree: a listing spanning several reads loses no entry" { try testing.expect(reads > 1); // the listing really did span several reads for (seen) |s| try testing.expect(s); } + +test { + _ = active; // the derived view's own tests run with the tree's +} + +// ---- /active: the derived view ------------------------------------------------ + +/// Walks `/active` down to one agent's directory, answering its node. +fn activeEntryNode(rig: *Rig, harness: []const u8, pid: []const u8) !u64 { + const act = rig.lookupName(90, root, "active"); + try testing.expect(act.reply.status == .ok); + // A listing is what rescans, so it comes before the walk. + _ = handle(rig.h, .{ .tag = 91, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize }); + const h_dir = rig.lookupName(92, act.reply.attr.node, harness); + try testing.expect(h_dir.reply.status == .ok); + const entry = rig.lookupName(93, h_dir.reply.attr.node, pid); + try testing.expect(entry.reply.status == .ok); + return entry.reply.attr.node; +} + +fn activeField(rig: *Rig, entry: u64, name: []const u8) ![]const u8 { + const f = rig.lookupName(94, entry, name); + try testing.expect(f.reply.status == .ok); + const r = handle(rig.h, .{ .tag = 95, .op = .read, .node = f.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(r.reply.status == .ok); + return r.bytes; +} + +test "active: a fixture process tree lists by harness and by pid" { + var rig: Rig = .{ .dir = undefined, .with_proc = true }; + try rig.start(); + defer rig.end(); + + // A Claude Code session, resolved the way the harness publishes it. + try rig.fakeProc(.{ .pid = 1001, .comm = "claude", .starttime = 5000, .argv = "claude\x00--print\x00" }); + var rec: [512]u8 = undefined; + try rig.put(".claude/sessions/1001.json", try std.fmt.bufPrint(&rec, + \\{{"pid":1001,"sessionId":"sess-abc","cwd":"{s}", + \\ "name":"fixture-one","status":"busy","procStart":"5000"}} + , .{rig.home})); + var slug_buf: [512]u8 = undefined; + const slug = active.slugOf(.claude, rig.home, rig.home, &slug_buf).?; + var tr: [640]u8 = undefined; + try rig.put( + try std.fmt.bufPrint(&tr, ".claude/projects/{s}/sess-abc.jsonl", .{slug}), + "{\"type\":\"summary\",\"aiTitle\":\"Fixture Title\"}\n", + ); + + const entry = try activeEntryNode(&rig, "claude", "1001"); + try testing.expectEqualStrings("1001\n", try activeField(&rig, entry, "pid")); + try testing.expectEqualStrings("sess-abc\n", try activeField(&rig, entry, "session")); + try testing.expectEqualStrings("registry\n", try activeField(&rig, entry, "via")); + try testing.expectEqualStrings("fixture-one\n", try activeField(&rig, entry, "name")); + try testing.expectEqualStrings("busy\n", try activeField(&rig, entry, "status")); + try testing.expectEqualStrings("Fixture Title\n", try activeField(&rig, entry, "title")); + // started is the boot time plus the process's own, in seconds. + try testing.expectEqualStrings("1000050\n", try activeField(&rig, entry, "started")); + // The transcript is served as the file it is, not as a field. + const t = rig.lookupName(96, entry, "transcript"); + try testing.expect(t.reply.status == .ok); + const bytes = handle(rig.h, .{ .tag = 97, .op = .read, .node = t.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(std.mem.indexOf(u8, bytes.bytes, "Fixture Title") != null); + // A field this harness does not publish is absent, not blank. + try expectNoent(rig.lookupName(98, entry, "model")); +} + +test "active: a daemon or helper is never listed as an agent" { + var rig: Rig = .{ .dir = undefined, .with_proc = true }; + try rig.start(); + defer rig.end(); + // The shapes actually running on this machine. + try rig.fakeProc(.{ .pid = 1010, .comm = "python3", .argv = "python3\x00-m\x00hermes_cli.main\x00gateway\x00run\x00" }); + try rig.fakeProc(.{ .pid = 1011, .comm = "omp", .argv = "omp\x00__omp_worker_daemon_broker\x00" }); + try rig.fakeProc(.{ .pid = 1012, .comm = "claude", .argv = "claude\x00mcp-server\x00" }); + // ...and one real session, so an empty answer cannot pass by default. + try rig.fakeProc(.{ .pid = 1013, .comm = "claude", .argv = "claude\x00" }); + + const act = rig.lookupName(1, root, "active"); + const listing = handle(rig.h, .{ .tag = 2, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(stageHas(listing.bytes, "claude")); + try testing.expect(!stageHas(listing.bytes, "hermes")); + try testing.expect(!stageHas(listing.bytes, "omp")); + const claude = rig.lookupName(3, act.reply.attr.node, "claude"); + const pids = handle(rig.h, .{ .tag = 4, .op = .readdir, .node = claude.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(stageHas(pids.bytes, "1013")); + try testing.expect(!stageHas(pids.bytes, "1012")); // the mcp server +} + +test "active: a pid reused by another process stops resolving" { + var rig: Rig = .{ .dir = undefined, .with_proc = true }; + try rig.start(); + defer rig.end(); + try rig.fakeProc(.{ .pid = 1020, .comm = "claude", .starttime = 5000, .argv = "claude\x00" }); + const entry = try activeEntryNode(&rig, "claude", "1020"); + try testing.expect(handle(rig.h, .{ .tag = 5, .op = .getattr, .node = entry }).reply.status == .ok); + + // The same pid, a different process: only the start time says so. + try rig.fakeProc(.{ .pid = 1020, .comm = "claude", .starttime = 9999, .argv = "claude\x00" }); + const act = rig.lookupName(6, root, "active"); + _ = handle(rig.h, .{ .tag = 7, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize }); + try expectNoent(handle(rig.h, .{ .tag = 8, .op = .getattr, .node = entry })); + // The pid is still there — as the new process, under a new node. + const fresh = try activeEntryNode(&rig, "claude", "1020"); + try testing.expect(fresh != entry); +} + +test "active: a process that exits leaves the tree" { + var rig: Rig = .{ .dir = undefined, .with_proc = true }; + try rig.start(); + defer rig.end(); + try rig.fakeProc(.{ .pid = 1030, .comm = "claude", .argv = "claude\x00" }); + const entry = try activeEntryNode(&rig, "claude", "1030"); + try testing.expect(handle(rig.h, .{ .tag = 9, .op = .getattr, .node = entry }).reply.status == .ok); + + try rig.reapProc(1030); + const act = rig.lookupName(10, root, "active"); + const listing = handle(rig.h, .{ .tag = 11, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(!stageHas(listing.bytes, "claude")); + try expectNoent(handle(rig.h, .{ .tag = 12, .op = .getattr, .node = entry })); + try expectNoent(rig.lookupName(13, act.reply.attr.node, "claude")); +} + +test "active: the daemon never lists the process tree it lives in" { + // 1041 is the daemon, its parent 1040 is a harness: showing it would + // let a client act on the tree serving it. + var rig: Rig = .{ .dir = undefined, .with_proc = true, .self_pid = 1041 }; + try rig.start(); + defer rig.end(); + try rig.fakeProc(.{ .pid = 1040, .comm = "claude", .argv = "claude\x00" }); + try rig.fakeProc(.{ .pid = 1041, .comm = "9harness", .ppid = 1040, .argv = "9harness\x00" }); + try rig.fakeProc(.{ .pid = 1042, .comm = "claude", .argv = "claude\x00" }); + + const act = rig.lookupName(14, root, "active"); + const claude = rig.lookupName(15, act.reply.attr.node, "claude"); + try testing.expect(claude.reply.status == .ok); + const pids = handle(rig.h, .{ .tag = 16, .op = .readdir, .node = claude.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(stageHas(pids.bytes, "1042")); // an unrelated session lists + try testing.expect(!stageHas(pids.bytes, "1040")); // its own parent does not +} |
