This commit is contained in:
2025-12-01 19:28:49 +01:00
parent 98a09a3f2b
commit 086fdc1ea1
8 changed files with 406 additions and 86 deletions

View File

@@ -1,79 +1,69 @@
# 🧭 Orchestrator Override — Expert Team Coordination Layer # 🧭 Orchestrator Mode
## Team Personality Layer ## Identity
All experts behave as a real elite engineering team:
- extremely concise
- radically honest
- focused on the whole system, not just their part
- minimal, purposeful dialogue when needed
- each speaks in their real-world personas voice:
- **Booch** (architecture clarity)
- **Hofstadter** (meaning, ambiguity resolution)
- **Carmack** (precision, system correctness)
- **Thompson** (minimal code correctness)
- **Rams** (design clarity)
- **Hamilton** (quality, safety)
- No expert tells another *how* to do their job.
- Experts correct each other briefly when something is structurally wrong.
Team dialogue must:
- stay extremely short (12 lines per expert if needed)
- always move toward clarity
- never repeat information
- never produce fluff
## Orchestrator Behavior
You are **Robert C. Martin**. You are **Robert C. Martin**.
Your job is to coordinate experts with: You assign objectives and coordinate the expert team.
- one cohesive objective at a time
- minimal essential context
- no methods or steps
- no technical explanation
- always the correct expert chosen by name
## “move on” Command ## Expert Personas
When the user writes **“move on”** (case-insensitive): - **Grady Booch** — architecture
- **Douglas Hofstadter** — meaning, ambiguity
- **John Carmack** — debugging, failures
- **Ken Thompson** — minimal TDD implementation
- **Dieter Rams** — design clarity
- **Margaret Hamilton** — quality & safety
- continue immediately with the next TODO Experts speak:
- if TODO list is empty, create the next logical task - extremely concise
- assign tasks autonomously using the required Roo tools - radically honest
- ALWAYS continue responding normally to the user - in their own personality
- NEVER ignore or pause user messages - only about their domain
- never explaining implementation steps
“move on” simply means: ## Team Micro-Dialogue
**continue executing TODOs autonomously and delegate the next task.** When a mode receives a task, it may briefly include a **micro-discussion**:
- only relevant experts speak
- max 1 short line each
- no repetition
- no fluff
- only insights, risks, corrections
## Objective Format Then the active mode proceeds with its tool call.
Each Orchestrator-issued task must:
- be single-purpose
- have enough context to avoid guessing
- never include method, technique, or how-to
- fit into the tool instructions required by Roo (especially new_task)
## Expert Assignment Guidance ## Orchestrator Mission
Choose experts strictly by domain: You produce **one clear objective** per step:
- **Hofstadter** → remove ambiguity - one purpose
- **Carmack** → find root cause failures - one domain area
- **Booch** → shape architecture - one reasoning path
- **Thompson** → tests + code - solvable by one expert alone
- **Rams** → design clarity
- **Hamilton** → quality and safety checks
The orchestrator does **not** tell them how. Each objective includes:
Only what needs to be accomplished. - what must happen
- minimal context
- the experts name
## Summary Output (attempt_completion for orchestration) Never include:
Orchestrator summaries must: - how
- be concise - steps
- contain stage, next expert, context, todo - methods
- never produce logs or narrative - long explanations
- prepare the next step clearly
## Team Integrity ## “move on”
The team must: When the user writes **“move on”**:
- look at the bigger picture - continue processing TODOs
- correct each other gently but directly - if TODOs exist → assign the next one
- avoid tunnel vision - if TODOs are empty → create the next logical objective
- stay coherent and aligned - always answer the user normally
- preserve Clean Architecture, TDD, BDD principles
- keep output minimal but meaningful ## Delegation Rules
- one objective at a time
- no mixed goals
- minimal wording
- always specify the expert by name
- trust the expert to know how to execute
## Completion
After an expert completes their task:
- update TODOs
- choose the next objective
- assign it
- repeat until the user stops you

View File

@@ -171,22 +171,24 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
return { success: false, error: connectionResult.error }; return { success: false, error: connectionResult.error };
} }
logger.info('Browser connection established'); logger.info('Browser connection established');
const checkAuthUseCase = container.getCheckAuthenticationUseCase(); const checkAuthUseCase = container.getCheckAuthenticationUseCase();
if (checkAuthUseCase) { if (checkAuthUseCase) {
const authResult = await checkAuthUseCase.execute(); const authResult = await checkAuthUseCase.execute({
verifyPageContent: true,
});
if (authResult.isOk()) { if (authResult.isOk()) {
const authState = authResult.unwrap(); const authState = authResult.unwrap();
if (authState !== AuthenticationState.AUTHENTICATED) { if (authState !== AuthenticationState.AUTHENTICATED) {
logger.warn('Not authenticated - automation cannot proceed', { authState }); logger.warn('Not authenticated or session expired - automation cannot proceed', { authState });
return { return {
success: false, success: false,
error: 'Not authenticated. Please login first.', error: 'Not authenticated or session expired. Please login first.',
authRequired: true, authRequired: true,
authState, authState,
}; };
} }
logger.info('Authentication verified'); logger.info('Authentication verified (cookies and page state)');
} else { } else {
logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message }); logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message });
} }

View File

@@ -142,10 +142,19 @@ export function App() {
if (result.success && result.sessionId) { if (result.success && result.sessionId) {
setSessionId(result.sessionId); setSessionId(result.sessionId);
} else { return;
setIsRunning(false);
alert(`Failed to start automation: ${result.error}`);
} }
setIsRunning(false);
if ((result as any).authRequired) {
const nextAuthState = (result as any).authState as AuthState | undefined;
setAuthState(nextAuthState ?? 'EXPIRED');
setAuthError(result.error ?? 'Authentication required before starting automation.');
return;
}
alert(`Failed to start automation: ${result.error}`);
}; };
const handleStopAutomation = async () => { const handleStopAutomation = async () => {

View File

@@ -164,9 +164,12 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
async initiateLogin(): Promise<Result<void>> { async initiateLogin(): Promise<Result<void>> {
try { try {
this.log('info', 'Opening login in Playwright browser'); const forceHeaded = true;
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
forceHeaded,
});
const connectResult = await this.browserSession.connect(); const connectResult = await this.browserSession.connect(forceHeaded);
if (!connectResult.success) { if (!connectResult.success) {
return Result.err(new Error(connectResult.error || 'Failed to connect browser')); return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
} }
@@ -183,7 +186,9 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
timeout: this.navigationTimeoutMs, timeout: this.navigationTimeoutMs,
}); });
this.log('info', 'Browser opened to login page, waiting for login...'); this.log('info', forceHeaded
? 'Browser opened to login page in headed mode, waiting for login...'
: 'Browser opened to login page, waiting for login...');
this.authState = AuthenticationState.UNKNOWN; this.authState = AuthenticationState.UNKNOWN;
const loginSuccess = await this.authFlow.waitForPostLoginRedirect( const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
@@ -219,7 +224,6 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
try { try {
await this.browserSession.disconnect(); await this.browserSession.disconnect();
} catch { } catch {
// ignore cleanup errors
} }
return Result.err(error instanceof Error ? error : new Error(message)); return Result.err(error instanceof Error ? error : new Error(message));
@@ -370,9 +374,9 @@ export class PlaywrightAuthSessionService implements IAuthenticationService {
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED; cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
const pageAuthenticated = const pageAuthenticated =
(isOnAuthenticatedPath && !isOnLoginPath && cookiesValid) || !hasLoginUI &&
hasAuthUI || !isOnLoginPath &&
(!hasLoginUI && !isOnLoginPath); ((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
this.log('debug', 'Page authentication check', { this.log('debug', 'Page authentication check', {
url, url,

View File

@@ -90,8 +90,23 @@ export class PlaywrightBrowserSession {
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> { async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
if (this.connected && this.page) { if (this.connected && this.page) {
this.log('debug', 'Already connected, reusing existing connection'); const shouldReuse =
return { success: true }; !forceHeaded ||
this.actualBrowserMode === 'headed';
if (shouldReuse) {
this.log('debug', 'Already connected, reusing existing connection', {
browserMode: this.actualBrowserMode,
forcedHeaded: forceHeaded,
});
return { success: true };
}
this.log('info', 'Existing browser connection is headless, reopening in headed mode for login', {
browserMode: this.actualBrowserMode,
forcedHeaded: forceHeaded,
});
await this.closeBrowserContext();
} }
if (this.isConnecting) { if (this.isConnecting) {

View File

@@ -105,4 +105,55 @@ describeMaybe('Real-site hosted session smoke login and wizard entry (member
}, },
300_000, 300_000,
); );
it(
'detects login guard and does not attempt Create a Race when not authenticated',
async () => {
const step1Result = await adapter.executeStep(StepId.create(1), {});
expect(step1Result.success).toBe(true);
const page = adapter.getPage();
expect(page).not.toBeNull();
const currentUrl = page!.url();
expect(currentUrl).not.toEqual('about:blank');
expect(currentUrl.toLowerCase()).toContain('iracing');
expect(currentUrl.toLowerCase()).toSatisfy((u: string) =>
u.includes('oauth.iracing.com') ||
u.includes('members.iracing.com') ||
u.includes('/login'),
);
const emailInput = page!
.locator(IRACING_SELECTORS.login.emailInput)
.first();
const passwordInput = page!
.locator(IRACING_SELECTORS.login.passwordInput)
.first();
const hasEmail = (await emailInput.count()) > 0;
const hasPassword = (await passwordInput.count()) > 0;
if (!hasEmail && !hasPassword) {
return;
}
await emailInput.waitFor({
state: 'visible',
timeout: IRACING_TIMEOUTS.elementWait,
});
await passwordInput.waitFor({
state: 'visible',
timeout: IRACING_TIMEOUTS.elementWait,
});
const createRaceButton = page!
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
.first();
const createRaceCount = await createRaceButton.count();
expect(createRaceCount).toBe(0);
},
300_000,
);
}); });

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
import type { ILogger } from '../../../packages/application/ports/ILogger';
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
import { Result } from '../../../packages/shared/result/Result';
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
const originalEnv = { ...process.env };
let mockBrowserSession: PlaywrightBrowserSession;
let mockCookieStore: SessionCookieStore;
let mockAuthFlow: IPlaywrightAuthFlow;
let mockLogger: ILogger;
let mockPage: Page;
beforeEach(() => {
process.env = { ...originalEnv };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
isClosed: vi.fn().mockReturnValue(false),
} as unknown as Page;
mockBrowserSession = {
connect: vi.fn().mockResolvedValue({ success: true }),
disconnect: vi.fn().mockResolvedValue(undefined),
getPersistentContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
getContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
getPage: vi.fn().mockReturnValue(mockPage),
getUserDataDir: vi.fn().mockReturnValue(''),
} as unknown as PlaywrightBrowserSession;
mockCookieStore = {
read: vi.fn().mockResolvedValue({
cookies: [],
origins: [],
}),
write: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
validateCookies: vi.fn().mockReturnValue(AuthenticationState.UNKNOWN),
getSessionExpiry: vi.fn(),
getValidCookiesForUrl: vi.fn().mockReturnValue([]),
} as unknown as SessionCookieStore;
mockAuthFlow = {
getLoginUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/login'),
getPostLoginLandingUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
isLoginUrl: vi.fn().mockReturnValue(false),
isAuthenticatedUrl: vi.fn().mockReturnValue(true),
isLoginSuccessUrl: vi.fn().mockReturnValue(true),
detectAuthenticatedUi: vi.fn().mockResolvedValue(true),
detectLoginUi: vi.fn().mockResolvedValue(false),
navigateToAuthenticatedArea: vi.fn().mockResolvedValue(undefined),
waitForPostLoginRedirect: vi.fn().mockResolvedValue(true),
} as unknown as IPlaywrightAuthFlow;
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
function createService() {
return new PlaywrightAuthSessionService(
mockBrowserSession,
mockCookieStore,
mockAuthFlow,
mockLogger,
{
navigationTimeoutMs: 1000,
loginWaitTimeoutMs: 1000,
},
);
}
it('always forces headed browser for login regardless of browser mode configuration', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockBrowserSession.connect).toHaveBeenCalledWith(true);
});
it('navigates the headed page to the non-blank login URL', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockAuthFlow.getLoginUrl).toHaveBeenCalledTimes(2);
expect(mockPage.goto).toHaveBeenCalledWith(
'https://members-ng.iracing.com/login',
expect.objectContaining({
waitUntil: 'domcontentloaded',
}),
);
const calledUrl = (mockPage.goto as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(calledUrl).not.toEqual('about:blank');
});
it('propagates connection failure from browserSession.connect', async () => {
(mockBrowserSession.connect as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
success: false,
error: 'boom',
});
const service = createService();
const result = await service.initiateLogin();
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err).toBeInstanceOf(Error);
expect(err.message).toContain('boom');
});
it('logs explicit headed login message for human companion flow', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockLogger.info).toHaveBeenCalledWith(
'Opening login in headed Playwright browser (forceHeaded=true)',
expect.objectContaining({ forceHeaded: true }),
);
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest';
import type { Page, Locator } from 'playwright';
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../packages/domain/value-objects/BrowserAuthenticationState';
import type { ILogger } from '../../../packages/application/ports/ILogger';
import type { Result } from '../../../packages/shared/result/Result';
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
function createService(deps: {
pageUrl: string;
hasLoginUi: boolean;
hasAuthUi: boolean;
cookieState: AuthenticationState;
}) {
const mockLogger: ILogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const mockLocator: Locator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockImplementation(async () => deps.hasLoginUi),
} as unknown as Locator;
const mockPage: Page = {
url: vi.fn().mockReturnValue(deps.pageUrl),
locator: vi.fn().mockReturnValue(mockLocator),
} as unknown as Page;
const mockBrowserSession: PlaywrightBrowserSession = {
getPersistentContext: vi.fn().mockReturnValue(null),
getContext: vi.fn().mockReturnValue(null),
getPage: vi.fn().mockReturnValue(mockPage),
} as unknown as PlaywrightBrowserSession;
const mockCookieStore: SessionCookieStore = {
read: vi.fn().mockResolvedValue({
cookies: [{ name: 'XSESSIONID', value: 'abc', domain: 'members-ng.iracing.com', path: '/', expires: -1 }],
origins: [],
}),
validateCookies: vi.fn().mockReturnValue(deps.cookieState),
getSessionExpiry: vi.fn(),
write: vi.fn(),
delete: vi.fn(),
} as unknown as SessionCookieStore;
const mockAuthFlow: IPlaywrightAuthFlow = {
getLoginUrl: () => 'https://members-ng.iracing.com/login',
getPostLoginLandingUrl: () => 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
isLoginUrl: (url: string) => url.includes('/login'),
isAuthenticatedUrl: (url: string) => url.includes('/web/racing/hosted'),
isLoginSuccessUrl: (url: string) => url.includes('/web/racing/hosted'),
detectAuthenticatedUi: vi.fn().mockResolvedValue(deps.hasAuthUi),
detectLoginUi: vi.fn(),
navigateToAuthenticatedArea: vi.fn(),
waitForPostLoginRedirect: vi.fn(),
} as unknown as IPlaywrightAuthFlow;
const service = new PlaywrightAuthSessionService(
mockBrowserSession,
mockCookieStore,
mockAuthFlow,
mockLogger,
);
return { service, mockCookieStore, mockAuthFlow, mockPage };
}
it('treats cookies-valid + login UI as EXPIRED (page wins over cookies)', async () => {
const { service } = createService({
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
hasLoginUi: true,
hasAuthUi: false,
cookieState: AuthenticationState.AUTHENTICATED,
});
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
expect(result.isOk()).toBe(true);
const browserState = result.unwrap();
expect(browserState.getCookieValidity()).toBe(true);
expect(browserState.getPageAuthenticationStatus()).toBe(false);
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
expect(browserState.requiresReauthentication()).toBe(true);
});
it('treats cookies-valid + authenticated UI without login UI as AUTHENTICATED', async () => {
const { service } = createService({
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
hasLoginUi: false,
hasAuthUi: true,
cookieState: AuthenticationState.AUTHENTICATED,
});
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
expect(result.isOk()).toBe(true);
const browserState = result.unwrap();
expect(browserState.getCookieValidity()).toBe(true);
expect(browserState.getPageAuthenticationStatus()).toBe(true);
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
expect(browserState.requiresReauthentication()).toBe(false);
});
});