diff --git a/API.md b/API.md index a86ef11..41045d5 100644 --- a/API.md +++ b/API.md @@ -78,7 +78,8 @@ Auth: `X-Admin-Key`. "agent": { "id": 1, "display_name": "Alice", "role": "entwickler", "desk_index": 0, "state": "idle", "current_ticket_id": null, - "created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z" + "created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z", + "is_present": true }, "api_key": "‹64 Hex-Zeichen, nur jetzt sichtbar›" } @@ -89,17 +90,23 @@ Auth: `X-Admin-Key`. Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }` Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich -`last_seen_at` jedes Agenten jederzeit von aussen pruefen, z.B. auf der Produktionsmaschine: +`last_seen_at`/`is_present` jedes Agenten jederzeit von aussen pruefen, z.B. auf der +Produktionsmaschine: ```bash -curl -s "$MONITOR_API_BASE/agents" | jq '.agents[] | {id, display_name, last_seen_at}' +curl -s "$MONITOR_API_BASE/agents" | jq '.agents[] | {id, display_name, last_seen_at, is_present}' ``` -Ein Agent gilt im Frontend nur noch als anwesend, solange `last_seen_at` hoechstens zehn -Minuten zurueckliegt (`isAgentPresent`, `html/js/office-scene.js`, Issue #17) - laeuft der -Timeout ab, bleibt sein Schreibtisch leer, obwohl er weiterhin registriert ist. Bei direktem -Zugriff auf die Produktionsmaschine laesst sich derselbe Wert auch direkt aus der -SQLite-Datei lesen (`MONITOR_DB_PATH`, Standard `api/data/monitor.sqlite`): +`is_present` gilt `true`, solange `last_seen_at` hoechstens zehn Minuten zurueckliegt, sonst +`false` (`AgentRepository::isPresent()`, Issue #17/#20) - laeuft der Timeout ab, bleibt der +Schreibtisch des Agenten leer, obwohl er weiterhin registriert ist. Berechnet wird das +serverseitig via `gmdate()`/`time()` (also unabhaengig von der Zeitzone des Clients); das +Frontend (`isAgentPresent`, `html/js/office-scene.js`) liest `is_present` nur noch direkt aus +der API, statt selbst aus `last_seen_at` zu rechnen - vorher fuehrte das je nach +Client-Zeitzone zu falschen Ergebnissen (Issue #20). Bei direktem Zugriff auf die +Produktionsmaschine laesst sich `last_seen_at` auch direkt aus der SQLite-Datei lesen +(`MONITOR_DB_PATH`, Standard `api/data/monitor.sqlite`), `is_present` selbst steht dort +allerdings nicht drin, da es erst bei der API-Antwort berechnet wird: ```bash sqlite3 "$MONITOR_DB_PATH" "SELECT id, display_name, last_seen_at FROM agents;" diff --git a/api/src/Repository/AgentRepository.php b/api/src/Repository/AgentRepository.php index 0e274f6..c1efb9d 100644 --- a/api/src/Repository/AgentRepository.php +++ b/api/src/Repository/AgentRepository.php @@ -14,6 +14,14 @@ final class AgentRepository /** @var string[] */ public const ROLES = ['entwickler', 'product_owner', 'qa', 'user']; + /** + * Praesenz-Timeout (Issue #17/#20): ein Agent gilt nur noch als anwesend, solange + * last_seen_at hoechstens so lange zurueckliegt. Der Server berechnet daraus das + * is_present-Feld selbst (gmdate()-basiert, also unabhaengig von der lokalen Zeitzone + * des Clients) - der Client macht dazu keine eigene Zeit-Arithmetik mehr. + */ + private const PRESENCE_TIMEOUT_SECONDS = 600; + public function __construct(private readonly Database $db, private readonly int $deskCount = 8) { } @@ -143,10 +151,29 @@ final class AgentRepository private function hidingKey(array $row): array { unset($row['api_key_hash']); + $row['is_present'] = $this->isPresent($row['last_seen_at'] ?? null); return $row; } + /** + * Fehlt last_seen_at oder ist es nicht parsbar, gilt der Agent defensiv als anwesend, + * statt faelschlich einen leeren Platz zu melden. + */ + private function isPresent(?string $lastSeenAt): bool + { + if ($lastSeenAt === null) { + return true; + } + + $lastSeenTimestamp = strtotime($lastSeenAt); + if ($lastSeenTimestamp === false) { + return true; + } + + return (time() - $lastSeenTimestamp) <= self::PRESENCE_TIMEOUT_SECONDS; + } + /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ public function fingerprint(): string { diff --git a/api/tests/Unit/Repository/AgentRepositoryTest.php b/api/tests/Unit/Repository/AgentRepositoryTest.php index c427cab..c94c276 100644 --- a/api/tests/Unit/Repository/AgentRepositoryTest.php +++ b/api/tests/Unit/Repository/AgentRepositoryTest.php @@ -15,12 +15,13 @@ final class AgentRepositoryTest extends TestCase { private AgentRepository $repository; private TicketRepository $tickets; + private Database $db; protected function setUp(): void { - $db = Database::inMemory(); - $this->repository = new AgentRepository($db, deskCount: 2); - $this->tickets = new TicketRepository($db); + $this->db = Database::inMemory(); + $this->repository = new AgentRepository($this->db, deskCount: 2); + $this->tickets = new TicketRepository($this->db); } public function testRegisterAssignsLowestFreeDesk(): void @@ -120,4 +121,26 @@ final class AgentRepositoryTest extends TestCase self::assertNotSame($before, $this->repository->fingerprint()); } + + public function testRegisterAndUpdateActivityReportFreshAgentAsPresent(): void + { + $result = $this->repository->register('Alice', 'entwickler'); + self::assertTrue($result['agent']['is_present']); + + $updated = $this->repository->updateActivity((int) $result['agent']['id'], 'working', null); + self::assertTrue($updated['is_present']); + } + + public function testFindByIdReportsAgentAsAbsentAfterPresenceTimeout(): void + { + $result = $this->repository->register('Alice', 'entwickler'); + $id = (int) $result['agent']['id']; + + $staleTimestamp = gmdate('Y-m-d\TH:i:s\Z', time() - 601); + $this->db->pdo()->prepare('UPDATE agents SET last_seen_at = :ts WHERE id = :id') + ->execute(['ts' => $staleTimestamp, 'id' => $id]); + + $agent = $this->repository->findById($id); + self::assertFalse($agent['is_present']); + } } diff --git a/html/js/office-render.js b/html/js/office-render.js index fe98c27..d6a15eb 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -1007,8 +1007,8 @@ export function renderOfficeScene(container, data = {}) { poState = "idle"; } - // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber - // last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19). + // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber das + // is_present-Feld wie registrierte Agenten (Issue #19). poIsLive = isLive; const groupedTickets = groupTicketsByColumn(tickets); diff --git a/html/js/office-scene.js b/html/js/office-scene.js index dee9a0b..989134c 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -355,27 +355,17 @@ export function resolveAgentVisualState(state) { return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle; } -// Praesenz-Timeout (Issue #17): ein registrierter Agent gilt nur noch als anwesend, solange -// last_seen_at nicht laenger als zehn Minuten zurueckliegt. Meldet er sich laenger nicht, -// bleibt sein Schreibtisch/Platz leer, obwohl er weiterhin registriert ist. -export const AGENT_PRESENCE_TIMEOUT_MS = 10 * 60 * 1000; - -/** - * @param {{last_seen_at?: string}} agent - * @param {number} [now] - Referenzzeitpunkt in ms seit Epoch (injizierbar fuer Tests). - * @returns {boolean} true, wenn der Agent als anwesend gilt. Fehlt last_seen_at (z.B. - * Demo-Daten ohne API-Felder) oder ist es nicht parsbar, gilt der Agent defensiv als - * anwesend, statt faelschlich einen leeren Platz zu zeigen. - */ -export function isAgentPresent(agent, now = Date.now()) { - if (!agent || !agent.last_seen_at) { +// Praesenz (Issue #17, seit Issue #20 vom Server berechnet): ein registrierter Agent gilt nur +// noch als anwesend, solange er sich innerhalb eines Timeouts zuletzt gemeldet hat. Der Server +// kennt ueber gmdate() zuverlaessig UTC und liefert das Ergebnis direkt als is_present-Feld mit +// (siehe AgentRepository::isPresent(), API.md) - der Client macht dazu keine eigene +// Zeit-Arithmetik/-Parsing mehr, das hatte in der lokalen Zeitzone des Clients zu falschen +// Ergebnissen gefuehrt. +export function isAgentPresent(agent) { + if (!agent || typeof agent.is_present === "undefined") { return true; } - const lastSeenAt = new Date(agent.last_seen_at).getTime(); - if (Number.isNaN(lastSeenAt)) { - return true; - } - return now - lastSeenAt <= AGENT_PRESENCE_TIMEOUT_MS; + return Boolean(agent.is_present); } /** @@ -383,19 +373,18 @@ export function isAgentPresent(agent, now = Date.now()) { * buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge, * sondern im Feld desk_index (API.md "Agenten"). Agenten ohne Schreibtisch (role "user" * oder alle Schreibtische belegt, desk_index === null) erscheinen hier nicht - sie kommen - * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die - * laenger als AGENT_PRESENCE_TIMEOUT_MS nicht mehr gesehen wurden (last_seen_at, Issue #17), - * erscheinen ebenfalls nicht - ihr Schreibtisch bleibt frei. + * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die laut + * is_present-Feld nicht mehr als anwesend gelten (Issue #17/#20), erscheinen ebenfalls nicht - + * ihr Schreibtisch bleibt frei. * * @param {Array} agents * @param {number} [deskCount] - * @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests). * @returns {Array} */ -export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT, now = Date.now()) { +export function mapAgentsToDesks(agents = [], deskCount = DEFAULT_DESK_COUNT) { const desks = new Array(deskCount).fill(null); for (const agent of agents) { - if (!isAgentPresent(agent, now)) { + if (!isAgentPresent(agent)) { continue; } const deskIndex = agent.desk_index; @@ -576,8 +565,8 @@ export function advanceDoorVisitStage(stage) { // Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die // Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen // Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js, -// PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber -// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten, siehe isAgentPresent()). +// PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber das +// is_present-Feld wie registrierte Agenten, siehe isAgentPresent()). export const PO_AGENT_ID = "po"; export const PO_DISPLAY_NAME = "PO"; diff --git a/tests/frontend/office-scene.test.js b/tests/frontend/office-scene.test.js index 4c42731..05715c0 100644 --- a/tests/frontend/office-scene.test.js +++ b/tests/frontend/office-scene.test.js @@ -25,7 +25,6 @@ import { mapAgentsToDesks, normalizeAgentForDisplay, isAgentPresent, - AGENT_PRESENCE_TIMEOUT_MS, pickIdleActivity, AnimationQueue, DOOR_VISIT_TYPES, @@ -288,33 +287,18 @@ test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rol assert.ok(desks.every((d) => d === null)); }); -test("isAgentPresent gilt als anwesend, solange last_seen_at hoechstens zehn Minuten zurueckliegt (Issue #17)", () => { - const now = Date.parse("2026-07-29T12:00:00Z"); - assert.equal( - isAgentPresent({ last_seen_at: "2026-07-29T11:50:00Z" }, now), - true, - "genau zehn Minuten alt gilt noch als anwesend" - ); - assert.equal( - isAgentPresent({ last_seen_at: "2026-07-29T11:49:59Z" }, now), - false, - "aelter als zehn Minuten gilt nicht mehr als anwesend" - ); +test("isAgentPresent liest die Praesenz direkt aus dem is_present-Feld der API (Issue #20)", () => { + assert.equal(isAgentPresent({ is_present: true }), true); + assert.equal(isAgentPresent({ is_present: false }), false); }); -test("isAgentPresent faellt bei fehlendem/kaputtem last_seen_at defensiv auf anwesend zurueck", () => { +test("isAgentPresent faellt bei fehlendem is_present-Feld defensiv auf anwesend zurueck", () => { assert.equal(isAgentPresent({ id: 1 }), true); - assert.equal(isAgentPresent({ id: 1, last_seen_at: "nicht-parsbar" }), true); + assert.equal(isAgentPresent(), true); }); -test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laenger als zehn Minuten nicht gesehen wurde", () => { - const now = Date.parse("2026-07-29T12:00:00Z"); - const staleSeenAt = new Date(now - AGENT_PRESENCE_TIMEOUT_MS - 1000).toISOString(); - const desks = mapAgentsToDesks( - [{ id: 1, desk_index: 0, last_seen_at: staleSeenAt }], - 2, - now - ); +test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laut is_present nicht mehr anwesend ist", () => { + const desks = mapAgentsToDesks([{ id: 1, desk_index: 0, is_present: false }], 2); assert.equal(desks[0], null); });