Skip to content
Merged
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
59 changes: 43 additions & 16 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5643,6 +5643,19 @@ pub const App = struct {
_ = c.InvalidateRect(self.window.hwnd, null, 0);
}

fn existingNodeTemplateDraft(node: GraphModel.Node) Forms.NodeDraft {
return .{
.title = node.title,
.loop_type = node.loop_type,
.first_instruction = if (node.trigger_prompt.len != 0) node.trigger_prompt else node.check_description,
.goal_summary = node.goal_summary,
.trigger_prompt = node.trigger_prompt,
.heartbeat_interval_seconds = node.heartbeat_interval_seconds,
.claude_permissions = "",
.copilot_permissions = "",
};
}

fn saveSelectedNodeAsTemplate(self: *App, index: usize) void {
const graph = self.model.graph orelse return;
if (index >= graph.nodes.items.len) return;
Expand All @@ -5659,22 +5672,7 @@ pub const App = struct {
} orelse return;
defer result.deinit(self.allocator);
const node = graph.nodes.items[index];
const draft = Forms.NodeDraft{
.title = self.allocator.dupe(u8, node.title) catch return,
.loop_type = self.allocator.dupe(u8, node.loop_type) catch return,
.first_instruction = self.allocator.dupe(u8, if (node.trigger_prompt.len != 0) node.trigger_prompt else node.check_description) catch return,
.goal_summary = self.allocator.dupe(u8, node.goal_summary) catch return,
.trigger_prompt = self.allocator.dupe(u8, node.trigger_prompt) catch return,
.claude_permissions = "",
.copilot_permissions = "",
};
defer {
self.allocator.free(draft.title);
self.allocator.free(draft.loop_type);
self.allocator.free(draft.first_instruction);
self.allocator.free(draft.goal_summary);
self.allocator.free(draft.trigger_prompt);
}
const draft = existingNodeTemplateDraft(node);
var template = TemplateLibrary.fromDraft(self.allocator, result.values[0], draft) catch {
self.setStatus("A template needs a name and prompt");
return;
Expand Down Expand Up @@ -23943,6 +23941,35 @@ test "new loop: remembered type defaults to Main and ignores Composite" {
try std.testing.expect(RememberedNewLoopType.fromWire("goalBased").? == .goal);
}

test "existing node template draft preserves timed heartbeat and raw trigger text" {
const modern = GraphModel.Node{
.id = @constCast("timed"),
.title = @constCast("Timed"),
.loop_type = @constCast("timeBased"),
.state = @constCast("running"),
.activity = &.{},
.presence = &.{},
.trigger_prompt = @constCast("/loop daily Inspect reports"),
.heartbeat_interval_seconds = 21600,
};
const modern_draft = App.existingNodeTemplateDraft(modern);
try std.testing.expectEqualStrings(modern.trigger_prompt, modern_draft.trigger_prompt);
try std.testing.expectEqual(modern.heartbeat_interval_seconds, modern_draft.heartbeat_interval_seconds);
var modern_template = try TemplateLibrary.fromDraft(std.testing.allocator, "Modern", modern_draft);
defer modern_template.deinit(std.testing.allocator);
try std.testing.expectEqualStrings(modern.trigger_prompt, modern_template.body);
try std.testing.expectEqualStrings("6h", modern_template.cadence.?);

var legacy = modern;
legacy.trigger_prompt = @constCast(" /every daily Inspect reports ");
legacy.heartbeat_interval_seconds = null;
const legacy_draft = App.existingNodeTemplateDraft(legacy);
var legacy_template = try TemplateLibrary.fromDraft(std.testing.allocator, "Legacy", legacy_draft);
defer legacy_template.deinit(std.testing.allocator);
try std.testing.expectEqualStrings(legacy.trigger_prompt, legacy_template.body);
try std.testing.expectEqual(@as(?[]u8, null), legacy_template.cadence);
}

test "new loop: pending creation owns the exact client id and project" {
var app = try nodeSubmissionTestApp(std.testing.allocator);
defer deinitNodeSubmissionTestApp(&app);
Expand Down
25 changes: 25 additions & 0 deletions graphcode-windows/src/GraphModel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub const Node = struct {
first_instruction: []u8 = &.{},
check_description: []u8 = &.{},
model_tier: []u8 = &.{},
heartbeat_interval_seconds: ?f64 = null,
poll_interval_seconds: ?f64 = null,
stall_after_seconds: ?f64 = null,
/// Whole Unix seconds; see `unixSecondsFromReferenceDate`.
Expand Down Expand Up @@ -1643,6 +1644,7 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node {
.state = &.{},
.activity = &.{},
.presence = &.{},
.heartbeat_interval_seconds = node.heartbeat_interval_seconds,
.poll_interval_seconds = node.poll_interval_seconds,
.stall_after_seconds = node.stall_after_seconds,
.created_at = node.created_at,
Expand Down Expand Up @@ -2150,6 +2152,7 @@ fn decodeNodes(
.state = &.{},
.activity = &.{},
.presence = &.{},
.heartbeat_interval_seconds = fields.get("heartbeatIntervalSeconds").float(),
.poll_interval_seconds = scopedFallback(&fields, &goal, "goal", "pollIntervalSeconds").float(),
.stall_after_seconds = scopedFallback(&fields, &goal, "goal", "stallAfterSeconds").float(),
.created_at = unixSecondsFromReferenceDate(fields.get("createdAt")),
Expand Down Expand Up @@ -2628,6 +2631,28 @@ test "sketch promotion first instruction survives decode clones and source repla
try std.testing.expectEqualStrings("new", model.graphFor("A").?.nodes.items[0].first_instruction);
}

test "node heartbeat cadence survives decode clones and source replacement" {
const allocator = std.testing.allocator;
var model = Model.init(allocator);
defer model.deinit();
_ = try model.updateFromFrame(
\\{"graphChanged":{"project":{"path":"A","name":"A"},"nodes":[{"id":"modern","title":"Modern","loopType":"timeBased","triggerPrompt":"Inspect reports","heartbeatIntervalSeconds":3600},{"id":"legacy","title":"Legacy","loopType":"timeBased","triggerPrompt":"/loop 1h Inspect reports"},{"id":"null","title":"Null","loopType":"timeBased","triggerPrompt":"/every daily Inspect reports","heartbeatIntervalSeconds":null},{"id":"invalid","title":"Invalid","loopType":"timeBased","heartbeatIntervalSeconds":"60"}],"edges":[]}}
);
const graph = model.graphFor("A").?;
try std.testing.expectEqual(@as(?f64, 3600), graph.nodes.items[0].heartbeat_interval_seconds);
try std.testing.expectEqual(@as(?f64, null), graph.nodes.items[1].heartbeat_interval_seconds);
try std.testing.expectEqual(@as(?f64, null), graph.nodes.items[2].heartbeat_interval_seconds);
try std.testing.expectEqual(@as(?f64, null), graph.nodes.items[3].heartbeat_interval_seconds);
const copy = try cloneNode(allocator, graph.nodes.items[0]);
defer freeNode(allocator, copy);
try std.testing.expectEqual(graph.nodes.items[0].heartbeat_interval_seconds, copy.heartbeat_interval_seconds);

_ = try model.updateFromFrame(
\\{"graphChanged":{"project":{"path":"A","name":"A"},"nodes":[{"id":"modern","title":"Modern","loopType":"timeBased","triggerPrompt":"Inspect reports","heartbeatIntervalSeconds":90}],"edges":[]}}
);
try std.testing.expectEqual(@as(?f64, 90), model.graphFor("A").?.nodes.items[0].heartbeat_interval_seconds);
}

test "sketch promotion first instruction single owner cleans every clone and decode allocation failure" {
const Probe = struct {
fn clone(allocator: std.mem.Allocator) !void {
Expand Down
24 changes: 24 additions & 0 deletions graphcode-windows/src/TemplateLibrary.zig
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,30 @@ test "new loop: saved timed templates retain heartbeat cadence" {
});
defer custom.deinit(allocator);
try std.testing.expectEqualStrings("90s", custom.cadence.?);

var directive_looking = try fromDraft(allocator, "Literal directive", .{
.title = "",
.loop_type = "timeBased",
.trigger_prompt = "/loop daily Review new failures",
.heartbeat_interval_seconds = 21600,
});
defer directive_looking.deinit(allocator);
try std.testing.expectEqualStrings("/loop daily Review new failures", directive_looking.body);
try std.testing.expectEqualStrings("6h", directive_looking.cadence.?);

for ([_][]const u8{
"/loop 1h Inspect reports",
" /every daily Inspect reports ",
}) |prompt| {
var legacy = try fromDraft(allocator, "Legacy review", .{
.title = "",
.loop_type = "timeBased",
.trigger_prompt = prompt,
});
defer legacy.deinit(allocator);
try std.testing.expectEqualStrings(prompt, legacy.body);
try std.testing.expectEqual(@as(?[]u8, null), legacy.cadence);
}
}

test "new loop: composite templates carry their graph and save vocabulary" {
Expand Down
Loading