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:
parent
1879aa7057
commit
49ff9dd8e4
6 changed files with 93 additions and 63 deletions
23
API.md
23
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;"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<object>} agents
|
||||
* @param {number} [deskCount]
|
||||
* @param {number} [now] - Referenzzeitpunkt fuer den Praesenz-Check (injizierbar fuer Tests).
|
||||
* @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);
|
||||
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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue