diff --git a/apps/backend/internal/agent/kubernetes/worker_presets_test.go b/apps/backend/internal/agent/kubernetes/worker_presets_test.go
new file mode 100644
index 0000000000..411e1183db
--- /dev/null
+++ b/apps/backend/internal/agent/kubernetes/worker_presets_test.go
@@ -0,0 +1,148 @@
+package kubernetes
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/resource"
+)
+
+func TestKubernetesWorkerPresetsCompose(t *testing.T) {
+ t.Parallel()
+
+ for _, name := range []string{"minimal", "node-pnpm", "python"} {
+ name := name
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ raw := readWorkerPreset(t, name)
+ template, err := ParsePodTemplate(raw)
+ if err != nil {
+ t.Fatalf("ParsePodTemplate() error = %v", err)
+ }
+
+ profile := ProfileConfig{
+ Platform: PlatformLinuxAMD64,
+ MainContainer: DefaultMainContainerName,
+ PodTemplateYAML: raw,
+ Workspace: WorkspaceConfig{
+ Mode: WorkspaceModeManagedPVC,
+ Size: "10Gi",
+ AccessModes: []string{"ReadWriteOnce"},
+ },
+ }
+ if err := profile.Validate(); err != nil {
+ t.Fatalf("ProfileConfig.Validate() error = %v", err)
+ }
+ pod, _, err := ComposePod(template, profile, PodOptions{
+ Name: "kandev-session-1", Namespace: "agents",
+ Identity: ResourceIdentity{
+ ExecutorID: "executor-1", ProfileID: "profile-1", InstanceID: "instance-1",
+ TaskID: "task-1", SessionID: "session-1", EnvironmentID: "environment-1",
+ },
+ Command: []string{"/bin/sh"}, Args: []string{"-ceu", "run-managed-entrypoint"},
+ WorkingDir: WorkspaceMountPath, ManagedPVCName: "workspace-pvc",
+ })
+ if err != nil {
+ t.Fatalf("ComposePod() error = %v", err)
+ }
+ assertWorkerPresetTemplate(t, template)
+ assertWorkerPresetResources(t, pod)
+ })
+ }
+}
+
+func TestKubernetesWorkerPresetsPreserveRuntimeOwnership(t *testing.T) {
+ t.Parallel()
+
+ for _, name := range []string{"minimal", "node-pnpm", "python"} {
+ name := name
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ template, err := ParsePodTemplate(readWorkerPreset(t, name))
+ if err != nil {
+ t.Fatalf("ParsePodTemplate() error = %v", err)
+ }
+ main := findWorkerPresetContainer(t, template.Template.Spec.Containers)
+ if main.SecurityContext == nil || main.SecurityContext.AllowPrivilegeEscalation == nil ||
+ *main.SecurityContext.AllowPrivilegeEscalation {
+ t.Fatal("preset must disable privilege escalation")
+ }
+ if main.SecurityContext.Capabilities == nil || len(main.SecurityContext.Capabilities.Drop) != 1 ||
+ main.SecurityContext.Capabilities.Drop[0] != corev1.Capability("ALL") {
+ t.Fatalf("preset capabilities = %#v, want drop ALL", main.SecurityContext.Capabilities)
+ }
+ if main.Env != nil {
+ for _, env := range main.Env {
+ if env.Name == "HOME" || env.Name == "KANDEV_HOME_DIR" {
+ t.Fatalf("preset owns runtime environment %q", env.Name)
+ }
+ }
+ }
+ })
+ }
+}
+
+func readWorkerPreset(t *testing.T, name string) string {
+ t.Helper()
+ _, source, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ root := filepath.Join(filepath.Dir(source), "../../../../..")
+ raw, err := os.ReadFile(filepath.Join(root, "k8s", "presets", name+".yaml"))
+ if err != nil {
+ t.Fatalf("read %s preset: %v", name, err)
+ }
+ return string(raw)
+}
+
+func assertWorkerPresetTemplate(t *testing.T, template *corev1.PodTemplate) {
+ t.Helper()
+ if template.Template.Spec.AutomountServiceAccountToken == nil ||
+ *template.Template.Spec.AutomountServiceAccountToken {
+ t.Fatal("preset must disable service-account token automount")
+ }
+ if template.Template.Spec.RestartPolicy != "" {
+ t.Fatalf("preset owns restartPolicy %q", template.Template.Spec.RestartPolicy)
+ }
+ if template.Template.Spec.SecurityContext == nil ||
+ template.Template.Spec.SecurityContext.RunAsNonRoot == nil ||
+ !*template.Template.Spec.SecurityContext.RunAsNonRoot ||
+ template.Template.Spec.SecurityContext.RunAsUser == nil ||
+ *template.Template.Spec.SecurityContext.RunAsUser != 1000 ||
+ template.Template.Spec.SecurityContext.FSGroup == nil ||
+ *template.Template.Spec.SecurityContext.FSGroup != 1000 {
+ t.Fatalf("preset pod security context = %#v", template.Template.Spec.SecurityContext)
+ }
+ main := findWorkerPresetContainer(t, template.Template.Spec.Containers)
+ if main.Command != nil || main.Args != nil || main.WorkingDir != "" || len(main.Ports) != 0 ||
+ len(main.VolumeMounts) != 0 {
+ t.Fatalf("preset owns Kandev launch fields: command=%v args=%v workingDir=%q ports=%v mounts=%v", main.Command, main.Args, main.WorkingDir, main.Ports, main.VolumeMounts)
+ }
+}
+
+func assertWorkerPresetResources(t *testing.T, pod *corev1.Pod) {
+ t.Helper()
+ main := findWorkerPresetContainer(t, pod.Spec.Containers)
+ if main.Resources.Requests.Cpu().Cmp(resource.MustParse("250m")) != 0 ||
+ main.Resources.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 {
+ t.Fatalf("resource requests = %v, want 250m CPU and 512Mi memory", main.Resources.Requests)
+ }
+ if main.Resources.Limits.Memory().Cmp(resource.MustParse("2Gi")) != 0 {
+ t.Fatalf("memory limit = %v, want 2Gi", main.Resources.Limits.Memory())
+ }
+}
+
+func findWorkerPresetContainer(t *testing.T, containers []corev1.Container) *corev1.Container {
+ t.Helper()
+ for i := range containers {
+ if containers[i].Name == DefaultMainContainerName {
+ return &containers[i]
+ }
+ }
+ t.Fatal("preset does not contain kandev-agent")
+ return nil
+}
diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go
index 30122e4b7b..0a1403a5ca 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go
@@ -45,11 +45,12 @@ var errKubernetesLifecycleRequestIncomplete = errors.New("kubernetes lifecycle r
// KubernetesExecutor owns Kubernetes Pod/PVC lifecycle and process-local
// forwards. Cluster clients are initialized lazily from the selected executor.
type KubernetesExecutor struct {
- agentctlResolver *AgentctlResolver
- logger *logger.Logger
- clientFactory kubernetesRuntimeClientFactory
- resolveBinary kubernetesAgentctlBinaryResolver
- healthRetryDelay time.Duration
+ agentctlResolver *AgentctlResolver
+ logger *logger.Logger
+ clientFactory kubernetesRuntimeClientFactory
+ resolveBinary kubernetesAgentctlBinaryResolver
+ healthRetryDelay time.Duration
+ launchTimingClock func() time.Time
mu sync.Mutex
sessions map[string]*kubernetesSession
@@ -79,12 +80,13 @@ const (
func NewKubernetesExecutor(agentctlResolver *AgentctlResolver, log *logger.Logger) *KubernetesExecutor {
runtime := &KubernetesExecutor{
- agentctlResolver: agentctlResolver,
- logger: log,
- clientFactory: newKubernetesRuntimeClient,
- healthRetryDelay: agentctlHealthRetryDelay,
- sessions: make(map[string]*kubernetesSession),
- locks: make(map[string]*kubernetesInstanceLock),
+ agentctlResolver: agentctlResolver,
+ logger: log,
+ clientFactory: newKubernetesRuntimeClient,
+ healthRetryDelay: agentctlHealthRetryDelay,
+ launchTimingClock: time.Now,
+ sessions: make(map[string]*kubernetesSession),
+ locks: make(map[string]*kubernetesInstanceLock),
}
if agentctlResolver != nil {
runtime.resolveBinary = func(platform kubeexecutor.Platform) ([]byte, error) {
@@ -149,36 +151,64 @@ func (r *KubernetesExecutor) createFresh(
executorConfig kubeexecutor.ExecutorConfig,
profile kubeexecutor.ProfileConfig,
) (_ *ExecutorInstance, returnedErr error) {
+ timing := newKubernetesLaunchTimingWithClock(r.logger, req, r.launchTimingClock)
+ defer func() { timing.complete(returnedErr) }()
+
launch, err := newKubernetesFreshLaunch(r, runtime, req, executorConfig, profile)
if err != nil {
+ timing.failureSite = "initialize"
return nil, err
}
defer func() {
returnedErr = launch.rollbackAfterFailure(ctx, returnedErr)
}()
- if err := launch.provisionWorkspace(ctx); err != nil {
+ if err := timing.runStage("storage", func() error {
+ return launch.provisionWorkspace(ctx)
+ }); err != nil {
return nil, err
}
- runningPod, err := launch.createRunningPod(ctx)
+ var runningPod *corev1.Pod
+ if err := timing.runStage("pod_ready", func() error {
+ var stageErr error
+ runningPod, stageErr = launch.createRunningPod(ctx)
+ return stageErr
+ }); err != nil {
+ return nil, err
+ }
+ var nonce string
+ var binary []byte
+ if err := timing.runStage("bootstrap", func() error {
+ var stageErr error
+ nonce, stageErr = generateBootstrapNonce()
+ if stageErr != nil {
+ return stageErr
+ }
+ binary, stageErr = r.resolveBinary(profile.Platform)
+ if stageErr != nil {
+ return fmt.Errorf("kubernetes lifecycle: resolve agentctl for %s: %w", profile.Platform, stageErr)
+ }
+ return r.bootstrapPod(ctx, runtime, req, runningPod, profile, nonce, binary)
+ }); err != nil {
+ return nil, err
+ }
+ var client *agentctl.Client
+ var finalForward kubeexecutor.PortForwardSession
+ var token string
+ var remotePort int
+ if err := timing.runStage("agentctl_connect", func() error {
+ var stageErr error
+ client, finalForward, token, remotePort, stageErr = r.connectNewAgentctl(
+ ctx, runtime, req, runningPod, nonce,
+ )
+ return stageErr
+ }); err != nil {
+ return nil, err
+ }
+ instance, err := launch.complete(ctx, runningPod, client, finalForward, token, nonce, remotePort)
if err != nil {
- return nil, err
+ timing.failureSite = "finalize"
}
- nonce, err := generateBootstrapNonce()
- if err != nil {
- return nil, err
- }
- binary, err := r.resolveBinary(profile.Platform)
- if err != nil {
- return nil, fmt.Errorf("kubernetes lifecycle: resolve agentctl for %s: %w", profile.Platform, err)
- }
- if err = r.bootstrapPod(ctx, runtime, req, runningPod, profile, nonce, binary); err != nil {
- return nil, err
- }
- client, finalForward, token, remotePort, err := r.connectNewAgentctl(ctx, runtime, req, runningPod, nonce)
- if err != nil {
- return nil, err
- }
- return launch.complete(ctx, runningPod, client, finalForward, token, nonce, remotePort)
+ return instance, err
}
type kubernetesFreshLaunch struct {
diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go
new file mode 100644
index 0000000000..d5d6015d29
--- /dev/null
+++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go
@@ -0,0 +1,123 @@
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+const (
+ kubernetesLaunchOutcomeSuccess = "success"
+ kubernetesLaunchOutcomeError = "error"
+ kubernetesLaunchOutcomeCanceled = "canceled"
+ kubernetesLaunchOutcomeTimeout = "timeout"
+)
+
+type kubernetesLaunchTiming struct {
+ logger *logger.Logger
+ now func() time.Time
+ startedAt time.Time
+ attemptID string
+ instanceID string
+ taskID string
+ sessionID string
+ failedStage string
+ failureSite string
+}
+
+func newKubernetesLaunchTiming(log *logger.Logger, req *ExecutorCreateRequest) *kubernetesLaunchTiming {
+ return newKubernetesLaunchTimingWithClock(log, req, time.Now)
+}
+
+func newKubernetesLaunchTimingWithClock(
+ log *logger.Logger,
+ req *ExecutorCreateRequest,
+ now func() time.Time,
+) *kubernetesLaunchTiming {
+ if now == nil {
+ now = time.Now
+ }
+ timing := &kubernetesLaunchTiming{
+ logger: log,
+ now: now,
+ startedAt: now(),
+ attemptID: uuid.NewString(),
+ }
+ if req != nil {
+ timing.instanceID = req.InstanceID
+ timing.taskID = req.TaskID
+ timing.sessionID = req.SessionID
+ }
+ return timing
+}
+
+func (t *kubernetesLaunchTiming) runStage(stage string, action func() error) error {
+ startedAt := t.now()
+ err := action()
+ t.recordStage(stage, startedAt, err)
+ return err
+}
+
+func (t *kubernetesLaunchTiming) recordStage(stage string, startedAt time.Time, err error) {
+ if err != nil && t.failedStage == "" {
+ t.failedStage = stage
+ }
+ if t.logger == nil {
+ return
+ }
+ t.logger.Info("kubernetes.launch.stage", append(
+ t.commonFields(t.elapsedMilliseconds(startedAt), kubernetesLaunchTimingOutcome(err)),
+ zap.String("stage", stage),
+ )...)
+}
+
+func (t *kubernetesLaunchTiming) complete(err error) {
+ if t.logger == nil {
+ return
+ }
+ fields := t.commonFields(t.elapsedMilliseconds(t.startedAt), kubernetesLaunchTimingOutcome(err))
+ if t.failedStage != "" {
+ fields = append(fields, zap.String("failed_stage", t.failedStage))
+ } else if t.failureSite != "" {
+ fields = append(fields, zap.String("failure_site", t.failureSite))
+ }
+ t.logger.Info("kubernetes.launch.completed", fields...)
+}
+
+func (t *kubernetesLaunchTiming) commonFields(duration int64, outcome string) []zap.Field {
+ return []zap.Field{
+ zap.String("attempt_id", t.attemptID),
+ zap.String("instance_id", t.instanceID),
+ zap.String("task_id", t.taskID),
+ zap.String("session_id", t.sessionID),
+ zap.String("mode", "fresh"),
+ zap.Int64("duration_ms", duration),
+ zap.String("outcome", outcome),
+ }
+}
+
+func (t *kubernetesLaunchTiming) elapsedMilliseconds(startedAt time.Time) int64 {
+ duration := t.now().Sub(startedAt).Milliseconds()
+ if duration < 0 {
+ return 0
+ }
+ return duration
+}
+
+func kubernetesLaunchTimingOutcome(err error) string {
+ switch {
+ case err == nil:
+ return kubernetesLaunchOutcomeSuccess
+ case errors.Is(err, context.DeadlineExceeded):
+ return kubernetesLaunchOutcomeTimeout
+ case errors.Is(err, context.Canceled):
+ return kubernetesLaunchOutcomeCanceled
+ default:
+ return kubernetesLaunchOutcomeError
+ }
+}
diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go
new file mode 100644
index 0000000000..eb5d500baf
--- /dev/null
+++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go
@@ -0,0 +1,232 @@
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+ "go.uber.org/zap/zaptest/observer"
+
+ corev1 "k8s.io/api/core/v1"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+func TestKubernetesLaunchTimingSuccess(t *testing.T) {
+ executor, logs := observedKubernetesExecutor(t)
+ executor.launchTimingClock = sequenceKubernetesTimingClock(
+ time.Date(2026, time.September, 9, 10, 0, 0, 0, time.UTC),
+ 10,
+ 10*time.Millisecond,
+ )
+
+ instance, err := executor.CreateInstance(context.Background(), validKubernetesCreateRequest())
+
+ require.NoError(t, err)
+ require.NotNil(t, instance)
+ stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
+ require.Len(t, stages, 4)
+ require.Equal(t, []string{"storage", "pod_ready", "bootstrap", "agentctl_connect"},
+ []string{
+ stages[0].ContextMap()["stage"].(string),
+ stages[1].ContextMap()["stage"].(string),
+ stages[2].ContextMap()["stage"].(string),
+ stages[3].ContextMap()["stage"].(string),
+ })
+ for _, entry := range stages {
+ fields := entry.ContextMap()
+ require.Equal(t, "success", fields["outcome"])
+ require.EqualValues(t, 10, fields["duration_ms"])
+ require.Equal(t, "fresh", fields["mode"])
+ }
+ completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
+ require.Len(t, completed, 1)
+ require.Equal(t, "success", completed[0].ContextMap()["outcome"])
+ require.EqualValues(t, 90, completed[0].ContextMap()["duration_ms"])
+ require.NotEmpty(t, completed[0].ContextMap()["attempt_id"])
+ require.Equal(t, "instance-1", completed[0].ContextMap()["instance_id"])
+ require.Equal(t, "task-1", completed[0].ContextMap()["task_id"])
+ require.Equal(t, "session-1", completed[0].ContextMap()["session_id"])
+}
+
+func TestKubernetesLaunchTimingFailures(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ configure func(*ExecutorCreateRequest, *fakeKubernetesResources, *recordingKubernetesExec)
+ failedStage string
+ outcome string
+ }{
+ {
+ name: "storage",
+ configure: func(req *ExecutorCreateRequest, resources *fakeKubernetesResources, _ *recordingKubernetesExec) {
+ setManagedKubernetesWorkspace(req)
+ resources.createPVCBeforeCommitErr = errors.New("storage failed")
+ },
+ failedStage: "storage",
+ outcome: "error",
+ },
+ {
+ name: "pod readiness",
+ configure: func(_ *ExecutorCreateRequest, resources *fakeKubernetesResources, _ *recordingKubernetesExec) {
+ resources.waitForPodRunning = func(context.Context, string, string, string) (*corev1.Pod, error) {
+ return nil, errors.New("pod readiness failed")
+ }
+ },
+ failedStage: "pod_ready",
+ outcome: "error",
+ },
+ {
+ name: "bootstrap cancellation",
+ configure: func(_ *ExecutorCreateRequest, _ *fakeKubernetesResources, execs *recordingKubernetesExec) {
+ execs.err = context.Canceled
+ },
+ failedStage: "bootstrap",
+ outcome: "canceled",
+ },
+ {
+ name: "agentctl connection",
+ configure: func(_ *ExecutorCreateRequest, _ *fakeKubernetesResources, _ *recordingKubernetesExec) {},
+ failedStage: "agentctl_connect",
+ outcome: "error",
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ resources := &fakeKubernetesResources{}
+ execs := &recordingKubernetesExec{}
+ executor, logs := observedKubernetesExecutorWith(
+ t, resources, execs, &recordingKubernetesForwarder{localPorts: map[uint16]uint16{}},
+ )
+ req := validKubernetesCreateRequest()
+ tt.configure(req, resources, execs)
+
+ _, err := executor.CreateInstance(context.Background(), req)
+
+ require.Error(t, err)
+ stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
+ require.NotEmpty(t, stages)
+ lastStage := stages[len(stages)-1].ContextMap()
+ require.Equal(t, tt.failedStage, lastStage["stage"])
+ require.Equal(t, tt.outcome, lastStage["outcome"])
+ completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
+ require.Len(t, completed, 1)
+ require.Equal(t, tt.outcome, completed[0].ContextMap()["outcome"])
+ require.Equal(t, tt.failedStage, completed[0].ContextMap()["failed_stage"])
+ })
+ }
+}
+
+func TestKubernetesLaunchTimingFinalization(t *testing.T) {
+ executor, logs := observedKubernetesExecutor(t)
+ finalizationErr := errors.New("finalization contained sentinel-secret")
+ req := validKubernetesCreateRequest()
+ req.CheckpointRuntimeInventory = func(_ context.Context, metadata map[string]interface{}) error {
+ if getMetadataString(metadata, MetadataKeyKubernetesInventoryState) ==
+ KubernetesInventoryStateReady {
+ return finalizationErr
+ }
+ return nil
+ }
+
+ _, err := executor.CreateInstance(context.Background(), req)
+
+ require.ErrorIs(t, err, finalizationErr)
+ stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
+ require.Len(t, stages, 4)
+ completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
+ require.Len(t, completed, 1)
+ require.Equal(t, "finalize", completed[0].ContextMap()["failure_site"])
+ require.NotContains(t, strings.Join(logMessages(logs), "\n"), "sentinel-secret")
+}
+
+func TestKubernetesLaunchTimingConcurrentAttempts(t *testing.T) {
+ timingOne := newKubernetesLaunchTiming(nil, &ExecutorCreateRequest{
+ InstanceID: "instance-one", TaskID: "task-one", SessionID: "session-one",
+ })
+ timingTwo := newKubernetesLaunchTiming(nil, &ExecutorCreateRequest{
+ InstanceID: "instance-two", TaskID: "task-two", SessionID: "session-two",
+ })
+ var wait sync.WaitGroup
+ for _, timing := range []*kubernetesLaunchTiming{timingOne, timingTwo} {
+ wait.Add(1)
+ go func(timing *kubernetesLaunchTiming) {
+ defer wait.Done()
+ timing.recordStage("storage", time.Now(), nil)
+ timing.complete(nil)
+ }(timing)
+ }
+ wait.Wait()
+ require.NotEqual(t, timingOne.attemptID, timingTwo.attemptID)
+ require.Equal(t, "instance-one", timingOne.instanceID)
+ require.Equal(t, "instance-two", timingTwo.instanceID)
+}
+
+func observedKubernetesExecutor(t *testing.T) (*KubernetesExecutor, *observer.ObservedLogs) {
+ t.Helper()
+ controlPort := startKubernetesAgentctlServer(t, true, 41001)
+ instancePort := startKubernetesAgentctlServer(t, false, 0)
+ return observedKubernetesExecutorWith(
+ t,
+ &fakeKubernetesResources{},
+ &recordingKubernetesExec{},
+ &recordingKubernetesForwarder{localPorts: map[uint16]uint16{
+ uint16(8765): controlPort,
+ 41001: instancePort,
+ }},
+ )
+}
+
+func observedKubernetesExecutorWith(
+ t *testing.T,
+ resources *fakeKubernetesResources,
+ execs *recordingKubernetesExec,
+ forwards *recordingKubernetesForwarder,
+) (*KubernetesExecutor, *observer.ObservedLogs) {
+ t.Helper()
+ core, logs := observer.New(zapcore.InfoLevel)
+ log, err := logger.NewFromZap(zap.New(core))
+ require.NoError(t, err)
+ executor := newFakeKubernetesExecutorWithForwarder(resources, execs, forwards)
+ executor.logger = log
+ executor.healthRetryDelay = time.Nanosecond
+ return executor, logs
+}
+
+func sequenceKubernetesTimingClock(start time.Time, calls int, step time.Duration) func() time.Time {
+ var mu sync.Mutex
+ index := 0
+ return func() time.Time {
+ mu.Lock()
+ defer mu.Unlock()
+ if index >= calls {
+ return start.Add(time.Duration(calls) * step)
+ }
+ current := start.Add(time.Duration(index) * step)
+ index++
+ return current
+ }
+}
+
+func kubernetesTimingEntries(logs *observer.ObservedLogs, message string) []observer.LoggedEntry {
+ return logs.FilterMessage(message).All()
+}
+
+func logMessages(logs *observer.ObservedLogs) []string {
+ entries := logs.All()
+ messages := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ messages = append(messages, entry.Message)
+ }
+ return messages
+}
diff --git a/apps/backend/internal/kubernetes/sessions.go b/apps/backend/internal/kubernetes/sessions.go
index cc599ac3e0..5907cfca55 100644
--- a/apps/backend/internal/kubernetes/sessions.go
+++ b/apps/backend/internal/kubernetes/sessions.go
@@ -19,15 +19,18 @@ import (
// SessionRow is a sanitized projection of one Kubernetes-backed running executor.
type SessionRow struct {
- SessionID string `json:"session_id"`
- TaskID string `json:"task_id"`
- PodName string `json:"pod_name,omitempty"`
- PodPhase string `json:"pod_phase,omitempty"`
- ContainerState string `json:"container_state,omitempty"`
- Restarts int32 `json:"restarts"`
- WorkspaceKind string `json:"workspace_kind,omitempty"`
- CreatedAt string `json:"created_at,omitempty"`
- FailureReason string `json:"failure_reason,omitempty"`
+ SessionID string `json:"session_id"`
+ TaskID string `json:"task_id"`
+ PodName string `json:"pod_name,omitempty"`
+ PodPhase string `json:"pod_phase,omitempty"`
+ ContainerState string `json:"container_state,omitempty"`
+ Restarts int32 `json:"restarts"`
+ WorkspaceKind string `json:"workspace_kind,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ FailureReason string `json:"failure_reason,omitempty"`
+ SessionState string `json:"session_state,omitempty"`
+ RetentionState string `json:"retention_state,omitempty"`
+ MainContainerRequests *MainContainerRequests `json:"main_container_requests,omitempty"`
}
// SessionImpact is the authoritative mutation impact of one Kubernetes executor.
@@ -186,6 +189,7 @@ func (h *Handler) sessionRow(
return SessionRow{}, false, err
}
row := newInventorySessionRow(run)
+ row.SessionState = projectedTaskSessionState(session.State)
if inventoryFailure := validateSessionInventory(run, executorID, row); inventoryFailure != "" {
row.FailureReason = inventoryFailure
return row, true, nil
@@ -194,22 +198,26 @@ func (h *Handler) sessionRow(
pod, err := client.CoreV1().Pods(namespace).Get(ctx, row.PodName, metav1.GetOptions{})
if err != nil {
row.FailureReason = podLookupFailure(err)
+ if apierrors.IsNotFound(err) {
+ row.RetentionState = kubernetesRetentionMissing
+ }
return row, true, nil
}
if !matchesSessionIdentity(pod, run) {
row.FailureReason = "Pod identity does not match runtime inventory"
return row, true, nil
}
- populatePodStatus(&row, pod, metadataString(run.Metadata, metadataMainContainer))
+ populatePodStatus(&row, pod, metadataString(run.Metadata, metadataMainContainer), session.State)
return row, true, nil
}
func newInventorySessionRow(run *models.ExecutorRunning) SessionRow {
row := SessionRow{
- SessionID: run.SessionID,
- TaskID: run.TaskID,
- PodName: metadataString(run.Metadata, metadataPodName),
- WorkspaceKind: metadataString(run.Metadata, metadataWorkspaceMode),
+ SessionID: run.SessionID,
+ TaskID: run.TaskID,
+ PodName: metadataString(run.Metadata, metadataPodName),
+ WorkspaceKind: metadataString(run.Metadata, metadataWorkspaceMode),
+ RetentionState: kubernetesRetentionUnknown,
}
if !run.CreatedAt.IsZero() {
row.CreatedAt = run.CreatedAt.UTC().Format(time.RFC3339)
@@ -279,30 +287,6 @@ func recordedResourceIdentity(metadata map[string]interface{}) (agentkubernetes.
return identity, true
}
-func populatePodStatus(row *SessionRow, pod *corev1.Pod, mainContainer string) {
- row.PodPhase = string(pod.Status.Phase)
- row.ContainerState = "unknown"
- failureReason := pod.Status.Reason
- for _, status := range pod.Status.ContainerStatuses {
- if status.Name != mainContainer {
- continue
- }
- row.Restarts = status.RestartCount
- switch {
- case status.State.Running != nil:
- row.ContainerState = "running"
- case status.State.Waiting != nil:
- row.ContainerState = "waiting"
- failureReason = status.State.Waiting.Reason
- case status.State.Terminated != nil:
- row.ContainerState = "terminated"
- failureReason = status.State.Terminated.Reason
- }
- break
- }
- row.FailureReason = sanitizedPodReason(failureReason)
-}
-
func metadataString(metadata map[string]interface{}, key string) string {
value, _ := metadata[key].(string)
return strings.TrimSpace(value)
diff --git a/apps/backend/internal/kubernetes/sessions_namespace_test.go b/apps/backend/internal/kubernetes/sessions_namespace_test.go
index aed0fc5b28..db2cf6d744 100644
--- a/apps/backend/internal/kubernetes/sessions_namespace_test.go
+++ b/apps/backend/internal/kubernetes/sessions_namespace_test.go
@@ -44,6 +44,7 @@ func TestListSessionsUsesRecordedNamespaceAfterExecutorNamespaceChange(t *testin
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
+ SessionState: "RUNNING", RetentionState: "active",
}}, rows)
require.Len(t, clientset.Actions(), 1)
getAction, ok := clientset.Actions()[0].(k8stesting.GetAction)
diff --git a/apps/backend/internal/kubernetes/sessions_retention.go b/apps/backend/internal/kubernetes/sessions_retention.go
new file mode 100644
index 0000000000..8de09b62b4
--- /dev/null
+++ b/apps/backend/internal/kubernetes/sessions_retention.go
@@ -0,0 +1,147 @@
+package kubernetes
+
+import (
+ corev1 "k8s.io/api/core/v1"
+
+ "github.com/kandev/kandev/internal/task/models"
+)
+
+const (
+ kubernetesRetentionActive = "active"
+ kubernetesRetentionRetained = "retained"
+ kubernetesRetentionTerminating = "terminating"
+ kubernetesRetentionTerminal = "terminal"
+ kubernetesRetentionMissing = "missing"
+ kubernetesRetentionUnknown = "unknown"
+)
+
+// MainContainerRequests contains only configured requests from the verified
+// Kandev main container. It does not represent observed usage or capacity.
+type MainContainerRequests struct {
+ CPU string `json:"cpu,omitempty"`
+ Memory string `json:"memory,omitempty"`
+}
+
+func projectedTaskSessionState(state models.TaskSessionState) string {
+ if isKnownTaskSessionState(state) {
+ return string(state)
+ }
+ return kubernetesRetentionUnknown
+}
+
+func isKnownTaskSessionState(state models.TaskSessionState) bool {
+ for _, known := range models.AllTaskSessionStates {
+ if state == known {
+ return true
+ }
+ }
+ return false
+}
+
+func populatePodStatus(
+ row *SessionRow,
+ pod *corev1.Pod,
+ mainContainer string,
+ sessionState models.TaskSessionState,
+) {
+ row.PodPhase = string(pod.Status.Phase)
+ row.ContainerState = "unknown"
+ failureReason := pod.Status.Reason
+ mainSpecFound := false
+ for _, container := range pod.Spec.Containers {
+ if container.Name != mainContainer {
+ continue
+ }
+ mainSpecFound = true
+ row.MainContainerRequests = requestsFromContainer(container)
+ break
+ }
+ if !mainSpecFound {
+ row.FailureReason = "Main container status is unavailable"
+ return
+ }
+
+ mainStatusFound := false
+ for _, status := range pod.Status.ContainerStatuses {
+ if status.Name != mainContainer {
+ continue
+ }
+ mainStatusFound = true
+ row.Restarts = status.RestartCount
+ switch {
+ case status.State.Running != nil:
+ row.ContainerState = "running"
+ case status.State.Waiting != nil:
+ row.ContainerState = "waiting"
+ failureReason = status.State.Waiting.Reason
+ case status.State.Terminated != nil:
+ row.ContainerState = "terminated"
+ failureReason = status.State.Terminated.Reason
+ }
+ break
+ }
+ if !mainStatusFound && failureReason == "" {
+ failureReason = "Main container status is unavailable"
+ }
+ row.FailureReason = sanitizedPodReason(failureReason)
+ row.RetentionState = kubernetesRetentionState(sessionState, pod)
+}
+
+func requestsFromContainer(container corev1.Container) *MainContainerRequests {
+ requests := &MainContainerRequests{}
+ if quantity, ok := container.Resources.Requests[corev1.ResourceCPU]; ok {
+ requests.CPU = quantity.String()
+ }
+ if quantity, ok := container.Resources.Requests[corev1.ResourceMemory]; ok {
+ requests.Memory = quantity.String()
+ }
+ if requests.CPU == "" && requests.Memory == "" {
+ return nil
+ }
+ return requests
+}
+
+func kubernetesRetentionState(sessionState models.TaskSessionState, pod *corev1.Pod) string {
+ if !isKnownTaskSessionState(sessionState) || pod == nil {
+ return kubernetesRetentionUnknown
+ }
+ if pod.DeletionTimestamp != nil {
+ return kubernetesRetentionTerminating
+ }
+ switch pod.Status.Phase {
+ case corev1.PodSucceeded, corev1.PodFailed:
+ return kubernetesRetentionTerminal
+ case corev1.PodPending, corev1.PodRunning:
+ if isRetainedTaskSessionState(sessionState) {
+ return kubernetesRetentionRetained
+ }
+ if isActiveTaskSessionState(sessionState) {
+ return kubernetesRetentionActive
+ }
+ }
+ return kubernetesRetentionUnknown
+}
+
+func isRetainedTaskSessionState(state models.TaskSessionState) bool {
+ switch state {
+ case models.TaskSessionStateCancelled,
+ models.TaskSessionStateCompleted,
+ models.TaskSessionStateFailed,
+ models.TaskSessionStateIdle:
+ return true
+ default:
+ return false
+ }
+}
+
+func isActiveTaskSessionState(state models.TaskSessionState) bool {
+ switch state {
+ case models.TaskSessionStateCreated,
+ models.TaskSessionStateStarting,
+ models.TaskSessionStateRunning,
+ models.TaskSessionStateWaitingForInput:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/apps/backend/internal/kubernetes/sessions_retention_test.go b/apps/backend/internal/kubernetes/sessions_retention_test.go
new file mode 100644
index 0000000000..349f74bbb6
--- /dev/null
+++ b/apps/backend/internal/kubernetes/sessions_retention_test.go
@@ -0,0 +1,210 @@
+package kubernetes
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kubernetesfake "k8s.io/client-go/kubernetes/fake"
+
+ agentkubernetes "github.com/kandev/kandev/internal/agent/kubernetes"
+ "github.com/kandev/kandev/internal/auth/authn"
+ "github.com/kandev/kandev/internal/task/models"
+ ws "github.com/kandev/kandev/pkg/websocket"
+)
+
+func TestKubernetesSessionRetentionProjectionTable(t *testing.T) {
+ tests := []struct {
+ name string
+ sessionState models.TaskSessionState
+ podPhase corev1.PodPhase
+ deleting bool
+ retentionState string
+ }{
+ {name: "created pending", sessionState: models.TaskSessionStateCreated, podPhase: corev1.PodPending, retentionState: "active"},
+ {name: "starting running", sessionState: models.TaskSessionStateStarting, podPhase: corev1.PodRunning, retentionState: "active"},
+ {name: "running pod", sessionState: models.TaskSessionStateRunning, podPhase: corev1.PodRunning, retentionState: "active"},
+ {name: "waiting running", sessionState: models.TaskSessionStateWaitingForInput, podPhase: corev1.PodRunning, retentionState: "active"},
+ {name: "cancelled running", sessionState: models.TaskSessionStateCancelled, podPhase: corev1.PodRunning, retentionState: "retained"},
+ {name: "idle running", sessionState: models.TaskSessionStateIdle, podPhase: corev1.PodRunning, retentionState: "retained"},
+ {name: "completed running", sessionState: models.TaskSessionStateCompleted, podPhase: corev1.PodRunning, retentionState: "retained"},
+ {name: "deleting active", sessionState: models.TaskSessionStateRunning, podPhase: corev1.PodRunning, deleting: true, retentionState: "terminating"},
+ {name: "succeeded retained state", sessionState: models.TaskSessionStateCancelled, podPhase: corev1.PodSucceeded, retentionState: "terminal"},
+ {name: "failed retained state", sessionState: models.TaskSessionStateFailed, podPhase: corev1.PodFailed, retentionState: "terminal"},
+ {name: "unknown session", sessionState: models.TaskSessionState("future-state"), podPhase: corev1.PodRunning, retentionState: "unknown"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
+ session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
+ session.State = test.sessionState
+ pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
+ pod.Status.Phase = test.podPhase
+ if test.deleting {
+ deletionTime := metav1.Now()
+ pod.DeletionTimestamp = &deletionTime
+ }
+ pod.Spec.Containers = []corev1.Container{{
+ Name: "kandev-agent",
+ Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
+ corev1.ResourceCPU: resource.MustParse("250m"),
+ corev1.ResourceMemory: resource.MustParse("512Mi"),
+ }},
+ }}
+ repo := &fakeResourceRepository{
+ executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
+ runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
+ }
+ clientset := kubernetesfake.NewSimpleClientset(pod)
+ handler := retentionProjectionHandler(repo, clientset)
+
+ row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
+
+ require.NoError(t, err)
+ require.True(t, visible)
+ wantSessionState := string(test.sessionState)
+ if !isKnownTaskSessionState(test.sessionState) {
+ wantSessionState = "unknown"
+ }
+ require.Equal(t, wantSessionState, row.SessionState)
+ require.Equal(t, test.retentionState, row.RetentionState)
+ require.Equal(t, &MainContainerRequests{CPU: "250m", Memory: "512Mi"}, row.MainContainerRequests)
+ })
+ }
+}
+
+func TestKubernetesRetentionProjectionOmitsUnverifiedRequests(t *testing.T) {
+ tests := []struct {
+ name string
+ pod *corev1.Pod
+ mainContainer string
+ retentionState string
+ failureReason string
+ }{
+ {name: "missing pod", retentionState: "missing", failureReason: "Pod not found"},
+ {name: "identity mismatch", pod: kubernetesOwnedPod("pod-1", "foreign-uid", "instance-1", kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")), retentionState: "unknown", failureReason: "Pod identity does not match runtime inventory"},
+ {name: "main container missing", pod: kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")), mainContainer: "missing-container", retentionState: "unknown", failureReason: "Main container status is unavailable"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
+ session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
+ session.State = models.TaskSessionStateCancelled
+ if test.mainContainer != "" {
+ run.Metadata[metadataMainContainer] = test.mainContainer
+ }
+ repo := &fakeResourceRepository{
+ executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
+ runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
+ }
+ var clientset *kubernetesfake.Clientset
+ if test.pod == nil {
+ clientset = kubernetesfake.NewSimpleClientset()
+ } else {
+ test.pod.Spec.Containers = []corev1.Container{{
+ Name: "kandev-agent",
+ Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
+ corev1.ResourceCPU: resource.MustParse("1"),
+ corev1.ResourceMemory: resource.MustParse("1Gi"),
+ }},
+ }}
+ clientset = kubernetesfake.NewSimpleClientset(test.pod)
+ }
+ handler := retentionProjectionHandler(repo, clientset)
+
+ row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
+
+ require.NoError(t, err)
+ require.True(t, visible)
+ require.Equal(t, string(models.TaskSessionStateCancelled), row.SessionState)
+ require.Equal(t, test.retentionState, row.RetentionState)
+ require.Nil(t, row.MainContainerRequests)
+ require.Equal(t, test.failureReason, row.FailureReason)
+ })
+ }
+}
+
+func TestKubernetesMainContainerRequestsPreserveExplicitZero(t *testing.T) {
+ run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
+ session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
+ session.State = models.TaskSessionStateRunning
+ pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
+ pod.Spec.Containers = []corev1.Container{{
+ Name: "sidecar", Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
+ corev1.ResourceCPU: resource.MustParse("9"), corev1.ResourceMemory: resource.MustParse("9Gi"),
+ }},
+ }, {
+ Name: "kandev-agent", Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
+ corev1.ResourceCPU: resource.MustParse("0"), corev1.ResourceMemory: resource.MustParse("512Mi"),
+ }},
+ }}
+ repo := &fakeResourceRepository{
+ executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
+ runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
+ }
+ clientset := kubernetesfake.NewSimpleClientset(pod)
+ handler := retentionProjectionHandler(repo, clientset)
+
+ row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
+
+ require.NoError(t, err)
+ require.True(t, visible)
+ require.Equal(t, &MainContainerRequests{CPU: "0", Memory: "512Mi"}, row.MainContainerRequests)
+}
+
+func TestKubernetesRetentionProjectionSerializesOverHTTPAndWebSocket(t *testing.T) {
+ run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
+ session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
+ session.State = models.TaskSessionStateCancelled
+ pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
+ pod.Spec.Containers = []corev1.Container{{Name: "kandev-agent", Resources: corev1.ResourceRequirements{
+ Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("250m")},
+ }}}
+ repo := &fakeResourceRepository{
+ executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
+ runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
+ }
+ clientset := kubernetesfake.NewSimpleClientset(pod)
+ handler := retentionProjectionHandler(repo, clientset)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ authn.SetOnGin(c, authn.Identity{UserID: "member-1", Role: authn.RoleMember})
+ c.Next()
+ })
+ handler.registerHTTP(router)
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/kubernetes/executors/executor-1/sessions", nil))
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ var rows []SessionRow
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rows))
+ require.Len(t, rows, 1)
+ require.Equal(t, "CANCELLED", rows[0].SessionState)
+ require.Equal(t, "retained", rows[0].RetentionState)
+ require.Equal(t, &MainContainerRequests{CPU: "250m"}, rows[0].MainContainerRequests)
+
+ dispatcher := ws.NewDispatcher()
+ handler.registerWS(dispatcher)
+ message, err := ws.NewRequest("req-1", "kubernetes.sessions.list", map[string]string{"executor_id": "executor-1"})
+ require.NoError(t, err)
+ response, err := dispatcher.Dispatch(authn.WithIdentity(context.Background(), authn.Identity{
+ UserID: "member-1", Role: authn.RoleMember,
+ }), message)
+ require.NoError(t, err)
+ require.Equal(t, ws.MessageTypeResponse, response.Type)
+ require.Contains(t, string(response.Payload), `"retention_state":"retained"`)
+}
+
+func retentionProjectionHandler(repo *fakeResourceRepository, clientset *kubernetesfake.Clientset) *Handler {
+ return NewHandler(repo, &fakeAccessChecker{}, func(agentkubernetes.ExecutorConfig) (*agentkubernetes.Client, error) {
+ return &agentkubernetes.Client{Clientset: clientset}, nil
+ })
+}
diff --git a/apps/backend/internal/kubernetes/sessions_test.go b/apps/backend/internal/kubernetes/sessions_test.go
index 5e07a05cf7..f9cc6099b4 100644
--- a/apps/backend/internal/kubernetes/sessions_test.go
+++ b/apps/backend/internal/kubernetes/sessions_test.go
@@ -67,6 +67,7 @@ func TestHTTPListSessionsAllowsMemberAndGetsOnlyAuthorizedRecordedPods(t *testin
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
+ SessionState: "RUNNING", RetentionState: "active",
}}, rows)
require.Equal(t, []string{"task-1", "task-2"}, access.calls)
require.Len(t, clientset.Actions(), 1)
@@ -455,6 +456,7 @@ func TestListSessionsFailsClosedOnPodIdentityMismatch(t *testing.T) {
require.Equal(t, []SessionRow{{
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
+ SessionState: "RUNNING", RetentionState: "unknown",
FailureReason: "Pod identity does not match runtime inventory",
}}, rows)
}
@@ -489,6 +491,7 @@ func TestListSessionsUsesAuthoritativeRunningExecutorAfterSessionRepoint(t *test
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
+ SessionState: "RUNNING", RetentionState: "active",
}}, rows)
}
@@ -710,6 +713,7 @@ func kubernetesTaskSession(sessionID, taskID, executorID, profileID string) *mod
return &models.TaskSession{
ID: sessionID, TaskID: taskID, ExecutorID: executorID,
ExecutorProfileID: profileID, TaskEnvironmentID: "environment-1",
+ State: models.TaskSessionStateRunning,
}
}
@@ -736,6 +740,7 @@ func kubernetesOwnedPodForIdentity(
ObjectMeta: metav1.ObjectMeta{
Name: podName, Namespace: "kandev", UID: types.UID(podUID), Labels: labels,
},
+ Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "kandev-agent"}}},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
ContainerStatuses: []corev1.ContainerStatus{{
diff --git a/apps/web/components/settings/kubernetes-session-lists.tsx b/apps/web/components/settings/kubernetes-session-lists.tsx
new file mode 100644
index 0000000000..d1f3e87d08
--- /dev/null
+++ b/apps/web/components/settings/kubernetes-session-lists.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { Fragment, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@kandev/ui/button";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
+import { IconChevronDown } from "@tabler/icons-react";
+import AppLink from "@/components/routing/app-link";
+import type { KubernetesSession } from "@/lib/types/http-kubernetes";
+import {
+ formatCreatedAt,
+ shortId,
+ taskHref,
+ translateTaskLink,
+ workspaceLabel,
+} from "./kubernetes-session-utils";
+import {
+ RequestSummary,
+ SessionDetails,
+ SessionIdentity,
+ SessionRuntimeDetails,
+ SessionStatusSummary,
+} from "./kubernetes-session-presentation";
+
+export function MobileSessionList({ sessions }: { sessions: KubernetesSession[] }) {
+ const { t } = useTranslation();
+ return (
+
+ {sessions.map((session) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) {
+ const { t } = useTranslation();
+ const [expandedSessionId, setExpandedSessionId] = useState(null);
+ return (
+
+
+
+
+ {t("executors:task")}
+ {t("executors:session")}
+ {t("executors:kubernetesPod")}
+ {t("executors:status")}
+ {t("executors:kubernetesRequests")}
+ {t("executors:kubernetesCreated")}
+
+
+
+ {sessions.map((session) => {
+ const expanded = expandedSessionId === session.session_id;
+ return (
+
+
+
+
+ {shortId(session.task_id)}
+
+
+
+ {shortId(session.session_id)}
+
+
+ {session.pod_name || "-"}
+
+ {workspaceLabel(session.workspace_kind, t)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatCreatedAt(session.created_at)}
+
+
+ {expanded && (
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/kubernetes-session-presentation.tsx b/apps/web/components/settings/kubernetes-session-presentation.tsx
new file mode 100644
index 0000000000..213b720769
--- /dev/null
+++ b/apps/web/components/settings/kubernetes-session-presentation.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import { Badge } from "@kandev/ui/badge";
+import type { KubernetesSession } from "@/lib/types/http-kubernetes";
+import {
+ normalizedState,
+ mainContainerStateValue,
+ podPhaseValue,
+ podStatusLabel,
+ retentionLabel,
+ sessionStateLabel,
+ formatCreatedAt,
+ workspaceLabel,
+} from "./kubernetes-session-utils";
+
+export function SessionIdentity({ session }: { session: KubernetesSession }) {
+ const { t } = useTranslation();
+ return (
+
+
{session.pod_name || "-"}
+
+
+
+ );
+}
+
+function SessionIdentityValue({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}:
+ {value}
+
+ );
+}
+
+export function SessionStatusSummary({ session }: { session: KubernetesSession }) {
+ const { t } = useTranslation();
+ const sessionState = normalizedState(session.session_state);
+ const retentionState = normalizedState(session.retention_state);
+ const podPhase = podPhaseValue(session);
+ const mainContainerState = mainContainerStateValue(session);
+ return (
+
+
+
+ {sessionStateLabel(sessionState, t)}
+
+
+ {retentionLabel(retentionState, t)}
+
+
+
+
+ {t("executors:kubernetesPodCompactValue", { value: podStatusLabel(podPhase, t) })}
+
+ ·
+
+ {t("executors:kubernetesContainerCompactValue", {
+ value: podStatusLabel(mainContainerState, t),
+ })}
+
+ {session.restarts > 0 && (
+ <>
+ ·
+
+ >
+ )}
+
+
+ );
+}
+
+export function SessionRuntimeDetails({ session }: { session: KubernetesSession }) {
+ const { t } = useTranslation();
+ const retentionState = normalizedState(session.retention_state);
+ const podPhase = podPhaseValue(session);
+ const mainContainerState = mainContainerStateValue(session);
+ return (
+
+
+ {t("executors:kubernetesSessionStateValue", {
+ value: sessionStateLabel(session.session_state, t),
+ })}
+
+
+ {t("executors:kubernetesRetentionStateValue", {
+ value: retentionLabel(retentionState, t),
+ })}
+
+
{t("executors:kubernetesPodStateValue", { value: podStatusLabel(podPhase, t) })}
+
+ {t("executors:kubernetesContainerStateValue", {
+ value: podStatusLabel(mainContainerState, t),
+ })}
+
+
{t("executors:kubernetesRestarts", { count: session.restarts })}
+
+ {t("executors:kubernetesWorkspaceValue", {
+ value: workspaceLabel(session.workspace_kind, t),
+ })}
+
+
{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}
+
+
+
+ );
+}
+
+export function RequestSummary({
+ session,
+ showLabel = false,
+}: {
+ session: KubernetesSession;
+ showLabel?: boolean;
+}) {
+ const { t } = useTranslation();
+ const requests = session.main_container_requests;
+ const values = [
+ requests?.cpu ? t("executors:kubernetesRequestCpu", { value: requests.cpu }) : null,
+ requests?.memory ? t("executors:kubernetesRequestMemory", { value: requests.memory }) : null,
+ ].filter((value): value is string => Boolean(value));
+ return (
+
+ {showLabel && {t("executors:kubernetesMainContainerRequests")}: }
+ {values.length > 0 ? values.join(" · ") : t("executors:kubernetesRequestsUnspecifiedValue")}
+
+ );
+}
+
+function RestartCount({ count }: { count: number }) {
+ const { t } = useTranslation();
+ return {t("executors:kubernetesRestarts", { count })};
+}
+
+export function SessionDetails({ session }: { session: KubernetesSession }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("executors:kubernetesWorkspaceValue", {
+ value: workspaceLabel(session.workspace_kind, t),
+ })}
+
+
{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}
+
+
+ );
+}
+
+export function SessionFailureReason({ session }: { session: KubernetesSession }) {
+ return session.failure_reason ? (
+ {session.failure_reason}
+ ) : null;
+}
diff --git a/apps/web/components/settings/kubernetes-session-utils.ts b/apps/web/components/settings/kubernetes-session-utils.ts
new file mode 100644
index 0000000000..6d650f288a
--- /dev/null
+++ b/apps/web/components/settings/kubernetes-session-utils.ts
@@ -0,0 +1,100 @@
+import type { TFunction } from "i18next";
+import { formatDateTime } from "@/lib/i18n/formats";
+import type { KubernetesSession } from "@/lib/types/http-kubernetes";
+
+const POD_STATUS_LABEL_KEYS: Record = {
+ running: "executors:kubernetesStatusRunning",
+ waiting: "executors:kubernetesStatusWaiting",
+ terminated: "executors:kubernetesStatusTerminated",
+ unknown: "executors:kubernetesStatusUnknown",
+ pending: "executors:kubernetesStatusPending",
+ succeeded: "executors:kubernetesStatusSucceeded",
+ failed: "executors:kubernetesStatusFailed",
+};
+
+const RETENTION_LABEL_KEYS: Record = {
+ active: "executors:kubernetesRetentionActive",
+ retained: "executors:kubernetesRetentionRetained",
+ terminating: "executors:kubernetesRetentionTerminating",
+ terminal: "executors:kubernetesRetentionTerminal",
+ missing: "executors:kubernetesRetentionMissing",
+ unknown: "executors:kubernetesRetentionUnknown",
+};
+
+const SESSION_STATE_LABEL_KEYS: Record = {
+ created: "executors:kubernetesSessionCreated",
+ starting: "executors:kubernetesSessionStarting",
+ running: "executors:kubernetesSessionRunning",
+ idle: "executors:kubernetesSessionIdle",
+ waiting_for_input: "executors:kubernetesSessionWaitingForInput",
+ completed: "executors:kubernetesSessionCompleted",
+ failed: "executors:kubernetesSessionFailed",
+ cancelled: "executors:kubernetesSessionCancelled",
+ unknown: "executors:kubernetesStatusUnknown",
+};
+
+export function workspaceLabel(value: string | undefined, t: TFunction): string {
+ const keys: Record = {
+ managed_pvc: "executors:kubernetesWorkspaceManagedPvc",
+ empty_dir: "executors:kubernetesWorkspaceEmptyDir",
+ existing_claim: "executors:kubernetesWorkspaceExistingClaim",
+ };
+ return value && keys[value] ? t(keys[value]) : value || "-";
+}
+
+export function podStatusLabel(value: string, t: TFunction): string {
+ const key = POD_STATUS_LABEL_KEYS[value.toLowerCase()];
+ return key ? t(key) : t("executors:kubernetesStatusUnknown");
+}
+
+export function retentionLabel(value: string, t: TFunction): string {
+ return t(RETENTION_LABEL_KEYS[value] ?? RETENTION_LABEL_KEYS.unknown);
+}
+
+export function sessionStateLabel(value: string | undefined, t: TFunction): string {
+ const state = normalizedState(value);
+ return t(SESSION_STATE_LABEL_KEYS[state] ?? SESSION_STATE_LABEL_KEYS.unknown);
+}
+
+export function normalizedState(value?: string): string {
+ return value?.trim().toLowerCase() || "unknown";
+}
+
+export function podStatusValue(session: KubernetesSession): string {
+ if (
+ session.container_state?.toLowerCase() === "terminated" &&
+ session.pod_phase?.toLowerCase() === "succeeded"
+ ) {
+ return session.pod_phase;
+ }
+ return session.container_state || session.pod_phase || "unknown";
+}
+
+export function podPhaseValue(session: KubernetesSession): string {
+ return session.pod_phase?.trim() || "unknown";
+}
+
+export function mainContainerStateValue(session: KubernetesSession): string {
+ return session.container_state?.trim() || "unknown";
+}
+
+export function translateTaskLink(session: KubernetesSession, t: TFunction): string {
+ return t("executors:kubernetesOpenTask", {
+ task: session.task_id,
+ session: session.session_id,
+ });
+}
+
+export function taskHref(taskId: string): string {
+ return `/t/${encodeURIComponent(taskId)}`;
+}
+
+export function shortId(value: string): string {
+ return value.slice(0, 8);
+}
+
+export function formatCreatedAt(value?: string): string {
+ if (!value) return "-";
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? value : formatDateTime(date);
+}
diff --git a/apps/web/components/settings/kubernetes-sessions-card.test.tsx b/apps/web/components/settings/kubernetes-sessions-card.test.tsx
new file mode 100644
index 0000000000..996c5d9c3c
--- /dev/null
+++ b/apps/web/components/settings/kubernetes-sessions-card.test.tsx
@@ -0,0 +1,95 @@
+import { cleanup, render, screen, within } from "@testing-library/react";
+import type { ComponentProps } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { KubernetesSessionsCard } from "./kubernetes-sessions-card";
+
+const responsive = vi.hoisted(() => ({ isMobile: false }));
+
+vi.mock("@/hooks/use-responsive-breakpoint", () => ({
+ useResponsiveBreakpoint: () => ({ isMobile: responsive.isMobile }),
+}));
+
+afterEach(cleanup);
+
+describe("KubernetesSessionsCard", () => {
+ it("shows retained state and main-container requests on both layouts", () => {
+ const state = sessionsState();
+ const rendered = render();
+
+ expect(screen.getByTestId("kubernetes-sessions-table").textContent).toContain("Retained");
+ expect(screen.getByTestId("kubernetes-session-request-summary").textContent).toContain(
+ "0 CPU · 512Mi memory",
+ );
+ expect(screen.getByTestId("kubernetes-session-guidance").textContent).toContain(
+ "Stop preserves Kubernetes resources",
+ );
+ expect(screen.getByTestId("kubernetes-session-guidance").textContent).toContain(
+ "Existing claims are not deleted by Kandev",
+ );
+ const desktopLink = screen.getByTestId("kubernetes-session-task-link");
+ expect(desktopLink.getAttribute("href")).toBe("/t/task-retained");
+ expect(desktopLink.getAttribute("aria-label")).toBe(
+ "Open task task-retained, session session-retained",
+ );
+
+ responsive.isMobile = true;
+ rendered.rerender();
+ const mobile = screen.getByTestId("kubernetes-mobile-session-list");
+ expect(within(mobile).getByText("Retained")).toBeTruthy();
+ expect(within(mobile).getByTestId("kubernetes-session-request-summary").textContent).toContain(
+ "0 CPU · 512Mi memory",
+ );
+ const mobileLink = within(mobile).getByRole("link");
+ expect(mobileLink.getAttribute("href")).toBe("/t/task-retained");
+ expect(mobileLink.getAttribute("aria-label")).toBe(
+ "Open task task-retained, session session-retained",
+ );
+ expect(mobileLink.className).toContain("cursor-pointer");
+ });
+
+ it("shows Pod phase and main-container state separately on both layouts", () => {
+ responsive.isMobile = false;
+ const state = sessionsState();
+ state.sessions[0] = {
+ ...state.sessions[0],
+ pod_phase: "Pending",
+ container_state: "waiting",
+ };
+
+ const rendered = render();
+ const desktopStatus = screen.getByTestId("kubernetes-session-status");
+ expect(within(desktopStatus).getByText("Pod: Pending")).toBeTruthy();
+ expect(within(desktopStatus).getByText("Container: Waiting")).toBeTruthy();
+
+ responsive.isMobile = true;
+ rendered.rerender();
+ const mobile = screen.getByTestId("kubernetes-mobile-session-list");
+ expect(within(mobile).getByText("Pod: Pending")).toBeTruthy();
+ expect(within(mobile).getByText("Container: Waiting")).toBeTruthy();
+ });
+});
+
+type KubernetesSessionsState = ComponentProps["state"];
+
+function sessionsState(): KubernetesSessionsState {
+ return {
+ sessions: [
+ {
+ task_id: "task-retained",
+ session_id: "session-retained",
+ pod_name: "kandev-retained",
+ pod_phase: "Running",
+ container_state: "running",
+ restarts: 0,
+ workspace_kind: "managed_pvc",
+ created_at: "2026-08-24T10:00:00Z",
+ session_state: "CANCELLED",
+ retention_state: "retained",
+ main_container_requests: { cpu: "0", memory: "512Mi" },
+ },
+ ],
+ loading: false,
+ error: null,
+ refresh: vi.fn(async () => []),
+ };
+}
diff --git a/apps/web/components/settings/kubernetes-sessions-card.tsx b/apps/web/components/settings/kubernetes-sessions-card.tsx
index c4f4995cb9..9ac72d2efa 100644
--- a/apps/web/components/settings/kubernetes-sessions-card.tsx
+++ b/apps/web/components/settings/kubernetes-sessions-card.tsx
@@ -1,29 +1,15 @@
"use client";
import { useTranslation } from "react-i18next";
-import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconLoader2 } from "@tabler/icons-react";
+import { useKubernetesSessions } from "@/hooks/domains/settings/use-kubernetes-settings";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { DesktopSessionTable, MobileSessionList } from "./kubernetes-session-lists";
import { SettingsCard } from "./settings-card";
import { SettingsCardHeader } from "./settings-card-header";
import { settingsActionClassName } from "./settings-control";
-import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
-import type { useKubernetesSessions } from "@/hooks/domains/settings/use-kubernetes-settings";
-import { formatDateTime } from "@/lib/i18n/formats";
-import { i18n, t as translate } from "@/lib/i18n";
-import type { KubernetesSession } from "@/lib/types/http-kubernetes";
-
-const STATUS_LABEL_KEYS: Record = {
- running: "executors:kubernetesStatusRunning",
- waiting: "executors:kubernetesStatusWaiting",
- terminated: "executors:kubernetesStatusTerminated",
- unknown: "executors:kubernetesStatusUnknown",
- pending: "executors:kubernetesStatusPending",
- succeeded: "executors:kubernetesStatusSucceeded",
- failed: "executors:kubernetesStatusFailed",
-};
type KubernetesSessionsState = ReturnType;
@@ -54,6 +40,7 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta
}
/>
+ {!state.error && }
{Boolean(state.error) && (
{errorMessage}
)}
@@ -74,158 +61,14 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta
);
}
-function MobileSessionList({ sessions }: { sessions: KubernetesSession[] }) {
- return (
-
- {sessions.map((session) => (
-
-
-
-
-
- ))}
-
- );
-}
-
-function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) {
+function SessionGuidance() {
const { t } = useTranslation();
return (
-
-
-
-
- {t("executors:task")}
- {t("executors:session")}
- {t("executors:kubernetesPod")}
- {t("executors:status")}
- {t("executors:kubernetesWorkspace")}
- {t("executors:kubernetesCreated")}
-
-
-
- {sessions.map((session) => (
-
- {shortId(session.task_id)}
- {shortId(session.session_id)}
-
- {session.pod_name || "-"}
-
-
-
-
-
-
-
- {workspaceLabel(session.workspace_kind)}
-
- {formatCreatedAt(session.created_at)}
-
-
- ))}
-
-
-
+
+ {t("executors:kubernetesActiveSessionsGuidance")}
+
);
}
-
-function SessionIdentity({ session }: { session: KubernetesSession }) {
- const { t } = useTranslation();
- return (
-
-
-
{t("executors:kubernetesPod")}
-
{session.pod_name || "-"}
-
-
-
-
-
-
- );
-}
-
-function SessionIdentityValue({ label, value }: { label: string; value: string }) {
- return (
-
- );
-}
-
-function SessionStatus({ session }: { session: KubernetesSession }) {
- const status =
- session.container_state?.toLowerCase() === "terminated" &&
- session.pod_phase?.toLowerCase() === "succeeded"
- ? session.pod_phase
- : session.container_state || session.pod_phase || "unknown";
- const running = status.toLowerCase() === "running";
- return (
-
- {statusLabel(status)}
- {session.restarts > 0 && }
-
- );
-}
-
-function RestartCount({ count }: { count: number }) {
- const { t } = useTranslation();
- return (
-
- {t("executors:kubernetesRestarts", { count })}
-
- );
-}
-
-function SessionDetails({ session }: { session: KubernetesSession }) {
- const { t } = useTranslation();
- return (
-
-
- {t("executors:kubernetesWorkspaceValue", {
- value: workspaceLabel(session.workspace_kind),
- })}
-
-
{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}
-
-
- );
-}
-
-function SessionFailureReason({ session }: { session: KubernetesSession }) {
- return session.failure_reason ? (
- {session.failure_reason}
- ) : null;
-}
-
-function workspaceLabel(value?: string): string {
- const keys: Record = {
- managed_pvc: "executors:kubernetesWorkspaceManagedPvc",
- empty_dir: "executors:kubernetesWorkspaceEmptyDir",
- existing_claim: "executors:kubernetesWorkspaceExistingClaim",
- };
- return value && keys[value] ? translationLabel(keys[value]) : value || "-";
-}
-
-function statusLabel(value: string): string {
- const key = STATUS_LABEL_KEYS[value.toLowerCase()];
- return key ? translationLabel(key, value) : value;
-}
-
-function translationLabel(key: string, fallback?: string): string {
- return i18n.exists(key) ? translate(key) : (fallback ?? key);
-}
-
-function shortId(value: string): string {
- return value.slice(0, 8);
-}
-
-function formatCreatedAt(value?: string): string {
- if (!value) return "-";
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? value : formatDateTime(date);
-}
diff --git a/apps/web/components/settings/kubernetes-settings-cards.test.tsx b/apps/web/components/settings/kubernetes-settings-cards.test.tsx
index de9aec0041..a66ea96097 100644
--- a/apps/web/components/settings/kubernetes-settings-cards.test.tsx
+++ b/apps/web/components/settings/kubernetes-settings-cards.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen, within } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KubernetesConnectionCard } from "./kubernetes-connection-card";
@@ -106,8 +106,29 @@ describe("Kubernetes settings cards", () => {
const card = screen.getByTestId("kubernetes-mobile-session-list");
expect(within(card).getByText("task-123456789")).toBeTruthy();
expect(within(card).getByText("session-987654321")).toBeTruthy();
- expect(within(card).getByText("Task")).toBeTruthy();
- expect(within(card).getByText("Session")).toBeTruthy();
+ expect(within(card).getByText(/^Task:/)).toBeTruthy();
+ expect(within(card).getByText(/^Session:/)).toBeTruthy();
+ });
+
+ it("shows retained requests, guidance, and task navigation on mobile", () => {
+ responsive.isMobile = true;
+ const state = sessionsState();
+ state.sessions[0] = {
+ ...state.sessions[0],
+ session_state: "CANCELLED",
+ retention_state: "retained",
+ main_container_requests: { cpu: "0", memory: "512Mi" },
+ };
+
+ render();
+
+ const card = screen.getByTestId("kubernetes-mobile-session-list");
+ expect(within(card).getByText("Retained")).toBeTruthy();
+ expect(within(card).getByTestId("kubernetes-session-request-summary").textContent).toContain(
+ "0 CPU · 512Mi memory",
+ );
+ expect(within(card).getByRole("link").getAttribute("href")).toBe("/t/task-123456789");
+ expect(screen.getByText(/Stop preserves Kubernetes resources/)).toBeTruthy();
});
});
@@ -118,8 +139,8 @@ describe("Kubernetes session status cards", () => {
const row = screen.getByText("task-123").closest("tr");
expect(row).not.toBeNull();
- const statusCell = within(row as HTMLTableRowElement).getByTestId("kubernetes-session-status");
- expect(within(statusCell).getByText("pods is forbidden: RBAC denied")).toBeTruthy();
+ fireEvent.click(within(row as HTMLTableRowElement).getByLabelText("Show session details"));
+ expect(screen.getByText("pods is forbidden: RBAC denied")).toBeTruthy();
});
it("labels a successfully completed Pod as succeeded instead of terminated", () => {
@@ -134,8 +155,9 @@ describe("Kubernetes session status cards", () => {
render();
- expect(screen.getByText("Succeeded")).toBeTruthy();
- expect(screen.queryByText("Terminated")).toBeNull();
+ const status = screen.getByTestId("kubernetes-session-status-summary");
+ expect(within(status).getByText("Pod: Succeeded")).toBeTruthy();
+ expect(within(status).getByText("Container: Terminated")).toBeTruthy();
});
});
@@ -154,6 +176,9 @@ function sessionsState(): KubernetesSessionsState {
workspace_kind: "managed_pvc",
created_at: "2026-08-24T10:00:00Z",
failure_reason: "pods is forbidden: RBAC denied",
+ session_state: "RUNNING",
+ retention_state: "active",
+ main_container_requests: { cpu: "250m", memory: "1Gi" },
},
],
loading: false,
diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts
index ebeda5e067..8d2775f756 100644
--- a/apps/web/e2e/fixtures/backend.ts
+++ b/apps/web/e2e/fixtures/backend.ts
@@ -50,6 +50,8 @@ export type BackendContext = {
frontendPort: number;
frontendUrl: string;
tmpDir: string;
+ /** Active structured backend log for assertions that need Info records. */
+ logPath: string;
/** Current backend PID, exposed for process-owned socket assertions. */
pid: () => number | undefined;
/**
@@ -178,6 +180,27 @@ type BackendFixtureLifecycle = {
removeTempRoot?: (tmpDir: string) => void;
};
+type BackendProcess = ChildProcess & {
+ waitForLogFile?: () => Promise;
+};
+
+type LogFileStream = Pick;
+
+export function observeLogFile(logFile: LogFileStream): () => Promise {
+ let logFileError: Error | undefined;
+ const logFileClosed = new Promise((resolve) => {
+ logFile.once("close", resolve);
+ });
+ logFile.on("error", (error: Error) => {
+ logFileError ??= error;
+ });
+
+ return async () => {
+ await logFileClosed;
+ if (logFileError) throw logFileError;
+ };
+}
+
function removeOwnedTempRoot(tmpDir: string): void {
fs.rmSync(tmpDir, {
recursive: true,
@@ -189,18 +212,20 @@ function removeOwnedTempRoot(tmpDir: string): void {
export async function runOwnedBackendFixture(
tmpDir: string,
- run: (registerProcess: (proc: ChildProcess) => void) => Promise,
+ run: (registerProcess: (proc: BackendProcess) => void) => Promise,
lifecycle: BackendFixtureLifecycle = {},
): Promise {
const stopProcess = lifecycle.stopProcess ?? killProcessGroup;
const removeTempRoot = lifecycle.removeTempRoot ?? removeOwnedTempRoot;
- let backendProc: ChildProcess | undefined;
+ let backendProc: BackendProcess | undefined;
+ const backendProcesses: BackendProcess[] = [];
let result: T | undefined;
const failures: unknown[] = [];
try {
result = await run((proc) => {
backendProc = proc;
+ backendProcesses.push(proc);
});
} catch (error) {
failures.push(error);
@@ -214,6 +239,15 @@ export async function runOwnedBackendFixture(
}
}
+ for (const proc of backendProcesses) {
+ if (!proc.waitForLogFile) continue;
+ try {
+ await proc.waitForLogFile();
+ } catch (error) {
+ failures.push(error);
+ }
+ }
+
try {
removeTempRoot(tmpDir);
} catch (error) {
@@ -236,31 +270,37 @@ function spawnBackendProcess(
env: Record,
debug: boolean,
port: number,
-): ChildProcess {
+ logPath: string,
+): BackendProcess {
const proc = spawn(KANDEV_BIN, ["__backend"], {
env: env as unknown as NodeJS.ProcessEnv,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
- const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${port}.log`) : null;
- proc.once("exit", () => {
- logFile?.end();
- });
+ const logFile = fs.createWriteStream(logPath, { flags: "a" });
+ const waitForLogFile = observeLogFile(logFile);
+ const closeLogFile = () => {
+ if (!logFile.writableEnded) logFile.end();
+ };
+ proc.once("close", closeLogFile);
+ proc.once("error", closeLogFile);
proc.stderr?.on("data", (chunk: Buffer) => {
+ logFile.write(chunk);
if (debug) {
process.stderr.write(`[backend:${port}] ${chunk.toString()}`);
- logFile?.write(chunk);
}
});
proc.stdout?.on("data", (chunk: Buffer) => {
+ logFile.write(chunk);
if (debug) {
process.stderr.write(`[backend-log:${port}] ${chunk.toString()}`);
- logFile?.write(chunk);
}
});
- return proc;
+ return Object.assign(proc, {
+ waitForLogFile,
+ });
}
/**
@@ -278,6 +318,8 @@ export const backendFixture = base.extend