From 4104c31fe22da8100d31b0b38ac952938b7f490f Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sat, 25 Apr 2026 09:07:46 +1000
Subject: [PATCH 1/2] Update agent status processing
---
web/app/api/inngest/route.ts | 2 --
web/app/api/v1/agent/status/route.ts | 21 +++------------------
web/lib/inngest/events/agent.ts | 10 ----------
web/lib/inngest/events/index.ts | 5 +----
web/lib/inngest/functions/agent-status.ts | 14 --------------
web/lib/inngest/functions/index.ts | 1 -
6 files changed, 4 insertions(+), 49 deletions(-)
delete mode 100644 web/lib/inngest/events/agent.ts
delete mode 100644 web/lib/inngest/functions/agent-status.ts
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts
index ad5a389..cc7f78e 100644
--- a/web/app/api/inngest/route.ts
+++ b/web/app/api/inngest/route.ts
@@ -19,7 +19,6 @@ import {
buildTriggerWorkflow,
backupTriggerWorkflow,
restoreTriggerWorkflow,
- processAgentStatus,
} from "@/lib/inngest/functions";
export const { GET, POST, PUT } = serve({
@@ -43,6 +42,5 @@ export const { GET, POST, PUT } = serve({
buildTriggerWorkflow,
backupTriggerWorkflow,
restoreTriggerWorkflow,
- processAgentStatus,
],
});
diff --git a/web/app/api/v1/agent/status/route.ts b/web/app/api/v1/agent/status/route.ts
index e609b75..e7b8dbd 100644
--- a/web/app/api/v1/agent/status/route.ts
+++ b/web/app/api/v1/agent/status/route.ts
@@ -1,10 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import { db } from "@/db";
-import { servers } from "@/db/schema";
-import { eq } from "drizzle-orm";
import { verifyAgentRequest } from "@/lib/agent-auth";
-import { inngest } from "@/lib/inngest/client";
-import type { StatusReport } from "@/lib/agent-status";
+import { applyStatusReport, type StatusReport } from "@/lib/agent-status";
export async function POST(request: NextRequest) {
const body = await request.text();
@@ -29,18 +25,7 @@ export async function POST(request: NextRequest) {
const { serverId } = auth;
- await db
- .update(servers)
- .set({ lastHeartbeat: new Date(), status: "online" })
- .where(eq(servers.id, serverId));
+ await applyStatusReport(serverId, data.statusReport);
- await inngest.send({
- name: "agent/status-reported",
- data: {
- serverId,
- report: data.statusReport,
- },
- });
-
- return NextResponse.json({ ok: true }, { status: 202 });
+ return NextResponse.json({ ok: true });
}
diff --git a/web/lib/inngest/events/agent.ts b/web/lib/inngest/events/agent.ts
deleted file mode 100644
index 1cfc45a..0000000
--- a/web/lib/inngest/events/agent.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { StatusReport } from "@/lib/agent-status";
-
-export type AgentEvents = {
- "agent/status-reported": {
- data: {
- serverId: string;
- report: StatusReport;
- };
- };
-};
diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts
index f60549e..c15c495 100644
--- a/web/lib/inngest/events/index.ts
+++ b/web/lib/inngest/events/index.ts
@@ -3,18 +3,15 @@ export type { MigrationEvents } from "./migration";
export type { BackupEvents } from "./backup";
export type { RestoreEvents } from "./restore";
export type { BuildEvents } from "./build";
-export type { AgentEvents } from "./agent";
import type { RolloutEvents } from "./rollout";
import type { MigrationEvents } from "./migration";
import type { BackupEvents } from "./backup";
import type { RestoreEvents } from "./restore";
import type { BuildEvents } from "./build";
-import type { AgentEvents } from "./agent";
export type Events = RolloutEvents &
MigrationEvents &
BackupEvents &
RestoreEvents &
- BuildEvents &
- AgentEvents;
+ BuildEvents;
diff --git a/web/lib/inngest/functions/agent-status.ts b/web/lib/inngest/functions/agent-status.ts
deleted file mode 100644
index a6fd729..0000000
--- a/web/lib/inngest/functions/agent-status.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { inngest } from "../client";
-import { applyStatusReport } from "@/lib/agent-status";
-
-export const processAgentStatus = inngest.createFunction(
- {
- id: "process-agent-status",
- concurrency: [{ limit: 5, key: "event.data.serverId" }],
- },
- { event: "agent/status-reported" },
- async ({ event }) => {
- const { serverId, report } = event.data;
- await applyStatusReport(serverId, report);
- },
-);
diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts
index f89bdd1..a5f4a94 100644
--- a/web/lib/inngest/functions/index.ts
+++ b/web/lib/inngest/functions/index.ts
@@ -16,4 +16,3 @@ export { buildWorkflow } from "./build-workflow";
export { buildTriggerWorkflow } from "./build-trigger-workflow";
export { backupTriggerWorkflow } from "./backup-trigger-workflow";
export { restoreTriggerWorkflow } from "./restore-trigger-workflow";
-export { processAgentStatus } from "./agent-status";
From b16e5e81077a485ae7ebdd144574f047c5e5dd39 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sat, 25 Apr 2026 21:19:46 +1000
Subject: [PATCH 2/2] Fix deployment config bugs
---
agent/internal/agent/agent.go | 68 ++++++-----
agent/internal/agent/drift.go | 52 ++++++++-
agent/internal/agent/run.go | 37 ++++--
agent/internal/agent/workqueue.go | 2 +-
.../[env]/services/[serviceId]/page.tsx | 33 +++---
web/app/api/v1/agent/work-queue/route.ts | 5 +-
.../service/details/deployment-progress.tsx | 11 +-
.../details/pending-changes-banner.tsx | 13 ++-
web/lib/agent-status.ts | 14 +--
web/lib/inngest/functions/rollout-helpers.ts | 1 +
web/lib/inngest/functions/rollout-workflow.ts | 45 ++++++--
web/lib/service-config.ts | 106 +++++++++++++-----
12 files changed, 277 insertions(+), 110 deletions(-)
diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go
index 8b46348..26cbb5b 100644
--- a/agent/internal/agent/agent.go
+++ b/agent/internal/agent/agent.go
@@ -59,25 +59,29 @@ type ActualState struct {
}
type Agent struct {
- state AgentState
- stateMutex sync.RWMutex
- Client *agenthttp.Client
- Reconciler *reconcile.Reconciler
- Config *Config
- PublicIP string
- PrivateIP string
- DataDir string
- expectedState *agenthttp.ExpectedState
- processingStart time.Time
- LogCollector *logs.Collector
- TraefikLogCollector *logs.TraefikCollector
- Builder *build.Builder
- isBuilding bool
- buildMutex sync.Mutex
- currentBuildID string
- IsProxy bool
- dnsInSync bool
- DisableDNS bool
+ state AgentState
+ stateMutex sync.RWMutex
+ reconcileRequested chan struct{}
+ statusReportRequested chan string
+ refreshMutex sync.Mutex
+ pendingExpectedStateRefresh bool
+ Client *agenthttp.Client
+ Reconciler *reconcile.Reconciler
+ Config *Config
+ PublicIP string
+ PrivateIP string
+ DataDir string
+ expectedState *agenthttp.ExpectedState
+ processingStart time.Time
+ LogCollector *logs.Collector
+ TraefikLogCollector *logs.TraefikCollector
+ Builder *build.Builder
+ isBuilding bool
+ buildMutex sync.Mutex
+ currentBuildID string
+ IsProxy bool
+ dnsInSync bool
+ DisableDNS bool
}
func NewAgent(
@@ -92,18 +96,20 @@ func NewAgent(
disableDNS bool,
) *Agent {
return &Agent{
- state: StateIdle,
- Client: client,
- Reconciler: reconciler,
- Config: config,
- PublicIP: publicIP,
- PrivateIP: privateIP,
- DataDir: dataDir,
- LogCollector: logCollector,
- TraefikLogCollector: traefikLogCollector,
- Builder: builder,
- IsProxy: isProxy,
- DisableDNS: disableDNS,
+ state: StateIdle,
+ reconcileRequested: make(chan struct{}, 1),
+ statusReportRequested: make(chan string, 1),
+ Client: client,
+ Reconciler: reconciler,
+ Config: config,
+ PublicIP: publicIP,
+ PrivateIP: privateIP,
+ DataDir: dataDir,
+ LogCollector: logCollector,
+ TraefikLogCollector: traefikLogCollector,
+ Builder: builder,
+ IsProxy: isProxy,
+ DisableDNS: disableDNS,
}
}
diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go
index 81e8002..021048c 100644
--- a/agent/internal/agent/drift.go
+++ b/agent/internal/agent/drift.go
@@ -24,6 +24,48 @@ func (a *Agent) Tick() {
}
}
+func (a *Agent) RequestReconcile(reason string) {
+ if a.GetState() == StateProcessing {
+ a.requestExpectedStateRefresh()
+ log.Printf("[reconcile] refresh requested during processing: %s", reason)
+ } else {
+ log.Printf("[reconcile] immediate reconcile requested: %s", reason)
+ }
+
+ select {
+ case a.reconcileRequested <- struct{}{}:
+ default:
+ }
+}
+
+func (a *Agent) requestExpectedStateRefresh() {
+ a.refreshMutex.Lock()
+ defer a.refreshMutex.Unlock()
+ a.pendingExpectedStateRefresh = true
+}
+
+func (a *Agent) consumeExpectedStateRefresh() bool {
+ a.refreshMutex.Lock()
+ defer a.refreshMutex.Unlock()
+
+ if !a.pendingExpectedStateRefresh {
+ return false
+ }
+
+ a.pendingExpectedStateRefresh = false
+ return true
+}
+
+func (a *Agent) transitionToIdle() {
+ a.SetState(StateIdle)
+ if a.consumeExpectedStateRefresh() {
+ log.Printf("[processing] fetching latest expected state after pending refresh")
+ // A deploy wake can arrive while processing a previous snapshot. Run one
+ // immediate idle pass after processing to pick up the latest expected state.
+ a.handleIdle()
+ }
+}
+
func (a *Agent) handleIdle() {
expected, fromCache, err := a.Client.GetExpectedStateWithFallback()
if err != nil {
@@ -61,14 +103,14 @@ func (a *Agent) handleIdle() {
func (a *Agent) handleProcessing() {
if time.Since(a.processingStart) > ProcessingTimeout {
log.Printf("[processing] timeout after %v, forcing transition to IDLE", ProcessingTimeout)
- a.SetState(StateIdle)
+ a.transitionToIdle()
return
}
actual, err := a.getActualState()
if err != nil {
log.Printf("[processing] failed to get actual state: %v", err)
- a.SetState(StateIdle)
+ a.transitionToIdle()
return
}
@@ -76,16 +118,18 @@ func (a *Agent) handleProcessing() {
if len(a.detectChanges(a.expectedState, actual)) == 0 {
log.Printf("[processing] state converged, transitioning to IDLE")
- a.SetState(StateIdle)
+ a.transitionToIdle()
return
}
err = a.reconcileOne(actual)
if err != nil {
log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err)
- a.SetState(StateIdle)
+ a.transitionToIdle()
return
}
+
+ a.RequestStatusReport("reconcile completed")
}
func (a *Agent) updateDnsInSync(expected *agenthttp.ExpectedState, actual *ActualState) {
diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go
index 489bde2..0760dd2 100644
--- a/agent/internal/agent/run.go
+++ b/agent/internal/agent/run.go
@@ -56,6 +56,13 @@ func (a *Agent) Run(ctx context.Context) {
return
case <-ticker.C:
a.Tick()
+ case <-a.reconcileRequested:
+ if a.GetState() == StateProcessing {
+ a.requestExpectedStateRefresh()
+ } else {
+ a.consumeExpectedStateRefresh()
+ }
+ a.Tick()
case <-logTickerC:
a.CollectLogs()
case <-cleanupTickerC:
@@ -65,10 +72,7 @@ func (a *Agent) Run(ctx context.Context) {
}
func (a *Agent) StatusReportLoop(ctx context.Context) {
- report := a.BuildStatusReport(true)
- if err := a.Client.ReportStatus(report); err != nil {
- log.Printf("[status] failed to report: %v", err)
- }
+ a.reportStatus("startup")
ticker := time.NewTicker(WorkQueueStatusInterval)
defer ticker.Stop()
@@ -78,14 +82,31 @@ func (a *Agent) StatusReportLoop(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
- report := a.BuildStatusReport(true)
- if err := a.Client.ReportStatus(report); err != nil {
- log.Printf("[status] failed to report: %v", err)
- }
+ a.reportStatus("periodic")
+ case reason := <-a.statusReportRequested:
+ a.reportStatus(reason)
}
}
}
+func (a *Agent) RequestStatusReport(reason string) {
+ log.Printf("[status] immediate report requested: %s", reason)
+ select {
+ case a.statusReportRequested <- reason:
+ default:
+ log.Printf("[status] immediate report already queued, dropping reason: %s", reason)
+ }
+}
+
+func (a *Agent) reportStatus(reason string) {
+ report := a.BuildStatusReport(true)
+ if err := a.Client.ReportStatus(report); err != nil {
+ log.Printf("[status] failed to report (%s): %v", reason, err)
+ return
+ }
+ log.Printf("[status] reported (%s)", reason)
+}
+
func (a *Agent) WorkQueueLoop(ctx context.Context) {
for {
select {
diff --git a/agent/internal/agent/workqueue.go b/agent/internal/agent/workqueue.go
index c0bd36e..ffc4adc 100644
--- a/agent/internal/agent/workqueue.go
+++ b/agent/internal/agent/workqueue.go
@@ -28,7 +28,7 @@ func (a *Agent) ProcessWorkQueue() {
case "stop":
processErr = a.ProcessStop(item)
case "deploy":
- log.Printf("[work-queue] deploy handled via expected state reconciliation, marking complete")
+ a.RequestReconcile("deploy work item " + Truncate(item.ID, 8))
case "force_cleanup":
processErr = a.ProcessForceCleanup(item)
case "cleanup_volumes":
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
index 0314f07..55850e3 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx
@@ -1,23 +1,23 @@
"use client";
-import { useState } from "react";
-import { toast } from "sonner";
import {
+ AlertTriangleIcon,
ChevronDownIcon,
+ PlayIcon,
RefreshCwIcon,
RotateCcwIcon,
- PlayIcon,
StopCircleIcon,
Trash2Icon,
- AlertTriangleIcon,
} from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { useSWRConfig } from "swr";
import {
deleteDeployments,
deployService,
restartService,
stopService,
} from "@/actions/projects";
-import { useService } from "@/components/service/service-layout-client";
import { DeploymentCanvas } from "@/components/service/details/deployment-canvas";
import {
DeploymentProgress,
@@ -25,15 +25,7 @@ import {
} from "@/components/service/details/deployment-progress";
import { PendingChangesBanner } from "@/components/service/details/pending-changes-banner";
import { RolloutHistory } from "@/components/service/details/rollout-history";
-import { Button } from "@/components/ui/button";
-import { ButtonGroup } from "@/components/ui/button-group";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+import { useService } from "@/components/service/service-layout-client";
import {
AlertDialog,
AlertDialogAction,
@@ -45,12 +37,22 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { ButtonGroup } from "@/components/ui/button-group";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
type ConfirmAction = "redeploy" | "stop" | "delete" | null;
export default function DeploymentsPage() {
const { service, pendingChanges, projectSlug, envName, onUpdate } =
useService();
+ const { mutate: mutateCache } = useSWRConfig();
const [isLoading, setIsLoading] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
@@ -68,6 +70,9 @@ export default function DeploymentsPage() {
if (successMessage) {
toast.success(successMessage);
}
+ if (actionName === "redeploy" || actionName === "start") {
+ await mutateCache(`/api/services/${service.id}/rollouts`);
+ }
onUpdate();
} catch (error) {
const errorMessage =
diff --git a/web/app/api/v1/agent/work-queue/route.ts b/web/app/api/v1/agent/work-queue/route.ts
index 4481762..ac06f04 100644
--- a/web/app/api/v1/agent/work-queue/route.ts
+++ b/web/app/api/v1/agent/work-queue/route.ts
@@ -5,7 +5,10 @@ import { eq, and, inArray } from "drizzle-orm";
import { verifyAgentRequest } from "@/lib/agent-auth";
const MAX_TIMEOUT = 30000;
-const POLL_INTERVAL = 2000;
+// Short interval keeps deploy wake latency low for now, but increases empty
+// polling load. Replace with Postgres LISTEN/NOTIFY or another wake mechanism
+// before scaling this broadly.
+const POLL_INTERVAL = 500;
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/web/components/service/details/deployment-progress.tsx b/web/components/service/details/deployment-progress.tsx
index ecb34d8..6e11386 100644
--- a/web/components/service/details/deployment-progress.tsx
+++ b/web/components/service/details/deployment-progress.tsx
@@ -1,18 +1,18 @@
"use client";
-import { memo, useMemo, useState, useEffect, useRef } from "react";
-import { useRouter } from "next/navigation";
import { Loader2, XCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { memo, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
-import { abortRollout } from "@/actions/projects";
import { cancelMigration } from "@/actions/migrations";
+import { abortRollout } from "@/actions/projects";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
-import type { ConfigChange } from "@/lib/service-config";
import type {
DeploymentStatus,
ServiceWithDetails as Service,
} from "@/db/types";
+import type { ConfigChange } from "@/lib/service-config";
type StageInfo = {
id: string;
@@ -278,6 +278,9 @@ export const DeploymentProgress = memo(function DeploymentProgress({
const isMigrationFailed = service.migrationStatus === "failed";
let status = currentStage?.label || "Deploying";
+ if (barState.stage === "health_check" && !service.healthCheckCmd) {
+ status = "Starting container";
+ }
if (isMigrating && service.migrationStatus) {
status =
MIGRATION_STAGES[service.migrationStatus] ||
diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx
index 5dcc18d..72504a0 100644
--- a/web/components/service/details/pending-changes-banner.tsx
+++ b/web/components/service/details/pending-changes-banner.tsx
@@ -1,14 +1,15 @@
"use client";
-import { memo, useState } from "react";
-import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowRight, Rocket } from "lucide-react";
-import { deployService } from "@/actions/projects";
+import { useRouter } from "next/navigation";
+import { memo, useState } from "react";
+import { useSWRConfig } from "swr";
import { triggerBuild } from "@/actions/builds";
+import { deployService } from "@/actions/projects";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
-import type { ConfigChange } from "@/lib/service-config";
import type { ServiceWithDetails as Service } from "@/db/types";
+import type { ConfigChange } from "@/lib/service-config";
interface PendingChangesBannerProps {
service: Service;
@@ -28,6 +29,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
barMode,
}: PendingChangesBannerProps) {
const router = useRouter();
+ const { mutate } = useSWRConfig();
const [isDeploying, setIsDeploying] = useState(false);
const totalReplicas = service.autoPlace
@@ -52,6 +54,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
);
} else {
await deployService(service.id);
+ await mutate(`/api/services/${service.id}/rollouts`);
}
onUpdate();
} finally {
@@ -83,7 +86,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({
{hasChanges ? (
- {changes.map((change, i) => (
+ {changes.map((change) => (
{
+ const service = await getService(serviceId);
+ const hasHealthCheck = service?.healthCheckCmd != null;
+
await db
.update(rollouts)
.set({ currentStage: "health_check" })
@@ -203,12 +206,34 @@ export const rolloutWorkflow = inngest.createFunction(
rolloutId,
serviceId,
"health_check",
- "Waiting for health checks",
+ hasHealthCheck ? "Waiting for health checks" : "Starting container",
);
});
+ const pendingHealthDeploymentIds = await step.run(
+ "get-pending-health-deployments",
+ async () => {
+ if (deploymentIds.length === 0) {
+ return [];
+ }
+
+ const alreadyHealthy = await db
+ .select({ id: deployments.id })
+ .from(deployments)
+ .where(
+ and(
+ inArray(deployments.id, deploymentIds),
+ inArray(deployments.status, ["healthy", "running"]),
+ ),
+ );
+
+ const alreadyHealthyIds = new Set(alreadyHealthy.map((d) => d.id));
+ return deploymentIds.filter((id) => !alreadyHealthyIds.has(id));
+ },
+ );
+
const healthResults = await Promise.all(
- deploymentIds.map((deploymentId) =>
+ pendingHealthDeploymentIds.map((deploymentId) =>
step.waitForEvent(`wait-healthy-${deploymentId}`, {
event: "deployment/healthy",
timeout: "10m",
@@ -219,7 +244,7 @@ export const rolloutWorkflow = inngest.createFunction(
const timedOutIndex = healthResults.indexOf(null);
if (timedOutIndex !== -1) {
- const failedDeploymentId = deploymentIds[timedOutIndex];
+ const failedDeploymentId = pendingHealthDeploymentIds[timedOutIndex];
await step.run("log-health-timeout", async () => {
await ingestRolloutLog(
rolloutId,
diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts
index fed68ef..8848a72 100644
--- a/web/lib/service-config.ts
+++ b/web/lib/service-config.ts
@@ -40,9 +40,15 @@ export type ResourceLimitsConfig = {
memoryMb?: number | null;
};
+export type PlacementConfig = {
+ autoPlace: boolean;
+ replicas: number;
+};
+
export type DeployedConfig = {
source: SourceConfig;
hostname?: string;
+ placement?: PlacementConfig;
replicas: ReplicaConfig[];
healthCheck: HealthCheckConfig | null;
startCommand?: string | null;
@@ -71,6 +77,8 @@ export function buildCurrentConfig(
startCommand: string | null;
resourceCpuLimit: number | null;
resourceMemoryLimitMb: number | null;
+ autoPlace: boolean;
+ replicas: number;
},
replicas: { serverId: string; serverName: string; count: number }[],
ports: { port: number; isPublic: boolean; domain: string | null }[],
@@ -86,6 +94,12 @@ export function buildCurrentConfig(
image: service.image,
},
hostname: service.hostname ?? undefined,
+ placement: {
+ autoPlace: service.autoPlace,
+ replicas: service.autoPlace
+ ? service.replicas
+ : replicas.reduce((sum, r) => sum + r.count, 0),
+ },
replicas: replicas.map((r) => ({
serverId: r.serverId,
serverName: r.serverName,
@@ -138,12 +152,20 @@ export function diffConfigs(
to: current.source.image,
});
}
- for (const replica of current.replicas) {
+ if (current.placement?.autoPlace) {
changes.push({
- field: `${replica.serverName} replicas`,
+ field: "Replicas",
from: "0",
- to: String(replica.count),
+ to: String(current.placement.replicas),
});
+ } else {
+ for (const replica of current.replicas) {
+ changes.push({
+ field: `${replica.serverName} replicas`,
+ from: "0",
+ to: String(replica.count),
+ });
+ }
}
if (current.healthCheck) {
changes.push({
@@ -214,38 +236,72 @@ export function diffConfigs(
});
}
- const deployedReplicasMap = new Map(
- (deployed.replicas || []).map((r) => [r.serverId, r]),
- );
- const currentReplicasMap = new Map(
- (current.replicas || []).map((r) => [r.serverId, r]),
+ const deployedTotalReplicas = (deployed.replicas || []).reduce(
+ (sum, r) => sum + r.count,
+ 0,
);
- for (const [serverId, currentReplica] of currentReplicasMap) {
- const deployedReplica = deployedReplicasMap.get(serverId);
- if (!deployedReplica) {
+ if (current.placement?.autoPlace) {
+ if (deployed.placement && !deployed.placement.autoPlace) {
changes.push({
- field: `${currentReplica.serverName} replicas`,
- from: "0",
- to: String(currentReplica.count),
+ field: "Placement",
+ from: "Manual",
+ to: "Auto-placement",
});
- } else if (deployedReplica.count !== currentReplica.count) {
+ }
+
+ const deployedReplicaCount =
+ deployed.placement?.replicas ?? deployedTotalReplicas;
+
+ if (deployedReplicaCount !== current.placement.replicas) {
changes.push({
- field: `${currentReplica.serverName} replicas`,
- from: String(deployedReplica.count),
- to: String(currentReplica.count),
+ field: "Replicas",
+ from: String(deployedReplicaCount),
+ to: String(current.placement.replicas),
});
}
- }
-
- for (const [serverId, deployedReplica] of deployedReplicasMap) {
- if (!currentReplicasMap.has(serverId)) {
+ } else {
+ if (deployed.placement?.autoPlace) {
changes.push({
- field: `${deployedReplica.serverName} replicas`,
- from: String(deployedReplica.count),
- to: "0 (removed)",
+ field: "Placement",
+ from: "Auto-placement",
+ to: "Manual",
});
}
+
+ const deployedReplicasMap = new Map(
+ (deployed.replicas || []).map((r) => [r.serverId, r]),
+ );
+ const currentReplicasMap = new Map(
+ (current.replicas || []).map((r) => [r.serverId, r]),
+ );
+
+ for (const [serverId, currentReplica] of currentReplicasMap) {
+ const deployedReplica = deployedReplicasMap.get(serverId);
+ if (!deployedReplica) {
+ changes.push({
+ field: `${currentReplica.serverName} replicas`,
+ from: "0",
+ to: String(currentReplica.count),
+ });
+ } else if (deployedReplica.count !== currentReplica.count) {
+ changes.push({
+ field: `${currentReplica.serverName} replicas`,
+ from: String(deployedReplica.count),
+ to: String(currentReplica.count),
+ });
+ }
+ }
+
+ for (const [serverId, deployedReplica] of deployedReplicasMap) {
+ if (!currentReplicasMap.has(serverId)) {
+ changes.push({
+ field: `${deployedReplica.serverName} replicas`,
+ from: String(deployedReplica.count),
+ to: "0 (removed)",
+ });
+ }
+ }
}
const deployedHc = deployed.healthCheck;