P4-B: notifications Web Push (VAPID) sur passage en waiting

- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)
This commit is contained in:
2026-06-15 12:13:56 +02:00
parent 25d6d09d24
commit 664e2b583c
13 changed files with 493 additions and 11 deletions

View File

@@ -2,17 +2,20 @@ import { EventEmitter } from 'node:events';
import { existsSync, statSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js';
import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
const KILL_GRACE_MS = 5000;
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
const NOTIFY_DEBOUNCE_MS = 1500;
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;
@@ -47,6 +50,10 @@ interface ManagedSession {
claudeSessionId: string | null;
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
tracker: SessionActivityTracker | null;
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
prevActivity: SessionActivity | null;
/** timer de notif push débouncée (annulé si la session quitte `waiting` avant l'échéance). */
notifyTimer: NodeJS.Timeout | null;
}
export interface PtyManagerEvents {
@@ -60,6 +67,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
constructor(
private readonly db: Db,
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
private readonly push: PushService | null = null,
) {
super();
}
@@ -94,11 +102,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
killTimer: null,
claudeSessionId: null,
tracker: null,
prevActivity: null,
notifyTimer: null,
};
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
if (command === 'claude') {
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
if (!session.exited) this.emit('session_update', this.summarize(session));
if (session.exited) return;
const summary = this.summarize(session);
this.maybeNotify(session, summary.activity ?? null);
this.emit('session_update', summary);
});
}
this.live.set(id, session);
@@ -308,6 +321,36 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
*/
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
const prev = s.prevActivity;
s.prevActivity = next;
if (!this.push) return;
if (next === 'waiting' && prev !== 'waiting') {
if (s.notifyTimer) clearTimeout(s.notifyTimer);
s.notifyTimer = setTimeout(() => {
s.notifyTimer = null;
const act = s.tracker?.snapshot();
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
void this.push?.notify({
sessionId: s.id,
title: basename(s.cwd) || s.cwd,
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
kind: act.dialog?.kind ?? null,
url: `/sessions/${s.id}`,
});
}, NOTIFY_DEBOUNCE_MS);
s.notifyTimer.unref();
} else if (next !== 'waiting' && s.notifyTimer) {
clearTimeout(s.notifyTimer);
s.notifyTimer = null;
}
}
private handleOutput(s: ManagedSession, chunk: Buffer): void {
s.ring.write(chunk);
s.tracker?.feed(chunk);
@@ -350,6 +393,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
s.tracker?.dispose();
s.tracker = null;
if (s.killTimer) clearTimeout(s.killTimer);
if (s.notifyTimer) clearTimeout(s.notifyTimer);
const endedAt = new Date().toISOString();
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
for (const c of s.clients) c.onDetached('session_exit');