Merge pull request 'Agent-Praesenz serverseitig berechnen (is_present-Feld)' (#22) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/22
This commit is contained in:
fds2610 2026-07-29 18:26:44 +02:00
commit f0018976bf
6 changed files with 93 additions and 63 deletions

23
API.md
View file

@ -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;"

View file

@ -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
{

View file

@ -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']);
}
}

View file

@ -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);

View file

@ -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";

View file

@ -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);
});