Agent-Praesenz serverseitig berechnen (is_present-Feld statt Client-Zeitarithmetik)

Der Client verglich last_seen_at bislang selbst gegen Date.now() - je nach
Client-Zeitzone/-Uhr konnte das falsche Ergebnisse liefern und liess Agenten
faelschlich als abwesend erscheinen (Issue #20, Folge von #17). Die API
liefert die Anwesenheit jetzt direkt als is_present-Feld mit, berechnet
serverseitig via gmdate()/time() (AgentRepository::isPresent()). Das Frontend
liest das Feld nur noch aus, ohne eigene Zeit-Arithmetik.
This commit is contained in:
fds2610-bot 2026-07-29 18:25:01 +02:00
parent 1879aa7057
commit 49ff9dd8e4
6 changed files with 93 additions and 63 deletions

23
API.md
View file

@ -78,7 +78,8 @@ Auth: `X-Admin-Key`.
"agent": { "agent": {
"id": 1, "display_name": "Alice", "role": "entwickler", "id": 1, "display_name": "Alice", "role": "entwickler",
"desk_index": 0, "state": "idle", "current_ticket_id": null, "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›" "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› ] }` Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`
Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich 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 ```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 `is_present` gilt `true`, solange `last_seen_at` hoechstens zehn Minuten zurueckliegt, sonst
Minuten zurueckliegt (`isAgentPresent`, `html/js/office-scene.js`, Issue #17) - laeuft der `false` (`AgentRepository::isPresent()`, Issue #17/#20) - laeuft der Timeout ab, bleibt der
Timeout ab, bleibt sein Schreibtisch leer, obwohl er weiterhin registriert ist. Bei direktem Schreibtisch des Agenten leer, obwohl er weiterhin registriert ist. Berechnet wird das
Zugriff auf die Produktionsmaschine laesst sich derselbe Wert auch direkt aus der serverseitig via `gmdate()`/`time()` (also unabhaengig von der Zeitzone des Clients); das
SQLite-Datei lesen (`MONITOR_DB_PATH`, Standard `api/data/monitor.sqlite`): 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 ```bash
sqlite3 "$MONITOR_DB_PATH" "SELECT id, display_name, last_seen_at FROM agents;" sqlite3 "$MONITOR_DB_PATH" "SELECT id, display_name, last_seen_at FROM agents;"

View file

@ -14,6 +14,14 @@ final class AgentRepository
/** @var string[] */ /** @var string[] */
public const ROLES = ['entwickler', 'product_owner', 'qa', 'user']; 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) 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 private function hidingKey(array $row): array
{ {
unset($row['api_key_hash']); unset($row['api_key_hash']);
$row['is_present'] = $this->isPresent($row['last_seen_at'] ?? null);
return $row; 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). */ /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
public function fingerprint(): string public function fingerprint(): string
{ {

View file

@ -15,12 +15,13 @@ final class AgentRepositoryTest extends TestCase
{ {
private AgentRepository $repository; private AgentRepository $repository;
private TicketRepository $tickets; private TicketRepository $tickets;
private Database $db;
protected function setUp(): void protected function setUp(): void
{ {
$db = Database::inMemory(); $this->db = Database::inMemory();
$this->repository = new AgentRepository($db, deskCount: 2); $this->repository = new AgentRepository($this->db, deskCount: 2);
$this->tickets = new TicketRepository($db); $this->tickets = new TicketRepository($this->db);
} }
public function testRegisterAssignsLowestFreeDesk(): void public function testRegisterAssignsLowestFreeDesk(): void
@ -120,4 +121,26 @@ final class AgentRepositoryTest extends TestCase
self::assertNotSame($before, $this->repository->fingerprint()); 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']);
}
} }

View file

@ -1007,8 +1007,8 @@ export function renderOfficeScene(container, data = {}) {
poState = "idle"; poState = "idle";
} }
// Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber // Der PO gilt als anwesend, sobald die API erreichbar ist (isLive) - nicht ueber das
// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten (Issue #19). // is_present-Feld wie registrierte Agenten (Issue #19).
poIsLive = isLive; poIsLive = isLive;
const groupedTickets = groupTicketsByColumn(tickets); const groupedTickets = groupTicketsByColumn(tickets);

View file

@ -355,27 +355,17 @@ export function resolveAgentVisualState(state) {
return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle; return AGENT_STATE_VISUALS[state] ?? AGENT_STATE_VISUALS.idle;
} }
// Praesenz-Timeout (Issue #17): ein registrierter Agent gilt nur noch als anwesend, solange // Praesenz (Issue #17, seit Issue #20 vom Server berechnet): ein registrierter Agent gilt nur
// last_seen_at nicht laenger als zehn Minuten zurueckliegt. Meldet er sich laenger nicht, // noch als anwesend, solange er sich innerhalb eines Timeouts zuletzt gemeldet hat. Der Server
// bleibt sein Schreibtisch/Platz leer, obwohl er weiterhin registriert ist. // kennt ueber gmdate() zuverlaessig UTC und liefert das Ergebnis direkt als is_present-Feld mit
export const AGENT_PRESENCE_TIMEOUT_MS = 10 * 60 * 1000; // (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.
* @param {{last_seen_at?: string}} agent export function isAgentPresent(agent) {
* @param {number} [now] - Referenzzeitpunkt in ms seit Epoch (injizierbar fuer Tests). if (!agent || typeof agent.is_present === "undefined") {
* @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) {
return true; return true;
} }
const lastSeenAt = new Date(agent.last_seen_at).getTime(); return Boolean(agent.is_present);
if (Number.isNaN(lastSeenAt)) {
return true;
}
return now - lastSeenAt <= AGENT_PRESENCE_TIMEOUT_MS;
} }
/** /**
@ -383,19 +373,18 @@ export function isAgentPresent(agent, now = Date.now()) {
* buildDeskLayout()/Demo-Daten steht der Sitzplatz hier nicht in der Array-Reihenfolge, * 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" * 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 * 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 * gemaess F2 nur anlassbezogen durch eine Tuer und haben keinen festen Platz. Agenten, die laut
* laenger als AGENT_PRESENCE_TIMEOUT_MS nicht mehr gesehen wurden (last_seen_at, Issue #17), * is_present-Feld nicht mehr als anwesend gelten (Issue #17/#20), erscheinen ebenfalls nicht -
* erscheinen ebenfalls nicht - ihr Schreibtisch bleibt frei. * ihr Schreibtisch bleibt frei.
* *
* @param {Array<object>} agents * @param {Array<object>} agents
* @param {number} [deskCount] * @param {number} [deskCount]
* @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests).
* @returns {Array<object|null>} * @returns {Array<object|null>}
*/ */
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); const desks = new Array(deskCount).fill(null);
for (const agent of agents) { for (const agent of agents) {
if (!isAgentPresent(agent, now)) { if (!isAgentPresent(agent)) {
continue; continue;
} }
const deskIndex = agent.desk_index; 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 // Der PO ist keine echte Registrierung ueber POST /api/v1/agents, sondern repraesentiert die
// Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen // Produktverantwortung des Systems selbst - er bekommt daher keinen dynamisch zugewiesenen
// Schreibtisch aus dem 8er-Raster, sondern einen eigenen, festen Platz (siehe office-render.js, // 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 // PO_DESK), und gilt als anwesend, sobald die API erreichbar ist (nicht ueber das
// last_seen_at/AGENT_PRESENCE_TIMEOUT_MS wie registrierte Agenten, siehe isAgentPresent()). // is_present-Feld wie registrierte Agenten, siehe isAgentPresent()).
export const PO_AGENT_ID = "po"; export const PO_AGENT_ID = "po";
export const PO_DISPLAY_NAME = "PO"; export const PO_DISPLAY_NAME = "PO";

View file

@ -25,7 +25,6 @@ import {
mapAgentsToDesks, mapAgentsToDesks,
normalizeAgentForDisplay, normalizeAgentForDisplay,
isAgentPresent, isAgentPresent,
AGENT_PRESENCE_TIMEOUT_MS,
pickIdleActivity, pickIdleActivity,
AnimationQueue, AnimationQueue,
DOOR_VISIT_TYPES, DOOR_VISIT_TYPES,
@ -288,33 +287,18 @@ test("mapAgentsToDesks ignoriert Agenten ohne Schreibtisch (desk_index null, Rol
assert.ok(desks.every((d) => d === null)); assert.ok(desks.every((d) => d === null));
}); });
test("isAgentPresent gilt als anwesend, solange last_seen_at hoechstens zehn Minuten zurueckliegt (Issue #17)", () => { test("isAgentPresent liest die Praesenz direkt aus dem is_present-Feld der API (Issue #20)", () => {
const now = Date.parse("2026-07-29T12:00:00Z"); assert.equal(isAgentPresent({ is_present: true }), true);
assert.equal( assert.equal(isAgentPresent({ is_present: false }), false);
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 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 }), 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", () => { test("mapAgentsToDesks laesst den Schreibtisch eines Agenten frei, der laut is_present nicht mehr anwesend ist", () => {
const now = Date.parse("2026-07-29T12:00:00Z"); const desks = mapAgentsToDesks([{ id: 1, desk_index: 0, is_present: false }], 2);
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
);
assert.equal(desks[0], null); assert.equal(desks[0], null);
}); });