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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
/// Время последнего обновления.
public let updatedAt: Date

/// Время остановки сессии. nil если сессия ещё не остановлена.
public let stoppedAt: Date?

enum CodingKeys: String, CodingKey {
case id
case projectId = "project_id"
Expand All @@ -49,6 +52,7 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
case errorReason = "error_reason"
case createdAt = "created_at"
case updatedAt = "updated_at"
case stoppedAt = "stopped_at"
}

public init(
Expand All @@ -62,7 +66,8 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
workDir: String = "",
errorReason: String? = nil,
createdAt: Date,
updatedAt: Date
updatedAt: Date,
stoppedAt: Date? = nil
) {
self.id = id
self.projectId = projectId
Expand All @@ -75,5 +80,6 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
self.errorReason = errorReason
self.createdAt = createdAt
self.updatedAt = updatedAt
self.stoppedAt = stoppedAt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab
public let profile: String

/// Текущее состояние терминала.
public let state: String
public let state: TerminalState

/// Время создания терминала.
public let createdAt: Date
Expand All @@ -36,7 +36,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab
id: String,
sessionId: String,
profile: String,
state: String,
state: TerminalState,
createdAt: Date,
updatedAt: Date
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

// MARK: - TerminalState

/// Состояние PTY-терминала внутри сессии.
public enum TerminalState: String, Codable, Equatable, Hashable, Sendable {
/// Терминал запускается.
case starting

/// Терминал готов к вводу.
case running

/// Терминал остановлен.
case stopped

public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = TerminalState(rawValue: rawValue) ?? .stopped
}
}
193 changes: 193 additions & 0 deletions MacApp/RelayTests/DomainModelAlignmentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import Foundation
import SharedModels
import Testing

// Tests for domain model alignment changes introduced in PR #289:
// - D-13: RemoteSession.stoppedAt (was missing; server already sent stopped_at)
// - D-15: TerminalState enum (was untyped String in RemoteTerminal)

@Suite("Domain Model Alignment")
struct DomainModelAlignmentTests {

private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()

private let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()

// MARK: - RemoteSession.stoppedAt (D-13)

@Test("stoppedAt: decodes stopped_at from JSON")
func stoppedAtDecodesFromJSON() throws {
let json = Data("""
{
"id": "sess-d13-1",
"project_id": "proj-001",
"branch": "main",
"image": "ubuntu:24.04",
"state": "stopped",
"work_dir": "/data/sessions/sess-d13-1",
"stopped_at": "2026-04-14T15:00:00Z",
"created_at": "2026-04-14T10:00:00Z",
"updated_at": "2026-04-14T15:00:00Z"
}
""".utf8)

let session = try decoder.decode(RemoteSession.self, from: json)

#expect(session.state == .stopped)
#expect(session.stoppedAt != nil)
}

@Test("stoppedAt: nil when field absent in JSON")
func stoppedAtNilWhenAbsent() throws {
let json = Data("""
{
"id": "sess-d13-2",
"project_id": "proj-001",
"branch": "main",
"image": "ubuntu:24.04",
"state": "running",
"work_dir": "/data/sessions/sess-d13-2",
"created_at": "2026-04-14T10:00:00Z",
"updated_at": "2026-04-14T10:00:00Z"
}
""".utf8)

let session = try decoder.decode(RemoteSession.self, from: json)

#expect(session.state == .running)
#expect(session.stoppedAt == nil)
}

@Test("stoppedAt: encode/decode roundtrip preserves value")
func stoppedAtRoundtrip() throws {
let stopDate = Date(timeIntervalSince1970: 1_744_639_200)
let original = RemoteSession(
id: "sess-d13-3",
projectId: "proj-001",
branch: "main",
state: .stopped,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: stopDate,
stoppedAt: stopDate
)

let encoded = try encoder.encode(original)
let decoded = try decoder.decode(RemoteSession.self, from: encoded)

#expect(decoded.stoppedAt != nil)
// Compare seconds to avoid sub-second ISO-8601 precision loss
#expect(decoded.stoppedAt?.timeIntervalSince1970 == stopDate.timeIntervalSince1970)
}

@Test("stoppedAt: nil when session is running — roundtrip")
func stoppedAtNilForRunningRoundtrip() throws {
let original = RemoteSession(
id: "sess-d13-4",
projectId: "proj-001",
branch: "main",
state: .running,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 60)
)

let encoded = try encoder.encode(original)
let decoded = try decoder.decode(RemoteSession.self, from: encoded)

#expect(decoded.stoppedAt == nil)
}

// MARK: - TerminalState (D-15)

@Test("TerminalState: all three values decode correctly")
func terminalStateAllValues() throws {
let json = Data("""
{ "states": ["starting", "running", "stopped"] }
""".utf8)

struct Wrapper: Decodable { let states: [TerminalState] }
let result = try decoder.decode(Wrapper.self, from: json)

#expect(result.states == [.starting, .running, .stopped])
}

@Test("TerminalState: unknown value falls back to .stopped without throw")
func terminalStateUnknownFallback() throws {
let json = Data("""
{ "states": ["paused", "zombie", ""] }
""".utf8)

struct Wrapper: Decodable { let states: [TerminalState] }
let result = try decoder.decode(Wrapper.self, from: json)

#expect(result.states.allSatisfy { $0 == .stopped })
}

// MARK: - RemoteTerminal (D-2 / D-15)

@Test("RemoteTerminal: decodes all fields including typed state")
func remoteTerminalDecodes() throws {
let json = Data("""
{
"id": "term-001",
"session_id": "sess-001",
"profile": "claude-code",
"state": "running",
"created_at": "2026-04-13T10:00:00Z",
"updated_at": "2026-04-13T10:30:00Z"
}
""".utf8)

let terminal = try decoder.decode(RemoteTerminal.self, from: json)

#expect(terminal.id == "term-001")
#expect(terminal.sessionId == "sess-001")
#expect(terminal.profile == "claude-code")
#expect(terminal.state == .running)
}

@Test("RemoteTerminal: sessionId FK is preserved")
func remoteTerminalSessionIdFK() throws {
let json = Data("""
{
"id": "term-002",
"session_id": "sess-abc",
"profile": "shell",
"state": "stopped",
"created_at": "2026-04-13T10:00:00Z",
"updated_at": "2026-04-13T11:00:00Z"
}
""".utf8)

let terminal = try decoder.decode(RemoteTerminal.self, from: json)

#expect(terminal.sessionId == "sess-abc")
#expect(terminal.state == .stopped)
}

@Test("RemoteTerminal: encode/decode roundtrip preserves state")
func remoteTerminalRoundtrip() throws {
let original = RemoteTerminal(
id: "term-003",
sessionId: "sess-003",
profile: "default",
state: .starting,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 60)
)

let encoded = try encoder.encode(original)
let decoded = try decoder.decode(RemoteTerminal.self, from: encoded)

#expect(decoded.id == original.id)
#expect(decoded.sessionId == original.sessionId)
#expect(decoded.state == original.state)
}
}
3 changes: 3 additions & 0 deletions docs/architecture/domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,6 @@ Session.image (explicit override)
| D-10 | `Project.default_image: String?` в image resolution chain (§5) | Добавлен в DB, struct, REST, Swift. Chain: Session → Project → config. | ✅ Закрыт |
| D-11 | `Server.default_image: String?` (клиентская сущность) | `ServerCredential.defaultImage` добавлен. | ✅ Закрыт |
| D-12 | `Session.work_dir: Path` (персистентное) | Добавлен в DB, struct, REST, Swift. | ✅ Закрыт |
| D-13 | `Session.stopped_at: Timestamp?` в Swift-модели | `stoppedAt: Date?` добавлен в `RemoteSession` (CodingKey `"stopped_at"`). Сервер уже отдавал поле. | ✅ Закрыт |
| D-14 | `Profile.id: String` отсутствует в REST и Swift | `ProfileResponse` и `SessionProfile` идентифицируют профиль по `name`. Сервер профили не персистирует — хранятся в конфиге. Для MVP приемлемо; добавить `id` при введении серверной персистенции профилей. | 🔵 MVP: по имени |
| D-15 | `Terminal.state` — нетипизированная строка | `TerminalState` enum добавлен (`starting/running/stopped`), `RemoteTerminal.state: TerminalState`. | ✅ Закрыт |
Loading
Loading