checkpoint: pre-split branch cleanup
This commit is contained in:
parent
4c2ae2e5b7
commit
b5db7a7753
276 changed files with 35912 additions and 60119 deletions
|
|
@ -1,41 +1,41 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { activityEventBus } from '../../../lib/realtime';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid projectRoot path' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectRoot = projectRootParam || undefined;
|
||||
const history = activityEventBus.getHistory(projectRoot);
|
||||
|
||||
return Response.json(history);
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { activityEventBus } from '../../../lib/realtime';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid projectRoot path' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectRoot = projectRootParam || undefined;
|
||||
const history = activityEventBus.getHistory(projectRoot);
|
||||
|
||||
return Response.json(history);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../../../lib/read-issues';
|
||||
import { activityEventBus } from '../../../../../lib/realtime';
|
||||
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
): Promise<Response> {
|
||||
const { agentId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json({ ok: false, error: 'Invalid projectRoot path' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
|
||||
const metrics = await getAgentMetrics(agentId, issues, activity);
|
||||
|
||||
return NextResponse.json({ ok: true, metrics });
|
||||
} catch (error) {
|
||||
console.error('[API/Agents/Stats] Failed:', error);
|
||||
return NextResponse.json({ ok: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../../../lib/read-issues';
|
||||
import { activityEventBus } from '../../../../../lib/realtime';
|
||||
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ agentId: string }> }
|
||||
): Promise<Response> {
|
||||
const { agentId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json({ ok: false, error: 'Invalid projectRoot path' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
|
||||
const metrics = await getAgentMetrics(agentId, issues, activity);
|
||||
|
||||
return NextResponse.json({ ok: true, metrics });
|
||||
} catch (error) {
|
||||
console.error('[API/Agents/Stats] Failed:', error);
|
||||
return NextResponse.json({ ok: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { handleMutationRequest } from '../_shared';
|
|||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'close');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { handleMutationRequest } from '../_shared';
|
|||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'comment');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { handleMutationRequest } from '../_shared';
|
|||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'create');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../../lib/read-issues';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
return NextResponse.json({ ok: true, issues });
|
||||
} catch (error) {
|
||||
console.error('[API/BeadsRead] Failed to read issues:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'internal_error',
|
||||
message: 'An internal error occurred while reading issues.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../../lib/read-issues';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
return NextResponse.json({ ok: true, issues });
|
||||
} catch (error) {
|
||||
console.error('[API/BeadsRead] Failed to read issues:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'internal_error',
|
||||
message: 'An internal error occurred while reading issues.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { handleMutationRequest } from '../_shared';
|
|||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'reopen');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { handleMutationRequest } from '../_shared';
|
|||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'update');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,154 +1,154 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath } from '../../../lib/pathing';
|
||||
import { issuesEventBus, activityEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame, toActivitySseFrame } from '../../../lib/realtime';
|
||||
import { getIssuesWatchManager } from '../../../lib/watcher';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const LAST_TOUCHED_POLL_MS = 1_000;
|
||||
|
||||
async function readLastTouchedVersion(filePath: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.mtimeMs;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
// Log non-ENOENT errors but don't swallow them silently
|
||||
console.error('[Events] Failed to read last-touched version:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
|
||||
|
||||
console.log(`[SSE /api/events] Connection request - raw param: "${projectRootSearchParam}", canonicalized: "${projectRoot}"`);
|
||||
|
||||
try {
|
||||
getIssuesWatchManager().startWatch(projectRoot);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Failed to initialize watcher.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let cleanup = () => { };
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false;
|
||||
const write = (payload: string) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
};
|
||||
|
||||
write(SSE_CONNECTED_FRAME);
|
||||
|
||||
console.log(`[SSE /api/events] Subscribing to event bus with projectRoot: ${projectRoot}`);
|
||||
const unsubscribeIssues = issuesEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ISSUES event from bus:', event.kind, 'projectRoot:', event.projectRoot);
|
||||
write(toSseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
console.log(`[SSE /api/events] Subscriber count after subscribe: ${issuesEventBus.getSubscriberCount()}`);
|
||||
|
||||
const unsubscribeActivity = activityEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ACTIVITY event from bus');
|
||||
write(toActivitySseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
write(SSE_HEARTBEAT_FRAME);
|
||||
}, HEARTBEAT_MS);
|
||||
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
|
||||
let lastTouchedVersion: number | null = null;
|
||||
|
||||
let isPolling = false;
|
||||
const pollLastTouched = async () => {
|
||||
if (isPolling) {
|
||||
return;
|
||||
}
|
||||
isPolling = true;
|
||||
try {
|
||||
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
|
||||
if (nextVersion === null) {
|
||||
return;
|
||||
}
|
||||
if (lastTouchedVersion === null) {
|
||||
lastTouchedVersion = nextVersion;
|
||||
return;
|
||||
}
|
||||
if (nextVersion !== lastTouchedVersion) {
|
||||
lastTouchedVersion = nextVersion;
|
||||
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
|
||||
}
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const touchedPoll = setInterval(() => {
|
||||
void pollLastTouched();
|
||||
}, LAST_TOUCHED_POLL_MS);
|
||||
void pollLastTouched();
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(touchedPoll);
|
||||
unsubscribeIssues();
|
||||
unsubscribeActivity();
|
||||
request.signal.removeEventListener('abort', close);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// stream already closed
|
||||
}
|
||||
};
|
||||
cleanup = close;
|
||||
|
||||
request.signal.addEventListener('abort', close);
|
||||
},
|
||||
cancel() {
|
||||
// Called when client closes EventSource/reader.
|
||||
// Ensures heartbeat + subscriber cleanup always runs.
|
||||
cleanup();
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath } from '../../../lib/pathing';
|
||||
import { issuesEventBus, activityEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame, toActivitySseFrame } from '../../../lib/realtime';
|
||||
import { getIssuesWatchManager } from '../../../lib/watcher';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const LAST_TOUCHED_POLL_MS = 1_000;
|
||||
|
||||
async function readLastTouchedVersion(filePath: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.mtimeMs;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
// Log non-ENOENT errors but don't swallow them silently
|
||||
console.error('[Events] Failed to read last-touched version:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam || process.cwd());
|
||||
|
||||
console.log(`[SSE /api/events] Connection request - raw param: "${projectRootSearchParam}", canonicalized: "${projectRoot}"`);
|
||||
|
||||
try {
|
||||
getIssuesWatchManager().startWatch(projectRoot);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Failed to initialize watcher.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let cleanup = () => { };
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false;
|
||||
const write = (payload: string) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
};
|
||||
|
||||
write(SSE_CONNECTED_FRAME);
|
||||
|
||||
console.log(`[SSE /api/events] Subscribing to event bus with projectRoot: ${projectRoot}`);
|
||||
const unsubscribeIssues = issuesEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ISSUES event from bus:', event.kind, 'projectRoot:', event.projectRoot);
|
||||
write(toSseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
console.log(`[SSE /api/events] Subscriber count after subscribe: ${issuesEventBus.getSubscriberCount()}`);
|
||||
|
||||
const unsubscribeActivity = activityEventBus.subscribe(
|
||||
(event) => {
|
||||
console.log('[SSE /api/events] Received ACTIVITY event from bus');
|
||||
write(toActivitySseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
write(SSE_HEARTBEAT_FRAME);
|
||||
}, HEARTBEAT_MS);
|
||||
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
|
||||
let lastTouchedVersion: number | null = null;
|
||||
|
||||
let isPolling = false;
|
||||
const pollLastTouched = async () => {
|
||||
if (isPolling) {
|
||||
return;
|
||||
}
|
||||
isPolling = true;
|
||||
try {
|
||||
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
|
||||
if (nextVersion === null) {
|
||||
return;
|
||||
}
|
||||
if (lastTouchedVersion === null) {
|
||||
lastTouchedVersion = nextVersion;
|
||||
return;
|
||||
}
|
||||
if (nextVersion !== lastTouchedVersion) {
|
||||
lastTouchedVersion = nextVersion;
|
||||
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
|
||||
}
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const touchedPoll = setInterval(() => {
|
||||
void pollLastTouched();
|
||||
}, LAST_TOUCHED_POLL_MS);
|
||||
void pollLastTouched();
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(touchedPoll);
|
||||
unsubscribeIssues();
|
||||
unsubscribeActivity();
|
||||
request.signal.removeEventListener('abort', close);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// stream already closed
|
||||
}
|
||||
};
|
||||
cleanup = close;
|
||||
|
||||
request.signal.addEventListener('abort', close);
|
||||
},
|
||||
cancel() {
|
||||
// Called when client closes EventSource/reader.
|
||||
// Ensures heartbeat + subscriber cleanup always runs.
|
||||
cleanup();
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,60 +1,60 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } {
|
||||
return {
|
||||
projects: projects.map((project) => ({ path: project.path })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPathFromBody(request: Request): Promise<string> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
throw new RegistryValidationError('Request body must be valid JSON.');
|
||||
}
|
||||
|
||||
const path = (body as { path?: unknown }).path;
|
||||
if (typeof path !== 'string' || path.trim().length === 0) {
|
||||
throw new RegistryValidationError('`path` is required and must be a non-empty string.');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const projects = await listProjects();
|
||||
return NextResponse.json(projectsPayload(projects), { status: 200 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await addProject(projectPath);
|
||||
return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await removeProject(projectPath);
|
||||
return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } {
|
||||
return {
|
||||
projects: projects.map((project) => ({ path: project.path })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPathFromBody(request: Request): Promise<string> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
throw new RegistryValidationError('Request body must be valid JSON.');
|
||||
}
|
||||
|
||||
const path = (body as { path?: unknown }).path;
|
||||
if (typeof path !== 'string' || path.trim().length === 0) {
|
||||
throw new RegistryValidationError('`path` is required and must be a non-empty string.');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const projects = await listProjects();
|
||||
return NextResponse.json(projectsPayload(projects), { status: 200 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await addProject(projectPath);
|
||||
return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await removeProject(projectPath);
|
||||
return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { scanForProjects } from '../../../lib/scanner';
|
||||
import type { ScanMode } from '../../../lib/scanner';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function parseMode(value: string | null): ScanMode {
|
||||
if (!value || value === 'default') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (value === 'full-drive') {
|
||||
return 'full-drive';
|
||||
}
|
||||
|
||||
throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.');
|
||||
}
|
||||
|
||||
function parseDepth(value: string | null): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('Depth must be a non-negative integer.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = parseMode(url.searchParams.get('mode'));
|
||||
const maxDepth = parseDepth(url.searchParams.get('depth'));
|
||||
const result = await scanForProjects({ mode, maxDepth });
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to scan projects.';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { scanForProjects } from '../../../lib/scanner';
|
||||
import type { ScanMode } from '../../../lib/scanner';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function parseMode(value: string | null): ScanMode {
|
||||
if (!value || value === 'default') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (value === 'full-drive') {
|
||||
return 'full-drive';
|
||||
}
|
||||
|
||||
throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.');
|
||||
}
|
||||
|
||||
function parseDepth(value: string | null): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('Depth must be a non-negative integer.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = parseMode(url.searchParams.get('mode'));
|
||||
const maxDepth = parseDepth(url.searchParams.get('depth'));
|
||||
const result = await scanForProjects({ mode, maxDepth });
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to scan projects.';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,71 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../lib/read-issues';
|
||||
import { activityEventBus } from '../../../lib/realtime';
|
||||
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap, calculateIncursions } from '../../../lib/agent-sessions';
|
||||
import { listAgents } from '../../../lib/agent-registry';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRoot)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
import { NextResponse } from 'next/server';
|
||||
import path from 'node:path';
|
||||
import { readIssuesFromDisk } from '../../../lib/read-issues';
|
||||
import { activityEventBus } from '../../../lib/realtime';
|
||||
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap, calculateIncursions } from '../../../lib/agent-sessions';
|
||||
import { listAgents } from '../../../lib/agent-registry';
|
||||
|
||||
function isValidProjectRoot(root: string): boolean {
|
||||
try {
|
||||
const resolved = path.resolve(root);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal by ensuring resolved path stays within the project root
|
||||
const allowedBase = process.cwd();
|
||||
const relative = path.relative(allowedBase, resolved);
|
||||
// If "resolved" is outside "allowedBase", "relative" will start with ".."
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootParam = url.searchParams.get('projectRoot');
|
||||
const projectRoot = projectRootParam ?? process.cwd();
|
||||
|
||||
if (projectRootParam && !isValidProjectRoot(projectRoot)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
|
||||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
const communication = await getCommunicationSummary(projectRoot);
|
||||
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
|
||||
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
|
||||
const incursions = await calculateIncursions(projectRoot, livenessMap);
|
||||
const agentsResult = await listAgents({}, { projectRoot });
|
||||
|
||||
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
feed,
|
||||
livenessMap,
|
||||
incursions,
|
||||
agents: agentsResult.data ?? []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API/Sessions] Failed to load session feed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'internal_error',
|
||||
message: 'An internal error occurred while loading the session feed.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const agentsResult = await listAgents({}, { projectRoot });
|
||||
|
||||
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
feed,
|
||||
livenessMap,
|
||||
incursions,
|
||||
agents: agentsResult.data ?? []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API/Sessions] Failed to load session feed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'internal_error',
|
||||
message: 'An internal error occurred while loading the session feed.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue