Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
167 changes: 146 additions & 21 deletions src/Recording.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -66,14 +83,57 @@ 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),
};
}

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.
Expand Down Expand Up @@ -108,29 +168,41 @@ pub fn empty(self: *const Recording) bool {
return self.ops.items.len == 0;
}

/// The tape, written as `<dir>/<name>.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 `<dir>/<name>.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 {};
Expand All @@ -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);
}
20 changes: 18 additions & 2 deletions src/tools.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down