many fixes
- JWT refresh logic to prevent dead sessions - drone status messages now arrive in IDE for display - WorkspaceService.deployProject method added to clone into a repo or create the directory (new project not yet in git)
This commit is contained in:
parent
c5e5d16a51
commit
bab0b1810f
@ -51,7 +51,7 @@ interface AppContextType {
|
|||||||
onSignOut: () => void;
|
onSignOut: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppContext = createContext<AppContextType | null>(null);
|
export const AppContext = createContext<AppContextType | null>(null);
|
||||||
|
|
||||||
export function useAppContext(): AppContextType {
|
export function useAppContext(): AppContextType {
|
||||||
const ctx = useContext(AppContext);
|
const ctx = useContext(AppContext);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { socketClient } from '../lib/socket';
|
import { socketClient } from '../lib/socket';
|
||||||
|
|
||||||
export type ConnectionStatus = 'connected' | 'connecting' | 'error' | 'disconnected';
|
export type ConnectionStatus = 'connected' | 'connecting' | 'error' | 'disconnected';
|
||||||
@ -33,6 +33,25 @@ function ConnectionIndicator({ status }: { status: ConnectionStatus }) {
|
|||||||
|
|
||||||
export default function StatusBar({ statusMessage = 'Ready.', projectSlug, sessionMode }: StatusBarProps) {
|
export default function StatusBar({ statusMessage = 'Ready.', projectSlug, sessionMode }: StatusBarProps) {
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
||||||
|
const [displayMessage, setDisplayMessage] = useState<string>(statusMessage);
|
||||||
|
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clearTimerRef.current) {
|
||||||
|
clearTimeout(clearTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayMessage(statusMessage);
|
||||||
|
clearTimerRef.current = setTimeout(() => {
|
||||||
|
setDisplayMessage('');
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (clearTimerRef.current) {
|
||||||
|
clearTimeout(clearTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [statusMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
@ -59,7 +78,7 @@ export default function StatusBar({ statusMessage = 'Ready.', projectSlug, sessi
|
|||||||
return (
|
return (
|
||||||
<footer className="h-status flex items-center justify-between px-4 bg-bg-secondary border-t border-border-subtle text-sm text-text-muted shrink-0">
|
<footer className="h-status flex items-center justify-between px-4 bg-bg-secondary border-t border-border-subtle text-sm text-text-muted shrink-0">
|
||||||
<div className="flex-1 truncate">
|
<div className="flex-1 truncate">
|
||||||
{statusMessage}
|
{displayMessage}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
const API_BASE = "";
|
const API_BASE = "";
|
||||||
|
|
||||||
const TOKEN_KEY = "dtp_auth_token";
|
const TOKEN_KEY = "dtp_auth_token";
|
||||||
|
const USER_KEY = "dtp_user";
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@ -18,10 +22,63 @@ function getToken(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUser(): User | null {
|
||||||
|
try {
|
||||||
|
const userData = localStorage.getItem(USER_KEY);
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(user: User | null): void {
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthToken(): Promise<string> {
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/auth/renew-token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text) as ApiResponse<{ token: string }>;
|
||||||
|
if (!json.success || !json.data?.token) {
|
||||||
|
throw new Error(json.message || "Token refresh failed");
|
||||||
|
}
|
||||||
|
return json.data.token;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid refresh response: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: Record<string, unknown>,
|
body?: Record<string, unknown>,
|
||||||
|
retryCount = 0,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@ -43,6 +100,28 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, options);
|
const response = await fetch(`${API_BASE}${path}`, options);
|
||||||
|
|
||||||
|
if (response.status === 401 && retryCount === 0) {
|
||||||
|
try {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = refreshAuthToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
setToken(newToken);
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
|
||||||
|
return request<T>(method, path, body, retryCount + 1);
|
||||||
|
} catch {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
signOut();
|
||||||
|
throw new Error("Session expired. Please sign in again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useContext } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { socketClient } from '../lib/socket';
|
import { socketClient } from '../lib/socket';
|
||||||
import { chatSessionApi, projectApi, type ChatSession, type ChatTurn, type Project } from '../lib/api';
|
import { chatSessionApi, projectApi, type ChatSession, type ChatTurn, type Project } from '../lib/api';
|
||||||
@ -6,6 +6,7 @@ import { WorkspaceMode } from '../lib/types';
|
|||||||
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
||||||
import FilesPanel from '../components/FilesPanel';
|
import FilesPanel from '../components/FilesPanel';
|
||||||
import LogPanel from '../components/LogPanel';
|
import LogPanel from '../components/LogPanel';
|
||||||
|
import { AppContext } from '../App';
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@ -32,6 +33,7 @@ export default function ChatSessionView() {
|
|||||||
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const socket = socketClient.socket;
|
const socket = socketClient.socket;
|
||||||
|
const appContext = useContext(AppContext);
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [session, setSession] = useState<ChatSession | null>(null);
|
const [session, setSession] = useState<ChatSession | null>(null);
|
||||||
@ -124,6 +126,7 @@ export default function ChatSessionView() {
|
|||||||
socket.on('workOrderComplete', handleWorkOrderComplete);
|
socket.on('workOrderComplete', handleWorkOrderComplete);
|
||||||
socket.on('workspaceModeChanged', handleWorkspaceModeChanged);
|
socket.on('workspaceModeChanged', handleWorkspaceModeChanged);
|
||||||
socket.on('log:entry', handleLogEntry);
|
socket.on('log:entry', handleLogEntry);
|
||||||
|
socket.on('status', handleStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanupSocketListeners = () => {
|
const cleanupSocketListeners = () => {
|
||||||
@ -135,6 +138,7 @@ export default function ChatSessionView() {
|
|||||||
socket.off('workOrderComplete', handleWorkOrderComplete);
|
socket.off('workOrderComplete', handleWorkOrderComplete);
|
||||||
socket.off('workspaceModeChanged', handleWorkspaceModeChanged);
|
socket.off('workspaceModeChanged', handleWorkspaceModeChanged);
|
||||||
socket.off('log:entry', handleLogEntry);
|
socket.off('log:entry', handleLogEntry);
|
||||||
|
socket.off('status', handleStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThinking = (content: string) => {
|
const handleThinking = (content: string) => {
|
||||||
@ -210,6 +214,10 @@ export default function ChatSessionView() {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStatus = (content: string) => {
|
||||||
|
appContext?.setStatusMessage(content);
|
||||||
|
};
|
||||||
|
|
||||||
const showToast = (message: string) => {
|
const showToast = (message: string) => {
|
||||||
setToast(message);
|
setToast(message);
|
||||||
if (toastTimerRef.current) {
|
if (toastTimerRef.current) {
|
||||||
|
|||||||
@ -84,14 +84,20 @@ export class AuthApiControllerV1 extends DtpController {
|
|||||||
|
|
||||||
async postRenewToken(req: Request, res: Response): Promise<void> {
|
async postRenewToken(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const user = await SessionService.verifyJsonWebToken(req.body.token);
|
if (!req.user) {
|
||||||
const token = await SessionService.createJsonWebToken(user);
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "No valid session found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await SessionService.createJsonWebToken(req.user);
|
||||||
req.session.token = token;
|
req.session.token = token;
|
||||||
this.log.info("user session token renewed", {
|
this.log.info("user session token renewed", {
|
||||||
user: {
|
user: {
|
||||||
_id: user._id,
|
_id: req.user._id,
|
||||||
displayName: user.displayName,
|
displayName: req.user.displayName,
|
||||||
flags: user.flags,
|
flags: req.user.flags,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.status(200).json({ success: true, token });
|
res.status(200).json({ success: true, token });
|
||||||
|
|||||||
@ -74,6 +74,7 @@ class WorkspaceService extends GadgetService {
|
|||||||
private workspaceFile: string = "";
|
private workspaceFile: string = "";
|
||||||
private _workspaceData: WorkspaceData | null = null;
|
private _workspaceData: WorkspaceData | null = null;
|
||||||
|
|
||||||
|
private workspaceGit?: SimpleGit;
|
||||||
private repos = new Map<string, SimpleGit>();
|
private repos = new Map<string, SimpleGit>();
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
@ -116,6 +117,9 @@ class WorkspaceService extends GadgetService {
|
|||||||
// Load or create workspace data
|
// Load or create workspace data
|
||||||
await this.loadOrCreateWorkspaceData(workspaceDir);
|
await this.loadOrCreateWorkspaceData(workspaceDir);
|
||||||
|
|
||||||
|
const options: Partial<SimpleGitOptions> = { baseDir: workspaceDir };
|
||||||
|
this.workspaceGit = simpleGit(options);
|
||||||
|
|
||||||
this.log.info("workspace initialized", {
|
this.log.info("workspace initialized", {
|
||||||
workspaceId: this._workspaceData?.workspaceId,
|
workspaceId: this._workspaceData?.workspaceId,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
@ -237,13 +241,33 @@ class WorkspaceService extends GadgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deployProject(project: IProject): Promise<void> {
|
async deployProject(project: IProject): Promise<void> {
|
||||||
assert(this.workspaceData, "workspace uninitialized");
|
assert(this.workspaceData, "workspace is uninitialized");
|
||||||
|
assert(this.workspaceGit, "workspace git interface is uninitialized");
|
||||||
|
|
||||||
|
const projectDir = this.getProjectDirectory(project.slug);
|
||||||
if (project.gitUrl) {
|
if (project.gitUrl) {
|
||||||
|
const oldDir = process.cwd();
|
||||||
|
|
||||||
|
process.chdir(this.workspaceData.workspaceDir);
|
||||||
|
this.log.info("cloning into git repo", {
|
||||||
|
url: project.gitUrl,
|
||||||
|
projectDir,
|
||||||
|
});
|
||||||
|
this.workspaceGit.clone(project.gitUrl, projectDir);
|
||||||
|
|
||||||
|
process.chdir(projectDir);
|
||||||
const git: SimpleGit = this.getGitRepo(project.slug);
|
const git: SimpleGit = this.getGitRepo(project.slug);
|
||||||
git.clone(project.gitUrl);
|
const status = await git.status();
|
||||||
|
this.log.info("project deployed", {
|
||||||
|
projectDir,
|
||||||
|
isClean: status.isClean(),
|
||||||
|
branch: status.current,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log.info("creating project directory", { projectDir });
|
||||||
|
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user