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.