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:
Rob Colbert 2026-05-03 04:15:48 -04:00
parent c5e5d16a51
commit bab0b1810f
6 changed files with 147 additions and 11 deletions

View File

@ -51,7 +51,7 @@ interface AppContextType {
onSignOut: () => void;
}
const AppContext = createContext<AppContextType | null>(null);
export const AppContext = createContext<AppContextType | null>(null);
export function useAppContext(): AppContextType {
const ctx = useContext(AppContext);

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { socketClient } from '../lib/socket';
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) {
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(() => {
const updateStatus = () => {
@ -59,7 +78,7 @@ export default function StatusBar({ statusMessage = 'Ready.', projectSlug, sessi
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">
<div className="flex-1 truncate">
{statusMessage}
{displayMessage}
</div>
<div className="flex items-center gap-4">

View File

@ -1,6 +1,10 @@
const API_BASE = "";
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> {
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>(
method: string,
path: string,
body?: Record<string, unknown>,
retryCount = 0,
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
@ -43,6 +100,28 @@ async function request<T>(
}
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();
if (!text) {

View File

@ -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 { socketClient } from '../lib/socket';
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 FilesPanel from '../components/FilesPanel';
import LogPanel from '../components/LogPanel';
import { AppContext } from '../App';
interface ChatMessage {
id: string;
@ -32,6 +33,7 @@ export default function ChatSessionView() {
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
const navigate = useNavigate();
const socket = socketClient.socket;
const appContext = useContext(AppContext);
const [project, setProject] = useState<Project | null>(null);
const [session, setSession] = useState<ChatSession | null>(null);
@ -124,6 +126,7 @@ export default function ChatSessionView() {
socket.on('workOrderComplete', handleWorkOrderComplete);
socket.on('workspaceModeChanged', handleWorkspaceModeChanged);
socket.on('log:entry', handleLogEntry);
socket.on('status', handleStatus);
};
const cleanupSocketListeners = () => {
@ -135,6 +138,7 @@ export default function ChatSessionView() {
socket.off('workOrderComplete', handleWorkOrderComplete);
socket.off('workspaceModeChanged', handleWorkspaceModeChanged);
socket.off('log:entry', handleLogEntry);
socket.off('status', handleStatus);
};
const handleThinking = (content: string) => {
@ -210,6 +214,10 @@ export default function ChatSessionView() {
]);
};
const handleStatus = (content: string) => {
appContext?.setStatusMessage(content);
};
const showToast = (message: string) => {
setToast(message);
if (toastTimerRef.current) {

View File

@ -84,14 +84,20 @@ export class AuthApiControllerV1 extends DtpController {
async postRenewToken(req: Request, res: Response): Promise<void> {
try {
const user = await SessionService.verifyJsonWebToken(req.body.token);
const token = await SessionService.createJsonWebToken(user);
if (!req.user) {
res.status(401).json({
success: false,
message: "No valid session found",
});
return;
}
const token = await SessionService.createJsonWebToken(req.user);
req.session.token = token;
this.log.info("user session token renewed", {
user: {
_id: user._id,
displayName: user.displayName,
flags: user.flags,
_id: req.user._id,
displayName: req.user.displayName,
flags: req.user.flags,
},
});
res.status(200).json({ success: true, token });

View File

@ -74,6 +74,7 @@ class WorkspaceService extends GadgetService {
private workspaceFile: string = "";
private _workspaceData: WorkspaceData | null = null;
private workspaceGit?: SimpleGit;
private repos = new Map<string, SimpleGit>();
get name(): string {
@ -116,6 +117,9 @@ class WorkspaceService extends GadgetService {
// Load or create workspace data
await this.loadOrCreateWorkspaceData(workspaceDir);
const options: Partial<SimpleGitOptions> = { baseDir: workspaceDir };
this.workspaceGit = simpleGit(options);
this.log.info("workspace initialized", {
workspaceId: this._workspaceData?.workspaceId,
workspaceDir,
@ -237,13 +241,33 @@ class WorkspaceService extends GadgetService {
}
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) {
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);
git.clone(project.gitUrl);
const status = await git.status();
this.log.info("project deployed", {
projectDir,
isClean: status.isClean(),
branch: status.current,
});
return;
}
this.log.info("creating project directory", { projectDir });
await fs.promises.mkdir(projectDir, { recursive: true });
}
/**