feat(booterm): PTY session metadata, terminal registry, WS attach enhancements
- Add PTY session metadata tracking (title, description, parent agent) - Extend terminal registry: structured session metadata - Extend WS attach: session-aware WebSocket lifecycle - Extend routes: terminals and sessions with metadata
This commit is contained in:
@@ -7,6 +7,8 @@ const ConfigSchema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
||||
PTY_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
|
||||
PTY_ABSOLUTE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -14,12 +14,13 @@ interface SessionInfo {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
||||
if (!pool) throw new Error('db pool not initialized');
|
||||
const res = await pool.query<SessionInfo>(
|
||||
`SELECT s.id, s.project_id, p.path AS project_path
|
||||
`SELECT s.id, s.project_id, p.path AS project_path, s.name
|
||||
FROM sessions s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = $1`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import * as registry from './registry.js';
|
||||
|
||||
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
@@ -162,3 +163,36 @@ export async function capturePane(
|
||||
if (res.code !== 0) return '';
|
||||
return res.stdout.replace(/(?:\r?\n)+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep the registry for expired sessions and kill the underlying tmux sessions.
|
||||
* Logs each kill with the expiry reason (idle timeout vs absolute timeout).
|
||||
* Returns the list of paneIds that were killed.
|
||||
*/
|
||||
export async function sweepExpired(
|
||||
tmuxConfPath: string,
|
||||
log: FastifyBaseLogger,
|
||||
): Promise<string[]> {
|
||||
const expired = registry.getTimedOutSessions();
|
||||
const killed: string[] = [];
|
||||
for (const meta of expired) {
|
||||
const reason =
|
||||
meta.idleExpiresAt &&
|
||||
(!meta.absoluteExpiresAt || meta.idleExpiresAt.getTime() <= meta.absoluteExpiresAt.getTime())
|
||||
? 'idle timeout'
|
||||
: 'absolute timeout';
|
||||
log.info({ paneId: meta.paneId, reason }, 'sweeping expired PTY session');
|
||||
const sessionName = tmuxSessionName(meta.paneId);
|
||||
try {
|
||||
const ok = await killSession(tmuxConfPath, sessionName);
|
||||
if (!ok) {
|
||||
log.warn({ paneId: meta.paneId, sessionName }, 'killSession returned false during sweep');
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ paneId: meta.paneId, err }, 'killSession threw during sweep');
|
||||
}
|
||||
registry.unregister(meta.paneId);
|
||||
killed.push(meta.paneId);
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,30 @@ export interface SessionMeta {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
parentAgent?: string;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
timeoutSeconds?: number;
|
||||
idleExpiresAt?: Date;
|
||||
absoluteExpiresAt?: Date;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, SessionMeta>();
|
||||
|
||||
export interface RegisterOpts {
|
||||
timeoutSeconds?: number;
|
||||
absoluteTimeoutSeconds?: number;
|
||||
description?: string;
|
||||
parentAgent?: string;
|
||||
}
|
||||
|
||||
export function register(
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
projectPath: string,
|
||||
title?: string,
|
||||
opts?: RegisterOpts,
|
||||
): void {
|
||||
const now = new Date();
|
||||
const existing = sessions.get(paneId);
|
||||
@@ -21,13 +34,24 @@ export function register(
|
||||
existing.lastActivityAt = now;
|
||||
return;
|
||||
}
|
||||
const idleExpiresAt = opts?.timeoutSeconds && opts.timeoutSeconds > 0
|
||||
? new Date(now.getTime() + opts.timeoutSeconds * 1000)
|
||||
: undefined;
|
||||
const absoluteExpiresAt = opts?.absoluteTimeoutSeconds && opts.absoluteTimeoutSeconds > 0
|
||||
? new Date(now.getTime() + opts.absoluteTimeoutSeconds * 1000)
|
||||
: undefined;
|
||||
sessions.set(paneId, {
|
||||
paneId,
|
||||
sessionId,
|
||||
projectPath,
|
||||
title,
|
||||
description: opts?.description,
|
||||
parentAgent: opts?.parentAgent,
|
||||
createdAt: now,
|
||||
lastActivityAt: now,
|
||||
timeoutSeconds: opts?.timeoutSeconds,
|
||||
idleExpiresAt,
|
||||
absoluteExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +60,18 @@ export function unregister(paneId: string): void {
|
||||
ringBuffers.delete(paneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the lastActivityAt timestamp for a pane.
|
||||
* Called on every PTY data write so the idle-timeout sweep knows when a session
|
||||
* was last active.
|
||||
*/
|
||||
export function touchActivity(paneId: string): void {
|
||||
const meta = sessions.get(paneId);
|
||||
if (meta) {
|
||||
meta.lastActivityAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
export function list(): SessionMeta[] {
|
||||
return Array.from(sessions.values());
|
||||
}
|
||||
@@ -44,6 +80,30 @@ export function get(paneId: string): SessionMeta | undefined {
|
||||
return sessions.get(paneId);
|
||||
}
|
||||
|
||||
// ── Pending metadata (POST /start → WS attach handoff) ──────────────────────
|
||||
//
|
||||
// The POST /start route stores optional description/parentAgent here; the WS
|
||||
// attach handler consumes it when calling register(). This avoids coupling the
|
||||
// HTTP route to the WS lifecycle while keeping the handoff single-process and
|
||||
// ephemeral (no DB writes).
|
||||
|
||||
const pendingMetadata = new Map<string, { description?: string; parentAgent?: string }>();
|
||||
|
||||
export function setPendingMetadata(
|
||||
paneId: string,
|
||||
meta: { description?: string; parentAgent?: string },
|
||||
): void {
|
||||
pendingMetadata.set(paneId, meta);
|
||||
}
|
||||
|
||||
export function consumePendingMetadata(
|
||||
paneId: string,
|
||||
): { description?: string; parentAgent?: string } | undefined {
|
||||
const meta = pendingMetadata.get(paneId);
|
||||
if (meta) pendingMetadata.delete(paneId);
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ── Ring buffer for PTY output search ──────────────────────────────────────
|
||||
|
||||
export interface SearchMatch {
|
||||
@@ -160,3 +220,21 @@ export function searchRingBuffer(
|
||||
export function clearBuffer(paneId: string): void {
|
||||
ringBuffers.delete(paneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all sessions whose idle-expiry or absolute-expiry has passed.
|
||||
* A session with no timeout configured is never included.
|
||||
* Called by the sweepExpired interval in manager.ts.
|
||||
*/
|
||||
export function getTimedOutSessions(): SessionMeta[] {
|
||||
const now = Date.now();
|
||||
const result: SessionMeta[] = [];
|
||||
for (const meta of sessions.values()) {
|
||||
const idleHit = meta.idleExpiresAt && now >= meta.idleExpiresAt.getTime();
|
||||
const absoluteHit = meta.absoluteExpiresAt && now >= meta.absoluteExpiresAt.getTime();
|
||||
if (idleHit || absoluteHit) {
|
||||
result.push(meta);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export function registerSessionRoutes(app: FastifyInstance): void {
|
||||
sessionId: s.sessionId,
|
||||
projectPath: s.projectPath,
|
||||
title: s.title ?? null,
|
||||
description: s.description ?? null,
|
||||
parentAgent: s.parentAgent ?? null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
lastActivityAt: s.lastActivityAt.toISOString(),
|
||||
})),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
killSession,
|
||||
hasSession,
|
||||
} from '../pty/manager.js';
|
||||
import { setPendingMetadata } from '../pty/registry.js';
|
||||
|
||||
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
||||
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
|
||||
@@ -17,6 +18,8 @@ const StartBodySchema = z
|
||||
.object({
|
||||
cols: z.coerce.number().int().min(1).max(2000).optional(),
|
||||
rows: z.coerce.number().int().min(1).max(2000).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
parentAgent: z.string().max(100).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional();
|
||||
@@ -29,7 +32,7 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
|
||||
// errors as HTTP responses (vs WS 1011 close codes).
|
||||
app.post<{
|
||||
Params: { sid: string; pid: string };
|
||||
Body: { cols?: number; rows?: number } | undefined;
|
||||
Body: { cols?: number; rows?: number; description?: string; parentAgent?: string } | undefined;
|
||||
}>(
|
||||
'/api/term/sessions/:sid/panes/:pid/start',
|
||||
async (req, reply) => {
|
||||
@@ -43,6 +46,14 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
|
||||
const cols = b.success ? b.data?.cols : undefined;
|
||||
const rows = b.success ? b.data?.rows : undefined;
|
||||
|
||||
// Store optional metadata for the WS attach handler to consume
|
||||
if (b.success && b.data) {
|
||||
const { description, parentAgent } = b.data;
|
||||
if (description || parentAgent) {
|
||||
setPendingMetadata(pid, { description, parentAgent });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await getSessionInfo(sid);
|
||||
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from '../pty/manager.js';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
import { register, unregister, appendOutput } from '../pty/registry.js';
|
||||
import { register, unregister, appendOutput, touchActivity, consumePendingMetadata } from '../pty/registry.js';
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
export function registerWsAttachRoute(
|
||||
app: FastifyInstance,
|
||||
tmuxConfPath: string,
|
||||
idleTimeoutSeconds?: number,
|
||||
absoluteTimeoutSeconds?: number,
|
||||
): void {
|
||||
app.get<{
|
||||
Params: { sid: string; pid: string };
|
||||
Querystring: { cols?: string; rows?: string };
|
||||
@@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
return;
|
||||
}
|
||||
|
||||
register(sid, pid, session.project_path);
|
||||
const pendingMeta = consumePendingMetadata(pid);
|
||||
const regOpts: {
|
||||
timeoutSeconds?: number;
|
||||
absoluteTimeoutSeconds?: number;
|
||||
description?: string;
|
||||
parentAgent?: string;
|
||||
} = {};
|
||||
if (idleTimeoutSeconds && idleTimeoutSeconds > 0) regOpts.timeoutSeconds = idleTimeoutSeconds;
|
||||
if (absoluteTimeoutSeconds && absoluteTimeoutSeconds > 0) regOpts.absoluteTimeoutSeconds = absoluteTimeoutSeconds;
|
||||
if (pendingMeta) {
|
||||
if (pendingMeta.description) regOpts.description = pendingMeta.description;
|
||||
if (pendingMeta.parentAgent) regOpts.parentAgent = pendingMeta.parentAgent;
|
||||
}
|
||||
const hasRegOpts =
|
||||
regOpts.timeoutSeconds !== undefined ||
|
||||
regOpts.absoluteTimeoutSeconds !== undefined ||
|
||||
regOpts.description !== undefined ||
|
||||
regOpts.parentAgent !== undefined;
|
||||
register(sid, pid, session.project_path, session.name ?? undefined, hasRegOpts ? regOpts : undefined);
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
@@ -108,6 +131,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
}
|
||||
// Feed the ring buffer for pattern-based search
|
||||
appendOutput(pid, data);
|
||||
// Bump activity timestamp for idle-timeout tracking
|
||||
touchActivity(pid);
|
||||
};
|
||||
handle.onData(onData);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user