- Task state machine: TIMED_OUT state, retriable steps, timeout detection - Paseo hub: paseo-client.ts (HTTP+CLI), PaseoBackend (AgentBackend), 14 tests - Collision detection: collision-detector.ts, conflict-index.ts, ws-frames type - PTY search: ring buffer, search route, capture-pane fallback
168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { sanitizeId, tmuxSessionName, capturePane } from '../pty/manager.js';
|
|
import { searchRingBuffer, clearBuffer } from '../pty/registry.js';
|
|
|
|
const ParamsSchema = z.object({
|
|
sid: z.string(),
|
|
pid: z.string(),
|
|
});
|
|
|
|
const MAX_PATTERN_LENGTH = 200;
|
|
|
|
// Zod-refined string: reject empty and overly-long patterns to prevent ReDoS
|
|
const PatternQuerySchema = z
|
|
.string()
|
|
.min(1, 'pattern is required')
|
|
.max(MAX_PATTERN_LENGTH, `pattern must not exceed ${MAX_PATTERN_LENGTH} characters`);
|
|
|
|
const QuerySchema = z.object({
|
|
pattern: PatternQuerySchema,
|
|
limit: z.coerce.number().int().min(1).max(500).default(50),
|
|
context: z.coerce.number().int().min(0).max(50).default(0),
|
|
});
|
|
|
|
interface SearchMatch {
|
|
line: number;
|
|
content: string;
|
|
contextBefore: string[];
|
|
contextAfter: string[];
|
|
}
|
|
|
|
interface SearchResponse {
|
|
matches: SearchMatch[];
|
|
total: number;
|
|
truncated: boolean;
|
|
source: 'ring' | 'capture';
|
|
}
|
|
|
|
/**
|
|
* Search a captured pane buffer using a regex. This is the fallback path
|
|
* when the ring buffer doesn't have enough matches.
|
|
*/
|
|
function grepBuffer(
|
|
text: string,
|
|
pattern: string,
|
|
limit: number,
|
|
context: number,
|
|
): SearchMatch[] {
|
|
let re: RegExp;
|
|
try {
|
|
re = new RegExp(pattern, 'u');
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const lines = text.split('\n');
|
|
const results: SearchMatch[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (results.length >= limit) break;
|
|
if (re.test(lines[i]!)) {
|
|
const contextBefore: string[] = [];
|
|
const contextAfter: string[] = [];
|
|
for (let c = 1; c <= context; c++) {
|
|
const ci = i - c;
|
|
if (ci >= 0) contextBefore.unshift(lines[ci]!);
|
|
}
|
|
for (let c = 1; c <= context; c++) {
|
|
const ci = i + c;
|
|
if (ci < lines.length) contextAfter.push(lines[ci]!);
|
|
}
|
|
results.push({
|
|
line: i + 1,
|
|
content: lines[i]!,
|
|
contextBefore,
|
|
contextAfter,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export function registerSearchRoutes(app: FastifyInstance, tmuxConfPath: string): void {
|
|
app.get<{
|
|
Params: { sid: string; pid: string };
|
|
Querystring: { pattern?: string; limit?: string; context?: string };
|
|
}>(
|
|
'/api/term/sessions/:sid/panes/:pid/search',
|
|
async (req, reply) => {
|
|
const p = ParamsSchema.safeParse(req.params);
|
|
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
|
|
|
const sid = sanitizeId(p.data.sid);
|
|
const pid = sanitizeId(p.data.pid);
|
|
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
|
|
|
const q = QuerySchema.safeParse(req.query);
|
|
if (!q.success) {
|
|
return reply.code(400).send({
|
|
error: 'bad_query',
|
|
details: q.error.flatten().fieldErrors,
|
|
});
|
|
}
|
|
|
|
const { pattern, limit, context } = q.data;
|
|
|
|
// ── Path 1: ring buffer search (fast, no tmux interaction) ──
|
|
const ringMatches = searchRingBuffer(pid, pattern, { limit, context });
|
|
if (ringMatches.length >= limit) {
|
|
return reply.code(200).send({
|
|
matches: ringMatches,
|
|
total: ringMatches.length,
|
|
truncated: ringMatches.length >= limit,
|
|
source: 'ring' as const,
|
|
});
|
|
}
|
|
|
|
// ── Path 2: capture-pane + grep fallback (10s timeout) ──
|
|
const sessionName = tmuxSessionName(pid);
|
|
|
|
let capture: string;
|
|
try {
|
|
capture = await withTimeout(
|
|
capturePane(tmuxConfPath, sessionName, 5000),
|
|
10_000,
|
|
);
|
|
} catch (err) {
|
|
req.log.warn({ err, pid }, 'capture-pane timed out or failed');
|
|
return reply.code(200).send({
|
|
matches: ringMatches,
|
|
total: ringMatches.length,
|
|
truncated: false,
|
|
source: 'ring' as const,
|
|
});
|
|
}
|
|
|
|
if (!capture) {
|
|
// tmux pane may no longer exist — return whatever ring had
|
|
return reply.code(200).send({
|
|
matches: ringMatches,
|
|
total: ringMatches.length,
|
|
truncated: false,
|
|
source: 'ring' as const,
|
|
});
|
|
}
|
|
|
|
const captureMatches = grepBuffer(capture, pattern, limit, context);
|
|
|
|
return reply.code(200).send({
|
|
matches: captureMatches,
|
|
total: captureMatches.length,
|
|
truncated: captureMatches.length >= limit,
|
|
source: 'capture' as const,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error('timeout')), ms),
|
|
),
|
|
]);
|
|
}
|