From 9a0cf165ea4deca9be21838c50457384fb352cbd Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 01:32:43 +0000 Subject: [PATCH 1/8] Add app release progress counts --- src/api/routes/cluster_agents/app_routes.zig | 17 +++++- .../routes/cluster_agents/deploy_routes.zig | 52 +++++++++++++++++-- src/manifest/apply_release.zig | 40 ++++++++++++-- src/manifest/cli/ops.zig | 30 ++++++++++- src/manifest/local_apply_backend.zig | 25 ++++++++- src/manifest/release_history.zig | 2 + src/manifest/update.zig | 2 + src/manifest/update/deployment_store.zig | 33 +++++++++++- src/runtime/cli/status_command.zig | 46 +++++++++++++--- src/state/schema/migrations.zig | 2 + src/state/schema/tables.zig | 2 + src/state/store.zig | 2 + src/state/store/deployments.zig | 50 +++++++++++++++--- 13 files changed, 275 insertions(+), 28 deletions(-) diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index 401f818..6b32201 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -120,6 +120,11 @@ fn formatAppHistoryResponse(alloc: std.mem.Allocator, deployments: []const store try writer.writeByte(','); try json_helpers.writeJsonStringField(writer, "manifest_hash", report.manifest_hash); try writer.print(",\"created_at\":{d}", .{report.created_at}); + try writer.print(",\"completed_targets\":{d},\"failed_targets\":{d},\"remaining_targets\":{d}", .{ + report.completed_targets, + report.failed_targets, + report.remainingTargets(), + }); try writer.writeByte(','); try json_helpers.writeNullableJsonStringField(writer, "source_release_id", report.source_release_id); try writer.writeByte(','); @@ -148,9 +153,12 @@ fn formatAppStatusResponse( try json_helpers.writeJsonStringField(writer, "status", report.status.toString()); try writer.writeByte(','); try json_helpers.writeJsonStringField(writer, "manifest_hash", report.manifest_hash); - try writer.print(",\"created_at\":{d},\"service_count\":{d}", .{ + try writer.print(",\"created_at\":{d},\"service_count\":{d},\"completed_targets\":{d},\"failed_targets\":{d},\"remaining_targets\":{d}", .{ report.created_at, report.service_count, + report.completed_targets, + report.failed_targets, + report.remainingTargets(), }); try writer.writeByte(','); try json_helpers.writeNullableJsonStringField(writer, "source_release_id", report.source_release_id); @@ -310,6 +318,9 @@ test "formatAppStatusResponse summarizes latest release" { try std.testing.expect(std.mem.indexOf(u8, json, "\"trigger\":\"apply\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"release_id\":\"dep-2\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"service_count\":2") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"completed_targets\":0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"failed_targets\":0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"remaining_targets\":2") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":null") != null); } @@ -323,6 +334,8 @@ test "formatAppStatusResponse includes structured rollback metadata" { .source_release_id = "dep-1", .manifest_hash = "sha256:333", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", + .completed_targets = 1, + .failed_targets = 0, .status = "completed", .message = "rollback to dep-1 completed: all placements succeeded", .created_at = 300, @@ -333,6 +346,8 @@ test "formatAppStatusResponse includes structured rollback metadata" { try std.testing.expect(std.mem.indexOf(u8, json, "\"trigger\":\"rollback\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":\"dep-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"completed_targets\":1") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"remaining_targets\":0") != null); } test "formatAppStatusResponse falls back to rollback metadata inferred from legacy message" { diff --git a/src/api/routes/cluster_agents/deploy_routes.zig b/src/api/routes/cluster_agents/deploy_routes.zig index 8842def..6edd52a 100644 --- a/src/api/routes/cluster_agents/deploy_routes.zig +++ b/src/api/routes/cluster_agents/deploy_routes.zig @@ -47,6 +47,8 @@ const ClusterReleaseTracker = struct { self.context.source_release_id, manifest_hash, self.config_snapshot, + 0, + 0, .pending, null, ) catch return ClusterApplyError.InternalError; @@ -55,9 +57,27 @@ const ClusterReleaseTracker = struct { } pub fn mark(self: *const ClusterReleaseTracker, id: []const u8, status: @import("../../../manifest/update/common.zig").DeploymentStatus, message: ?[]const u8) !void { + try self.markProgress(id, status, message, 0, 0); + } + + pub fn markProgress( + self: *const ClusterReleaseTracker, + id: []const u8, + status: @import("../../../manifest/update/common.zig").DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, + ) !void { const resolved_message = apply_release.materializeMessage(self.alloc, self.context, status, message) catch return ClusterApplyError.InternalError; defer if (resolved_message) |msg| self.alloc.free(msg); - deployment_store.updateDeploymentStatusInDb(self.db, id, status, resolved_message) catch return ClusterApplyError.InternalError; + deployment_store.updateDeploymentProgressInDb( + self.db, + id, + status, + resolved_message, + completed_targets, + failed_targets, + ) catch return ClusterApplyError.InternalError; } pub fn freeReleaseId(self: *const ClusterReleaseTracker, id: []const u8) void { @@ -74,11 +94,14 @@ const ClusterApplyBackend = struct { pub fn apply(self: *const ClusterApplyBackend) ClusterApplyError!apply_release.ApplyOutcome { var placed: usize = 0; var failed: usize = 0; + var completed_targets: usize = 0; + var failed_targets: usize = 0; for (self.requests) |req| { if (req.gang_world_size > 0) { const gang_placements = scheduler.scheduleGang(self.alloc, req, self.agents) catch { failed += 1; + failed_targets += 1; continue; }; @@ -108,11 +131,14 @@ const ClusterApplyBackend = struct { if (gang_ok) { placed += gps.len; + completed_targets += 1; } else { failed += req.gang_world_size; + failed_targets += 1; } } else { failed += req.gang_world_size; + failed_targets += 1; } } } @@ -123,6 +149,7 @@ const ClusterApplyBackend = struct { if (req.gang_world_size == 0) { normal_requests.append(self.alloc, req) catch { failed += 1; + failed_targets += 1; continue; }; } @@ -148,27 +175,32 @@ const ClusterApplyBackend = struct { std.time.timestamp(), ) catch { failed += 1; + failed_targets += 1; continue; }; _ = self.node.propose(sql) catch return ClusterApplyError.NotLeader; placed += 1; + completed_targets += 1; } else { failed += 1; + failed_targets += 1; } } } return .{ - .status = if (failed == 0) + .status = if (failed_targets == 0) .completed - else if (placed > 0) + else if (completed_targets > 0) .partially_failed else .failed, .message = if (failed == 0) "all placements succeeded" else "one or more placements failed", .placed = placed, .failed = failed, + .completed_targets = completed_targets, + .failed_targets = failed_targets, }; } @@ -283,10 +315,13 @@ fn formatAppApplyResponse(alloc: std.mem.Allocator, report: apply_release.ApplyR try json_helpers.writeJsonStringField(writer, "release_id", report.release_id orelse ""); try writer.writeByte(','); try json_helpers.writeJsonStringField(writer, "status", report.status.toString()); - try writer.print(",\"service_count\":{d},\"placed\":{d},\"failed\":{d}", .{ + try writer.print(",\"service_count\":{d},\"placed\":{d},\"failed\":{d},\"completed_targets\":{d},\"failed_targets\":{d},\"remaining_targets\":{d}", .{ report.service_count, report.placed, report.failed, + report.completed_targets, + report.failed_targets, + report.remainingTargets(), }); try writer.writeByte(','); try json_helpers.writeNullableJsonStringField(writer, "source_release_id", report.source_release_id); @@ -310,6 +345,8 @@ test "formatAppApplyResponse includes app release metadata" { .service_count = 2, .placed = 2, .failed = 0, + .completed_targets = 2, + .failed_targets = 0, }); defer alloc.free(json); @@ -320,6 +357,9 @@ test "formatAppApplyResponse includes app release metadata" { try std.testing.expect(std.mem.indexOf(u8, json, "\"service_count\":2") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"placed\":2") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"failed\":0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"completed_targets\":2") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"failed_targets\":0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"remaining_targets\":0") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":null") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"message\":\"apply completed\"") != null); } @@ -333,6 +373,8 @@ test "formatAppApplyResponse includes rollback trigger metadata" { .service_count = 2, .placed = 2, .failed = 0, + .completed_targets = 2, + .failed_targets = 0, .message = "all placements succeeded", .trigger = .rollback, .source_release_id = "dep-1", @@ -353,6 +395,8 @@ test "formatAppApplyResponse includes partially failed status" { .service_count = 2, .placed = 1, .failed = 1, + .completed_targets = 1, + .failed_targets = 1, .message = "one or more placements failed", }); defer alloc.free(json); diff --git a/src/manifest/apply_release.zig b/src/manifest/apply_release.zig index 811d918..81edcfa 100644 --- a/src/manifest/apply_release.zig +++ b/src/manifest/apply_release.zig @@ -25,6 +25,8 @@ pub const ApplyOutcome = struct { message: ?[]const u8 = null, placed: usize = 0, failed: usize = 0, + completed_targets: usize = 0, + failed_targets: usize = 0, }; pub const ApplyResult = struct { @@ -39,6 +41,8 @@ pub const ApplyResult = struct { .service_count = service_count, .placed = self.outcome.placed, .failed = self.outcome.failed, + .completed_targets = self.outcome.completed_targets, + .failed_targets = self.outcome.failed_targets, .message = self.outcome.message, .manifest_hash = "", .created_at = 0, @@ -55,6 +59,8 @@ pub const ApplyReport = struct { service_count: usize, placed: usize, failed: usize, + completed_targets: usize, + failed_targets: usize, message: ?[]const u8 = null, manifest_hash: []const u8 = "", created_at: i64 = 0, @@ -76,6 +82,11 @@ pub const ApplyReport = struct { return materializeMessage(alloc, self.context(), self.status, self.message); } + pub fn remainingTargets(self: ApplyReport) usize { + const accounted = @min(self.service_count, self.completed_targets + self.failed_targets); + return self.service_count - accounted; + } + pub fn summaryText(self: ApplyReport, alloc: std.mem.Allocator) ![]u8 { const status_text = self.status.toString(); const message = try self.resolvedMessage(alloc); @@ -106,6 +117,8 @@ pub fn reportFromDeployment(dep: store.DeploymentRecord) ApplyReport { .service_count = countServices(dep.config_snapshot), .placed = 0, .failed = 0, + .completed_targets = dep.completed_targets, + .failed_targets = dep.failed_targets, .message = dep.message, .manifest_hash = dep.manifest_hash, .created_at = dep.created_at, @@ -200,9 +213,15 @@ fn markReleaseIfPresent( release_id: ?[]const u8, status: update_common.DeploymentStatus, message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, ) !void { if (release_id) |id| { - try tracker.mark(id, status, message); + if (@hasDecl(std.meta.Child(@TypeOf(tracker)), "markProgress")) { + try tracker.markProgress(id, status, message, completed_targets, failed_targets); + } else { + try tracker.mark(id, status, message); + } } } @@ -210,14 +229,21 @@ pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { const release_id = try tracker.begin(); errdefer if (release_id) |id| tracker.freeReleaseId(id); - try markReleaseIfPresent(tracker, release_id, .in_progress, null); + try markReleaseIfPresent(tracker, release_id, .in_progress, null, 0, 0); const outcome = backend.apply() catch |err| { - try markReleaseIfPresent(tracker, release_id, .failed, backend.failureMessage(err)); + try markReleaseIfPresent(tracker, release_id, .failed, backend.failureMessage(err), 0, 0); return err; }; - try markReleaseIfPresent(tracker, release_id, outcome.status, outcome.message); + try markReleaseIfPresent( + tracker, + release_id, + outcome.status, + outcome.message, + outcome.completed_targets, + outcome.failed_targets, + ); return .{ .release_id = release_id, @@ -322,6 +348,8 @@ test "ApplyResult projects to shared apply report" { .message = null, .placed = 3, .failed = 0, + .completed_targets = 3, + .failed_targets = 0, }, }; @@ -332,6 +360,8 @@ test "ApplyResult projects to shared apply report" { try std.testing.expectEqual(@as(usize, 3), report.service_count); try std.testing.expectEqual(@as(usize, 3), report.placed); try std.testing.expectEqual(@as(usize, 0), report.failed); + try std.testing.expectEqual(@as(usize, 3), report.completed_targets); + try std.testing.expectEqual(@as(usize, 0), report.failed_targets); try std.testing.expect(report.message == null); try std.testing.expectEqual(ApplyTrigger.apply, report.trigger); try std.testing.expect(report.source_release_id == null); @@ -346,6 +376,8 @@ test "ApplyReport summaryText includes release status and counts" { .service_count = 3, .placed = 3, .failed = 0, + .completed_targets = 3, + .failed_targets = 0, .message = "all requested services started", }; diff --git a/src/manifest/cli/ops.zig b/src/manifest/cli/ops.zig index 3940f48..b204d22 100644 --- a/src/manifest/cli/ops.zig +++ b/src/manifest/cli/ops.zig @@ -243,6 +243,9 @@ const HistoryEntryView = struct { status: []const u8, manifest_hash: []const u8, created_at: i64, + completed_targets: usize, + failed_targets: usize, + remaining_targets: usize, source_release_id: ?[]const u8, message: ?[]const u8, }; @@ -257,6 +260,9 @@ fn historyEntryFromDeployment(dep: store.DeploymentRecord) HistoryEntryView { .status = report.status.toString(), .manifest_hash = report.manifest_hash, .created_at = report.created_at, + .completed_targets = report.completed_targets, + .failed_targets = report.failed_targets, + .remaining_targets = report.remainingTargets(), .source_release_id = report.source_release_id, .message = report.message, }; @@ -271,6 +277,9 @@ fn parseHistoryObject(obj: []const u8) HistoryEntryView { .status = json_helpers.extractJsonString(obj, "status") orelse "?", .manifest_hash = json_helpers.extractJsonString(obj, "manifest_hash") orelse "?", .created_at = json_helpers.extractJsonInt(obj, "created_at") orelse 0, + .completed_targets = @intCast(@max(0, json_helpers.extractJsonInt(obj, "completed_targets") orelse 0)), + .failed_targets = @intCast(@max(0, json_helpers.extractJsonInt(obj, "failed_targets") orelse 0)), + .remaining_targets = @intCast(@max(0, json_helpers.extractJsonInt(obj, "remaining_targets") orelse 0)), .source_release_id = json_helpers.extractJsonString(obj, "source_release_id"), .message = json_helpers.extractJsonString(obj, "message"), }; @@ -304,6 +313,9 @@ fn writeHistoryJsonObject(w: *json_out.JsonWriter, entry: HistoryEntryView) void w.stringField("status", entry.status); w.stringField("manifest_hash", entry.manifest_hash); w.intField("created_at", entry.created_at); + w.uintField("completed_targets", entry.completed_targets); + w.uintField("failed_targets", entry.failed_targets); + w.uintField("remaining_targets", entry.remaining_targets); if (entry.source_release_id) |source_release_id| w.stringField("source_release_id", source_release_id) else w.nullField("source_release_id"); if (entry.message) |message| w.stringField("message", message) else w.nullField("message"); w.endObject(); @@ -369,7 +381,7 @@ test "historyEntryFromDeployment matches remote app history shape" { const local = historyEntryFromDeployment(dep); const remote = parseHistoryObject( - \\{"id":"dep-1","app":"demo-app","service":"demo-app","trigger":"apply","status":"completed","manifest_hash":"sha256:123","created_at":42,"source_release_id":null,"message":"healthy"} + \\{"id":"dep-1","app":"demo-app","service":"demo-app","trigger":"apply","status":"completed","manifest_hash":"sha256:123","created_at":42,"completed_targets":0,"failed_targets":0,"remaining_targets":0,"source_release_id":null,"message":"healthy"} ); try std.testing.expectEqualStrings(local.id, remote.id); @@ -379,6 +391,9 @@ test "historyEntryFromDeployment matches remote app history shape" { try std.testing.expectEqualStrings(local.status, remote.status); try std.testing.expectEqualStrings(local.manifest_hash, remote.manifest_hash); try std.testing.expectEqual(local.created_at, remote.created_at); + try std.testing.expectEqual(local.completed_targets, remote.completed_targets); + try std.testing.expectEqual(local.failed_targets, remote.failed_targets); + try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } @@ -392,6 +407,9 @@ test "writeHistoryJsonObject round-trips through remote parser" { .status = "completed", .manifest_hash = "sha256:123", .created_at = 42, + .completed_targets = 1, + .failed_targets = 0, + .remaining_targets = 0, .source_release_id = "dep-0", .message = "healthy", }; @@ -407,6 +425,9 @@ test "writeHistoryJsonObject round-trips through remote parser" { try std.testing.expectEqualStrings(entry.status, parsed.status); try std.testing.expectEqualStrings(entry.manifest_hash, parsed.manifest_hash); try std.testing.expectEqual(entry.created_at, parsed.created_at); + try std.testing.expectEqual(entry.completed_targets, parsed.completed_targets); + try std.testing.expectEqual(entry.failed_targets, parsed.failed_targets); + try std.testing.expectEqual(entry.remaining_targets, parsed.remaining_targets); try std.testing.expectEqualStrings(entry.source_release_id.?, parsed.source_release_id.?); try std.testing.expectEqualStrings(entry.message.?, parsed.message.?); } @@ -418,6 +439,8 @@ test "historyEntryFromDeployment preserves partially failed local release state" .service_name = "demo-app", .manifest_hash = "sha256:333", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}", + .completed_targets = 1, + .failed_targets = 1, .status = "partially_failed", .message = "one or more placements failed", .created_at = 300, @@ -425,7 +448,7 @@ test "historyEntryFromDeployment preserves partially failed local release state" const local = historyEntryFromDeployment(dep); const remote = parseHistoryObject( - \\{"id":"dep-3","app":"demo-app","service":"demo-app","trigger":"apply","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"source_release_id":null,"message":"one or more placements failed"} + \\{"id":"dep-3","app":"demo-app","service":"demo-app","trigger":"apply","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"completed_targets":1,"failed_targets":1,"remaining_targets":0,"source_release_id":null,"message":"one or more placements failed"} ); try std.testing.expectEqualStrings(local.id, remote.id); @@ -435,6 +458,9 @@ test "historyEntryFromDeployment preserves partially failed local release state" try std.testing.expectEqualStrings(local.status, remote.status); try std.testing.expectEqualStrings(local.manifest_hash, remote.manifest_hash); try std.testing.expectEqual(local.created_at, remote.created_at); + try std.testing.expectEqual(local.completed_targets, remote.completed_targets); + try std.testing.expectEqual(local.failed_targets, remote.failed_targets); + try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } diff --git a/src/manifest/local_apply_backend.zig b/src/manifest/local_apply_backend.zig index 8e12092..ec1f6ab 100644 --- a/src/manifest/local_apply_backend.zig +++ b/src/manifest/local_apply_backend.zig @@ -261,6 +261,8 @@ fn runReplacementPlan( .message = "one or more local service replacements failed", .placed = placed, .failed = failed, + .completed_targets = placed, + .failed_targets = failed, }; } @@ -272,6 +274,8 @@ fn runReplacementPlan( "all requested services started", .placed = placed, .failed = 0, + .completed_targets = placed, + .failed_targets = 0, }; } @@ -301,10 +305,27 @@ const LocalReleaseTracker = struct { } pub fn mark(self: *const LocalReleaseTracker, id: []const u8, status: @import("update/common.zig").DeploymentStatus, message: ?[]const u8) !void { + try self.markProgress(id, status, message, 0, 0); + } + + pub fn markProgress( + self: *const LocalReleaseTracker, + id: []const u8, + status: @import("update/common.zig").DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, + ) !void { const resolved_message = try apply_release.materializeMessage(self.plan.alloc, self.context, status, message); defer if (resolved_message) |msg| self.plan.alloc.free(msg); - release_history.markAppReleaseStatus(id, status, resolved_message) catch {}; + @import("update/deployment_store.zig").updateDeploymentProgress( + id, + status, + resolved_message, + completed_targets, + failed_targets, + ) catch {}; } pub fn freeReleaseId(self: *const LocalReleaseTracker, id: []const u8) void { @@ -381,6 +402,8 @@ const ScopedApplyRunner = struct { .status = .completed, .message = "all requested services started", .placed = self.backend.release.resolvedServiceCount(), + .completed_targets = self.backend.release.resolvedServiceCount(), + .failed_targets = 0, }; } diff --git a/src/manifest/release_history.zig b/src/manifest/release_history.zig index d47a8d7..4f77331 100644 --- a/src/manifest/release_history.zig +++ b/src/manifest/release_history.zig @@ -17,6 +17,8 @@ pub fn recordAppReleaseStart(plan: *const release_plan.ReleasePlan, context: app context.source_release_id, plan.manifest_hash, plan.config_snapshot, + 0, + 0, .pending, null, ); diff --git a/src/manifest/update.zig b/src/manifest/update.zig index 7844b65..0326669 100644 --- a/src/manifest/update.zig +++ b/src/manifest/update.zig @@ -81,6 +81,8 @@ pub fn performRollingUpdate( null, context.manifest_hash, context.config_snapshot, + 0, + 0, .in_progress, null, ) catch { diff --git a/src/manifest/update/deployment_store.zig b/src/manifest/update/deployment_store.zig index 44d1530..c2cd8af 100644 --- a/src/manifest/update/deployment_store.zig +++ b/src/manifest/update/deployment_store.zig @@ -32,6 +32,8 @@ pub fn recordDeployment( source_release_id: ?[]const u8, manifest_hash: []const u8, config_snapshot: []const u8, + completed_targets: usize, + failed_targets: usize, status: common.DeploymentStatus, message: ?[]const u8, ) !void { @@ -43,6 +45,8 @@ pub fn recordDeployment( .source_release_id = source_release_id, .manifest_hash = manifest_hash, .config_snapshot = config_snapshot, + .completed_targets = completed_targets, + .failed_targets = failed_targets, .status = status.toString(), .message = message, .created_at = std.time.timestamp(), @@ -58,6 +62,8 @@ pub fn recordDeploymentInDb( source_release_id: ?[]const u8, manifest_hash: []const u8, config_snapshot: []const u8, + completed_targets: usize, + failed_targets: usize, status: common.DeploymentStatus, message: ?[]const u8, ) !void { @@ -69,6 +75,8 @@ pub fn recordDeploymentInDb( .source_release_id = source_release_id, .manifest_hash = manifest_hash, .config_snapshot = config_snapshot, + .completed_targets = completed_targets, + .failed_targets = failed_targets, .status = status.toString(), .message = message, .created_at = std.time.timestamp(), @@ -80,7 +88,7 @@ pub fn updateDeploymentStatus( status: common.DeploymentStatus, message: ?[]const u8, ) !void { - store.updateDeploymentStatus(id, status.toString(), message) catch return error.StoreFailed; + try updateDeploymentProgress(id, status, message, 0, 0); } pub fn updateDeploymentStatusInDb( @@ -89,5 +97,26 @@ pub fn updateDeploymentStatusInDb( status: common.DeploymentStatus, message: ?[]const u8, ) !void { - store.updateDeploymentStatusInDb(db, id, status.toString(), message) catch return error.StoreFailed; + try updateDeploymentProgressInDb(db, id, status, message, 0, 0); +} + +pub fn updateDeploymentProgress( + id: []const u8, + status: common.DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, +) !void { + store.updateDeploymentProgress(id, status.toString(), message, completed_targets, failed_targets) catch return error.StoreFailed; +} + +pub fn updateDeploymentProgressInDb( + db: *sqlite.Db, + id: []const u8, + status: common.DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, +) !void { + store.updateDeploymentProgressInDb(db, id, status.toString(), message, completed_targets, failed_targets) catch return error.StoreFailed; } diff --git a/src/runtime/cli/status_command.zig b/src/runtime/cli/status_command.zig index 1ffab7e..4a5a4ed 100644 --- a/src/runtime/cli/status_command.zig +++ b/src/runtime/cli/status_command.zig @@ -106,6 +106,9 @@ const AppStatusSnapshot = struct { manifest_hash: []const u8, created_at: i64, service_count: usize, + completed_targets: usize, + failed_targets: usize, + remaining_targets: usize, source_release_id: ?[]const u8, message: ?[]const u8, }; @@ -239,15 +242,18 @@ fn printAppStatus(snapshot: AppStatusSnapshot) void { } write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s}\n", .{ - "APP", "RELEASE", "STATUS", "TIMESTAMP", "SERVICES", "MESSAGE", + "APP", "RELEASE", "STATUS", "TIMESTAMP", "PROGRESS", "MESSAGE", }); var ts_buf: [20]u8 = undefined; const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{snapshot.created_at}) catch "?"; const msg = snapshot.message orelse ""; - var count_buf: [16]u8 = undefined; - const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{snapshot.service_count}) catch "?"; + var count_buf: [32]u8 = undefined; + const count_str = std.fmt.bufPrint(&count_buf, "{d}/{d}", .{ + snapshot.completed_targets, + snapshot.service_count, + }) catch "?"; write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s}\n", .{ snapshot.app_name, @@ -268,6 +274,9 @@ fn parseAppStatusResponse(json: []const u8) AppStatusSnapshot { .manifest_hash = extractJsonString(json, "manifest_hash") orelse "?", .created_at = extractJsonInt(json, "created_at") orelse 0, .service_count = @intCast(@max(0, extractJsonInt(json, "service_count") orelse 0)), + .completed_targets = @intCast(@max(0, extractJsonInt(json, "completed_targets") orelse 0)), + .failed_targets = @intCast(@max(0, extractJsonInt(json, "failed_targets") orelse 0)), + .remaining_targets = @intCast(@max(0, extractJsonInt(json, "remaining_targets") orelse 0)), .source_release_id = extractJsonString(json, "source_release_id"), .message = extractJsonString(json, "message"), }; @@ -282,6 +291,9 @@ fn writeAppStatusJsonObject(w: *json_out.JsonWriter, snapshot: AppStatusSnapshot w.stringField("manifest_hash", snapshot.manifest_hash); w.intField("created_at", snapshot.created_at); w.uintField("service_count", snapshot.service_count); + w.uintField("completed_targets", snapshot.completed_targets); + w.uintField("failed_targets", snapshot.failed_targets); + w.uintField("remaining_targets", snapshot.remaining_targets); if (snapshot.source_release_id) |source_release_id| w.stringField("source_release_id", source_release_id) else w.nullField("source_release_id"); if (snapshot.message) |message| w.stringField("message", message) else w.nullField("message"); } @@ -295,6 +307,9 @@ fn appStatusFromReport(report: apply_release.ApplyReport) AppStatusSnapshot { .manifest_hash = report.manifest_hash, .created_at = report.created_at, .service_count = report.service_count, + .completed_targets = report.completed_targets, + .failed_targets = report.failed_targets, + .remaining_targets = report.remainingTargets(), .source_release_id = report.source_release_id, .message = report.message, }; @@ -407,7 +422,7 @@ fn parsePsiFromJson(json: []const u8, some_key: []const u8, full_key: []const u8 test "parseAppStatusResponse extracts app fields" { const snapshot = parseAppStatusResponse( - \\{"app_name":"demo-app","trigger":"apply","release_id":"abc123def456","status":"completed","manifest_hash":"sha256:123","created_at":42,"service_count":2,"source_release_id":null,"message":null} + \\{"app_name":"demo-app","trigger":"apply","release_id":"abc123def456","status":"completed","manifest_hash":"sha256:123","created_at":42,"service_count":2,"completed_targets":2,"failed_targets":0,"remaining_targets":0,"source_release_id":null,"message":null} ); try std.testing.expectEqualStrings("demo-app", snapshot.app_name); @@ -417,6 +432,9 @@ test "parseAppStatusResponse extracts app fields" { try std.testing.expectEqualStrings("sha256:123", snapshot.manifest_hash); try std.testing.expectEqual(@as(i64, 42), snapshot.created_at); try std.testing.expectEqual(@as(usize, 2), snapshot.service_count); + try std.testing.expectEqual(@as(usize, 2), snapshot.completed_targets); + try std.testing.expectEqual(@as(usize, 0), snapshot.failed_targets); + try std.testing.expectEqual(@as(usize, 0), snapshot.remaining_targets); try std.testing.expect(snapshot.source_release_id == null); try std.testing.expect(snapshot.message == null); } @@ -429,6 +447,8 @@ test "appStatusFromReport matches remote app status shape" { .service_count = 2, .placed = 2, .failed = 0, + .completed_targets = 2, + .failed_targets = 0, .message = "all placements healthy", .manifest_hash = "sha256:222", .created_at = 200, @@ -436,7 +456,7 @@ test "appStatusFromReport matches remote app status shape" { const local = appStatusFromReport(report); const remote = parseAppStatusResponse( - \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-2","status":"completed","manifest_hash":"sha256:222","created_at":200,"service_count":2,"source_release_id":null,"message":"all placements healthy"} + \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-2","status":"completed","manifest_hash":"sha256:222","created_at":200,"service_count":2,"completed_targets":2,"failed_targets":0,"remaining_targets":0,"source_release_id":null,"message":"all placements healthy"} ); try std.testing.expectEqualStrings(local.app_name, remote.app_name); @@ -446,6 +466,9 @@ test "appStatusFromReport matches remote app status shape" { try std.testing.expectEqualStrings(local.manifest_hash, remote.manifest_hash); try std.testing.expectEqual(local.created_at, remote.created_at); try std.testing.expectEqual(local.service_count, remote.service_count); + try std.testing.expectEqual(local.completed_targets, remote.completed_targets); + try std.testing.expectEqual(local.failed_targets, remote.failed_targets); + try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } @@ -459,6 +482,9 @@ test "writeAppStatusJsonObject round-trips through remote parser" { .manifest_hash = "sha256:222", .created_at = 200, .service_count = 2, + .completed_targets = 1, + .failed_targets = 1, + .remaining_targets = 0, .source_release_id = "dep-1", .message = "all placements healthy", }; @@ -474,6 +500,9 @@ test "writeAppStatusJsonObject round-trips through remote parser" { try std.testing.expectEqualStrings(snapshot.manifest_hash, parsed.manifest_hash); try std.testing.expectEqual(snapshot.created_at, parsed.created_at); try std.testing.expectEqual(snapshot.service_count, parsed.service_count); + try std.testing.expectEqual(snapshot.completed_targets, parsed.completed_targets); + try std.testing.expectEqual(snapshot.failed_targets, parsed.failed_targets); + try std.testing.expectEqual(snapshot.remaining_targets, parsed.remaining_targets); try std.testing.expectEqualStrings(snapshot.source_release_id.?, parsed.source_release_id.?); try std.testing.expectEqualStrings(snapshot.message.?, parsed.message.?); } @@ -485,6 +514,8 @@ test "appStatusFromReport preserves partially failed local release state" { .service_name = "demo-app", .manifest_hash = "sha256:333", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}", + .completed_targets = 1, + .failed_targets = 1, .status = "partially_failed", .message = "one or more placements failed", .created_at = 300, @@ -492,7 +523,7 @@ test "appStatusFromReport preserves partially failed local release state" { const local = appStatusFromReport(apply_release.reportFromDeployment(dep)); const remote = parseAppStatusResponse( - \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-3","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"service_count":2,"source_release_id":null,"message":"one or more placements failed"} + \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-3","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"service_count":2,"completed_targets":1,"failed_targets":1,"remaining_targets":0,"source_release_id":null,"message":"one or more placements failed"} ); try std.testing.expectEqualStrings(local.app_name, remote.app_name); @@ -502,6 +533,9 @@ test "appStatusFromReport preserves partially failed local release state" { try std.testing.expectEqualStrings(local.manifest_hash, remote.manifest_hash); try std.testing.expectEqual(local.created_at, remote.created_at); try std.testing.expectEqual(local.service_count, remote.service_count); + try std.testing.expectEqual(local.completed_targets, remote.completed_targets); + try std.testing.expectEqual(local.failed_targets, remote.failed_targets); + try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } diff --git a/src/state/schema/migrations.zig b/src/state/schema/migrations.zig index 048698d..a5e04a9 100644 --- a/src/state/schema/migrations.zig +++ b/src/state/schema/migrations.zig @@ -130,6 +130,8 @@ fn migrateDeployments(db: *sqlite.Db) void { addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN app_name TEXT;") catch {}; addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN trigger TEXT NOT NULL DEFAULT 'apply';") catch {}; addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN source_release_id TEXT;") catch {}; + addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN completed_targets INTEGER NOT NULL DEFAULT 0;") catch {}; + addColumnIfMissing(db, "ALTER TABLE deployments ADD COLUMN failed_targets INTEGER NOT NULL DEFAULT 0;") catch {}; db.exec("UPDATE deployments SET trigger = 'apply' WHERE trigger IS NULL OR trigger = '';", .{}, .{}) catch {}; } diff --git a/src/state/schema/tables.zig b/src/state/schema/tables.zig index 3cbd21e..b3b77f3 100644 --- a/src/state/schema/tables.zig +++ b/src/state/schema/tables.zig @@ -166,6 +166,8 @@ pub fn initCoreTables(db: *sqlite.Db) SchemaError!void { \\ source_release_id TEXT, \\ manifest_hash TEXT NOT NULL, \\ config_snapshot TEXT NOT NULL DEFAULT '', + \\ completed_targets INTEGER NOT NULL DEFAULT 0, + \\ failed_targets INTEGER NOT NULL DEFAULT 0, \\ status TEXT NOT NULL DEFAULT 'pending', \\ message TEXT, \\ created_at INTEGER NOT NULL diff --git a/src/state/store.zig b/src/state/store.zig index 4a88b6e..a11ed8a 100644 --- a/src/state/store.zig +++ b/src/state/store.zig @@ -86,6 +86,8 @@ pub const listDeploymentsByApp = @import("store/deployments.zig").listDeployment pub const listDeploymentsByAppInDb = @import("store/deployments.zig").listDeploymentsByAppInDb; pub const updateDeploymentStatus = @import("store/deployments.zig").updateDeploymentStatus; pub const updateDeploymentStatusInDb = @import("store/deployments.zig").updateDeploymentStatusInDb; +pub const updateDeploymentProgress = @import("store/deployments.zig").updateDeploymentProgress; +pub const updateDeploymentProgressInDb = @import("store/deployments.zig").updateDeploymentProgressInDb; pub const getLatestDeployment = @import("store/deployments.zig").getLatestDeployment; pub const getLatestDeploymentByApp = @import("store/deployments.zig").getLatestDeploymentByApp; pub const getLatestDeploymentByAppInDb = @import("store/deployments.zig").getLatestDeploymentByAppInDb; diff --git a/src/state/store/deployments.zig b/src/state/store/deployments.zig index bfe7d27..e80c7a8 100644 --- a/src/state/store/deployments.zig +++ b/src/state/store/deployments.zig @@ -14,6 +14,8 @@ pub const DeploymentRecord = struct { source_release_id: ?[]const u8 = null, manifest_hash: []const u8, config_snapshot: []const u8, + completed_targets: usize = 0, + failed_targets: usize = 0, status: []const u8, message: ?[]const u8, created_at: i64, @@ -32,7 +34,7 @@ pub const DeploymentRecord = struct { }; const deployment_columns = - "id, app_name, service_name, trigger, source_release_id, manifest_hash, config_snapshot, status, message, created_at"; + "id, app_name, service_name, trigger, source_release_id, manifest_hash, config_snapshot, completed_targets, failed_targets, status, message, created_at"; const DeploymentRow = struct { id: sqlite.Text, @@ -42,6 +44,8 @@ const DeploymentRow = struct { source_release_id: ?sqlite.Text, manifest_hash: sqlite.Text, config_snapshot: sqlite.Text, + completed_targets: i64, + failed_targets: i64, status: sqlite.Text, message: ?sqlite.Text, created_at: i64, @@ -56,6 +60,8 @@ fn rowToRecord(row: DeploymentRow) DeploymentRecord { .source_release_id = if (row.source_release_id) |source_release_id| source_release_id.data else null, .manifest_hash = row.manifest_hash.data, .config_snapshot = row.config_snapshot.data, + .completed_targets = @intCast(@max(@as(i64, 0), row.completed_targets)), + .failed_targets = @intCast(@max(@as(i64, 0), row.failed_targets)), .status = row.status.data, .message = if (row.message) |message| message.data else null, .created_at = row.created_at, @@ -69,7 +75,7 @@ pub fn saveDeployment(record: DeploymentRecord) StoreError!void { pub fn saveDeploymentInDb(db: *sqlite.Db, record: DeploymentRecord) StoreError!void { db.exec( - "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", .{}, .{ record.id, @@ -79,6 +85,8 @@ pub fn saveDeploymentInDb(db: *sqlite.Db, record: DeploymentRecord) StoreError!v record.source_release_id, record.manifest_hash, record.config_snapshot, + @as(i64, @intCast(record.completed_targets)), + @as(i64, @intCast(record.failed_targets)), record.status, record.message, record.created_at, @@ -164,11 +172,33 @@ pub fn updateDeploymentStatusInDb( id: []const u8, status: []const u8, message: ?[]const u8, +) StoreError!void { + return updateDeploymentProgressInDb(db, id, status, message, 0, 0); +} + +pub fn updateDeploymentProgress( + id: []const u8, + status: []const u8, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, +) StoreError!void { + const db = try common.getDb(); + return updateDeploymentProgressInDb(db, id, status, message, completed_targets, failed_targets); +} + +pub fn updateDeploymentProgressInDb( + db: *sqlite.Db, + id: []const u8, + status: []const u8, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, ) StoreError!void { db.exec( - "UPDATE deployments SET status = ?, message = ? WHERE id = ?;", + "UPDATE deployments SET status = ?, message = ?, completed_targets = ?, failed_targets = ? WHERE id = ?;", .{}, - .{ status, message, id }, + .{ status, message, @as(i64, @intCast(completed_targets)), @as(i64, @intCast(failed_targets)), id }, ) catch return StoreError.WriteFailed; } @@ -220,9 +250,9 @@ test "deployment record round-trip via sqlite" { try schema.init(&db); db.exec( - "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", .{}, - .{ "dep001", "demo-app", "web", "apply", null, "sha256:abc", "{\"image\":\"nginx:latest\"}", "completed", "initial deploy", @as(i64, 1000) }, + .{ "dep001", "demo-app", "web", "apply", null, "sha256:abc", "{\"image\":\"nginx:latest\"}", @as(i64, 1), @as(i64, 0), "completed", "initial deploy", @as(i64, 1000) }, ) catch unreachable; const alloc = std.testing.allocator; @@ -237,6 +267,8 @@ test "deployment record round-trip via sqlite" { try std.testing.expect(record.source_release_id == null); try std.testing.expectEqualStrings("sha256:abc", record.manifest_hash); try std.testing.expectEqualStrings("{\"image\":\"nginx:latest\"}", record.config_snapshot); + try std.testing.expectEqual(@as(usize, 1), record.completed_targets); + try std.testing.expectEqual(@as(usize, 0), record.failed_targets); try std.testing.expectEqualStrings("completed", record.status); try std.testing.expectEqualStrings("initial deploy", record.message.?); try std.testing.expectEqual(@as(i64, 1000), record.created_at); @@ -270,9 +302,9 @@ test "deployment stores rollback transition metadata" { try schema.init(&db); db.exec( - "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO deployments (" ++ deployment_columns ++ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", .{}, - .{ "dep-rb", "demo-app", "demo-app", "rollback", "dep-1", "sha256:rb", "{}", "completed", "rollback completed", @as(i64, 2100) }, + .{ "dep-rb", "demo-app", "demo-app", "rollback", "dep-1", "sha256:rb", "{}", @as(i64, 1), @as(i64, 0), "completed", "rollback completed", @as(i64, 2100) }, ) catch unreachable; const alloc = std.testing.allocator; @@ -282,6 +314,8 @@ test "deployment stores rollback transition metadata" { try std.testing.expectEqualStrings("rollback", record.trigger.?); try std.testing.expectEqualStrings("dep-1", record.source_release_id.?); + try std.testing.expectEqual(@as(usize, 1), record.completed_targets); + try std.testing.expectEqual(@as(usize, 0), record.failed_targets); } test "deployment list ordered by timestamp desc" { From ff0976dac7be3eef0c4a389ad6cef5d8d6b8e971 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 02:24:56 +0000 Subject: [PATCH 2/8] Emit live app release progress updates --- .../routes/cluster_agents/deploy_routes.zig | 19 ++++ src/manifest/apply_release.zig | 98 ++++++++++++++++++- src/manifest/local_apply_backend.zig | 71 +++++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/src/api/routes/cluster_agents/deploy_routes.zig b/src/api/routes/cluster_agents/deploy_routes.zig index 6edd52a..a152b72 100644 --- a/src/api/routes/cluster_agents/deploy_routes.zig +++ b/src/api/routes/cluster_agents/deploy_routes.zig @@ -90,6 +90,11 @@ const ClusterApplyBackend = struct { node: *cluster_node.Node, requests: []scheduler.PlacementRequest, agents: []agent_registry.AgentRecord, + progress: ?apply_release.ProgressRecorder = null, + + pub fn attachProgressRecorder(self: *@This(), recorder: apply_release.ProgressRecorder) void { + self.progress = recorder; + } pub fn apply(self: *const ClusterApplyBackend) ClusterApplyError!apply_release.ApplyOutcome { var placed: usize = 0; @@ -102,6 +107,7 @@ const ClusterApplyBackend = struct { const gang_placements = scheduler.scheduleGang(self.alloc, req, self.agents) catch { failed += 1; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); continue; }; @@ -132,13 +138,16 @@ const ClusterApplyBackend = struct { if (gang_ok) { placed += gps.len; completed_targets += 1; + self.reportProgress(completed_targets, failed_targets); } else { failed += req.gang_world_size; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); } } else { failed += req.gang_world_size; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); } } } @@ -150,6 +159,7 @@ const ClusterApplyBackend = struct { normal_requests.append(self.alloc, req) catch { failed += 1; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); continue; }; } @@ -176,15 +186,18 @@ const ClusterApplyBackend = struct { ) catch { failed += 1; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); continue; }; _ = self.node.propose(sql) catch return ClusterApplyError.NotLeader; placed += 1; completed_targets += 1; + self.reportProgress(completed_targets, failed_targets); } else { failed += 1; failed_targets += 1; + self.reportProgress(completed_targets, failed_targets); } } } @@ -204,6 +217,12 @@ const ClusterApplyBackend = struct { }; } + fn reportProgress(self: *const ClusterApplyBackend, completed_targets: usize, failed_targets: usize) void { + if (self.progress) |progress| { + progress.mark(.in_progress, null, completed_targets, failed_targets) catch {}; + } + } + pub fn failureMessage(_: *const ClusterApplyBackend, err: ClusterApplyError) ?[]const u8 { return switch (err) { error.NotLeader => "leadership changed during apply", diff --git a/src/manifest/apply_release.zig b/src/manifest/apply_release.zig index 81edcfa..a93f814 100644 --- a/src/manifest/apply_release.zig +++ b/src/manifest/apply_release.zig @@ -225,10 +225,66 @@ fn markReleaseIfPresent( } } +pub const ProgressRecorder = struct { + ctx: *anyopaque, + release_id: []const u8, + markFn: *const fn ( + ctx: *anyopaque, + release_id: []const u8, + status: update_common.DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, + ) anyerror!void, + + pub fn mark( + self: ProgressRecorder, + status: update_common.DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, + ) !void { + try self.markFn(self.ctx, self.release_id, status, message, completed_targets, failed_targets); + } +}; + +fn makeProgressRecorder(tracker: anytype, release_id: []const u8) ProgressRecorder { + const TrackerPtr = @TypeOf(tracker); + const Adapter = struct { + fn mark( + ctx: *anyopaque, + id: []const u8, + status: update_common.DeploymentStatus, + message: ?[]const u8, + completed_targets: usize, + failed_targets: usize, + ) anyerror!void { + const typed: TrackerPtr = @ptrCast(@alignCast(ctx)); + try markReleaseIfPresent(typed, id, status, message, completed_targets, failed_targets); + } + }; + + return .{ + .ctx = @ptrCast(tracker), + .release_id = release_id, + .markFn = Adapter.mark, + }; +} + +fn attachProgressRecorderIfSupported(backend: anytype, recorder: ProgressRecorder) void { + if (@hasDecl(std.meta.Child(@TypeOf(backend)), "attachProgressRecorder")) { + backend.attachProgressRecorder(recorder); + } +} + pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { const release_id = try tracker.begin(); errdefer if (release_id) |id| tracker.freeReleaseId(id); + if (release_id) |id| { + attachProgressRecorderIfSupported(backend, makeProgressRecorder(tracker, id)); + } + try markReleaseIfPresent(tracker, release_id, .in_progress, null, 0, 0); const outcome = backend.apply() catch |err| { @@ -254,8 +310,8 @@ pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { const TestTracker = struct { alloc: std.mem.Allocator, release_id: []const u8, - statuses: [2]?update_common.DeploymentStatus = [_]?update_common.DeploymentStatus{null} ** 2, - messages: [2]?[]const u8 = [_]?[]const u8{null} ** 2, + statuses: [4]?update_common.DeploymentStatus = [_]?update_common.DeploymentStatus{null} ** 4, + messages: [4]?[]const u8 = [_]?[]const u8{null} ** 4, mark_count: usize = 0, fn begin(self: *@This()) !?[]const u8 { @@ -340,6 +396,44 @@ test "execute marks failed releases on backend error" { try expectTrackedStatuses(&tracker, .in_progress, .failed, "service startup failed"); } +test "execute attaches a live progress recorder when backend supports it" { + const alloc = std.testing.allocator; + + const Backend = struct { + attached: bool = false, + progress: ?ProgressRecorder = null, + + fn attachProgressRecorder(self: *@This(), recorder: ProgressRecorder) void { + self.attached = true; + self.progress = recorder; + } + + fn apply(self: *@This()) !ApplyOutcome { + try self.progress.?.mark(.in_progress, null, 1, 0); + return .{ + .status = .completed, + .completed_targets = 1, + }; + } + + fn failureMessage(_: *@This(), _: anytype) ?[]const u8 { + return "backend failed"; + } + }; + + var tracker = TestTracker{ .alloc = alloc, .release_id = "dep789" }; + var backend = Backend{}; + + const result = try execute(&tracker, &backend); + defer alloc.free(result.release_id.?); + + try std.testing.expect(backend.attached); + try std.testing.expectEqual(@as(usize, 3), tracker.mark_count); + try std.testing.expectEqual(update_common.DeploymentStatus.in_progress, tracker.statuses[0].?); + try std.testing.expectEqual(update_common.DeploymentStatus.in_progress, tracker.statuses[1].?); + try std.testing.expectEqual(update_common.DeploymentStatus.completed, tracker.statuses[2].?); +} + test "ApplyResult projects to shared apply report" { const result = ApplyResult{ .release_id = "dep789", diff --git a/src/manifest/local_apply_backend.zig b/src/manifest/local_apply_backend.zig index ec1f6ab..28dd2cc 100644 --- a/src/manifest/local_apply_backend.zig +++ b/src/manifest/local_apply_backend.zig @@ -236,11 +236,13 @@ fn runReplacementPlan( for (new_indexes) |idx| { runner.start(idx, &completed_workers) catch { failed += 1; + reportProgressIfSupported(runner, placed, failed); if (!mutated) return error.StartFailed; continue; }; placed += 1; mutated = true; + reportProgressIfSupported(runner, placed, failed); } for (replacement_indexes) |idx| { @@ -248,9 +250,11 @@ fn runReplacementPlan( mutated = true; runner.start(idx, &completed_workers) catch { failed += 1; + reportProgressIfSupported(runner, placed, failed); continue; }; placed += 1; + reportProgressIfSupported(runner, placed, failed); } runner.finish(); @@ -279,6 +283,12 @@ fn runReplacementPlan( }; } +fn reportProgressIfSupported(runner: anytype, completed_targets: usize, failed_targets: usize) void { + if (@hasDecl(std.meta.Child(@TypeOf(runner)), "reportProgress")) { + runner.reportProgress(completed_targets, failed_targets); + } +} + fn runScopedApply(scope: LocalApplyScope, runner: anytype) !apply_release.ApplyOutcome { return switch (scope.mode) { .fresh => runner.runFresh(), @@ -337,6 +347,11 @@ const LocalApplyBackend = struct { orch: *orchestrator.Orchestrator, release: *const release_plan.ReleasePlan, scope: LocalApplyScope, + progress: ?apply_release.ProgressRecorder = null, + + pub fn attachProgressRecorder(self: *@This(), recorder: apply_release.ProgressRecorder) void { + self.progress = recorder; + } pub fn apply(self: *const LocalApplyBackend) !apply_release.ApplyOutcome { var runner = ScopedApplyRunner{ .backend = self }; @@ -366,6 +381,7 @@ const LocalApplyBackend = struct { var runner = struct { orch: *orchestrator.Orchestrator, + progress: ?apply_release.ProgressRecorder, fn start(runner_self: *@This(), idx: usize, completed_workers: *std.StringHashMapUnmanaged(void)) !void { try runner_self.orch.startServiceByIndex(idx, completed_workers); @@ -378,7 +394,13 @@ const LocalApplyBackend = struct { fn finish(runner_self: *@This()) void { runner_self.orch.startTlsProxy(); } - }{ .orch = self.orch }; + + fn reportProgress(runner_self: *@This(), completed_targets: usize, failed_targets: usize) void { + if (runner_self.progress) |progress| { + progress.mark(.in_progress, null, completed_targets, failed_targets) catch {}; + } + } + }{ .orch = self.orch, .progress = self.progress }; return runReplacementPlan( &runner, @@ -398,6 +420,9 @@ const ScopedApplyRunner = struct { fn runFresh(self: *@This()) !apply_release.ApplyOutcome { try self.backend.orch.startAll(); + if (self.backend.progress) |progress| { + progress.mark(.in_progress, null, self.backend.release.resolvedServiceCount(), 0) catch {}; + } return .{ .status = .completed, .message = "all requested services started", @@ -666,6 +691,50 @@ test "runReplacementPlan reports partial failure after mutation" { try std.testing.expectEqual(@as(usize, 1), runner.stopped.items.len); } +test "runReplacementPlan emits live target progress after each update" { + const alloc = std.testing.allocator; + + const Progress = struct { + completed_targets: usize, + failed_targets: usize, + }; + + const Runner = struct { + started: std.ArrayList(usize), + progress_updates: std.ArrayList(Progress), + + fn start(self: *@This(), idx: usize, _: *std.StringHashMapUnmanaged(void)) !void { + try self.started.append(alloc, idx); + } + + fn stop(_: *@This(), _: usize) void {} + + fn finish(_: *@This()) void {} + + fn reportProgress(self: *@This(), completed_targets: usize, failed_targets: usize) void { + self.progress_updates.append(alloc, .{ + .completed_targets = completed_targets, + .failed_targets = failed_targets, + }) catch unreachable; + } + }; + + var runner = Runner{ + .started = .empty, + .progress_updates = .empty, + }; + defer runner.started.deinit(alloc); + defer runner.progress_updates.deinit(alloc); + + _ = try runReplacementPlan(&runner, alloc, &.{0}, &.{1}); + + try std.testing.expectEqual(@as(usize, 2), runner.progress_updates.items.len); + try std.testing.expectEqual(@as(usize, 1), runner.progress_updates.items[0].completed_targets); + try std.testing.expectEqual(@as(usize, 0), runner.progress_updates.items[0].failed_targets); + try std.testing.expectEqual(@as(usize, 2), runner.progress_updates.items[1].completed_targets); + try std.testing.expectEqual(@as(usize, 0), runner.progress_updates.items[1].failed_targets); +} + test "runScopedApply chooses replacement branch for replacement candidates" { const Runner = struct { fresh_calls: usize = 0, From aa617ca6bc68318c053de857cc334d67b5effb27 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 02:47:58 +0000 Subject: [PATCH 3/8] Show previous successful app release in status --- src/api/routes/cluster_agents/app_routes.zig | 52 ++++++++++++-- src/runtime/cli/status_command.zig | 75 +++++++++++++++++--- src/state/store.zig | 2 + src/state/store/deployments.zig | 69 ++++++++++++++++++ 4 files changed, 184 insertions(+), 14 deletions(-) diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index 6b32201..de5b105 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -60,7 +60,17 @@ pub fn handleAppStatus(alloc: std.mem.Allocator, app_name: []const u8, ctx: Rout }; defer latest.deinit(alloc); - const body = formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)) catch + const previous_successful = store.getPreviousSuccessfulDeploymentByAppInDb(node.stateMachineDb(), alloc, app_name, latest.id) catch |err| switch (err) { + error.NotFound => null, + else => return common.internalError(), + }; + defer if (previous_successful) |dep| dep.deinit(alloc); + + const body = formatAppStatusResponse( + alloc, + apply_release.reportFromDeployment(latest), + if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, + ) catch return common.internalError(); return .{ .status = .ok, .body = body, .allocated = true }; } @@ -138,6 +148,7 @@ fn formatAppHistoryResponse(alloc: std.mem.Allocator, deployments: []const store fn formatAppStatusResponse( alloc: std.mem.Allocator, report: apply_release.ApplyReport, + previous_successful: ?apply_release.ApplyReport, ) ![]u8 { var json_buf: std.ArrayList(u8) = .empty; errdefer json_buf.deinit(alloc); @@ -163,6 +174,15 @@ fn formatAppStatusResponse( try writer.writeByte(','); try json_helpers.writeNullableJsonStringField(writer, "source_release_id", report.source_release_id); try writer.writeByte(','); + try json_helpers.writeNullableJsonStringField(writer, "previous_successful_release_id", if (previous_successful) |prev| prev.release_id else null); + try writer.writeByte(','); + try json_helpers.writeNullableJsonStringField(writer, "previous_successful_manifest_hash", if (previous_successful) |prev| prev.manifest_hash else null); + if (previous_successful) |prev| { + try writer.print(",\"previous_successful_created_at\":{d}", .{prev.created_at}); + } else { + try writer.writeAll(",\"previous_successful_created_at\":null"); + } + try writer.writeByte(','); try json_helpers.writeNullableJsonStringField(writer, "message", report.message); try writer.writeByte('}'); return json_buf.toOwnedSlice(alloc); @@ -311,7 +331,7 @@ test "formatAppStatusResponse summarizes latest release" { .created_at = 200, }; - const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest), null); defer alloc.free(json); try std.testing.expect(std.mem.indexOf(u8, json, "\"app_name\":\"demo-app\"") != null); @@ -322,6 +342,7 @@ test "formatAppStatusResponse summarizes latest release" { try std.testing.expect(std.mem.indexOf(u8, json, "\"failed_targets\":0") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"remaining_targets\":2") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"source_release_id\":null") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"previous_successful_release_id\":null") != null); } test "formatAppStatusResponse includes structured rollback metadata" { @@ -341,7 +362,7 @@ test "formatAppStatusResponse includes structured rollback metadata" { .created_at = 300, }; - const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest), null); defer alloc.free(json); try std.testing.expect(std.mem.indexOf(u8, json, "\"trigger\":\"rollback\"") != null); @@ -363,7 +384,7 @@ test "formatAppStatusResponse falls back to rollback metadata inferred from lega .created_at = 400, }; - const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + const json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest), null); defer alloc.free(json); try std.testing.expect(std.mem.indexOf(u8, json, "\"trigger\":\"rollback\"") != null); @@ -381,6 +402,7 @@ test "app status and history surface rollback release metadata from persisted ro .id = "dep-1", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "apply", .manifest_hash = "sha256:111", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", .status = "completed", @@ -418,12 +440,20 @@ test "app status and history surface rollback release metadata from persisted ro const latest = try store.getLatestDeploymentByAppInDb(&db, alloc, "demo-app"); defer latest.deinit(alloc); - const status_json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + const previous_successful = try store.getPreviousSuccessfulDeploymentByAppInDb(&db, alloc, "demo-app", latest.id); + defer previous_successful.deinit(alloc); + + const status_json = try formatAppStatusResponse( + alloc, + apply_release.reportFromDeployment(latest), + apply_release.reportFromDeployment(previous_successful), + ); defer alloc.free(status_json); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"release_id\":\"dep-2\"") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"trigger\":\"rollback\"") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"source_release_id\":\"dep-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, status_json, "\"previous_successful_release_id\":\"dep-1\"") != null); } test "app status and history surface failed apply metadata from persisted rows" { @@ -437,6 +467,7 @@ test "app status and history surface failed apply metadata from persisted rows" .id = "dep-1", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "apply", .manifest_hash = "sha256:111", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"}]}", .status = "completed", @@ -447,6 +478,7 @@ test "app status and history surface failed apply metadata from persisted rows" .id = "dep-2", .app_name = "demo-app", .service_name = "demo-app", + .trigger = "apply", .manifest_hash = "sha256:222", .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}", .status = "failed", @@ -472,7 +504,14 @@ test "app status and history surface failed apply metadata from persisted rows" const latest = try store.getLatestDeploymentByAppInDb(&db, alloc, "demo-app"); defer latest.deinit(alloc); - const status_json = try formatAppStatusResponse(alloc, apply_release.reportFromDeployment(latest)); + const previous_successful = try store.getPreviousSuccessfulDeploymentByAppInDb(&db, alloc, "demo-app", latest.id); + defer previous_successful.deinit(alloc); + + const status_json = try formatAppStatusResponse( + alloc, + apply_release.reportFromDeployment(latest), + apply_release.reportFromDeployment(previous_successful), + ); defer alloc.free(status_json); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"release_id\":\"dep-2\"") != null); @@ -480,6 +519,7 @@ test "app status and history surface failed apply metadata from persisted rows" try std.testing.expect(std.mem.indexOf(u8, status_json, "\"status\":\"failed\"") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"service_count\":2") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"source_release_id\":null") != null); + try std.testing.expect(std.mem.indexOf(u8, status_json, "\"previous_successful_release_id\":\"dep-1\"") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"message\":\"scheduler error during apply\"") != null); } diff --git a/src/runtime/cli/status_command.zig b/src/runtime/cli/status_command.zig index 4a5a4ed..4bdda0e 100644 --- a/src/runtime/cli/status_command.zig +++ b/src/runtime/cli/status_command.zig @@ -110,6 +110,9 @@ const AppStatusSnapshot = struct { failed_targets: usize, remaining_targets: usize, source_release_id: ?[]const u8, + previous_successful_release_id: ?[]const u8, + previous_successful_manifest_hash: ?[]const u8, + previous_successful_created_at: ?i64, message: ?[]const u8, }; @@ -126,7 +129,19 @@ fn statusLocalApp(alloc: std.mem.Allocator, app_name: []const u8) StatusError!vo }; defer latest.deinit(alloc); - const snapshot = appStatusFromReport(apply_release.reportFromDeployment(latest)); + const previous_successful = store.getPreviousSuccessfulDeploymentByApp(alloc, app_name, latest.id) catch |err| switch (err) { + error.NotFound => null, + else => { + writeErr("failed to read app status\n", .{}); + return StatusError.StoreError; + }, + }; + defer if (previous_successful) |dep| dep.deinit(alloc); + + const snapshot = appStatusFromReports( + apply_release.reportFromDeployment(latest), + if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, + ); printAppStatus(snapshot); } @@ -241,8 +256,8 @@ fn printAppStatus(snapshot: AppStatusSnapshot) void { return; } - write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s}\n", .{ - "APP", "RELEASE", "STATUS", "TIMESTAMP", "PROGRESS", "MESSAGE", + write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s:<14} {s}\n", .{ + "APP", "RELEASE", "STATUS", "TIMESTAMP", "PROGRESS", "PREV OK", "MESSAGE", }); var ts_buf: [20]u8 = undefined; @@ -255,12 +270,18 @@ fn printAppStatus(snapshot: AppStatusSnapshot) void { snapshot.service_count, }) catch "?"; - write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s}\n", .{ + const previous_successful = if (snapshot.previous_successful_release_id) |release_id| + cli.truncate(release_id, 12) + else + "-"; + + write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s:<14} {s}\n", .{ snapshot.app_name, cli.truncate(snapshot.release_id, 12), snapshot.status, ts_str, count_str, + previous_successful, cli.truncate(msg, 40), }); } @@ -278,6 +299,9 @@ fn parseAppStatusResponse(json: []const u8) AppStatusSnapshot { .failed_targets = @intCast(@max(0, extractJsonInt(json, "failed_targets") orelse 0)), .remaining_targets = @intCast(@max(0, extractJsonInt(json, "remaining_targets") orelse 0)), .source_release_id = extractJsonString(json, "source_release_id"), + .previous_successful_release_id = extractJsonString(json, "previous_successful_release_id"), + .previous_successful_manifest_hash = extractJsonString(json, "previous_successful_manifest_hash"), + .previous_successful_created_at = extractJsonInt(json, "previous_successful_created_at"), .message = extractJsonString(json, "message"), }; } @@ -295,10 +319,16 @@ fn writeAppStatusJsonObject(w: *json_out.JsonWriter, snapshot: AppStatusSnapshot w.uintField("failed_targets", snapshot.failed_targets); w.uintField("remaining_targets", snapshot.remaining_targets); if (snapshot.source_release_id) |source_release_id| w.stringField("source_release_id", source_release_id) else w.nullField("source_release_id"); + if (snapshot.previous_successful_release_id) |release_id| w.stringField("previous_successful_release_id", release_id) else w.nullField("previous_successful_release_id"); + if (snapshot.previous_successful_manifest_hash) |manifest_hash| w.stringField("previous_successful_manifest_hash", manifest_hash) else w.nullField("previous_successful_manifest_hash"); + if (snapshot.previous_successful_created_at) |created_at| w.intField("previous_successful_created_at", created_at) else w.nullField("previous_successful_created_at"); if (snapshot.message) |message| w.stringField("message", message) else w.nullField("message"); } -fn appStatusFromReport(report: apply_release.ApplyReport) AppStatusSnapshot { +fn appStatusFromReports( + report: apply_release.ApplyReport, + previous_successful: ?apply_release.ApplyReport, +) AppStatusSnapshot { return .{ .app_name = report.app_name, .trigger = report.trigger.toString(), @@ -311,6 +341,9 @@ fn appStatusFromReport(report: apply_release.ApplyReport) AppStatusSnapshot { .failed_targets = report.failed_targets, .remaining_targets = report.remainingTargets(), .source_release_id = report.source_release_id, + .previous_successful_release_id = if (previous_successful) |prev| prev.release_id else null, + .previous_successful_manifest_hash = if (previous_successful) |prev| prev.manifest_hash else null, + .previous_successful_created_at = if (previous_successful) |prev| prev.created_at else null, .message = report.message, }; } @@ -436,6 +469,9 @@ test "parseAppStatusResponse extracts app fields" { try std.testing.expectEqual(@as(usize, 0), snapshot.failed_targets); try std.testing.expectEqual(@as(usize, 0), snapshot.remaining_targets); try std.testing.expect(snapshot.source_release_id == null); + try std.testing.expect(snapshot.previous_successful_release_id == null); + try std.testing.expect(snapshot.previous_successful_manifest_hash == null); + try std.testing.expect(snapshot.previous_successful_created_at == null); try std.testing.expect(snapshot.message == null); } @@ -454,7 +490,7 @@ test "appStatusFromReport matches remote app status shape" { .created_at = 200, }; - const local = appStatusFromReport(report); + const local = appStatusFromReports(report, null); const remote = parseAppStatusResponse( \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-2","status":"completed","manifest_hash":"sha256:222","created_at":200,"service_count":2,"completed_targets":2,"failed_targets":0,"remaining_targets":0,"source_release_id":null,"message":"all placements healthy"} ); @@ -470,6 +506,7 @@ test "appStatusFromReport matches remote app status shape" { try std.testing.expectEqual(local.failed_targets, remote.failed_targets); try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); + try std.testing.expect(local.previous_successful_release_id == null); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } @@ -486,6 +523,9 @@ test "writeAppStatusJsonObject round-trips through remote parser" { .failed_targets = 1, .remaining_targets = 0, .source_release_id = "dep-1", + .previous_successful_release_id = "dep-0", + .previous_successful_manifest_hash = "sha256:111", + .previous_successful_created_at = 100, .message = "all placements healthy", }; @@ -504,6 +544,9 @@ test "writeAppStatusJsonObject round-trips through remote parser" { try std.testing.expectEqual(snapshot.failed_targets, parsed.failed_targets); try std.testing.expectEqual(snapshot.remaining_targets, parsed.remaining_targets); try std.testing.expectEqualStrings(snapshot.source_release_id.?, parsed.source_release_id.?); + try std.testing.expectEqualStrings(snapshot.previous_successful_release_id.?, parsed.previous_successful_release_id.?); + try std.testing.expectEqualStrings(snapshot.previous_successful_manifest_hash.?, parsed.previous_successful_manifest_hash.?); + try std.testing.expectEqual(snapshot.previous_successful_created_at.?, parsed.previous_successful_created_at.?); try std.testing.expectEqualStrings(snapshot.message.?, parsed.message.?); } @@ -521,9 +564,22 @@ test "appStatusFromReport preserves partially failed local release state" { .created_at = 300, }; - const local = appStatusFromReport(apply_release.reportFromDeployment(dep)); + const previous_successful = apply_release.ApplyReport{ + .app_name = "demo-app", + .release_id = "dep-2", + .status = .completed, + .service_count = 2, + .placed = 2, + .failed = 0, + .completed_targets = 2, + .failed_targets = 0, + .manifest_hash = "sha256:222", + .created_at = 200, + }; + + const local = appStatusFromReports(apply_release.reportFromDeployment(dep), previous_successful); const remote = parseAppStatusResponse( - \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-3","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"service_count":2,"completed_targets":1,"failed_targets":1,"remaining_targets":0,"source_release_id":null,"message":"one or more placements failed"} + \\{"app_name":"demo-app","trigger":"apply","release_id":"dep-3","status":"partially_failed","manifest_hash":"sha256:333","created_at":300,"service_count":2,"completed_targets":1,"failed_targets":1,"remaining_targets":0,"source_release_id":null,"previous_successful_release_id":"dep-2","previous_successful_manifest_hash":"sha256:222","previous_successful_created_at":200,"message":"one or more placements failed"} ); try std.testing.expectEqualStrings(local.app_name, remote.app_name); @@ -537,5 +593,8 @@ test "appStatusFromReport preserves partially failed local release state" { try std.testing.expectEqual(local.failed_targets, remote.failed_targets); try std.testing.expectEqual(local.remaining_targets, remote.remaining_targets); try std.testing.expect(local.source_release_id == null); + try std.testing.expectEqualStrings(local.previous_successful_release_id.?, remote.previous_successful_release_id.?); + try std.testing.expectEqualStrings(local.previous_successful_manifest_hash.?, remote.previous_successful_manifest_hash.?); + try std.testing.expectEqual(local.previous_successful_created_at.?, remote.previous_successful_created_at.?); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } diff --git a/src/state/store.zig b/src/state/store.zig index a11ed8a..dff0fea 100644 --- a/src/state/store.zig +++ b/src/state/store.zig @@ -93,6 +93,8 @@ pub const getLatestDeploymentByApp = @import("store/deployments.zig").getLatestD pub const getLatestDeploymentByAppInDb = @import("store/deployments.zig").getLatestDeploymentByAppInDb; pub const getLastSuccessfulDeployment = @import("store/deployments.zig").getLastSuccessfulDeployment; pub const getLastSuccessfulDeploymentByApp = @import("store/deployments.zig").getLastSuccessfulDeploymentByApp; +pub const getPreviousSuccessfulDeploymentByApp = @import("store/deployments.zig").getPreviousSuccessfulDeploymentByApp; +pub const getPreviousSuccessfulDeploymentByAppInDb = @import("store/deployments.zig").getPreviousSuccessfulDeploymentByAppInDb; pub const saveTrainingJob = @import("store/training.zig").saveTrainingJob; pub const updateTrainingJobState = @import("store/training.zig").updateTrainingJobState; diff --git a/src/state/store/deployments.zig b/src/state/store/deployments.zig index e80c7a8..8929027 100644 --- a/src/state/store/deployments.zig +++ b/src/state/store/deployments.zig @@ -244,6 +244,29 @@ pub fn getLastSuccessfulDeploymentByApp(alloc: Allocator, app_name: []const u8) ); } +pub fn getPreviousSuccessfulDeploymentByApp( + alloc: Allocator, + app_name: []const u8, + exclude_id: []const u8, +) StoreError!DeploymentRecord { + const db = try common.getDb(); + return getPreviousSuccessfulDeploymentByAppInDb(db, alloc, app_name, exclude_id); +} + +pub fn getPreviousSuccessfulDeploymentByAppInDb( + db: *sqlite.Db, + alloc: Allocator, + app_name: []const u8, + exclude_id: []const u8, +) StoreError!DeploymentRecord { + return queryOneInDb( + db, + alloc, + "SELECT " ++ deployment_columns ++ " FROM deployments WHERE app_name = ? AND status = 'completed' AND id != ? ORDER BY created_at DESC, rowid DESC LIMIT 1;", + .{ app_name, exclude_id }, + ); +} + test "deployment record round-trip via sqlite" { var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); defer db.deinit(); @@ -318,6 +341,52 @@ test "deployment stores rollback transition metadata" { try std.testing.expectEqual(@as(usize, 0), record.failed_targets); } +test "getPreviousSuccessfulDeploymentByAppInDb excludes current release" { + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + try saveDeploymentInDb(&db, .{ + .id = "dep-1", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:111", + .config_snapshot = "{}", + .status = "completed", + .message = null, + .created_at = 100, + }); + try saveDeploymentInDb(&db, .{ + .id = "dep-2", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:222", + .config_snapshot = "{}", + .status = "failed", + .message = null, + .created_at = 200, + }); + try saveDeploymentInDb(&db, .{ + .id = "dep-3", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:333", + .config_snapshot = "{}", + .status = "completed", + .message = null, + .created_at = 300, + }); + + const previous = try getPreviousSuccessfulDeploymentByAppInDb(&db, std.testing.allocator, "demo-app", "dep-3"); + defer previous.deinit(std.testing.allocator); + + try std.testing.expectEqualStrings("dep-1", previous.id); + try std.testing.expectEqualStrings("completed", previous.status); +} + test "deployment list ordered by timestamp desc" { var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); defer db.deinit(); From d95df346a7a8f4cf57f11826baa7ee4f4e5275f5 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 03:46:02 +0000 Subject: [PATCH 4/8] Add app release summary command --- src/api/routes/cluster_agents.zig | 12 ++ src/api/routes/cluster_agents/app_routes.zig | 102 +++++++++++++++++ src/lib/command_registry.zig | 1 + src/lib/completion.zig | 1 + src/runtime/cli/status_command.zig | 114 +++++++++++++++++++ src/runtime/commands.zig | 4 + src/state/store.zig | 2 + src/state/store/deployments.zig | 99 ++++++++++++++++ 8 files changed, 335 insertions(+) diff --git a/src/api/routes/cluster_agents.zig b/src/api/routes/cluster_agents.zig index 40c9c54..3c63475 100644 --- a/src/api/routes/cluster_agents.zig +++ b/src/api/routes/cluster_agents.zig @@ -203,6 +203,18 @@ test "route rejects app status without cluster" { } } +test "route rejects app list without cluster" { + const ctx: RouteContext = .{ .cluster = null, .join_token = null }; + const req = testRequest(.GET, "/apps"); + + const response = route(req, testing.allocator, ctx); + try testing.expect(response != null); + if (response) |resp| { + try testing.expectEqual(http.StatusCode.bad_request, resp.status); + if (resp.allocated) testing.allocator.free(resp.body); + } +} + test "route validates agent ID format" { const ctx: RouteContext = .{ .cluster = null, .join_token = null }; diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index de5b105..c02a96f 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -13,6 +13,9 @@ const Response = common.Response; const RouteContext = common.RouteContext; pub fn route(request: @import("../../http.zig").Request, alloc: std.mem.Allocator, ctx: RouteContext) ?Response { + if (request.method == .GET and std.mem.eql(u8, request.path_only, "/apps")) { + return handleListApps(alloc, ctx); + } if (!std.mem.startsWith(u8, request.path_only, "/apps/")) return null; const rest = request.path_only["/apps/".len..]; @@ -39,6 +42,18 @@ pub fn route(request: @import("../../http.zig").Request, alloc: std.mem.Allocato return null; } +pub fn handleListApps(alloc: std.mem.Allocator, ctx: RouteContext) Response { + const node = ctx.cluster orelse return common.badRequest("not running in cluster mode"); + var latest = store.listLatestDeploymentsByAppInDb(node.stateMachineDb(), alloc) catch return common.internalError(); + defer { + for (latest.items) |dep| dep.deinit(alloc); + latest.deinit(alloc); + } + + const body = formatAppsResponse(alloc, node.stateMachineDb(), latest.items) catch return common.internalError(); + return .{ .status = .ok, .body = body, .allocated = true }; +} + pub fn handleAppHistory(alloc: std.mem.Allocator, app_name: []const u8, ctx: RouteContext) Response { const node = ctx.cluster orelse return common.badRequest("not running in cluster mode"); var deployments = store.listDeploymentsByAppInDb(node.stateMachineDb(), alloc, app_name) catch @@ -108,6 +123,36 @@ pub fn handleAppRollback( return deploy_routes.handleAppRollbackApply(alloc, apply_request, ctx, release_id); } +fn formatAppsResponse( + alloc: std.mem.Allocator, + db: *sqlite.Db, + latest_deployments: []const store.DeploymentRecord, +) ![]u8 { + var json_buf: std.ArrayList(u8) = .empty; + errdefer json_buf.deinit(alloc); + const writer = json_buf.writer(alloc); + + try writer.writeByte('['); + for (latest_deployments, 0..) |latest, i| { + const previous_successful = store.getPreviousSuccessfulDeploymentByAppInDb(db, alloc, latest.app_name.?, latest.id) catch |err| switch (err) { + error.NotFound => null, + else => return err, + }; + defer if (previous_successful) |dep| dep.deinit(alloc); + + if (i > 0) try writer.writeByte(','); + const json = try formatAppStatusResponse( + alloc, + apply_release.reportFromDeployment(latest), + if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, + ); + defer alloc.free(json); + try writer.writeAll(json); + } + try writer.writeByte(']'); + return json_buf.toOwnedSlice(alloc); +} + fn formatAppHistoryResponse(alloc: std.mem.Allocator, deployments: []const store.DeploymentRecord) ![]u8 { var json_buf: std.ArrayList(u8) = .empty; errdefer json_buf.deinit(alloc); @@ -345,6 +390,63 @@ test "formatAppStatusResponse summarizes latest release" { try std.testing.expect(std.mem.indexOf(u8, json, "\"previous_successful_release_id\":null") != null); } +test "formatAppsResponse emits one latest summary per app" { + const alloc = std.testing.allocator; + + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + try store.saveDeploymentInDb(&db, .{ + .id = "dep-1", + .app_name = "app-a", + .service_name = "app-a", + .trigger = "apply", + .manifest_hash = "sha256:a1", + .config_snapshot = "{\"app_name\":\"app-a\",\"services\":[{\"name\":\"web\"}]}", + .status = "completed", + .message = "apply completed", + .created_at = 100, + }); + try store.saveDeploymentInDb(&db, .{ + .id = "dep-2", + .app_name = "app-b", + .service_name = "app-b", + .trigger = "apply", + .manifest_hash = "sha256:b1", + .config_snapshot = "{\"app_name\":\"app-b\",\"services\":[{\"name\":\"api\"}]}", + .status = "completed", + .message = "apply completed", + .created_at = 150, + }); + try store.saveDeploymentInDb(&db, .{ + .id = "dep-3", + .app_name = "app-a", + .service_name = "app-a", + .trigger = "apply", + .manifest_hash = "sha256:a2", + .config_snapshot = "{\"app_name\":\"app-a\",\"services\":[{\"name\":\"web\"},{\"name\":\"db\"}]}", + .status = "failed", + .message = "scheduler error during apply", + .created_at = 200, + }); + + var latest = try store.listLatestDeploymentsByAppInDb(&db, alloc); + defer { + for (latest.items) |dep| dep.deinit(alloc); + latest.deinit(alloc); + } + + const json = try formatAppsResponse(alloc, &db, latest.items); + defer alloc.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"app_name\":\"app-a\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"release_id\":\"dep-3\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"previous_successful_release_id\":\"dep-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"app_name\":\"app-b\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"release_id\":\"dep-2\"") != null); +} + test "formatAppStatusResponse includes structured rollback metadata" { const alloc = std.testing.allocator; const latest = store.DeploymentRecord{ diff --git a/src/lib/command_registry.zig b/src/lib/command_registry.zig index 7bc85cc..1456484 100644 --- a/src/lib/command_registry.zig +++ b/src/lib/command_registry.zig @@ -44,6 +44,7 @@ pub const command_specs = [_]CommandSpec{ .{ .name = "restart", .group = .runtime, .usage = "restart ", .description = "restart a container", .handler = container_cmds.restart }, .{ .name = "exec", .group = .runtime, .usage = "exec [args...]", .description = "run a command in a running container", .handler = container_cmds.exec_cmd }, .{ .name = "status", .group = .runtime, .usage = "status [--app [name]] [--verbose] [--server h:p]", .description = "show service or app status", .handler = runtime_cmds.status }, + .{ .name = "apps", .group = .runtime, .usage = "apps [--server h:p] [--json]", .description = "list app release summaries", .handler = runtime_cmds.apps }, .{ .name = "metrics", .group = .runtime, .usage = "metrics [service] [--server h:p]", .description = "show per-service network metrics", .handler = runtime_cmds.metrics }, .{ .name = "gpu", .group = .runtime, .usage = "gpu [--json]", .description = "GPU topology, diagnostics, and benchmarking", .handler = gpu_cmds.gpu }, diff --git a/src/lib/completion.zig b/src/lib/completion.zig index aaadf99..3349330 100644 --- a/src/lib/completion.zig +++ b/src/lib/completion.zig @@ -42,6 +42,7 @@ const command_meta = [_]CommandMeta{ .{ .name = "restart" }, .{ .name = "exec" }, .{ .name = "status", .flags = &.{ "--app", "--verbose", "-v", "--server" } }, + .{ .name = "apps", .flags = &.{ "--server", "--json" } }, .{ .name = "metrics", .flags = &.{ "--server", "--pairs" } }, .{ .name = "gpu", .subcommands = &.{ .{ .name = "topo", .flags = &.{"--json"} }, diff --git a/src/runtime/cli/status_command.zig b/src/runtime/cli/status_command.zig index 4bdda0e..fd1eb19 100644 --- a/src/runtime/cli/status_command.zig +++ b/src/runtime/cli/status_command.zig @@ -74,6 +74,31 @@ pub fn status(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void { try statusLocal(alloc, verbose); } +pub fn apps(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void { + var server: ?cli.ServerAddr = null; + + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--json")) { + cli.output_mode = .json; + } else if (std.mem.eql(u8, arg, "--server")) { + const addr_str = args.next() orelse { + writeErr("--server requires a host:port address\n", .{}); + return StatusError.InvalidArgument; + }; + server = cli.parseServerAddr(addr_str); + } else { + writeErr("usage: yoq apps [--server host:port] [--json]\n", .{}); + return StatusError.InvalidArgument; + } + } + + if (server) |s| { + try appsRemote(alloc, s.ip, s.port); + } else { + try appsLocal(alloc); + } +} + fn statusLocal(alloc: std.mem.Allocator, verbose: bool) StatusError!void { var records = store.listAll(alloc) catch { writeErr("failed to list containers\n", .{}); @@ -247,6 +272,64 @@ fn statusRemoteApp(alloc: std.mem.Allocator, addr: [4]u8, port: u16, app_name: [ printAppStatus(snapshot); } +fn appsLocal(alloc: std.mem.Allocator) StatusError!void { + var latest = store.listLatestDeploymentsByApp(alloc) catch { + writeErr("failed to read app list\n", .{}); + return StatusError.StoreError; + }; + defer { + for (latest.items) |dep| dep.deinit(alloc); + latest.deinit(alloc); + } + + var snapshots: std.ArrayList(AppStatusSnapshot) = .empty; + defer snapshots.deinit(alloc); + + for (latest.items) |dep| { + const previous_successful = store.getPreviousSuccessfulDeploymentByApp(alloc, dep.app_name.?, dep.id) catch |err| switch (err) { + error.NotFound => null, + else => { + writeErr("failed to read app list\n", .{}); + return StatusError.StoreError; + }, + }; + defer if (previous_successful) |prev| prev.deinit(alloc); + + snapshots.append(alloc, appStatusFromReports( + apply_release.reportFromDeployment(dep), + if (previous_successful) |prev| apply_release.reportFromDeployment(prev) else null, + )) catch return StatusError.OutOfMemory; + } + + printAppStatuses(snapshots.items); +} + +fn appsRemote(alloc: std.mem.Allocator, addr: [4]u8, port: u16) StatusError!void { + var token_buf: [64]u8 = undefined; + const token = cli.readApiToken(&token_buf); + + var resp = http_client.getWithAuth(alloc, addr, port, "/apps", token) catch { + writeErr("failed to connect to server\n", .{}); + return StatusError.ConnectionFailed; + }; + defer resp.deinit(alloc); + + if (resp.status_code != 200) { + writeErr("server returned status {d}\n", .{resp.status_code}); + return StatusError.ServerError; + } + + var snapshots: std.ArrayList(AppStatusSnapshot) = .empty; + defer snapshots.deinit(alloc); + + var iter = json_helpers.extractJsonObjects(resp.body); + while (iter.next()) |obj| { + snapshots.append(alloc, parseAppStatusResponse(obj)) catch return StatusError.OutOfMemory; + } + + printAppStatuses(snapshots.items); +} + fn printAppStatus(snapshot: AppStatusSnapshot) void { if (cli.output_mode == .json) { var w = json_out.JsonWriter{}; @@ -256,10 +339,41 @@ fn printAppStatus(snapshot: AppStatusSnapshot) void { return; } + printAppStatusHeader(); + printAppStatusRow(snapshot); +} + +fn printAppStatuses(snapshots: []const AppStatusSnapshot) void { + if (cli.output_mode == .json) { + var w = json_out.JsonWriter{}; + w.beginArray(); + for (snapshots) |snapshot| { + writeAppStatusJsonObject(&w, snapshot); + w.endObject(); + } + w.endArray(); + w.flush(); + return; + } + + if (snapshots.len == 0) { + write("no app releases found\n", .{}); + return; + } + + printAppStatusHeader(); + for (snapshots) |snapshot| { + printAppStatusRow(snapshot); + } +} + +fn printAppStatusHeader() void { write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s:<14} {s}\n", .{ "APP", "RELEASE", "STATUS", "TIMESTAMP", "PROGRESS", "PREV OK", "MESSAGE", }); +} +fn printAppStatusRow(snapshot: AppStatusSnapshot) void { var ts_buf: [20]u8 = undefined; const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{snapshot.created_at}) catch "?"; const msg = snapshot.message orelse ""; diff --git a/src/runtime/commands.zig b/src/runtime/commands.zig index 34a207e..9b6a3bd 100644 --- a/src/runtime/commands.zig +++ b/src/runtime/commands.zig @@ -12,6 +12,10 @@ pub fn status(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void { return status_command.status(args, alloc); } +pub fn apps(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void { + return status_command.apps(args, alloc); +} + pub fn metrics(args: *std.process.ArgIterator, alloc: std.mem.Allocator) !void { return metrics_command.metrics(args, alloc); } diff --git a/src/state/store.zig b/src/state/store.zig index dff0fea..2edaec3 100644 --- a/src/state/store.zig +++ b/src/state/store.zig @@ -84,6 +84,8 @@ pub const getDeploymentInDb = @import("store/deployments.zig").getDeploymentInDb pub const listDeployments = @import("store/deployments.zig").listDeployments; pub const listDeploymentsByApp = @import("store/deployments.zig").listDeploymentsByApp; pub const listDeploymentsByAppInDb = @import("store/deployments.zig").listDeploymentsByAppInDb; +pub const listLatestDeploymentsByApp = @import("store/deployments.zig").listLatestDeploymentsByApp; +pub const listLatestDeploymentsByAppInDb = @import("store/deployments.zig").listLatestDeploymentsByAppInDb; pub const updateDeploymentStatus = @import("store/deployments.zig").updateDeploymentStatus; pub const updateDeploymentStatusInDb = @import("store/deployments.zig").updateDeploymentStatusInDb; pub const updateDeploymentProgress = @import("store/deployments.zig").updateDeploymentProgress; diff --git a/src/state/store/deployments.zig b/src/state/store/deployments.zig index 8929027..fa0036e 100644 --- a/src/state/store/deployments.zig +++ b/src/state/store/deployments.zig @@ -162,6 +162,53 @@ pub fn listDeploymentsByAppInDb( ); } +pub fn listLatestDeploymentsByApp(alloc: Allocator) StoreError!std.ArrayList(DeploymentRecord) { + const db = try common.getDb(); + return listLatestDeploymentsByAppInDb(db, alloc); +} + +pub fn listLatestDeploymentsByAppInDb( + db: *sqlite.Db, + alloc: Allocator, +) StoreError!std.ArrayList(DeploymentRecord) { + var deployments: std.ArrayList(DeploymentRecord) = .empty; + errdefer { + for (deployments.items) |dep| dep.deinit(alloc); + deployments.deinit(alloc); + } + + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer seen.deinit(alloc); + + var stmt = db.prepare( + "SELECT " ++ deployment_columns ++ " FROM deployments WHERE app_name IS NOT NULL ORDER BY created_at DESC, rowid DESC;", + ) catch return StoreError.ReadFailed; + defer stmt.deinit(); + + var iter = stmt.iterator(DeploymentRow, .{}) catch return StoreError.ReadFailed; + while (iter.nextAlloc(alloc, .{}) catch return StoreError.ReadFailed) |row| { + const record = rowToRecord(row); + const app_name = record.app_name orelse { + record.deinit(alloc); + continue; + }; + const gop = seen.getOrPut(alloc, app_name) catch { + record.deinit(alloc); + return StoreError.ReadFailed; + }; + if (gop.found_existing) { + record.deinit(alloc); + continue; + } + deployments.append(alloc, record) catch { + record.deinit(alloc); + return StoreError.ReadFailed; + }; + } + + return deployments; +} + pub fn updateDeploymentStatus(id: []const u8, status: []const u8, message: ?[]const u8) StoreError!void { const db = try common.getDb(); return updateDeploymentStatusInDb(db, id, status, message); @@ -387,6 +434,58 @@ test "getPreviousSuccessfulDeploymentByAppInDb excludes current release" { try std.testing.expectEqualStrings("completed", previous.status); } +test "listLatestDeploymentsByAppInDb returns one latest row per app" { + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + try saveDeploymentInDb(&db, .{ + .id = "dep-1", + .app_name = "app-a", + .service_name = "app-a", + .trigger = "apply", + .manifest_hash = "sha256:a1", + .config_snapshot = "{}", + .status = "completed", + .message = null, + .created_at = 100, + }); + try saveDeploymentInDb(&db, .{ + .id = "dep-2", + .app_name = "app-b", + .service_name = "app-b", + .trigger = "apply", + .manifest_hash = "sha256:b1", + .config_snapshot = "{}", + .status = "completed", + .message = null, + .created_at = 150, + }); + try saveDeploymentInDb(&db, .{ + .id = "dep-3", + .app_name = "app-a", + .service_name = "app-a", + .trigger = "apply", + .manifest_hash = "sha256:a2", + .config_snapshot = "{}", + .status = "failed", + .message = null, + .created_at = 200, + }); + + var latest = try listLatestDeploymentsByAppInDb(&db, std.testing.allocator); + defer { + for (latest.items) |dep| dep.deinit(std.testing.allocator); + latest.deinit(std.testing.allocator); + } + + try std.testing.expectEqual(@as(usize, 2), latest.items.len); + try std.testing.expectEqualStrings("dep-3", latest.items[0].id); + try std.testing.expectEqualStrings("app-a", latest.items[0].app_name.?); + try std.testing.expectEqualStrings("dep-2", latest.items[1].id); + try std.testing.expectEqualStrings("app-b", latest.items[1].app_name.?); +} + test "deployment list ordered by timestamp desc" { var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); defer db.deinit(); From 498d37d9ad1e8655d1d98617f449fa8e52e744c8 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 11:40:05 +0000 Subject: [PATCH 5/8] Improve app rollout progress display --- src/runtime/cli/status_command.zig | 93 +++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/status_command.zig b/src/runtime/cli/status_command.zig index fd1eb19..2aa7455 100644 --- a/src/runtime/cli/status_command.zig +++ b/src/runtime/cli/status_command.zig @@ -368,8 +368,8 @@ fn printAppStatuses(snapshots: []const AppStatusSnapshot) void { } fn printAppStatusHeader() void { - write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s:<14} {s}\n", .{ - "APP", "RELEASE", "STATUS", "TIMESTAMP", "PROGRESS", "PREV OK", "MESSAGE", + write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<22} {s:<14} {s}\n", .{ + "APP", "RELEASE", "STATUS", "TIMESTAMP", "TARGETS", "PREV OK", "MESSAGE", }); } @@ -378,28 +378,42 @@ fn printAppStatusRow(snapshot: AppStatusSnapshot) void { const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{snapshot.created_at}) catch "?"; const msg = snapshot.message orelse ""; - var count_buf: [32]u8 = undefined; - const count_str = std.fmt.bufPrint(&count_buf, "{d}/{d}", .{ - snapshot.completed_targets, - snapshot.service_count, - }) catch "?"; + var progress_buf: [64]u8 = undefined; + const progress_str = formatAppProgress(&progress_buf, snapshot); const previous_successful = if (snapshot.previous_successful_release_id) |release_id| cli.truncate(release_id, 12) else "-"; - write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<14} {s:<14} {s}\n", .{ + write("{s:<14} {s:<14} {s:<14} {s:<20} {s:<22} {s:<14} {s}\n", .{ snapshot.app_name, cli.truncate(snapshot.release_id, 12), snapshot.status, ts_str, - count_str, + progress_str, previous_successful, cli.truncate(msg, 40), }); } +fn formatAppProgress(buf: []u8, snapshot: AppStatusSnapshot) []const u8 { + if (snapshot.failed_targets == 0 and snapshot.remaining_targets == 0) { + return std.fmt.bufPrint(buf, "{d} ok", .{snapshot.completed_targets}) catch "?"; + } + if (snapshot.remaining_targets == 0) { + return std.fmt.bufPrint(buf, "{d} ok, {d} fail", .{ + snapshot.completed_targets, + snapshot.failed_targets, + }) catch "?"; + } + return std.fmt.bufPrint(buf, "{d} ok, {d} fail, {d} left", .{ + snapshot.completed_targets, + snapshot.failed_targets, + snapshot.remaining_targets, + }) catch "?"; +} + fn parseAppStatusResponse(json: []const u8) AppStatusSnapshot { return .{ .app_name = extractJsonString(json, "app_name") orelse "?", @@ -712,3 +726,64 @@ test "appStatusFromReport preserves partially failed local release state" { try std.testing.expectEqual(local.previous_successful_created_at.?, remote.previous_successful_created_at.?); try std.testing.expectEqualStrings(local.message.?, remote.message.?); } + +test "formatAppProgress summarizes in-flight and partial outcomes" { + var buf: [64]u8 = undefined; + + const in_progress = AppStatusSnapshot{ + .app_name = "demo-app", + .trigger = "apply", + .release_id = "dep-2", + .status = "in_progress", + .manifest_hash = "sha256:222", + .created_at = 200, + .service_count = 4, + .completed_targets = 1, + .failed_targets = 1, + .remaining_targets = 2, + .source_release_id = null, + .previous_successful_release_id = null, + .previous_successful_manifest_hash = null, + .previous_successful_created_at = null, + .message = "apply in progress", + }; + try std.testing.expectEqualStrings("1 ok, 1 fail, 2 left", formatAppProgress(&buf, in_progress)); + + const partial = AppStatusSnapshot{ + .app_name = "demo-app", + .trigger = "apply", + .release_id = "dep-3", + .status = "partially_failed", + .manifest_hash = "sha256:333", + .created_at = 300, + .service_count = 2, + .completed_targets = 1, + .failed_targets = 1, + .remaining_targets = 0, + .source_release_id = null, + .previous_successful_release_id = null, + .previous_successful_manifest_hash = null, + .previous_successful_created_at = null, + .message = "one or more placements failed", + }; + try std.testing.expectEqualStrings("1 ok, 1 fail", formatAppProgress(&buf, partial)); + + const completed = AppStatusSnapshot{ + .app_name = "demo-app", + .trigger = "apply", + .release_id = "dep-4", + .status = "completed", + .manifest_hash = "sha256:444", + .created_at = 400, + .service_count = 2, + .completed_targets = 2, + .failed_targets = 0, + .remaining_targets = 0, + .source_release_id = null, + .previous_successful_release_id = null, + .previous_successful_manifest_hash = null, + .previous_successful_created_at = null, + .message = "apply completed", + }; + try std.testing.expectEqualStrings("2 ok", formatAppProgress(&buf, completed)); +} From 91736fc44af4c4e9b7e1d00c047b827d15ca7514 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 12:49:57 +0000 Subject: [PATCH 6/8] Fix rollback selection and failure progress counts --- src/manifest/apply_release.zig | 81 ++++++++++++++++++++++++++++-- src/manifest/release_history.zig | 84 ++++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/manifest/apply_release.zig b/src/manifest/apply_release.zig index a93f814..a42e476 100644 --- a/src/manifest/apply_release.zig +++ b/src/manifest/apply_release.zig @@ -228,6 +228,8 @@ fn markReleaseIfPresent( pub const ProgressRecorder = struct { ctx: *anyopaque, release_id: []const u8, + completed_targets_ptr: ?*usize = null, + failed_targets_ptr: ?*usize = null, markFn: *const fn ( ctx: *anyopaque, release_id: []const u8, @@ -244,11 +246,18 @@ pub const ProgressRecorder = struct { completed_targets: usize, failed_targets: usize, ) !void { + if (self.completed_targets_ptr) |ptr| ptr.* = completed_targets; + if (self.failed_targets_ptr) |ptr| ptr.* = failed_targets; try self.markFn(self.ctx, self.release_id, status, message, completed_targets, failed_targets); } }; -fn makeProgressRecorder(tracker: anytype, release_id: []const u8) ProgressRecorder { +fn makeProgressRecorder( + tracker: anytype, + release_id: []const u8, + completed_targets_ptr: *usize, + failed_targets_ptr: *usize, +) ProgressRecorder { const TrackerPtr = @TypeOf(tracker); const Adapter = struct { fn mark( @@ -267,6 +276,8 @@ fn makeProgressRecorder(tracker: anytype, release_id: []const u8) ProgressRecord return .{ .ctx = @ptrCast(tracker), .release_id = release_id, + .completed_targets_ptr = completed_targets_ptr, + .failed_targets_ptr = failed_targets_ptr, .markFn = Adapter.mark, }; } @@ -280,15 +291,27 @@ fn attachProgressRecorderIfSupported(backend: anytype, recorder: ProgressRecorde pub fn execute(tracker: anytype, backend: anytype) !ApplyResult { const release_id = try tracker.begin(); errdefer if (release_id) |id| tracker.freeReleaseId(id); + var completed_targets: usize = 0; + var failed_targets: usize = 0; if (release_id) |id| { - attachProgressRecorderIfSupported(backend, makeProgressRecorder(tracker, id)); + attachProgressRecorderIfSupported( + backend, + makeProgressRecorder(tracker, id, &completed_targets, &failed_targets), + ); } try markReleaseIfPresent(tracker, release_id, .in_progress, null, 0, 0); const outcome = backend.apply() catch |err| { - try markReleaseIfPresent(tracker, release_id, .failed, backend.failureMessage(err), 0, 0); + try markReleaseIfPresent( + tracker, + release_id, + .failed, + backend.failureMessage(err), + completed_targets, + failed_targets, + ); return err; }; @@ -312,6 +335,8 @@ const TestTracker = struct { release_id: []const u8, statuses: [4]?update_common.DeploymentStatus = [_]?update_common.DeploymentStatus{null} ** 4, messages: [4]?[]const u8 = [_]?[]const u8{null} ** 4, + completed_targets: [4]usize = [_]usize{0} ** 4, + failed_targets: [4]usize = [_]usize{0} ** 4, mark_count: usize = 0, fn begin(self: *@This()) !?[]const u8 { @@ -326,6 +351,19 @@ const TestTracker = struct { self.mark_count += 1; } + fn markProgress( + self: *@This(), + id: []const u8, + status: update_common.DeploymentStatus, + message: ?[]const u8, + completed: usize, + failed: usize, + ) !void { + try self.mark(id, status, message); + self.completed_targets[self.mark_count - 1] = completed; + self.failed_targets[self.mark_count - 1] = failed; + } + fn freeReleaseId(self: *@This(), id: []const u8) void { self.alloc.free(id); } @@ -434,6 +472,43 @@ test "execute attaches a live progress recorder when backend supports it" { try std.testing.expectEqual(update_common.DeploymentStatus.completed, tracker.statuses[2].?); } +test "execute preserves live progress counts when backend fails after mutation" { + const alloc = std.testing.allocator; + + const Backend = struct { + progress: ?ProgressRecorder = null, + + fn attachProgressRecorder(self: *@This(), recorder: ProgressRecorder) void { + self.progress = recorder; + } + + fn apply(self: *@This()) anyerror!ApplyOutcome { + try self.progress.?.mark(.in_progress, null, 1, 0); + return error.StartupFailed; + } + + fn failureMessage(_: *@This(), err: anyerror) ?[]const u8 { + return switch (err) { + error.StartupFailed => "service startup failed", + else => "backend failed", + }; + } + }; + + var tracker = TestTracker{ .alloc = alloc, .release_id = "dep999" }; + var backend = Backend{}; + + try std.testing.expectError(error.StartupFailed, execute(&tracker, &backend)); + try std.testing.expectEqual(@as(usize, 3), tracker.mark_count); + try std.testing.expectEqual(update_common.DeploymentStatus.in_progress, tracker.statuses[0].?); + try std.testing.expectEqual(update_common.DeploymentStatus.in_progress, tracker.statuses[1].?); + try std.testing.expectEqual(@as(usize, 1), tracker.completed_targets[1]); + try std.testing.expectEqual(update_common.DeploymentStatus.failed, tracker.statuses[2].?); + try std.testing.expectEqualStrings("service startup failed", tracker.messages[2].?); + try std.testing.expectEqual(@as(usize, 1), tracker.completed_targets[2]); + try std.testing.expectEqual(@as(usize, 0), tracker.failed_targets[2]); +} + test "ApplyResult projects to shared apply report" { const result = ApplyResult{ .release_id = "dep789", diff --git a/src/manifest/release_history.zig b/src/manifest/release_history.zig index 4f77331..3112a1e 100644 --- a/src/manifest/release_history.zig +++ b/src/manifest/release_history.zig @@ -38,9 +38,13 @@ pub fn markAppReleaseFailed(id: []const u8, message: ?[]const u8) !void { } pub fn rollbackApp(alloc: std.mem.Allocator, app_name: []const u8) ![]const u8 { - const prev = try store.getLastSuccessfulDeploymentByApp(alloc, app_name); - defer prev.deinit(alloc); - return alloc.dupe(u8, prev.config_snapshot); + const latest = try store.getLatestDeploymentByApp(alloc, app_name); + defer latest.deinit(alloc); + + const previous_successful = try store.getPreviousSuccessfulDeploymentByApp(alloc, app_name, latest.id); + defer previous_successful.deinit(alloc); + + return alloc.dupe(u8, previous_successful.config_snapshot); } pub fn listAppReleases(alloc: std.mem.Allocator, app_name: []const u8) !std.ArrayList(store.DeploymentRecord) { @@ -147,3 +151,77 @@ test "recordAppReleaseStart persists rollback transition metadata" { try std.testing.expectEqualStrings("rollback", dep.trigger.?); try std.testing.expectEqualStrings("dep-1", dep.source_release_id.?); } + +test "rollbackApp returns previous successful snapshot instead of current successful release" { + const alloc = std.testing.allocator; + try store.initTestDb(); + defer store.deinitTestDb(); + + try store.saveDeployment(.{ + .id = "dep-1", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:111", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:1\"}]}", + .status = "completed", + .message = "apply completed", + .created_at = 100, + }); + try store.saveDeployment(.{ + .id = "dep-2", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:222", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:2\"}]}", + .status = "completed", + .message = "apply completed", + .created_at = 200, + }); + + const config = try rollbackApp(alloc, "demo-app"); + defer alloc.free(config); + + try std.testing.expectEqualStrings( + "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:1\"}]}", + config, + ); +} + +test "rollbackApp returns last successful snapshot when latest release failed" { + const alloc = std.testing.allocator; + try store.initTestDb(); + defer store.deinitTestDb(); + + try store.saveDeployment(.{ + .id = "dep-1", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:111", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:1\"}]}", + .status = "completed", + .message = "apply completed", + .created_at = 100, + }); + try store.saveDeployment(.{ + .id = "dep-2", + .app_name = "demo-app", + .service_name = "demo-app", + .trigger = "apply", + .manifest_hash = "sha256:222", + .config_snapshot = "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:2\"}]}", + .status = "failed", + .message = "apply failed", + .created_at = 200, + }); + + const config = try rollbackApp(alloc, "demo-app"); + defer alloc.free(config); + + try std.testing.expectEqualStrings( + "{\"app_name\":\"demo-app\",\"services\":[{\"name\":\"web\",\"image\":\"nginx:1\"}]}", + config, + ); +} From ec3c668420eea6fa242d17af65ac0c1057916a13 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 12:50:07 +0000 Subject: [PATCH 7/8] Simplify app status read paths --- src/api/routes/cluster_agents/app_routes.zig | 52 +++++++++++++------- src/runtime/cli/status_command.zig | 49 ++++++++++-------- 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index c02a96f..5dfd3c4 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -75,18 +75,15 @@ pub fn handleAppStatus(alloc: std.mem.Allocator, app_name: []const u8, ctx: Rout }; defer latest.deinit(alloc); - const previous_successful = store.getPreviousSuccessfulDeploymentByAppInDb(node.stateMachineDb(), alloc, app_name, latest.id) catch |err| switch (err) { - error.NotFound => null, - else => return common.internalError(), - }; + const previous_successful = loadPreviousSuccessfulDeployment( + node.stateMachineDb(), + alloc, + app_name, + latest.id, + ) catch return common.internalError(); defer if (previous_successful) |dep| dep.deinit(alloc); - const body = formatAppStatusResponse( - alloc, - apply_release.reportFromDeployment(latest), - if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, - ) catch - return common.internalError(); + const body = formatAppStatusResponseFromDeployments(alloc, latest, previous_successful) catch return common.internalError(); return .{ .status = .ok, .body = body, .allocated = true }; } @@ -134,18 +131,11 @@ fn formatAppsResponse( try writer.writeByte('['); for (latest_deployments, 0..) |latest, i| { - const previous_successful = store.getPreviousSuccessfulDeploymentByAppInDb(db, alloc, latest.app_name.?, latest.id) catch |err| switch (err) { - error.NotFound => null, - else => return err, - }; + const previous_successful = try loadPreviousSuccessfulDeployment(db, alloc, latest.app_name.?, latest.id); defer if (previous_successful) |dep| dep.deinit(alloc); if (i > 0) try writer.writeByte(','); - const json = try formatAppStatusResponse( - alloc, - apply_release.reportFromDeployment(latest), - if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, - ); + const json = try formatAppStatusResponseFromDeployments(alloc, latest, previous_successful); defer alloc.free(json); try writer.writeAll(json); } @@ -153,6 +143,30 @@ fn formatAppsResponse( return json_buf.toOwnedSlice(alloc); } +fn loadPreviousSuccessfulDeployment( + db: *sqlite.Db, + alloc: std.mem.Allocator, + app_name: []const u8, + exclude_release_id: []const u8, +) !?store.DeploymentRecord { + return store.getPreviousSuccessfulDeploymentByAppInDb(db, alloc, app_name, exclude_release_id) catch |err| switch (err) { + error.NotFound => null, + else => return err, + }; +} + +fn formatAppStatusResponseFromDeployments( + alloc: std.mem.Allocator, + latest: store.DeploymentRecord, + previous_successful: ?store.DeploymentRecord, +) ![]u8 { + return formatAppStatusResponse( + alloc, + apply_release.reportFromDeployment(latest), + if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, + ); +} + fn formatAppHistoryResponse(alloc: std.mem.Allocator, deployments: []const store.DeploymentRecord) ![]u8 { var json_buf: std.ArrayList(u8) = .empty; errdefer json_buf.deinit(alloc); diff --git a/src/runtime/cli/status_command.zig b/src/runtime/cli/status_command.zig index 2aa7455..7caabcf 100644 --- a/src/runtime/cli/status_command.zig +++ b/src/runtime/cli/status_command.zig @@ -154,19 +154,13 @@ fn statusLocalApp(alloc: std.mem.Allocator, app_name: []const u8) StatusError!vo }; defer latest.deinit(alloc); - const previous_successful = store.getPreviousSuccessfulDeploymentByApp(alloc, app_name, latest.id) catch |err| switch (err) { - error.NotFound => null, - else => { - writeErr("failed to read app status\n", .{}); - return StatusError.StoreError; - }, + const previous_successful = loadPreviousSuccessfulDeployment(alloc, app_name, latest.id) catch { + writeErr("failed to read app status\n", .{}); + return StatusError.StoreError; }; defer if (previous_successful) |dep| dep.deinit(alloc); - const snapshot = appStatusFromReports( - apply_release.reportFromDeployment(latest), - if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, - ); + const snapshot = snapshotFromDeployments(latest, previous_successful); printAppStatus(snapshot); } @@ -286,24 +280,29 @@ fn appsLocal(alloc: std.mem.Allocator) StatusError!void { defer snapshots.deinit(alloc); for (latest.items) |dep| { - const previous_successful = store.getPreviousSuccessfulDeploymentByApp(alloc, dep.app_name.?, dep.id) catch |err| switch (err) { - error.NotFound => null, - else => { - writeErr("failed to read app list\n", .{}); - return StatusError.StoreError; - }, + const previous_successful = loadPreviousSuccessfulDeployment(alloc, dep.app_name.?, dep.id) catch { + writeErr("failed to read app list\n", .{}); + return StatusError.StoreError; }; defer if (previous_successful) |prev| prev.deinit(alloc); - snapshots.append(alloc, appStatusFromReports( - apply_release.reportFromDeployment(dep), - if (previous_successful) |prev| apply_release.reportFromDeployment(prev) else null, - )) catch return StatusError.OutOfMemory; + snapshots.append(alloc, snapshotFromDeployments(dep, previous_successful)) catch return StatusError.OutOfMemory; } printAppStatuses(snapshots.items); } +fn loadPreviousSuccessfulDeployment( + alloc: std.mem.Allocator, + app_name: []const u8, + exclude_release_id: []const u8, +) !?store.DeploymentRecord { + return store.getPreviousSuccessfulDeploymentByApp(alloc, app_name, exclude_release_id) catch |err| switch (err) { + error.NotFound => null, + else => return err, + }; +} + fn appsRemote(alloc: std.mem.Allocator, addr: [4]u8, port: u16) StatusError!void { var token_buf: [64]u8 = undefined; const token = cli.readApiToken(&token_buf); @@ -476,6 +475,16 @@ fn appStatusFromReports( }; } +fn snapshotFromDeployments( + latest: store.DeploymentRecord, + previous_successful: ?store.DeploymentRecord, +) AppStatusSnapshot { + return appStatusFromReports( + apply_release.reportFromDeployment(latest), + if (previous_successful) |dep| apply_release.reportFromDeployment(dep) else null, + ); +} + fn currentAppNameAlloc(alloc: std.mem.Allocator) ![]u8 { var cwd_buf: [4096]u8 = undefined; const cwd = std.fs.cwd().realpath(".", &cwd_buf) catch return StatusError.StoreError; From 78d6ef6e8d76169dc4e17e4646393caaeee97256 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Thu, 9 Apr 2026 16:11:37 +0000 Subject: [PATCH 8/8] Document app summaries and empty states --- README.md | 2 ++ docs/cluster-guide.md | 4 +++- docs/users-guide.md | 2 ++ src/api/routes/cluster_agents/app_routes.zig | 13 +++++++++++++ src/state/store/deployments.zig | 11 +++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 35e36b6..a026fd7 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ yoq status [--verbose] show service status and resources yoq status --app [name] show local app release status yoq status --app [name] --server host:port show remote app release status +yoq apps [--json] list local app release summaries +yoq apps --server host:port [--json] list remote app release summaries yoq metrics [service] show service metrics yoq metrics --pairs show service-to-service metrics yoq policy deny block traffic between services diff --git a/docs/cluster-guide.md b/docs/cluster-guide.md index 400d6ef..167c943 100644 --- a/docs/cluster-guide.md +++ b/docs/cluster-guide.md @@ -141,12 +141,13 @@ the `--server` flag tells yoq to submit the manifest to the cluster API instead after deploy, use the app-first day-2 commands: ``` +yoq apps --server 10.0.0.1:7700 yoq status --app [name] --server 10.0.0.1:7700 yoq history --app [name] --server 10.0.0.1:7700 yoq rollback --app [name] --server 10.0.0.1:7700 --release ``` -`status --app` shows the latest release metadata, `history --app` lists prior releases, and remote `rollback --app ... --release` re-applies a stored app snapshot. +`yoq apps` shows the latest release summary for every app, `status --app` shows the latest release metadata for one app, `history --app` lists prior releases, and remote `rollback --app ... --release` re-applies a stored app snapshot. --- @@ -344,6 +345,7 @@ for app operations, the important write paths are: the important read paths are: +- `GET /apps` - `GET /apps//status` - `GET /apps//history` diff --git a/docs/users-guide.md b/docs/users-guide.md index bb76c78..5e33898 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -169,6 +169,7 @@ this gives the operator one app-first day-2 model: - `yoq history --app [name]` — app release history - `yoq rollback --app [name]` — print the last successful local app snapshot - `yoq rollback --app [name] --server host:port --release ` — re-apply a prior remote app release +- `yoq apps` — list app release summaries across all known apps ### dev mode @@ -220,6 +221,7 @@ cluster manifest deploys now use `POST /apps/apply` as the canonical write path. the cluster API also exposes app-scoped day-2 reads and rollback: +- `GET /apps` — latest release summary per app - `GET /apps//status` — latest app release metadata - `GET /apps//history` — app release history - `POST /apps//rollback` with `{"release_id":"..."}` — re-apply a stored app release snapshot diff --git a/src/api/routes/cluster_agents/app_routes.zig b/src/api/routes/cluster_agents/app_routes.zig index 5dfd3c4..e4f0fc6 100644 --- a/src/api/routes/cluster_agents/app_routes.zig +++ b/src/api/routes/cluster_agents/app_routes.zig @@ -461,6 +461,19 @@ test "formatAppsResponse emits one latest summary per app" { try std.testing.expect(std.mem.indexOf(u8, json, "\"release_id\":\"dep-2\"") != null); } +test "formatAppsResponse returns empty array when no app releases exist" { + const alloc = std.testing.allocator; + + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + const json = try formatAppsResponse(alloc, &db, &.{}); + defer alloc.free(json); + + try std.testing.expectEqualStrings("[]", json); +} + test "formatAppStatusResponse includes structured rollback metadata" { const alloc = std.testing.allocator; const latest = store.DeploymentRecord{ diff --git a/src/state/store/deployments.zig b/src/state/store/deployments.zig index fa0036e..0382360 100644 --- a/src/state/store/deployments.zig +++ b/src/state/store/deployments.zig @@ -486,6 +486,17 @@ test "listLatestDeploymentsByAppInDb returns one latest row per app" { try std.testing.expectEqualStrings("app-b", latest.items[1].app_name.?); } +test "listLatestDeploymentsByAppInDb returns empty list when no app releases exist" { + var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); + defer db.deinit(); + try schema.init(&db); + + var latest = try listLatestDeploymentsByAppInDb(&db, std.testing.allocator); + defer latest.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 0), latest.items.len); +} + test "deployment list ordered by timestamp desc" { var db = try sqlite.Db.init(.{ .mode = .Memory, .open_flags = .{ .write = true } }); defer db.deinit();