diff --git a/build.zig b/build.zig index 1a5fc7c..54b9d5e 100644 --- a/build.zig +++ b/build.zig @@ -51,7 +51,17 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .root_source_file = b.path("src/diagnostics.zig"), }) }); - const test_step = b.step("test", "Run the std-only tests: the bridge's, and reading compiler diagnostics"); + const recording = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/Recording.zig"), + .imports = &.{ + .{ .name = "fizzy_sdk", .module = sdk.module("fizzy_sdk") }, + .{ .name = "tape", .module = sdk.module("tape") }, + }, + }) }); + const test_step = b.step("test", "Run the tests: the bridge's, reading compiler diagnostics, and a session's tape"); + test_step.dependOn(&b.addRunArtifact(recording).step); test_step.dependOn(&b.addRunArtifact(tests).step); test_step.dependOn(&b.addRunArtifact(diagnostics).step); } diff --git a/src/Recording.zig b/src/Recording.zig index ea8eca1..f5b6885 100644 --- a/src/Recording.zig +++ b/src/Recording.zig @@ -8,6 +8,11 @@ //! second document) is left out: a replay changes the tape's document and nothing else. Each step //! takes as long as it goes on showing its work, however long the agent thought, so the tape plays //! at the pace of the work, one step flowing into the next. +//! +//! Hands other than the agent's work on the document too (the person, another agent). Before each +//! step the document is read again, and when it is not what the last step left, a keyframe of it +//! goes into the tape at that moment, with a chapter naming who: a replay then shows whose work +//! is whose, and a seek back to it lands on the document as that hand left it. const Recording = @This(); const std = @import("std"); @@ -27,6 +32,11 @@ mount_path: []u8, owner_id: []u8, /// The document's bytes when it was claimed: the keyframe's file. bytes: []u8, +/// The document as this session last saw it: as claimed, then as each of its steps left it +/// (`settle`). What differs from it at the next step was done by another hand. +known: []u8, +/// The keyframes after the first: the document as another hand left it. +others: std.ArrayListUnmanaged(Other) = .empty, ops: std.ArrayListUnmanaged(Tape.Op) = .empty, /// Strings the ops hold. strings: std.ArrayListUnmanaged([]u8) = .empty, @@ -35,6 +45,13 @@ last_at: u32 = 0, /// as it ends, so a replay flows from one to the next. last_ms: u32 = 0, +const Other = struct { + at: u32, + bytes: []u8, + /// The chapter's title. + title: []u8, +}; + /// Between a step and the next when the first shows nothing over time: a moment to see it. const gap_ms = 150; /// From the keyframe to the first command: time to see where it starts. @@ -66,6 +83,7 @@ pub fn start(gpa: std.mem.Allocator, doc: sdk.DocHandle, path: []const u8, label .mount_path = mount_path, .owner_id = try gpa.dupe(u8, doc.owner.id), .bytes = bytes, + .known = try gpa.dupe(u8, bytes), }; } @@ -73,7 +91,49 @@ pub fn deinit(self: *Recording) void { for (self.strings.items) |s| self.gpa.free(s); self.strings.deinit(self.gpa); self.ops.deinit(self.gpa); - inline for (.{ "name", "label", "path", "mount_path", "owner_id", "bytes" }) |f| self.gpa.free(@field(self, f)); + for (self.others.items) |o| { + self.gpa.free(o.bytes); + self.gpa.free(o.title); + } + self.others.deinit(self.gpa); + inline for (.{ "name", "label", "path", "mount_path", "owner_id", "bytes", "known" }) |f| self.gpa.free(@field(self, f)); +} + +/// The document as it is now, in the format `start` recorded it in. In `gpa`. +pub fn current(self: *const Recording, doc: sdk.DocHandle) ![]u8 { + const bytes_path = try doc.owner.bytesPath(doc, self.gpa); + defer self.gpa.free(bytes_path); + return (try doc.owner.documentBytes(doc, bytes_path, self.gpa)) orelse error.NotRecordable; +} + +/// Before a step: `now` is the document, and when it is not what the last step left, someone +/// else changed it since. Keyframe it at this moment, credited to `by`. +pub fn adopt(self: *Recording, now: []const u8, by: []const u8) !void { + if (std.mem.eql(u8, now, self.known)) return; + const base = std.fs.path.basename(self.mount_path); + const title = try std.fmt.allocPrint(self.gpa, "{s} changed {s}", .{ by, base }); + errdefer self.gpa.free(title); + const bytes = try self.gpa.dupe(u8, now); + errdefer self.gpa.free(bytes); + const known = try self.gpa.dupe(u8, now); + errdefer self.gpa.free(known); + const at = if (self.ops.items.len == 0) lead_ms else self.last_at + @max(self.last_ms, gap_ms); + try self.others.append(self.gpa, .{ .at = at, .bytes = bytes, .title = title }); + errdefer _ = self.others.pop(); + // The keyframe's own index: the claimed one is 0. + try self.ops.append(self.gpa, .{ .at = at, .do = .{ .keyframe = @intCast(self.others.items.len) } }); + self.gpa.free(self.known); + self.known = known; + self.last_at = at; + self.last_ms = 0; +} + +/// After a step of this session's own: `now` is the document it left, which is not another +/// hand's work at the next. +pub fn settle(self: *Recording, now: []const u8) !void { + const known = try self.gpa.dupe(u8, now); + self.gpa.free(self.known); + self.known = known; } /// A command that ran (and succeeded): kept when it acts on this document. @@ -108,29 +168,41 @@ pub fn empty(self: *const Recording) bool { return self.ops.items.len == 0; } -/// The tape, written as `/.tape` (the binary form). Returns its path. -pub fn write(self: *Recording, dir: []const u8, io: std.Io) ![]u8 { +/// The tape: its slices are in `arena`, its strings the recording's. +pub fn toTape(self: *const Recording, arena: std.mem.Allocator) !Tape { // The name it was recorded under (`start`): an untitled document's has its format's extension. const base = std.fs.path.basename(self.mount_path); - var ops: std.ArrayListUnmanaged(Tape.Op) = .empty; - defer ops.deinit(self.gpa); - try ops.append(self.gpa, .{ .at = 0, .do = .{ .keyframe = 0 } }); - try ops.appendSlice(self.gpa, self.ops.items); - const title = try std.fmt.allocPrint(self.gpa, "{s} works on {s}", .{ self.label, base }); - defer self.gpa.free(title); - const t: Tape = .{ - .name = self.name, - .title = title, - .ops = ops.items, - .chapters = &.{.{ .at = 0, .title = title }}, - .keyframes = &.{.{ - .root = std.fs.path.dirname(self.mount_path).?, - .files = &.{.{ .path = base, .text = self.bytes }}, - .open = &.{base}, - // As the person has it: a seek back then changes nothing but the document. - .layout = .keep, - }}, + const root = std.fs.path.dirname(self.mount_path).?; + const ops = try arena.alloc(Tape.Op, self.ops.items.len + 1); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + @memcpy(ops[1..], self.ops.items); + const title = try std.fmt.allocPrint(arena, "{s} works on {s}", .{ self.label, base }); + const chapters = try arena.alloc(Tape.Chapter, self.others.items.len + 1); + chapters[0] = .{ .at = 0, .title = title }; + const keyframes = try arena.alloc(Tape.Keyframe, self.others.items.len + 1); + keyframes[0] = try keyframe(arena, root, base, self.bytes); + for (self.others.items, 1..) |o, i| { + chapters[i] = .{ .at = o.at, .title = o.title }; + keyframes[i] = try keyframe(arena, root, base, o.bytes); + } + return .{ .name = self.name, .title = title, .ops = ops, .chapters = chapters, .keyframes = keyframes }; +} + +fn keyframe(arena: std.mem.Allocator, root: []const u8, base: []const u8, bytes: []const u8) !Tape.Keyframe { + return .{ + .root = root, + .files = try arena.dupe(Tape.File, &.{.{ .path = base, .text = bytes }}), + .open = try arena.dupe([]const u8, &.{base}), + // As the person has it: a seek back then changes nothing but the document. + .layout = .keep, }; +} + +/// The tape, written as `/.tape` (the binary form). Returns its path. +pub fn write(self: *Recording, dir: []const u8, io: std.Io) ![]u8 { + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const t = try self.toTape(arena_state.allocator()); const encoded = try tape.binary.encode(self.gpa, t, .{}); defer self.gpa.free(encoded); std.Io.Dir.cwd().createDirPath(io, dir) catch {}; @@ -141,3 +213,56 @@ pub fn write(self: *Recording, dir: []const u8, io: std.Io) ![]u8 { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out, .data = encoded }); return out; } + +test "a document another hand changed becomes a keyframe, credited, between the agent's steps" { + const gpa = std.testing.allocator; + var rec: Recording = .{ + .gpa = gpa, + .name = try gpa.dupe(u8, "sword-1"), + .label = try gpa.dupe(u8, "Claude"), + .path = try gpa.dupe(u8, "/p/sword.pixi"), + .mount_path = try gpa.dupe(u8, "demo://sword-1/sword.pixi"), + .owner_id = try gpa.dupe(u8, "pixi"), + .bytes = try gpa.dupe(u8, "claimed"), + .known = try gpa.dupe(u8, "claimed"), + }; + defer rec.deinit(); + + // The agent's first step, on the document as claimed: nothing else happened. + try rec.adopt("claimed", "The person"); + try rec.note("pixi.draw", ".{ .path = \"/p/sword.pixi\" }", 400); + try rec.settle("claimed+draw"); + + // The person edits; the agent's next step finds it. + try rec.adopt("claimed+draw+person", "The person"); + try rec.note("pixi.draw", ".{ .path = \"/p/sword.pixi\" }", 0); + try rec.settle("claimed+draw+person+draw"); + + // Nothing changed behind the agent: no keyframe. + try rec.adopt("claimed+draw+person+draw", "The person"); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const t = try rec.toTape(arena_state.allocator()); + try std.testing.expectEqual(@as(usize, 2), t.keyframes.len); + try std.testing.expectEqualStrings("claimed", t.keyframes[0].files[0].text); + try std.testing.expectEqualStrings("claimed+draw+person", t.keyframes[1].files[0].text); + try std.testing.expectEqual(@as(usize, 2), t.chapters.len); + try std.testing.expectEqualStrings("The person changed sword.pixi", t.chapters[1].title); + + // Ops: keyframe 0, the agent's step, the person's keyframe, the agent's next step. + try std.testing.expectEqual(@as(usize, 4), t.ops.len); + try std.testing.expectEqual(@as(u32, 0), t.ops[0].do.keyframe); + try std.testing.expect(t.ops[1].do == .command); + try std.testing.expectEqual(@as(u32, 1), t.ops[2].do.keyframe); + try std.testing.expectEqual(t.chapters[1].at, t.ops[2].at); + try std.testing.expect(t.ops[3].do == .command); + try std.testing.expect(t.ops[1].at < t.ops[2].at and t.ops[2].at < t.ops[3].at); + + // It survives the binary form a tape is written in. + const encoded = try tape.binary.encode(gpa, t, .{}); + defer gpa.free(encoded); + var back = try tape.binary.read(gpa, encoded, .{}); + defer back.deinit(); + try std.testing.expectEqual(@as(usize, 2), back.tape.keyframes.len); +} diff --git a/src/tools.zig b/src/tools.zig index adfa890..5f371e4 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -321,6 +321,7 @@ fn agentCallAwaiting(host: *sdk.Host, who: u32, id: []const u8, args: []const u8 ) catch "undo acts on the active document, which is not yours" } }; const doc_path: ?[]const u8 = if (target(host, id, arena.dupeSentinel(u8, args, 0) catch "", arena)) |doc| blk: { const h = session(host, who, doc, authorOf(who).label) catch break :blk null; + if (h.rec) |*rec| keyframeOthers(host, rec, doc); break :blk arena.dupe(u8, h.path) catch null; } else null; // What the command opens or creates lands beside the person, not over their document, and @@ -336,14 +337,29 @@ fn agentCallAwaiting(host: *sdk.Host, who: u32, id: []const u8, args: []const u8 return out; } -/// Note step `id` in `who`'s session on `path`, for its tape. +/// Note step `id` in `who`'s session on `path`, for its tape, and what the document is after it. fn note(host: *sdk.Host, who: u32, path: []const u8, id: []const u8, args: []const u8) void { for (held.items) |*h| { if (h.who != who or !eql(h.path, path)) continue; - if (h.rec) |*rec| rec.note(id, args, host.last_showing_ms) catch {}; + const rec = if (h.rec) |*r| r else continue; + rec.note(id, args, host.last_showing_ms) catch {}; + const doc = host.docFromPath(path) orelse continue; + const bytes = rec.current(doc) catch continue; + defer rec.gpa.free(bytes); + rec.settle(bytes) catch {}; } } +/// Before a step on `doc`: if it is not what the session's last step left, another hand changed it +/// (the person, another agent), and the tape gets a keyframe of it, credited to whoever made the +/// top step of its history. +fn keyframeOthers(host: *sdk.Host, rec: *Recording, doc: sdk.DocHandle) void { + const bytes = rec.current(doc) catch return; + defer rec.gpa.free(bytes); + const by = if (host.topStep(doc.id)) |top| (if (top.author.kind == .person or top.author.label.len == 0) "The person" else top.author.label) else "The person"; + rec.adopt(bytes, by) catch {}; +} + /// An `agent.call` whose command answers later: who to answer, and the step to note then. Owns /// its strings, in the host's allocator. Let go of with the plugin's sinks if the plugin goes /// first, which leaks it once, at unload.