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) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
package-lock.json
generated
132
package-lock.json
generated
@@ -1873,6 +1873,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -2241,6 +2251,15 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -2281,6 +2300,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2369,6 +2400,12 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
@@ -2405,6 +2442,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@@ -2512,7 +2555,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2599,6 +2641,15 @@
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -2953,6 +3004,15 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -2973,6 +3033,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -3089,6 +3162,27 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -3447,6 +3541,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -3496,7 +3596,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
@@ -3993,6 +4092,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -4683,6 +4788,25 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -4753,12 +4877,14 @@
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
"fastify": "^5.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"bin": {
|
||||
"arboretum": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -28,9 +28,11 @@
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
"fastify": "^5.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
|
||||
import { PtyManager } from './core/pty-manager.js';
|
||||
import { DiscoveryService } from './core/discovery-service.js';
|
||||
import { WorktreeManager } from './core/worktree-manager.js';
|
||||
import { PushService } from './core/push-service.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -32,13 +34,15 @@ export interface AppBundle {
|
||||
manager: PtyManager;
|
||||
discovery: DiscoveryService;
|
||||
worktrees: WorktreeManager;
|
||||
push: PushService;
|
||||
}
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||
const auth = new AuthService(db);
|
||||
const limiter = new LoginRateLimiter();
|
||||
const manager = new PtyManager(db, config.claudeSessionsDir);
|
||||
const push = new PushService(db, config.vapidContact);
|
||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||
const discovery = new DiscoveryService({
|
||||
ptyManager: manager,
|
||||
projectsDir: config.claudeProjectsDir,
|
||||
@@ -87,6 +91,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
@@ -105,5 +110,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager, discovery, worktrees };
|
||||
return { app, auth, manager, discovery, worktrees, push };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface Config {
|
||||
claudeProjectsDir: string;
|
||||
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
|
||||
claudeSessionsDir: string;
|
||||
/** sujet VAPID des notifications Web Push (mailto: ou URL). */
|
||||
vapidContact: string;
|
||||
}
|
||||
|
||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
@@ -29,6 +31,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
||||
// racine de l'install Claude (~/.claude par défaut) — surchargée par les tests d'acceptation.
|
||||
'claude-home': { type: 'string' },
|
||||
// sujet VAPID des notifications push (contact requis par la spec Web Push).
|
||||
'vapid-contact': { type: 'string' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
@@ -55,5 +59,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
printToken: values['print-token'] ?? false,
|
||||
claudeProjectsDir: join(claudeHome, 'projects'),
|
||||
claudeSessionsDir: join(claudeHome, 'sessions'),
|
||||
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
116
packages/server/src/core/push-service.ts
Normal file
116
packages/server/src/core/push-service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// PushService : notifications Web Push (VAPID). Clés VAPID générées une fois au bootstrap
|
||||
// et stockées dans `settings` (comme le server_secret de l'auth) ; abonnements liés au token
|
||||
// d'auth (token_id). Quand une session managée passe en `waiting`, pty-manager appelle notify().
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
|
||||
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
||||
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||
const require = createRequire(import.meta.url);
|
||||
const webpush = require('web-push') as typeof import('web-push');
|
||||
|
||||
export interface PushSubscriptionInput {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}
|
||||
|
||||
/** Charge utile JSON poussée au service worker (cf. packages/web/src/sw.ts). */
|
||||
export interface PushPayload {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
kind: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SubRow {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/** Envoi d'une notif à un abonnement — injectable pour les tests ; défaut = web-push réel. */
|
||||
export type PushSender = (
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string,
|
||||
options: unknown,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export class PushService {
|
||||
private readonly vapidPublic: string;
|
||||
private readonly vapidPrivate: string;
|
||||
private readonly send: PushSender;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly contact: string = 'mailto:arboretum@localhost',
|
||||
sender?: PushSender,
|
||||
) {
|
||||
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
||||
let pub = getSetting(db, 'vapid_public');
|
||||
let priv = getSetting(db, 'vapid_private');
|
||||
if (!pub || !priv) {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
pub = keys.publicKey;
|
||||
priv = keys.privateKey;
|
||||
setSetting(db, 'vapid_public', pub);
|
||||
setSetting(db, 'vapid_private', priv);
|
||||
}
|
||||
this.vapidPublic = pub;
|
||||
this.vapidPrivate = priv;
|
||||
}
|
||||
|
||||
/** Clé publique VAPID — sûre à exposer (applicationServerKey côté navigateur). */
|
||||
publicKey(): string {
|
||||
return this.vapidPublic;
|
||||
}
|
||||
|
||||
/** Enregistre (ou ré-associe) un abonnement, lié au token authentifié. */
|
||||
subscribe(tokenId: string, sub: PushSubscriptionInput, userAgent: string | null): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO push_subscriptions (id, token_id, endpoint, p256dh, auth, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
token_id = excluded.token_id, p256dh = excluded.p256dh,
|
||||
auth = excluded.auth, user_agent = excluded.user_agent`,
|
||||
)
|
||||
.run(randomUUID(), tokenId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, userAgent, new Date().toISOString());
|
||||
}
|
||||
|
||||
unsubscribe(endpoint: string): void {
|
||||
this.db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pousse une notification à TOUS les abonnements (un seul utilisateur, plusieurs appareils).
|
||||
* Un abonnement expiré (404/410 Gone) est purgé ; les autres erreurs sont ignorées (best-effort,
|
||||
* un appareil injoignable ne doit pas bloquer les autres).
|
||||
*/
|
||||
async notify(payload: PushPayload): Promise<void> {
|
||||
const rows = this.db.prepare('SELECT id, endpoint, p256dh, auth FROM push_subscriptions').all() as unknown as SubRow[];
|
||||
if (rows.length === 0) return;
|
||||
const body = JSON.stringify(payload);
|
||||
const options = {
|
||||
vapidDetails: { subject: this.contact, publicKey: this.vapidPublic, privateKey: this.vapidPrivate },
|
||||
TTL: 60,
|
||||
};
|
||||
await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
try {
|
||||
await this.send({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }, body, options);
|
||||
this.db.prepare('UPDATE push_subscriptions SET last_ok_at = ? WHERE id = ?').run(new Date().toISOString(), r.id);
|
||||
} catch (err) {
|
||||
const code = (err as { statusCode?: number }).statusCode;
|
||||
if (code === 404 || code === 410) this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(r.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,23 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// P4 — abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings.
|
||||
id: 4,
|
||||
sql: `
|
||||
CREATE TABLE push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_ok_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
35
packages/server/src/routes/push.ts
Normal file
35
packages/server/src/routes/push.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Routes Web Push (P4). Toutes sous l'auth globale (preValidation) : aucune route publique.
|
||||
// L'abonnement est lié au token authentifié (req.authContext.tokenId).
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
|
||||
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
|
||||
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
||||
|
||||
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
||||
const body = req.body as Partial<PushSubscribeRequest> | null;
|
||||
if (!body || typeof body.endpoint !== 'string' || !body.keys || typeof body.keys.p256dh !== 'string' || typeof body.keys.auth !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint and keys{p256dh,auth} are required' } });
|
||||
}
|
||||
// garanti non-null : la route est protégée par le preValidation global.
|
||||
const tokenId = req.authContext!.tokenId;
|
||||
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
||||
return reply.status(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/v1/push/unsubscribe', async (req, reply) => {
|
||||
const body = req.body as Partial<PushUnsubscribeRequest> | null;
|
||||
if (!body || typeof body.endpoint !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
||||
}
|
||||
push.unsubscribe(body.endpoint);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// Notification de test (diagnostic mobile) : pousse vers tous les abonnements de l'utilisateur.
|
||||
app.post('/api/v1/push/test', async (_req, reply) => {
|
||||
await push.notify({ sessionId: 'test', title: 'Arboretum', body: 'Push notifications are working.', kind: null, url: '/' });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
|
||||
import type { PushPayload, PushService } from '../src/core/push-service.js';
|
||||
import { openDb, type Db } from '../src/db/index.js';
|
||||
|
||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||
@@ -344,6 +345,45 @@ describe('PtyManager (pty mocké)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('push trigger (P4-B)', () => {
|
||||
it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => {
|
||||
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-'));
|
||||
const notifies: PushPayload[] = [];
|
||||
const fakePush = {
|
||||
notify: async (p: PushPayload) => {
|
||||
notifies.push(p);
|
||||
},
|
||||
} as unknown as PushService;
|
||||
const m = new PtyManager(db, sessDir, fakePush);
|
||||
try {
|
||||
const summary = m.spawn({ cwd, command: 'claude' });
|
||||
const p = lastPty();
|
||||
const regFile = join(sessDir, `${p.pid}.json`);
|
||||
const writeReg = (status: string, waitingFor?: string): void =>
|
||||
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) }));
|
||||
|
||||
// busy : front montant vers busy, pas de notif
|
||||
writeReg('busy');
|
||||
p.emitData('working…');
|
||||
await sleep(260);
|
||||
expect(notifies).toHaveLength(0);
|
||||
|
||||
// waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms)
|
||||
writeReg('waiting', 'permission prompt');
|
||||
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\n');
|
||||
await sleep(260);
|
||||
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
|
||||
|
||||
await sleep(1500); // dépasse le debounce
|
||||
expect(notifies).toHaveLength(1);
|
||||
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` });
|
||||
} finally {
|
||||
m.shutdown();
|
||||
rmSync(sessDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('flow control', () => {
|
||||
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
|
||||
79
packages/server/test/push-service.test.ts
Normal file
79
packages/server/test/push-service.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PushService, type PushSender, type PushPayload } from '../src/core/push-service.js';
|
||||
import { openDb, getSetting, type Db } from '../src/db/index.js';
|
||||
|
||||
function sub(endpoint: string): { endpoint: string; keys: { p256dh: string; auth: string } } {
|
||||
return { endpoint, keys: { p256dh: `p-${endpoint}`, auth: `a-${endpoint}` } };
|
||||
}
|
||||
const payload: PushPayload = { sessionId: 's1', title: 't', body: 'b', kind: 'permission', url: '/sessions/s1' };
|
||||
|
||||
describe('PushService', () => {
|
||||
it('génère les clés VAPID une seule fois (idempotent) et les persiste', () => {
|
||||
const db: Db = openDb(':memory:');
|
||||
const a = new PushService(db);
|
||||
const pub = a.publicKey();
|
||||
expect(pub).toMatch(/.{20,}/); // clé base64url non triviale
|
||||
expect(getSetting(db, 'vapid_private')).not.toBeNull();
|
||||
// une seconde instance sur la même db réutilise les clés (pas de régénération)
|
||||
const b = new PushService(db);
|
||||
expect(b.publicKey()).toBe(pub);
|
||||
});
|
||||
|
||||
it('subscribe (UPSERT par endpoint), count et unsubscribe', () => {
|
||||
const db = openDb(':memory:');
|
||||
const p = new PushService(db);
|
||||
p.subscribe('tok1', sub('https://push/a'), 'UA');
|
||||
p.subscribe('tok1', sub('https://push/b'), null);
|
||||
expect(p.count()).toBe(2);
|
||||
// ré-abonnement du même endpoint → pas de doublon
|
||||
p.subscribe('tok2', sub('https://push/a'), 'UA2');
|
||||
expect(p.count()).toBe(2);
|
||||
p.unsubscribe('https://push/a');
|
||||
expect(p.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('notify : supprime l’abonnement sur 410 Gone, conserve sur autre erreur, marque last_ok_at sur succès', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const sender: PushSender = vi.fn(async (s) => {
|
||||
if (s.endpoint.endsWith('/gone')) throw Object.assign(new Error('gone'), { statusCode: 410 });
|
||||
if (s.endpoint.endsWith('/flaky')) throw Object.assign(new Error('boom'), { statusCode: 500 });
|
||||
return { statusCode: 201 };
|
||||
});
|
||||
const p = new PushService(db, 'mailto:test@x', sender);
|
||||
p.subscribe('tok', sub('https://push/ok'), null);
|
||||
p.subscribe('tok', sub('https://push/gone'), null);
|
||||
p.subscribe('tok', sub('https://push/flaky'), null);
|
||||
|
||||
await p.notify(payload);
|
||||
|
||||
expect(sender).toHaveBeenCalledTimes(3);
|
||||
// 410 → purgé ; 500 → conservé ; ok → conservé
|
||||
expect(p.count()).toBe(2);
|
||||
const rows = db.prepare('SELECT endpoint, last_ok_at FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string; last_ok_at: string | null }>;
|
||||
const byEndpoint = Object.fromEntries(rows.map((r) => [r.endpoint, r.last_ok_at]));
|
||||
expect(byEndpoint['https://push/ok']).not.toBeNull(); // succès horodaté
|
||||
expect(byEndpoint['https://push/flaky']).toBeNull(); // échec transitoire : pas d'horodatage, mais conservé
|
||||
expect(byEndpoint['https://push/gone']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('notify sans abonnement : ne touche pas au sender', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const sender: PushSender = vi.fn(async () => ({}));
|
||||
const p = new PushService(db, 'mailto:test@x', sender);
|
||||
await p.notify(payload);
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('le payload poussé est le JSON sérialisé de PushPayload', async () => {
|
||||
const db = openDb(':memory:');
|
||||
let captured = '';
|
||||
const sender: PushSender = async (_s, body) => {
|
||||
captured = body;
|
||||
return {};
|
||||
};
|
||||
const p = new PushService(db, 'mailto:test@x', sender);
|
||||
p.subscribe('tok', sub('https://push/ok'), null);
|
||||
await p.notify(payload);
|
||||
expect(JSON.parse(captured)).toEqual(payload);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -90,3 +90,16 @@ export interface AdoptWorktreeRequest {
|
||||
runHooks?: boolean;
|
||||
preTrust?: boolean;
|
||||
}
|
||||
|
||||
// ---- Web Push (P4) ----
|
||||
export interface VapidKeyResponse {
|
||||
/** clé publique VAPID (applicationServerKey côté navigateur). */
|
||||
key: string;
|
||||
}
|
||||
export interface PushSubscribeRequest {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}
|
||||
export interface PushUnsubscribeRequest {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user