diff --git a/CLAUDE.md b/CLAUDE.md index 76c1dfc1d..a1988cfca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -619,6 +619,92 @@ const result = await this.executeCommand>('data/list' --- +## πŸ¦€ RUST β†’ TYPESCRIPT TYPE BOUNDARIES (ts-rs) + +**Single source of truth: Rust defines wire types, ts-rs generates TypeScript. NEVER hand-write duplicate types.** + +### How It Works + +1. **Rust struct** with `#[derive(TS)]` defines the canonical type +2. **ts-rs macro** generates TypeScript `export type` at compile time +3. **TypeScript** imports from `shared/generated/` β€” no manual duplication +4. **Serde** handles JSON serialization on both sides + +### Pattern + +```rust +// Rust (source of truth) +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/WriteResult.ts")] +pub struct WriteResult { + pub success: bool, + #[ts(optional)] + pub change_id: Option, + pub file_path: String, + #[ts(type = "number")] // u64 β†’ number (not bigint) + pub bytes_written: u64, + #[ts(optional)] + pub error: Option, +} +``` + +```typescript +// TypeScript (generated β€” DO NOT EDIT) +export type WriteResult = { success: boolean, change_id?: string, file_path: string, bytes_written: number, error?: string }; + +// Consuming code imports from generated barrel +import type { WriteResult, ReadResult, EditMode } from '@shared/generated/code'; +``` + +### ts-rs Attribute Reference + +| Attribute | Purpose | Example | +|-----------|---------|---------| +| `#[ts(export)]` | Mark for TS generation | `#[derive(TS)] #[ts(export)]` | +| `#[ts(export_to = "path")]` | Output file path (relative to `bindings/`) | `"../../../shared/generated/code/X.ts"` | +| `#[ts(type = "string")]` | Override TS type for field | Uuid β†’ string | +| `#[ts(type = "number")]` | Override TS type for field | u64 β†’ number | +| `#[ts(optional)]` | Mark as optional in TS | Option β†’ `field?: T` | +| `#[ts(type = "Array")]` | Complex type mapping | Vec β†’ Array | + +### Regenerating Bindings + +```bash +cargo test --package continuum-core --lib # Generates all *.ts in shared/generated/ +``` + +### Generated Output Structure + +``` +shared/generated/ +β”œβ”€β”€ index.ts # Barrel export (re-exports all modules) +β”œβ”€β”€ code/ # Code module (file ops, change graph, search, tree) +β”‚ β”œβ”€β”€ index.ts +β”‚ β”œβ”€β”€ ChangeNode.ts, EditMode.ts, WriteResult.ts, ReadResult.ts, ... +β”œβ”€β”€ persona/ # Persona cognition (state, inbox, channels) +β”‚ β”œβ”€β”€ index.ts +β”‚ β”œβ”€β”€ PersonaState.ts, InboxMessage.ts, CognitionDecision.ts, ... +β”œβ”€β”€ rag/ # RAG pipeline (context, messages, options) +β”‚ β”œβ”€β”€ index.ts +β”‚ β”œβ”€β”€ RagContext.ts, LlmMessage.ts, ... +└── ipc/ # IPC protocol types + β”œβ”€β”€ index.ts + └── InboxMessageRequest.ts +``` + +### Rules (Non-Negotiable) + +1. **NEVER hand-write types that cross the Rust↔TS boundary** β€” add `#[derive(TS)]` to the Rust struct +2. **NEVER use `object`, `any`, `unknown`, or `Record`** for Rust wire types β€” import the generated type +3. **IDs are `UUID`** (from `CrossPlatformUUID`) β€” never plain `string` for identity fields +4. **Use `CommandParams.userId`** for caller identity β€” it's already on the base type, auto-injected by infrastructure +5. **Barrel exports** β€” every generated module has an `index.ts`; import from the barrel, not individual files +6. **Regenerate after Rust changes** β€” `cargo test` triggers ts-rs macro; commit both Rust and generated TS + +--- + ## πŸ“ PATH ALIASES (New! Use These Going Forward) **TypeScript path aliases are now configured** to eliminate relative import hell (`../../../../`). diff --git a/src/debug/jtag/.gitignore b/src/debug/jtag/.gitignore index e3cafa747..688e7aa89 100644 --- a/src/debug/jtag/.gitignore +++ b/src/debug/jtag/.gitignore @@ -16,6 +16,29 @@ models/ # Persona cognitive logs (mind, body, soul, cns) .continuum/personas/*/logs/ +# Persona workspaces (sandbox mode runtime data) +.continuum/personas/*/workspace/ + +# Session runtime data +.continuum/sessions/ + +# Reports (generated at runtime) +.continuum/reports/ + +# Blobs (uploaded files, media) +.continuum/blobs/ + +# Shared runtime state +.continuum/shared/ + +# Runtime config +.continuum/logging.json +.continuum/test-jobs.json +.continuum/.DS_Store + +# Media uploads +.continuum/media/ + # Temporary files /tmp/ *.pyc diff --git a/src/debug/jtag/api/data-seed/RoomDataSeed.ts b/src/debug/jtag/api/data-seed/RoomDataSeed.ts index a38977d2e..77f20523f 100644 --- a/src/debug/jtag/api/data-seed/RoomDataSeed.ts +++ b/src/debug/jtag/api/data-seed/RoomDataSeed.ts @@ -215,6 +215,37 @@ export class RoomDataSeed { newsroom.tags = ['news', 'current-events', 'awareness']; rooms.push(newsroom); + // Code room - collaborative software development + const code = new RoomEntity(); + code.uniqueId = ROOM_UNIQUE_IDS.CODE; + code.name = 'code'; + code.displayName = 'Code'; + code.description = 'Collaborative coding β€” reading, writing, reviewing, and shipping code as a team'; + code.topic = 'Software development with real tools and real agent loops'; + code.type = 'public'; + code.status = 'active'; + code.ownerId = humanUserId; + code.lastMessageAt = now; + code.recipeId = 'coding'; + code.privacy = { + isPublic: true, + requiresInvite: false, + allowGuestAccess: false, + searchable: true + }; + code.settings = { + allowThreads: true, + allowReactions: true, + allowFileSharing: true, + messageRetentionDays: 365, + slowMode: 0 + }; + code.members = [ + { userId: humanUserId, role: 'owner', joinedAt: now } + ]; + code.tags = ['coding', 'development', 'engineering']; + rooms.push(code); + return { rooms: rooms as readonly RoomEntity[], totalCount: rooms.length, diff --git a/src/debug/jtag/browser/generated.ts b/src/debug/jtag/browser/generated.ts index d65766765..a526bda02 100644 --- a/src/debug/jtag/browser/generated.ts +++ b/src/debug/jtag/browser/generated.ts @@ -1,7 +1,7 @@ /** * Browser Structure Registry - Auto-generated * - * Contains 11 daemons and 166 commands and 2 adapters and 27 widgets. + * Contains 11 daemons and 186 commands and 2 adapters and 28 widgets. * Generated by scripts/generate-structure.ts - DO NOT EDIT MANUALLY */ @@ -43,6 +43,21 @@ import { AIValidateResponseBrowserCommand } from './../commands/ai/validate-resp import { CanvasStrokeAddBrowserCommand } from './../commands/canvas/stroke/add/browser/CanvasStrokeAddBrowserCommand'; import { CanvasStrokeListBrowserCommand } from './../commands/canvas/stroke/list/browser/CanvasStrokeListBrowserCommand'; import { CanvasVisionBrowserCommand } from './../commands/canvas/vision/browser/CanvasVisionBrowserCommand'; +import { CodeDiffBrowserCommand } from './../commands/code/diff/browser/CodeDiffBrowserCommand'; +import { CodeEditBrowserCommand } from './../commands/code/edit/browser/CodeEditBrowserCommand'; +import { CodeGitBrowserCommand } from './../commands/code/git/browser/CodeGitBrowserCommand'; +import { CodeHistoryBrowserCommand } from './../commands/code/history/browser/CodeHistoryBrowserCommand'; +import { CodeReadBrowserCommand } from './../commands/code/read/browser/CodeReadBrowserCommand'; +import { CodeSearchBrowserCommand } from './../commands/code/search/browser/CodeSearchBrowserCommand'; +import { CodeShellExecuteBrowserCommand } from './../commands/code/shell/execute/browser/CodeShellExecuteBrowserCommand'; +import { CodeShellKillBrowserCommand } from './../commands/code/shell/kill/browser/CodeShellKillBrowserCommand'; +import { CodeShellSentinelBrowserCommand } from './../commands/code/shell/sentinel/browser/CodeShellSentinelBrowserCommand'; +import { CodeShellStatusBrowserCommand } from './../commands/code/shell/status/browser/CodeShellStatusBrowserCommand'; +import { CodeShellWatchBrowserCommand } from './../commands/code/shell/watch/browser/CodeShellWatchBrowserCommand'; +import { CodeTreeBrowserCommand } from './../commands/code/tree/browser/CodeTreeBrowserCommand'; +import { CodeUndoBrowserCommand } from './../commands/code/undo/browser/CodeUndoBrowserCommand'; +import { CodeVerifyBrowserCommand } from './../commands/code/verify/browser/CodeVerifyBrowserCommand'; +import { CodeWriteBrowserCommand } from './../commands/code/write/browser/CodeWriteBrowserCommand'; import { ActivityUserPresentCommand } from './../commands/collaboration/activity/user-present/browser/ActivityUserPresentCommand'; import { ChatAnalyzeBrowserCommand } from './../commands/collaboration/chat/analyze/browser/ChatAnalyzeBrowserCommand'; import { ChatExportBrowserCommand } from './../commands/collaboration/chat/export/browser/ChatExportBrowserCommand'; @@ -141,6 +156,11 @@ import { SessionCreateBrowserCommand } from './../commands/session/create/browse import { SessionDestroyBrowserCommand } from './../commands/session/destroy/browser/SessionDestroyBrowserCommand'; import { SessionGetIdBrowserCommand } from './../commands/session/get-id/browser/SessionGetIdBrowserCommand'; import { SessionGetUserBrowserCommand } from './../commands/session/get-user/browser/SessionGetUserBrowserCommand'; +import { SkillActivateBrowserCommand } from './../commands/skill/activate/browser/SkillActivateBrowserCommand'; +import { SkillGenerateBrowserCommand } from './../commands/skill/generate/browser/SkillGenerateBrowserCommand'; +import { SkillListBrowserCommand } from './../commands/skill/list/browser/SkillListBrowserCommand'; +import { SkillProposeBrowserCommand } from './../commands/skill/propose/browser/SkillProposeBrowserCommand'; +import { SkillValidateBrowserCommand } from './../commands/skill/validate/browser/SkillValidateBrowserCommand'; import { SocialBrowseBrowserCommand } from './../commands/social/browse/browser/SocialBrowseBrowserCommand'; import { SocialClassifyBrowserCommand } from './../commands/social/classify/browser/SocialClassifyBrowserCommand'; import { SocialCommentBrowserCommand } from './../commands/social/comment/browser/SocialCommentBrowserCommand'; @@ -216,6 +236,7 @@ import { SettingsWidget } from './../widgets/settings/SettingsWidget'; import { PanelLayoutWidget } from './../widgets/shared/PanelLayoutWidget'; import { ThemeWidget } from './../widgets/shared/ThemeWidget'; import { SidebarWidget } from './../widgets/sidebar/SidebarWidget'; +import { TerminalWidget } from './../widgets/terminal/TerminalWidget'; import { UserProfileWidget } from './../widgets/user-profile/UserProfileWidget'; import { WebViewWidget } from './../widgets/web-view/WebViewWidget'; @@ -407,6 +428,81 @@ export const BROWSER_COMMANDS: CommandEntry[] = [ className: 'CanvasVisionBrowserCommand', commandClass: CanvasVisionBrowserCommand }, +{ + name: 'code/diff', + className: 'CodeDiffBrowserCommand', + commandClass: CodeDiffBrowserCommand + }, +{ + name: 'code/edit', + className: 'CodeEditBrowserCommand', + commandClass: CodeEditBrowserCommand + }, +{ + name: 'code/git', + className: 'CodeGitBrowserCommand', + commandClass: CodeGitBrowserCommand + }, +{ + name: 'code/history', + className: 'CodeHistoryBrowserCommand', + commandClass: CodeHistoryBrowserCommand + }, +{ + name: 'code/read', + className: 'CodeReadBrowserCommand', + commandClass: CodeReadBrowserCommand + }, +{ + name: 'code/search', + className: 'CodeSearchBrowserCommand', + commandClass: CodeSearchBrowserCommand + }, +{ + name: 'code/shell/execute', + className: 'CodeShellExecuteBrowserCommand', + commandClass: CodeShellExecuteBrowserCommand + }, +{ + name: 'code/shell/kill', + className: 'CodeShellKillBrowserCommand', + commandClass: CodeShellKillBrowserCommand + }, +{ + name: 'code/shell/sentinel', + className: 'CodeShellSentinelBrowserCommand', + commandClass: CodeShellSentinelBrowserCommand + }, +{ + name: 'code/shell/status', + className: 'CodeShellStatusBrowserCommand', + commandClass: CodeShellStatusBrowserCommand + }, +{ + name: 'code/shell/watch', + className: 'CodeShellWatchBrowserCommand', + commandClass: CodeShellWatchBrowserCommand + }, +{ + name: 'code/tree', + className: 'CodeTreeBrowserCommand', + commandClass: CodeTreeBrowserCommand + }, +{ + name: 'code/undo', + className: 'CodeUndoBrowserCommand', + commandClass: CodeUndoBrowserCommand + }, +{ + name: 'code/verify', + className: 'CodeVerifyBrowserCommand', + commandClass: CodeVerifyBrowserCommand + }, +{ + name: 'code/write', + className: 'CodeWriteBrowserCommand', + commandClass: CodeWriteBrowserCommand + }, { name: 'collaboration/activity/user-present', className: 'ActivityUserPresentCommand', @@ -897,6 +993,31 @@ export const BROWSER_COMMANDS: CommandEntry[] = [ className: 'SessionGetUserBrowserCommand', commandClass: SessionGetUserBrowserCommand }, +{ + name: 'skill/activate', + className: 'SkillActivateBrowserCommand', + commandClass: SkillActivateBrowserCommand + }, +{ + name: 'skill/generate', + className: 'SkillGenerateBrowserCommand', + commandClass: SkillGenerateBrowserCommand + }, +{ + name: 'skill/list', + className: 'SkillListBrowserCommand', + commandClass: SkillListBrowserCommand + }, +{ + name: 'skill/propose', + className: 'SkillProposeBrowserCommand', + commandClass: SkillProposeBrowserCommand + }, +{ + name: 'skill/validate', + className: 'SkillValidateBrowserCommand', + commandClass: SkillValidateBrowserCommand + }, { name: 'social/browse', className: 'SocialBrowseBrowserCommand', @@ -1289,6 +1410,12 @@ export const BROWSER_WIDGETS: WidgetEntry[] = [ widgetClass: SidebarWidget, tagName: 'Sidebar'.replace(/([A-Z])/g, (match, p1, offset) => offset > 0 ? '-' + p1.toLowerCase() : p1.toLowerCase()) + '-widget' }, +{ + name: 'Terminal', + className: 'TerminalWidget', + widgetClass: TerminalWidget, + tagName: 'Terminal'.replace(/([A-Z])/g, (match, p1, offset) => offset > 0 ? '-' + p1.toLowerCase() : p1.toLowerCase()) + '-widget' + }, { name: 'UserProfile', className: 'UserProfileWidget', diff --git a/src/debug/jtag/challenges/task-manager/task-manager.test.ts b/src/debug/jtag/challenges/task-manager/task-manager.test.ts new file mode 100644 index 000000000..51d55fcb5 --- /dev/null +++ b/src/debug/jtag/challenges/task-manager/task-manager.test.ts @@ -0,0 +1,118 @@ +/** + * TaskManager Tests + * + * Run with: npx tsx challenges/task-manager/task-manager.test.ts + * + * These tests verify the TaskManager module works correctly. + * Currently some tests are FAILING β€” find and fix the bugs! + */ + +import { TaskManager } from './task-manager'; +import assert from 'node:assert'; + +let passed = 0; +let failed = 0; + +function test(name: string, fn: () => void) { + try { + fn(); + console.log(` βœ… ${name}`); + passed++; + } catch (err: any) { + console.log(` ❌ ${name}`); + console.log(` ${err.message}`); + failed++; + } +} + +console.log('TaskManager Tests\n'); + +// ── Setup ── +const tm = new TaskManager(); + +// ── Test: Adding tasks ── +console.log('Adding tasks:'); + +const t1 = tm.add('Buy groceries', 'high'); +test('first task gets id=1', () => { + assert.strictEqual(t1.id, 1); +}); + +const t2 = tm.add('Write documentation', 'low'); +const t3 = tm.add('Fix critical bug', 'high'); +const t4 = tm.add('Update dependencies', 'medium'); + +test('four tasks added', () => { + assert.strictEqual(tm.count, 4); +}); + +// ── Test: Completing tasks ── +console.log('\nCompleting tasks:'); + +test('complete existing task returns true', () => { + assert.strictEqual(tm.complete(1), true); +}); + +test('complete non-existent task returns false', () => { + assert.strictEqual(tm.complete(999), false); +}); + +// ── Test: Priority filtering ── +console.log('\nPriority filtering:'); + +test('getByPriority("high") returns only high-priority tasks', () => { + const highTasks = tm.getByPriority('high'); + assert.strictEqual(highTasks.length, 2, `Expected 2 high-priority tasks, got ${highTasks.length}`); + assert.ok( + highTasks.every(t => t.priority === 'high'), + `Not all returned tasks are high priority: ${highTasks.map(t => `${t.title}(${t.priority})`).join(', ')}` + ); +}); + +test('getByPriority("low") returns only low-priority tasks', () => { + const lowTasks = tm.getByPriority('low'); + assert.strictEqual(lowTasks.length, 1, `Expected 1 low-priority task, got ${lowTasks.length}`); + assert.strictEqual(lowTasks[0].title, 'Write documentation'); +}); + +// ── Test: Pending/Completed filtering ── +console.log('\nPending/Completed filtering:'); + +test('getCompleted returns only completed tasks', () => { + const completed = tm.getCompleted(); + assert.strictEqual(completed.length, 1, `Expected 1 completed task, got ${completed.length}`); + assert.strictEqual(completed[0].title, 'Buy groceries'); +}); + +test('getPending returns only non-completed tasks', () => { + const pending = tm.getPending(); + assert.strictEqual(pending.length, 3, `Expected 3 pending tasks, got ${pending.length}`); + assert.ok( + pending.every(t => !t.completed), + `Some returned tasks are completed: ${pending.filter(t => t.completed).map(t => t.title).join(', ')}` + ); +}); + +// ── Test: Remove completed ── +console.log('\nRemove completed:'); + +test('removeCompleted removes only completed tasks', () => { + const removedCount = tm.removeCompleted(); + assert.strictEqual(removedCount, 1, `Expected 1 removed, got ${removedCount}`); +}); + +test('after removal, only pending tasks remain', () => { + assert.strictEqual(tm.count, 3, `Expected 3 remaining tasks, got ${tm.count}`); + const remaining = tm.getCompleted(); + assert.strictEqual(remaining.length, 0, 'No completed tasks should remain'); +}); + +// ── Summary ── +console.log(`\n${'─'.repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + console.log('\n⚠️ Some tests failed! Find and fix the bugs in task-manager.ts'); + process.exit(1); +} else { + console.log('\nπŸŽ‰ All tests passed!'); +} diff --git a/src/debug/jtag/challenges/task-manager/task-manager.ts b/src/debug/jtag/challenges/task-manager/task-manager.ts new file mode 100644 index 000000000..d696abb15 --- /dev/null +++ b/src/debug/jtag/challenges/task-manager/task-manager.ts @@ -0,0 +1,68 @@ +/** + * TaskManager - A simple task management module + * + * Provides CRUD operations for tasks with priority filtering and completion tracking. + */ + +export interface Task { + id: number; + title: string; + completed: boolean; + priority: 'low' | 'medium' | 'high'; + createdAt: Date; +} + +export class TaskManager { + private tasks: Task[] = []; + private nextId = 1; + + /** Add a new task */ + add(title: string, priority: 'low' | 'medium' | 'high' = 'medium'): Task { + const task: Task = { + id: this.nextId++, + title, + completed: false, + priority, + createdAt: new Date(), + }; + this.tasks.push(task); + return task; + } + + /** Mark a task as completed by ID. Returns true if found. */ + complete(id: number): boolean { + const task = this.tasks.find(t => t.id === id); + if (task) { + task.completed = true; + return true; + } + return false; + } + + /** Get all tasks matching a specific priority */ + getByPriority(priority: 'low' | 'medium' | 'high'): Task[] { + return this.tasks.filter(t => t.priority !== priority); + } + + /** Get all completed tasks */ + getCompleted(): Task[] { + return this.tasks.filter(t => t.completed); + } + + /** Get all pending (not completed) tasks */ + getPending(): Task[] { + return this.tasks; + } + + /** Remove all completed tasks. Returns count of removed tasks. */ + removeCompleted(): number { + const before = this.tasks.length; + this.tasks = this.tasks.filter(t => t.completed); + return before - this.tasks.length; + } + + /** Get total task count */ + get count(): number { + return this.tasks.length; + } +} diff --git a/src/debug/jtag/cli.ts b/src/debug/jtag/cli.ts index c88e240a7..36031b37d 100644 --- a/src/debug/jtag/cli.ts +++ b/src/debug/jtag/cli.ts @@ -15,7 +15,6 @@ import { COMMANDS } from './shared/generated-command-constants'; import { DATA_COMMANDS } from './commands/data/shared/DataCommandConstants'; import { FILE_COMMANDS } from './commands/file/shared/FileCommandConstants'; import { USER_COMMANDS } from './commands/shared/SystemCommandConstants'; -import { CODE_COMMANDS } from './commands/development/code/shared/CodeCommandConstants'; import * as fs from 'fs'; import * as path from 'path'; @@ -226,8 +225,8 @@ async function main() { // Map of commands to their primary parameter name const singleParamCommands: Record = { 'help': 'commandName', - [CODE_COMMANDS.READ]: 'path', - [CODE_COMMANDS.FIND]: 'pattern', + 'code/read': 'path', + 'code/search': 'pattern', [FILE_COMMANDS.LOAD]: 'path', [FILE_COMMANDS.SAVE]: 'path', [DATA_COMMANDS.READ]: 'id', @@ -389,8 +388,11 @@ async function main() { const isInferenceCommand = command.startsWith('inference/'); const isSocialCommand = command.startsWith('social/'); const isCollaborationCommand = command.startsWith('collaboration/'); - const needsLongerTimeout = isAICommand || isInferenceCommand || isSocialCommand || isInterfaceCommand || isCollaborationCommand; - const timeoutMs = isGenomeCommand ? 300000 : needsLongerTimeout ? 60000 : 10000; // 5min for genome, 60s for AI/inference/social/interface/collaboration, 10s for others + const isChallengeCommand = command.startsWith('challenge/'); + const isCodeCommand = command.startsWith('code/'); + const needsLongerTimeout = isAICommand || isInferenceCommand || isSocialCommand || isInterfaceCommand || isCollaborationCommand || isCodeCommand; + const needsLongTimeout = isGenomeCommand || isChallengeCommand; + const timeoutMs = needsLongTimeout ? 300000 : needsLongerTimeout ? 60000 : 10000; // 5min for genome/challenge, 60s for AI/inference/social/interface/collaboration/code, 10s for others const timeoutSeconds = timeoutMs / 1000; const commandTimeout = new Promise((_, reject) => diff --git a/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts b/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts index 0a83686b8..f31d8b84e 100644 --- a/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts +++ b/src/debug/jtag/commands/ai/rag/inspect/server/RAGInspectServerCommand.ts @@ -101,9 +101,8 @@ export class RAGInspectServerCommand extends RAGInspectCommand { if (params.triggerMessageId) { try { // Load the trigger message - const msgResult = await DataDaemon.read(ChatMessageEntity.collection, params.triggerMessageId); - if (msgResult.success && msgResult.data) { - const msg = msgResult.data.data; + const msg = await DataDaemon.read(ChatMessageEntity.collection, params.triggerMessageId); + if (msg) { // Get actual decision from ThoughtStream const coordinator = getThoughtStreamCoordinator(); diff --git a/src/debug/jtag/commands/ai/should-respond-fast/server/ShouldRespondFastServerCommand.ts b/src/debug/jtag/commands/ai/should-respond-fast/server/ShouldRespondFastServerCommand.ts index a28127036..e308dac0c 100644 --- a/src/debug/jtag/commands/ai/should-respond-fast/server/ShouldRespondFastServerCommand.ts +++ b/src/debug/jtag/commands/ai/should-respond-fast/server/ShouldRespondFastServerCommand.ts @@ -38,6 +38,12 @@ export class ShouldRespondFastServerCommand extends ShouldRespondFastCommand { }); } + if (!params.messageText) { + return this.buildResult(params, false, 0, { + reasoning: 'Missing required parameter: messageText' + }); + } + // Default contextId to a placeholder if not provided (allows tool to work) const contextId = params.contextId ?? 'default-context'; diff --git a/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts b/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts index b23bea4e2..e0884eed2 100644 --- a/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts +++ b/src/debug/jtag/commands/ai/thoughtstream/server/ThoughtStreamServerCommand.ts @@ -74,16 +74,14 @@ export class ThoughtStreamServerCommand extends ThoughtStreamCommand { try { // Query data daemon for the message - const result = await DataDaemon.read( + const msg = await DataDaemon.read( COLLECTIONS.CHAT_MESSAGES, stream.messageId ); - if (result.success && result.data) { - const msg = result.data as any; - // Try different possible structures for message data - messageSender = msg.senderName || msg.data?.senderName || 'Unknown'; - messageContent = msg.content?.text || msg.data?.content?.text || msg.text || ''; + if (msg) { + messageSender = msg.senderName || 'Unknown'; + messageContent = msg.content?.text ?? ''; } } catch (error) { console.warn(`⚠️ Could not load message ${stream.messageId}:`, error); @@ -585,14 +583,13 @@ export class ThoughtStreamServerCommand extends ThoughtStreamCommand { private async getPersonaName(personaId: string, params: ThoughtStreamParams): Promise { try { - const result = await DataDaemon.read( + const user = await DataDaemon.read( COLLECTIONS.USERS, personaId ); - if (result.success && result.data) { - const userData = result.data as any; - return userData.displayName || userData.name || personaId.slice(0, 8); + if (user) { + return user.displayName || personaId.slice(0, 8); } return personaId.slice(0, 8); } catch { diff --git a/src/debug/jtag/commands/code/diff/.npmignore b/src/debug/jtag/commands/code/diff/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/diff/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/diff/README.md b/src/debug/jtag/commands/code/diff/README.md new file mode 100644 index 000000000..859e3a386 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/README.md @@ -0,0 +1,159 @@ +# Code Diff Command + +Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/diff --filePath= --editType= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/diff', { + // your parameters here +}); +``` + +## Parameters + +- **filePath** (required): `string` - Relative path to file within workspace +- **editType** (required): `string` - Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' +- **search** (optional): `string` - Text to find (for search_replace mode) +- **replace** (optional): `string` - Replacement text (for search_replace mode) +- **replaceAll** (optional): `boolean` - Replace all occurrences (for search_replace mode) +- **startLine** (optional): `number` - Start line (for line_range mode) +- **endLine** (optional): `number` - End line (for line_range mode) +- **newContent** (optional): `string` - New content (for line_range mode) +- **line** (optional): `number` - Line number (for insert_at mode) +- **content** (optional): `string` - Content to insert or append + +## Result + +Returns `CodeDiffResult` with: + +Returns CommandResult with: +- **unified**: `string` - Unified diff text showing the proposed changes + +## Examples + +### Preview a search-replace diff + +```bash +./jtag code/diff --filePath="src/main.ts" --editType="search_replace" --search="console.log" --replace="logger.info" +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/diff +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/diff' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/diff +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/diff' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Diff/test/unit/CodeDiffCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Diff/test/integration/CodeDiffIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeDiffTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeDiffBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeDiffServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeDiffCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeDiffIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/diff/browser/CodeDiffBrowserCommand.ts b/src/debug/jtag/commands/code/diff/browser/CodeDiffBrowserCommand.ts new file mode 100644 index 000000000..9457b4a3f --- /dev/null +++ b/src/debug/jtag/commands/code/diff/browser/CodeDiffBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Diff Command - Browser Implementation + * + * Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeDiffParams, CodeDiffResult } from '../shared/CodeDiffTypes'; + +export class CodeDiffBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/diff', context, subpath, commander); + } + + async execute(params: CodeDiffParams): Promise { + console.log('🌐 BROWSER: Delegating Code Diff to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/diff/package.json b/src/debug/jtag/commands/code/diff/package.json new file mode 100644 index 000000000..6f042bfc9 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/diff", + "version": "1.0.0", + "description": "Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit.", + "main": "server/CodeDiffServerCommand.ts", + "types": "shared/CodeDiffTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeDiffIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/diff" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/diff/server/CodeDiffServerCommand.ts b/src/debug/jtag/commands/code/diff/server/CodeDiffServerCommand.ts new file mode 100644 index 000000000..e61522918 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/server/CodeDiffServerCommand.ts @@ -0,0 +1,80 @@ +/** + * Code Diff Command - Server Implementation + * + * Preview an edit as a unified diff without applying it. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeDiffParams, CodeDiffResult } from '../shared/CodeDiffTypes'; +import { createCodeDiffResultFromParams } from '../shared/CodeDiffTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; +import type { WorkspaceEditMode } from '@daemons/code-daemon/shared/CodeDaemonTypes'; + +export class CodeDiffServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/diff', context, subpath, commander); + } + + async execute(params: CodeDiffParams): Promise { + if (!params.filePath || params.filePath.trim() === '') { + throw new ValidationError( + 'filePath', + `Missing required parameter 'filePath'. See the code/diff README for usage.` + ); + } + if (!params.editType) { + throw new ValidationError( + 'editType', + `Missing required parameter 'editType'. Must be 'search_replace', 'line_range', 'insert_at', or 'append'.` + ); + } + + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const editMode = this.buildEditMode(params); + + const result = await CodeDaemon.workspaceDiff( + personaId, + params.filePath, + editMode + ); + + return createCodeDiffResultFromParams(params, { + success: result.success, + unified: result.unified, + }); + } + + private buildEditMode(params: CodeDiffParams): WorkspaceEditMode { + switch (params.editType) { + case 'search_replace': + if (!params.search) throw new ValidationError('search', `'search' is required for search_replace mode.`); + if (params.replace === undefined) throw new ValidationError('replace', `'replace' is required for search_replace mode.`); + return { type: 'search_replace', search: params.search, replace: params.replace, all: params.replaceAll ?? false }; + + case 'line_range': + if (!params.startLine) throw new ValidationError('startLine', `'startLine' is required for line_range mode.`); + if (!params.endLine) throw new ValidationError('endLine', `'endLine' is required for line_range mode.`); + if (params.newContent === undefined) throw new ValidationError('newContent', `'newContent' is required for line_range mode.`); + return { type: 'line_range', start_line: params.startLine, end_line: params.endLine, new_content: params.newContent }; + + case 'insert_at': + if (!params.line) throw new ValidationError('line', `'line' is required for insert_at mode.`); + if (params.content === undefined) throw new ValidationError('content', `'content' is required for insert_at mode.`); + return { type: 'insert_at', line: params.line, content: params.content }; + + case 'append': + if (params.content === undefined) throw new ValidationError('content', `'content' is required for append mode.`); + return { type: 'append', content: params.content }; + + default: + throw new ValidationError('editType', `Invalid editType '${params.editType}'.`); + } + } +} diff --git a/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts b/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts new file mode 100644 index 000000000..dd99414c6 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/shared/CodeDiffTypes.ts @@ -0,0 +1,128 @@ +/** + * Code Diff Command - Shared Types + * + * Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Diff Command Parameters + */ +export interface CodeDiffParams extends CommandParams { + // Relative path to file within workspace + filePath: string; + // Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' + editType: string; + // Text to find (for search_replace mode) + search?: string; + // Replacement text (for search_replace mode) + replace?: string; + // Replace all occurrences (for search_replace mode) + replaceAll?: boolean; + // Start line (for line_range mode) + startLine?: number; + // End line (for line_range mode) + endLine?: number; + // New content (for line_range mode) + newContent?: string; + // Line number (for insert_at mode) + line?: number; + // Content to insert or append + content?: string; +} + +/** + * Factory function for creating CodeDiffParams + */ +export const createCodeDiffParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Relative path to file within workspace + filePath: string; + // Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' + editType: string; + // Text to find (for search_replace mode) + search?: string; + // Replacement text (for search_replace mode) + replace?: string; + // Replace all occurrences (for search_replace mode) + replaceAll?: boolean; + // Start line (for line_range mode) + startLine?: number; + // End line (for line_range mode) + endLine?: number; + // New content (for line_range mode) + newContent?: string; + // Line number (for insert_at mode) + line?: number; + // Content to insert or append + content?: string; + } +): CodeDiffParams => createPayload(context, sessionId, { + search: data.search ?? '', + replace: data.replace ?? '', + replaceAll: data.replaceAll ?? false, + startLine: data.startLine ?? 0, + endLine: data.endLine ?? 0, + newContent: data.newContent ?? '', + line: data.line ?? 0, + content: data.content ?? '', + ...data +}); + +/** + * Code Diff Command Result + */ +export interface CodeDiffResult extends CommandResult { + success: boolean; + // Unified diff text showing the proposed changes + unified: string; + error?: JTAGError; +} + +/** + * Factory function for creating CodeDiffResult with defaults + */ +export const createCodeDiffResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Unified diff text showing the proposed changes + unified?: string; + error?: JTAGError; + } +): CodeDiffResult => createPayload(context, sessionId, { + unified: data.unified ?? '', + ...data +}); + +/** + * Smart Code Diff-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeDiffResultFromParams = ( + params: CodeDiffParams, + differences: Omit +): CodeDiffResult => transformPayload(params, differences); + +/** + * Code Diff β€” Type-safe command executor + * + * Usage: + * import { CodeDiff } from '...shared/CodeDiffTypes'; + * const result = await CodeDiff.execute({ ... }); + */ +export const CodeDiff = { + execute(params: CommandInput): Promise { + return Commands.execute('code/diff', params as Partial); + }, + commandName: 'code/diff' as const, +} as const; diff --git a/src/debug/jtag/commands/code/diff/test/integration/CodeDiffIntegration.test.ts b/src/debug/jtag/commands/code/diff/test/integration/CodeDiffIntegration.test.ts new file mode 100644 index 000000000..bf1bd05c4 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/test/integration/CodeDiffIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeDiff Command Integration Tests + * + * Tests Code Diff command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Diff/test/integration/CodeDiffIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeDiff Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Diff command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Diff command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Diff']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Diff returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Diff succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Diff']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Diff']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Diff']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Diff']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Diff']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeDiffIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeDiff Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeDiff INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeDiff integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeDiffIntegrationTests(); +} else { + module.exports = { runAllCodeDiffIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/diff/test/unit/CodeDiffCommand.test.ts b/src/debug/jtag/commands/code/diff/test/unit/CodeDiffCommand.test.ts new file mode 100644 index 000000000..a4527d409 --- /dev/null +++ b/src/debug/jtag/commands/code/diff/test/unit/CodeDiffCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeDiff Command Unit Tests + * + * Tests Code Diff command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Diff/test/unit/CodeDiffCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeDiffParams, CodeDiffResult } from '../../shared/CodeDiffTypes'; + +console.log('πŸ§ͺ CodeDiff Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Diff logic for testing + */ +async function mockCodeDiffCommand(params: CodeDiffParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Diff' or see the Code Diff README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeDiffResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeDiffCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeDiff command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Diff command + const validParams: CodeDiffParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeDiffExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Diff command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeDiffParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeDiffCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeDiffRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeDiffParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeDiffParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeDiffCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeDiffOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeDiffParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeDiffCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeDiffParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeDiffCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeDiffPerformance(): Promise { + console.log('\n⚑ Test 5: CodeDiff performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeDiffCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeDiffParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeDiff completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeDiffResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeDiff result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeDiffCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeDiffParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeDiffUnitTests(): Promise { + console.log('πŸš€ Starting CodeDiff Command Unit Tests\n'); + + try { + testCodeDiffCommandStructure(); + await testMockCodeDiffExecution(); + await testCodeDiffRequiredParams(); + await testCodeDiffOptionalParams(); + await testCodeDiffPerformance(); + await testCodeDiffResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeDiff UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeDiff unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeDiffUnitTests(); +} else { + module.exports = { runAllCodeDiffUnitTests }; +} diff --git a/src/debug/jtag/commands/code/edit/.npmignore b/src/debug/jtag/commands/code/edit/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/edit/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/edit/README.md b/src/debug/jtag/commands/code/edit/README.md new file mode 100644 index 000000000..7b690ba83 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/README.md @@ -0,0 +1,180 @@ +# Code Edit Command + +Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/edit --filePath= --editType= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/edit', { + // your parameters here +}); +``` + +## Parameters + +- **filePath** (required): `string` - Relative path to file within workspace +- **editType** (required): `string` - Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' +- **search** (optional): `string` - Text to find (for search_replace mode) +- **replace** (optional): `string` - Replacement text (for search_replace mode) +- **replaceAll** (optional): `boolean` - Replace all occurrences (for search_replace mode, default: false) +- **startLine** (optional): `number` - Start line (for line_range mode, 1-indexed) +- **endLine** (optional): `number` - End line (for line_range mode, 1-indexed, inclusive) +- **newContent** (optional): `string` - New content (for line_range mode) +- **line** (optional): `number` - Line number to insert at (for insert_at mode) +- **content** (optional): `string` - Content to insert or append +- **description** (optional): `string` - Description of what this change does + +## Result + +Returns `CodeEditResult` with: + +Returns CommandResult with: +- **changeId**: `string` - UUID of the ChangeNode created (for undo) +- **filePath**: `string` - Resolved file path +- **bytesWritten**: `number` - New file size in bytes + +## Examples + +### Search and replace + +```bash +./jtag code/edit --filePath="src/main.ts" --editType="search_replace" --search="old text" --replace="new text" +``` + +### Replace line range + +```bash +./jtag code/edit --filePath="src/main.ts" --editType="line_range" --startLine=5 --endLine=10 --newContent="replacement content" +``` + +### Insert at line + +```bash +./jtag code/edit --filePath="src/main.ts" --editType="insert_at" --line=1 --content="// Header comment" +``` + +### Append to file + +```bash +./jtag code/edit --filePath="src/main.ts" --editType="append" --content="// Footer" +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/edit +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/edit' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/edit +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/edit' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Edit/test/unit/CodeEditCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Edit/test/integration/CodeEditIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeEditTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeEditBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeEditServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeEditCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeEditIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/edit/browser/CodeEditBrowserCommand.ts b/src/debug/jtag/commands/code/edit/browser/CodeEditBrowserCommand.ts new file mode 100644 index 000000000..dea1109a0 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/browser/CodeEditBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Edit Command - Browser Implementation + * + * Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeEditParams, CodeEditResult } from '../shared/CodeEditTypes'; + +export class CodeEditBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/edit', context, subpath, commander); + } + + async execute(params: CodeEditParams): Promise { + console.log('🌐 BROWSER: Delegating Code Edit to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/edit/package.json b/src/debug/jtag/commands/code/edit/package.json new file mode 100644 index 000000000..1f148eb54 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/edit", + "version": "1.0.0", + "description": "Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications.", + "main": "server/CodeEditServerCommand.ts", + "types": "shared/CodeEditTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeEditIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/edit" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/edit/server/CodeEditServerCommand.ts b/src/debug/jtag/commands/code/edit/server/CodeEditServerCommand.ts new file mode 100644 index 000000000..b83d39e08 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/server/CodeEditServerCommand.ts @@ -0,0 +1,84 @@ +/** + * Code Edit Command - Server Implementation + * + * Edits a file using search-replace, line-range, insert-at, or append. + * Creates a ChangeNode for undo support. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeEditParams, CodeEditResult } from '../shared/CodeEditTypes'; +import { createCodeEditResultFromParams } from '../shared/CodeEditTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; +import type { WorkspaceEditMode } from '@daemons/code-daemon/shared/CodeDaemonTypes'; + +export class CodeEditServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/edit', context, subpath, commander); + } + + async execute(params: CodeEditParams): Promise { + if (!params.filePath || params.filePath.trim() === '') { + throw new ValidationError( + 'filePath', + `Missing required parameter 'filePath'. See the code/edit README for usage.` + ); + } + if (!params.editType) { + throw new ValidationError( + 'editType', + `Missing required parameter 'editType'. Must be 'search_replace', 'line_range', 'insert_at', or 'append'.` + ); + } + + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const editMode = this.buildEditMode(params); + + const result = await CodeDaemon.workspaceEdit( + personaId, + params.filePath, + editMode, + params.description + ); + + return createCodeEditResultFromParams(params, { + success: result.success, + changeId: result.change_id || '', + filePath: result.file_path, + bytesWritten: result.bytes_written, + }); + } + + private buildEditMode(params: CodeEditParams): WorkspaceEditMode { + switch (params.editType) { + case 'search_replace': + if (!params.search) throw new ValidationError('search', `'search' is required for search_replace edit mode.`); + if (params.replace === undefined) throw new ValidationError('replace', `'replace' is required for search_replace edit mode.`); + return { type: 'search_replace', search: params.search, replace: params.replace, all: params.replaceAll ?? false }; + + case 'line_range': + if (!params.startLine) throw new ValidationError('startLine', `'startLine' is required for line_range edit mode.`); + if (!params.endLine) throw new ValidationError('endLine', `'endLine' is required for line_range edit mode.`); + if (params.newContent === undefined) throw new ValidationError('newContent', `'newContent' is required for line_range edit mode.`); + return { type: 'line_range', start_line: params.startLine, end_line: params.endLine, new_content: params.newContent }; + + case 'insert_at': + if (!params.line) throw new ValidationError('line', `'line' is required for insert_at edit mode.`); + if (params.content === undefined) throw new ValidationError('content', `'content' is required for insert_at edit mode.`); + return { type: 'insert_at', line: params.line, content: params.content }; + + case 'append': + if (params.content === undefined) throw new ValidationError('content', `'content' is required for append edit mode.`); + return { type: 'append', content: params.content }; + + default: + throw new ValidationError('editType', `Invalid editType '${params.editType}'. Must be 'search_replace', 'line_range', 'insert_at', or 'append'.`); + } + } +} diff --git a/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts b/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts new file mode 100644 index 000000000..b6af24c4f --- /dev/null +++ b/src/debug/jtag/commands/code/edit/shared/CodeEditTypes.ts @@ -0,0 +1,143 @@ +/** + * Code Edit Command - Shared Types + * + * Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Edit Command Parameters + */ +export interface CodeEditParams extends CommandParams { + // Relative path to file within workspace + filePath: string; + // Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' + editType: string; + // Text to find (for search_replace mode) + search?: string; + // Replacement text (for search_replace mode) + replace?: string; + // Replace all occurrences (for search_replace mode, default: false) + replaceAll?: boolean; + // Start line (for line_range mode, 1-indexed) + startLine?: number; + // End line (for line_range mode, 1-indexed, inclusive) + endLine?: number; + // New content (for line_range mode) + newContent?: string; + // Line number to insert at (for insert_at mode) + line?: number; + // Content to insert or append + content?: string; + // Description of what this change does + description?: string; +} + +/** + * Factory function for creating CodeEditParams + */ +export const createCodeEditParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Relative path to file within workspace + filePath: string; + // Edit mode: 'search_replace', 'line_range', 'insert_at', or 'append' + editType: string; + // Text to find (for search_replace mode) + search?: string; + // Replacement text (for search_replace mode) + replace?: string; + // Replace all occurrences (for search_replace mode, default: false) + replaceAll?: boolean; + // Start line (for line_range mode, 1-indexed) + startLine?: number; + // End line (for line_range mode, 1-indexed, inclusive) + endLine?: number; + // New content (for line_range mode) + newContent?: string; + // Line number to insert at (for insert_at mode) + line?: number; + // Content to insert or append + content?: string; + // Description of what this change does + description?: string; + } +): CodeEditParams => createPayload(context, sessionId, { + search: data.search ?? '', + replace: data.replace ?? '', + replaceAll: data.replaceAll ?? false, + startLine: data.startLine ?? 0, + endLine: data.endLine ?? 0, + newContent: data.newContent ?? '', + line: data.line ?? 0, + content: data.content ?? '', + description: data.description ?? '', + ...data +}); + +/** + * Code Edit Command Result + */ +export interface CodeEditResult extends CommandResult { + success: boolean; + // UUID of the ChangeNode created (for undo) + changeId: string; + // Resolved file path + filePath: string; + // New file size in bytes + bytesWritten: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeEditResult with defaults + */ +export const createCodeEditResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // UUID of the ChangeNode created (for undo) + changeId?: string; + // Resolved file path + filePath?: string; + // New file size in bytes + bytesWritten?: number; + error?: JTAGError; + } +): CodeEditResult => createPayload(context, sessionId, { + changeId: data.changeId ?? '', + filePath: data.filePath ?? '', + bytesWritten: data.bytesWritten ?? 0, + ...data +}); + +/** + * Smart Code Edit-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeEditResultFromParams = ( + params: CodeEditParams, + differences: Omit +): CodeEditResult => transformPayload(params, differences); + +/** + * Code Edit β€” Type-safe command executor + * + * Usage: + * import { CodeEdit } from '...shared/CodeEditTypes'; + * const result = await CodeEdit.execute({ ... }); + */ +export const CodeEdit = { + execute(params: CommandInput): Promise { + return Commands.execute('code/edit', params as Partial); + }, + commandName: 'code/edit' as const, +} as const; diff --git a/src/debug/jtag/commands/code/edit/test/integration/CodeEditIntegration.test.ts b/src/debug/jtag/commands/code/edit/test/integration/CodeEditIntegration.test.ts new file mode 100644 index 000000000..0818db946 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/test/integration/CodeEditIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeEdit Command Integration Tests + * + * Tests Code Edit command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Edit/test/integration/CodeEditIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeEdit Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Edit command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Edit command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Edit']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Edit returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Edit succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Edit']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Edit']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Edit']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Edit']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Edit']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeEditIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeEdit Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeEdit INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeEdit integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeEditIntegrationTests(); +} else { + module.exports = { runAllCodeEditIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/edit/test/unit/CodeEditCommand.test.ts b/src/debug/jtag/commands/code/edit/test/unit/CodeEditCommand.test.ts new file mode 100644 index 000000000..9d79e2ea1 --- /dev/null +++ b/src/debug/jtag/commands/code/edit/test/unit/CodeEditCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeEdit Command Unit Tests + * + * Tests Code Edit command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Edit/test/unit/CodeEditCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeEditParams, CodeEditResult } from '../../shared/CodeEditTypes'; + +console.log('πŸ§ͺ CodeEdit Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Edit logic for testing + */ +async function mockCodeEditCommand(params: CodeEditParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Edit' or see the Code Edit README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeEditResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeEditCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeEdit command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Edit command + const validParams: CodeEditParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeEditExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Edit command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeEditParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeEditCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeEditRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeEditParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeEditParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeEditCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeEditOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeEditParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeEditCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeEditParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeEditCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeEditPerformance(): Promise { + console.log('\n⚑ Test 5: CodeEdit performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeEditCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeEditParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeEdit completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeEditResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeEdit result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeEditCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeEditParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeEditUnitTests(): Promise { + console.log('πŸš€ Starting CodeEdit Command Unit Tests\n'); + + try { + testCodeEditCommandStructure(); + await testMockCodeEditExecution(); + await testCodeEditRequiredParams(); + await testCodeEditOptionalParams(); + await testCodeEditPerformance(); + await testCodeEditResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeEdit UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeEdit unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeEditUnitTests(); +} else { + module.exports = { runAllCodeEditUnitTests }; +} diff --git a/src/debug/jtag/commands/code/git/README.md b/src/debug/jtag/commands/code/git/README.md new file mode 100644 index 000000000..e87c0688d --- /dev/null +++ b/src/debug/jtag/commands/code/git/README.md @@ -0,0 +1,50 @@ +# code/git + +Workspace-scoped git operations for the coding agent pipeline. All operations route through the Rust IPC backend for per-persona workspace isolation. + +## Operations + +| Operation | Description | Required Params | +|-----------|-------------|-----------------| +| `status` | Show workspace git status | - | +| `diff` | Show uncommitted changes | `staged?` | +| `log` | Show recent commits | `count?` | +| `add` | Stage files for commit | `paths` | +| `commit` | Create a commit | `message` | +| `push` | Push to remote | `remote?`, `branch?` | + +## Usage + +```bash +# Check workspace status +./jtag code/git --userId="persona-id" --operation=status + +# View changes +./jtag code/git --userId="persona-id" --operation=diff +./jtag code/git --userId="persona-id" --operation=diff --staged=true + +# View history +./jtag code/git --userId="persona-id" --operation=log --count=5 + +# Stage and commit +./jtag code/git --userId="persona-id" --operation=add --paths='["."]' +./jtag code/git --userId="persona-id" --operation=commit --message="Add feature" + +# Push (requires system tier in coding pipeline) +./jtag code/git --userId="persona-id" --operation=push +``` + +## Security Tiers + +- `status`, `diff`, `log`: Read tier (read-only operations) +- `add`, `commit`: Write tier (modifies repository state) +- `push`: Write tier via CLI; system tier when used in coding pipeline plans + +## Programmatic Usage + +```typescript +import { CodeGit } from './shared/CodeGitTypes'; + +const status = await CodeGit.execute({ userId: 'persona-id', operation: 'status' }); +console.log(status.status?.branch, status.status?.modified); +``` diff --git a/src/debug/jtag/commands/code/git/browser/CodeGitBrowserCommand.ts b/src/debug/jtag/commands/code/git/browser/CodeGitBrowserCommand.ts new file mode 100644 index 000000000..80be3536a --- /dev/null +++ b/src/debug/jtag/commands/code/git/browser/CodeGitBrowserCommand.ts @@ -0,0 +1,20 @@ +/** + * Code Git Command - Browser Implementation + * + * Workspace-scoped git operations for the coding agent pipeline. Operations: status, diff, log, add, commit, push. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeGitParams, CodeGitResult } from '../shared/CodeGitTypes'; + +export class CodeGitBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/git', context, subpath, commander); + } + + async execute(params: CodeGitParams): Promise { + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/git/package.json b/src/debug/jtag/commands/code/git/package.json new file mode 100644 index 000000000..15fa821c0 --- /dev/null +++ b/src/debug/jtag/commands/code/git/package.json @@ -0,0 +1,34 @@ +{ + "name": "@jtag-commands/code/git", + "version": "1.0.0", + "description": "Workspace-scoped git operations for the coding agent pipeline. Operations: status, diff, log, add, commit, push. All operations route through Rust IPC for per-persona isolation.", + "main": "server/CodeGitServerCommand.ts", + "types": "shared/CodeGitTypes.ts", + "scripts": { + "test": "npm run test:unit", + "test:unit": "npx vitest run test/unit/*.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/git" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/git/server/CodeGitServerCommand.ts b/src/debug/jtag/commands/code/git/server/CodeGitServerCommand.ts new file mode 100644 index 000000000..6496aa01a --- /dev/null +++ b/src/debug/jtag/commands/code/git/server/CodeGitServerCommand.ts @@ -0,0 +1,155 @@ +/** + * Code Git Command - Server Implementation + * + * Routes git operations through the Rust IPC backend for workspace isolation. + * All operations are scoped to the persona's registered workspace. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeGitParams, CodeGitResult, GitOperation } from '../shared/CodeGitTypes'; +import { createCodeGitResultFromParams } from '../shared/CodeGitTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +const VALID_OPERATIONS = new Set(['status', 'diff', 'log', 'add', 'commit', 'push']); + +export class CodeGitServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/git', context, subpath, commander); + } + + async execute(params: CodeGitParams): Promise { + // Validate required parameters + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Missing required parameter userId (persona/workspace handle).' + ); + } + + if (!params.operation || !VALID_OPERATIONS.has(params.operation)) { + throw new ValidationError( + 'operation', + `Invalid operation '${params.operation ?? ''}'. Must be one of: ${Array.from(VALID_OPERATIONS).join(', ')}` + ); + } + + const operation = params.operation as GitOperation; + + switch (operation) { + case 'status': + return this.handleStatus(params); + case 'diff': + return this.handleDiff(params); + case 'log': + return this.handleLog(params); + case 'add': + return this.handleAdd(params); + case 'commit': + return this.handleCommit(params); + case 'push': + return this.handlePush(params); + } + } + + private async handleStatus(params: CodeGitParams): Promise { + const statusInfo = await CodeDaemon.workspaceGitStatus(params.userId); + + const totalChanges = statusInfo.modified.length + statusInfo.added.length + + statusInfo.deleted.length + statusInfo.untracked.length; + + return createCodeGitResultFromParams(params, { + success: statusInfo.success, + operation: 'status', + status: { + branch: statusInfo.branch, + modified: statusInfo.modified, + added: statusInfo.added, + deleted: statusInfo.deleted, + untracked: statusInfo.untracked, + }, + summary: statusInfo.success + ? `Branch: ${statusInfo.branch ?? 'unknown'}, ${totalChanges} changed file(s)` + : `Git status failed: ${statusInfo.error ?? 'unknown error'}`, + }); + } + + private async handleDiff(params: CodeGitParams): Promise { + const result = await CodeDaemon.workspaceGitDiff(params.userId, params.staged ?? false); + + return createCodeGitResultFromParams(params, { + success: result.success, + operation: 'diff', + diff: result.diff, + summary: result.diff + ? `${result.diff.split('\n').length} lines of diff output` + : 'No changes', + }); + } + + private async handleLog(params: CodeGitParams): Promise { + const result = await CodeDaemon.workspaceGitLog(params.userId, params.count ?? 10); + + return createCodeGitResultFromParams(params, { + success: result.success, + operation: 'log', + log: result.log, + summary: result.log + ? `${result.log.trim().split('\n').length} commit(s)` + : 'No commits', + }); + } + + private async handleAdd(params: CodeGitParams): Promise { + if (!params.paths || params.paths.length === 0) { + throw new ValidationError( + 'paths', + 'The add operation requires at least one path. Use ["."] to stage all changes.' + ); + } + + const result = await CodeDaemon.workspaceGitAdd(params.userId, params.paths); + + return createCodeGitResultFromParams(params, { + success: true, + operation: 'add', + staged: result.staged, + summary: `Staged ${result.staged.length} path(s)`, + }); + } + + private async handleCommit(params: CodeGitParams): Promise { + if (!params.message || params.message.trim() === '') { + throw new ValidationError( + 'message', + 'The commit operation requires a non-empty message.' + ); + } + + const result = await CodeDaemon.workspaceGitCommit(params.userId, params.message.trim()); + + return createCodeGitResultFromParams(params, { + success: true, + operation: 'commit', + commitHash: result.hash, + summary: `Committed: ${result.hash.substring(0, 8)}`, + }); + } + + private async handlePush(params: CodeGitParams): Promise { + const result = await CodeDaemon.workspaceGitPush( + params.userId, + params.remote ?? 'origin', + params.branch ?? '' + ); + + return createCodeGitResultFromParams(params, { + success: true, + operation: 'push', + pushOutput: result.output, + summary: `Pushed to ${params.remote ?? 'origin'}${params.branch ? '/' + params.branch : ''}`, + }); + } +} diff --git a/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts b/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts new file mode 100644 index 000000000..e63e144b2 --- /dev/null +++ b/src/debug/jtag/commands/code/git/shared/CodeGitTypes.ts @@ -0,0 +1,146 @@ +/** + * Code Git Command - Shared Types + * + * Workspace-scoped git operations for the coding agent pipeline. + * Operations: status, diff, log, add, commit, push. + * All operations are routed through the Rust IPC backend for per-persona workspace isolation. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Supported git operations. + */ +export type GitOperation = 'status' | 'diff' | 'log' | 'add' | 'commit' | 'push'; + +/** + * Code Git Command Parameters + */ +export interface CodeGitParams extends CommandParams { + /** Persona/workspace handle */ + userId: string; + /** Git operation to perform */ + operation: string; + /** File paths to stage (for 'add' operation) */ + paths?: string[]; + /** Commit message (for 'commit' operation) */ + message?: string; + /** Remote name (for 'push' operation, default: 'origin') */ + remote?: string; + /** Branch name (for 'push' operation) */ + branch?: string; + /** Show staged changes (for 'diff' operation) */ + staged?: boolean; + /** Number of commits to show (for 'log' operation, default: 10) */ + count?: number; +} + +/** + * Factory function for creating CodeGitParams + */ +export const createCodeGitParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + userId: string; + operation: string; + paths?: string[]; + message?: string; + remote?: string; + branch?: string; + staged?: boolean; + count?: number; + } +): CodeGitParams => createPayload(context, sessionId, { + paths: data.paths ?? [], + message: data.message ?? '', + remote: data.remote ?? '', + branch: data.branch ?? '', + staged: data.staged ?? false, + count: data.count ?? 0, + ...data +}); + +/** + * Git status information + */ +export interface GitStatusInfo { + branch?: string; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; +} + +/** + * Code Git Command Result + */ +export interface CodeGitResult extends CommandResult { + success: boolean; + /** Which operation was performed */ + operation: string; + /** Git status info (for 'status' operation) */ + status?: GitStatusInfo; + /** Diff output (for 'diff' operation) */ + diff?: string; + /** Log output (for 'log' operation) */ + log?: string; + /** Staged file paths (for 'add' operation) */ + staged?: string[]; + /** Commit hash (for 'commit' operation) */ + commitHash?: string; + /** Push output (for 'push' operation) */ + pushOutput?: string; + /** Human-readable summary */ + summary: string; + error?: JTAGError; +} + +/** + * Factory function for creating CodeGitResult with defaults + */ +export const createCodeGitResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + operation: string; + status?: GitStatusInfo; + diff?: string; + log?: string; + staged?: string[]; + commitHash?: string; + pushOutput?: string; + summary?: string; + error?: JTAGError; + } +): CodeGitResult => createPayload(context, sessionId, { + summary: data.summary ?? '', + ...data +}); + +/** + * Smart result inheritance from params + */ +export const createCodeGitResultFromParams = ( + params: CodeGitParams, + differences: Omit +): CodeGitResult => transformPayload(params, differences); + +/** + * Code Git - Type-safe command executor + * + * Usage: + * import { CodeGit } from '...shared/CodeGitTypes'; + * const result = await CodeGit.execute({ userId: 'persona-id', operation: 'status' }); + */ +export const CodeGit = { + execute(params: CommandInput): Promise { + return Commands.execute('code/git', params as Partial); + }, + commandName: 'code/git' as const, +} as const; diff --git a/src/debug/jtag/commands/code/history/.npmignore b/src/debug/jtag/commands/code/history/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/history/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/history/README.md b/src/debug/jtag/commands/code/history/README.md new file mode 100644 index 000000000..36accb805 --- /dev/null +++ b/src/debug/jtag/commands/code/history/README.md @@ -0,0 +1,158 @@ +# Code History Command + +Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/history [options] +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/history', { + // your parameters here +}); +``` + +## Parameters + +- **filePath** (optional): `string` - Filter history to a specific file (optional, defaults to all) +- **limit** (optional): `number` - Maximum number of history entries to return (default: 50) + +## Result + +Returns `CodeHistoryResult` with: + +Returns CommandResult with: +- **nodes**: `object[]` - Array of ChangeNode objects with id, filePath, operation, timestamp, description, and diffs +- **totalCount**: `number` - Total number of changes in history + +## Examples + +### Get all workspace history + +```bash +./jtag code/history +``` + +### Get history for specific file + +```bash +./jtag code/history --filePath="src/main.ts" --limit=10 +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/history +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/history' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/history +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/history' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code History/test/unit/CodeHistoryCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code History/test/integration/CodeHistoryIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeHistoryTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeHistoryBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeHistoryServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeHistoryCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeHistoryIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/history/browser/CodeHistoryBrowserCommand.ts b/src/debug/jtag/commands/code/history/browser/CodeHistoryBrowserCommand.ts new file mode 100644 index 000000000..895dc3ee0 --- /dev/null +++ b/src/debug/jtag/commands/code/history/browser/CodeHistoryBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code History Command - Browser Implementation + * + * Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeHistoryParams, CodeHistoryResult } from '../shared/CodeHistoryTypes'; + +export class CodeHistoryBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/history', context, subpath, commander); + } + + async execute(params: CodeHistoryParams): Promise { + console.log('🌐 BROWSER: Delegating Code History to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/history/package.json b/src/debug/jtag/commands/code/history/package.json new file mode 100644 index 000000000..070a1ffae --- /dev/null +++ b/src/debug/jtag/commands/code/history/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/history", + "version": "1.0.0", + "description": "Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions.", + "main": "server/CodeHistoryServerCommand.ts", + "types": "shared/CodeHistoryTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeHistoryIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/history" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/history/server/CodeHistoryServerCommand.ts b/src/debug/jtag/commands/code/history/server/CodeHistoryServerCommand.ts new file mode 100644 index 000000000..5c9ae90a2 --- /dev/null +++ b/src/debug/jtag/commands/code/history/server/CodeHistoryServerCommand.ts @@ -0,0 +1,38 @@ +/** + * Code History Command - Server Implementation + * + * Get change history for a specific file or the entire workspace. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeHistoryParams, CodeHistoryResult } from '../shared/CodeHistoryTypes'; +import { createCodeHistoryResultFromParams } from '../shared/CodeHistoryTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeHistoryServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/history', context, subpath, commander); + } + + async execute(params: CodeHistoryParams): Promise { + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceHistory( + personaId, + params.filePath, + params.limit + ); + + return createCodeHistoryResultFromParams(params, { + success: result.success, + nodes: result.nodes, + totalCount: result.total_count, + }); + } +} diff --git a/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts b/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts new file mode 100644 index 000000000..712685a69 --- /dev/null +++ b/src/debug/jtag/commands/code/history/shared/CodeHistoryTypes.ts @@ -0,0 +1,96 @@ +/** + * Code History Command - Shared Types + * + * Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { ChangeNode } from '@shared/generated/code/ChangeNode'; + +/** + * Code History Command Parameters + */ +export interface CodeHistoryParams extends CommandParams { + // Filter history to a specific file (optional, defaults to all) + filePath?: string; + // Maximum number of history entries to return (default: 50) + limit?: number; +} + +/** + * Factory function for creating CodeHistoryParams + */ +export const createCodeHistoryParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Filter history to a specific file (optional, defaults to all) + filePath?: string; + // Maximum number of history entries to return (default: 50) + limit?: number; + } +): CodeHistoryParams => createPayload(context, sessionId, { + filePath: data.filePath ?? '', + limit: data.limit ?? 0, + ...data +}); + +/** + * Code History Command Result + */ +export interface CodeHistoryResult extends CommandResult { + success: boolean; + // Change graph nodes from Rust (generated type via ts-rs) + nodes: ChangeNode[]; + // Total number of changes in history + totalCount: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeHistoryResult with defaults + */ +export const createCodeHistoryResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Change graph nodes from Rust (generated type via ts-rs) + nodes?: ChangeNode[]; + // Total number of changes in history + totalCount?: number; + error?: JTAGError; + } +): CodeHistoryResult => createPayload(context, sessionId, { + nodes: data.nodes ?? [], + totalCount: data.totalCount ?? 0, + ...data +}); + +/** + * Smart Code History-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeHistoryResultFromParams = ( + params: CodeHistoryParams, + differences: Omit +): CodeHistoryResult => transformPayload(params, differences); + +/** + * Code History β€” Type-safe command executor + * + * Usage: + * import { CodeHistory } from '...shared/CodeHistoryTypes'; + * const result = await CodeHistory.execute({ ... }); + */ +export const CodeHistory = { + execute(params: CommandInput): Promise { + return Commands.execute('code/history', params as Partial); + }, + commandName: 'code/history' as const, +} as const; diff --git a/src/debug/jtag/commands/code/history/test/integration/CodeHistoryIntegration.test.ts b/src/debug/jtag/commands/code/history/test/integration/CodeHistoryIntegration.test.ts new file mode 100644 index 000000000..39131e7c7 --- /dev/null +++ b/src/debug/jtag/commands/code/history/test/integration/CodeHistoryIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeHistory Command Integration Tests + * + * Tests Code History command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code History/test/integration/CodeHistoryIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeHistory Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code History command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code History command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code History']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code History returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code History succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code History']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code History']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code History']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code History']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code History']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeHistoryIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeHistory Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeHistory INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeHistory integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeHistoryIntegrationTests(); +} else { + module.exports = { runAllCodeHistoryIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/history/test/unit/CodeHistoryCommand.test.ts b/src/debug/jtag/commands/code/history/test/unit/CodeHistoryCommand.test.ts new file mode 100644 index 000000000..9e6c2fdc2 --- /dev/null +++ b/src/debug/jtag/commands/code/history/test/unit/CodeHistoryCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeHistory Command Unit Tests + * + * Tests Code History command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code History/test/unit/CodeHistoryCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeHistoryParams, CodeHistoryResult } from '../../shared/CodeHistoryTypes'; + +console.log('πŸ§ͺ CodeHistory Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code History logic for testing + */ +async function mockCodeHistoryCommand(params: CodeHistoryParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code History' or see the Code History README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeHistoryResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeHistoryCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeHistory command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code History command + const validParams: CodeHistoryParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeHistoryExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code History command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeHistoryParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeHistoryCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeHistoryRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeHistoryParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeHistoryParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeHistoryCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeHistoryOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeHistoryParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeHistoryCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeHistoryParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeHistoryCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeHistoryPerformance(): Promise { + console.log('\n⚑ Test 5: CodeHistory performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeHistoryCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeHistoryParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeHistory completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeHistoryResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeHistory result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeHistoryCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeHistoryParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeHistoryUnitTests(): Promise { + console.log('πŸš€ Starting CodeHistory Command Unit Tests\n'); + + try { + testCodeHistoryCommandStructure(); + await testMockCodeHistoryExecution(); + await testCodeHistoryRequiredParams(); + await testCodeHistoryOptionalParams(); + await testCodeHistoryPerformance(); + await testCodeHistoryResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeHistory UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeHistory unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeHistoryUnitTests(); +} else { + module.exports = { runAllCodeHistoryUnitTests }; +} diff --git a/src/debug/jtag/commands/code/read/.npmignore b/src/debug/jtag/commands/code/read/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/read/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/read/README.md b/src/debug/jtag/commands/code/read/README.md new file mode 100644 index 000000000..aa9eba939 --- /dev/null +++ b/src/debug/jtag/commands/code/read/README.md @@ -0,0 +1,164 @@ +# Code Read Command + +Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/read --filePath= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/read', { + // your parameters here +}); +``` + +## Parameters + +- **filePath** (required): `string` - Relative path to file within workspace +- **startLine** (optional): `number` - First line to read (1-indexed, inclusive) +- **endLine** (optional): `number` - Last line to read (1-indexed, inclusive) + +## Result + +Returns `CodeReadResult` with: + +Returns CommandResult with: +- **content**: `string` - File content (or line range) +- **filePath**: `string` - Resolved file path +- **totalLines**: `number` - Total lines in file +- **linesReturned**: `number` - Number of lines returned +- **startLine**: `number` - Start line of returned content +- **endLine**: `number` - End line of returned content +- **sizeBytes**: `number` - File size in bytes + +## Examples + +### Read entire file + +```bash +./jtag code/read --filePath="src/main.ts" +``` + +### Read line range + +```bash +./jtag code/read --filePath="src/main.ts" --startLine=10 --endLine=25 +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/read +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/read' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/read +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/read' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Read/test/unit/CodeReadCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Read/test/integration/CodeReadIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeReadTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeReadBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeReadServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeReadCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeReadIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/read/browser/CodeReadBrowserCommand.ts b/src/debug/jtag/commands/code/read/browser/CodeReadBrowserCommand.ts new file mode 100644 index 000000000..7891cfed4 --- /dev/null +++ b/src/debug/jtag/commands/code/read/browser/CodeReadBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Read Command - Browser Implementation + * + * Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeReadParams, CodeReadResult } from '../shared/CodeReadTypes'; + +export class CodeReadBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/read', context, subpath, commander); + } + + async execute(params: CodeReadParams): Promise { + console.log('🌐 BROWSER: Delegating Code Read to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/read/package.json b/src/debug/jtag/commands/code/read/package.json new file mode 100644 index 000000000..3b21b4bf5 --- /dev/null +++ b/src/debug/jtag/commands/code/read/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/read", + "version": "1.0.0", + "description": "Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters.", + "main": "server/CodeReadServerCommand.ts", + "types": "shared/CodeReadTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeReadIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/read" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/read/server/CodeReadServerCommand.ts b/src/debug/jtag/commands/code/read/server/CodeReadServerCommand.ts new file mode 100644 index 000000000..ed400f981 --- /dev/null +++ b/src/debug/jtag/commands/code/read/server/CodeReadServerCommand.ts @@ -0,0 +1,51 @@ +/** + * Code Read Command - Server Implementation + * + * Reads a file or line range from the persona's workspace via Rust IPC. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeReadParams, CodeReadResult } from '../shared/CodeReadTypes'; +import { createCodeReadResultFromParams } from '../shared/CodeReadTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeReadServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/read', context, subpath, commander); + } + + async execute(params: CodeReadParams): Promise { + if (!params.filePath || params.filePath.trim() === '') { + throw new ValidationError( + 'filePath', + `Missing required parameter 'filePath'. See the code/read README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceRead( + personaId, + params.filePath, + params.startLine, + params.endLine + ); + + return createCodeReadResultFromParams(params, { + success: result.success, + content: result.content || '', + filePath: result.file_path, + totalLines: result.total_lines, + linesReturned: result.lines_returned, + startLine: result.start_line, + endLine: result.end_line, + sizeBytes: result.size_bytes, + }); + } +} diff --git a/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts b/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts new file mode 100644 index 000000000..b832ab970 --- /dev/null +++ b/src/debug/jtag/commands/code/read/shared/CodeReadTypes.ts @@ -0,0 +1,124 @@ +/** + * Code Read Command - Shared Types + * + * Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Read Command Parameters + */ +export interface CodeReadParams extends CommandParams { + // Relative path to file within workspace + filePath: string; + // First line to read (1-indexed, inclusive) + startLine?: number; + // Last line to read (1-indexed, inclusive) + endLine?: number; +} + +/** + * Factory function for creating CodeReadParams + */ +export const createCodeReadParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Relative path to file within workspace + filePath: string; + // First line to read (1-indexed, inclusive) + startLine?: number; + // Last line to read (1-indexed, inclusive) + endLine?: number; + } +): CodeReadParams => createPayload(context, sessionId, { + startLine: data.startLine ?? 0, + endLine: data.endLine ?? 0, + ...data +}); + +/** + * Code Read Command Result + */ +export interface CodeReadResult extends CommandResult { + success: boolean; + // File content (or line range) + content: string; + // Resolved file path + filePath: string; + // Total lines in file + totalLines: number; + // Number of lines returned + linesReturned: number; + // Start line of returned content + startLine: number; + // End line of returned content + endLine: number; + // File size in bytes + sizeBytes: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeReadResult with defaults + */ +export const createCodeReadResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // File content (or line range) + content?: string; + // Resolved file path + filePath?: string; + // Total lines in file + totalLines?: number; + // Number of lines returned + linesReturned?: number; + // Start line of returned content + startLine?: number; + // End line of returned content + endLine?: number; + // File size in bytes + sizeBytes?: number; + error?: JTAGError; + } +): CodeReadResult => createPayload(context, sessionId, { + content: data.content ?? '', + filePath: data.filePath ?? '', + totalLines: data.totalLines ?? 0, + linesReturned: data.linesReturned ?? 0, + startLine: data.startLine ?? 0, + endLine: data.endLine ?? 0, + sizeBytes: data.sizeBytes ?? 0, + ...data +}); + +/** + * Smart Code Read-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeReadResultFromParams = ( + params: CodeReadParams, + differences: Omit +): CodeReadResult => transformPayload(params, differences); + +/** + * Code Read β€” Type-safe command executor + * + * Usage: + * import { CodeRead } from '...shared/CodeReadTypes'; + * const result = await CodeRead.execute({ ... }); + */ +export const CodeRead = { + execute(params: CommandInput): Promise { + return Commands.execute('code/read', params as Partial); + }, + commandName: 'code/read' as const, +} as const; diff --git a/src/debug/jtag/commands/code/read/test/integration/CodeReadIntegration.test.ts b/src/debug/jtag/commands/code/read/test/integration/CodeReadIntegration.test.ts new file mode 100644 index 000000000..a11b3807f --- /dev/null +++ b/src/debug/jtag/commands/code/read/test/integration/CodeReadIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeRead Command Integration Tests + * + * Tests Code Read command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Read/test/integration/CodeReadIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeRead Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Read command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Read command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Read']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Read returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Read succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Read']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Read']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Read']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Read']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Read']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeReadIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeRead Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeRead INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeRead integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeReadIntegrationTests(); +} else { + module.exports = { runAllCodeReadIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/read/test/unit/CodeReadCommand.test.ts b/src/debug/jtag/commands/code/read/test/unit/CodeReadCommand.test.ts new file mode 100644 index 000000000..45c9d1f9e --- /dev/null +++ b/src/debug/jtag/commands/code/read/test/unit/CodeReadCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeRead Command Unit Tests + * + * Tests Code Read command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Read/test/unit/CodeReadCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeReadParams, CodeReadResult } from '../../shared/CodeReadTypes'; + +console.log('πŸ§ͺ CodeRead Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Read logic for testing + */ +async function mockCodeReadCommand(params: CodeReadParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Read' or see the Code Read README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeReadResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeReadCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeRead command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Read command + const validParams: CodeReadParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeReadExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Read command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeReadParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeReadCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeReadRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeReadParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeReadParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeReadCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeReadOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeReadParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeReadCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeReadParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeReadCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeReadPerformance(): Promise { + console.log('\n⚑ Test 5: CodeRead performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeReadCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeReadParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeRead completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeReadResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeRead result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeReadCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeReadParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeReadUnitTests(): Promise { + console.log('πŸš€ Starting CodeRead Command Unit Tests\n'); + + try { + testCodeReadCommandStructure(); + await testMockCodeReadExecution(); + await testCodeReadRequiredParams(); + await testCodeReadOptionalParams(); + await testCodeReadPerformance(); + await testCodeReadResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeRead UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeRead unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeReadUnitTests(); +} else { + module.exports = { runAllCodeReadUnitTests }; +} diff --git a/src/debug/jtag/commands/code/search/.npmignore b/src/debug/jtag/commands/code/search/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/search/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/search/README.md b/src/debug/jtag/commands/code/search/README.md new file mode 100644 index 000000000..96ddfff04 --- /dev/null +++ b/src/debug/jtag/commands/code/search/README.md @@ -0,0 +1,160 @@ +# Code Search Command + +Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/search --pattern= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/search', { + // your parameters here +}); +``` + +## Parameters + +- **pattern** (required): `string` - Regex pattern to search for +- **fileGlob** (optional): `string` - Glob pattern to filter files (e.g., '*.ts', 'src/**/*.rs') +- **maxResults** (optional): `number` - Maximum number of matches to return (default: 100) + +## Result + +Returns `CodeSearchResult` with: + +Returns CommandResult with: +- **matches**: `object[]` - Array of SearchMatch objects with filePath, lineNumber, lineContent, matchStart, matchEnd +- **totalMatches**: `number` - Total number of matches found +- **filesSearched**: `number` - Number of files searched + +## Examples + +### Search for function definitions + +```bash +./jtag code/search --pattern="function\s+\w+" --fileGlob="*.ts" +``` + +### Search for TODO comments + +```bash +./jtag code/search --pattern="TODO|FIXME|HACK" --maxResults=50 +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/search +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/search' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/search +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/search' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Search/test/unit/CodeSearchCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Search/test/integration/CodeSearchIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeSearchTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeSearchBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeSearchServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeSearchCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeSearchIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/search/browser/CodeSearchBrowserCommand.ts b/src/debug/jtag/commands/code/search/browser/CodeSearchBrowserCommand.ts new file mode 100644 index 000000000..edf04edd3 --- /dev/null +++ b/src/debug/jtag/commands/code/search/browser/CodeSearchBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Search Command - Browser Implementation + * + * Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeSearchParams, CodeSearchResult } from '../shared/CodeSearchTypes'; + +export class CodeSearchBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/search', context, subpath, commander); + } + + async execute(params: CodeSearchParams): Promise { + console.log('🌐 BROWSER: Delegating Code Search to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/search/package.json b/src/debug/jtag/commands/code/search/package.json new file mode 100644 index 000000000..050922766 --- /dev/null +++ b/src/debug/jtag/commands/code/search/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/search", + "version": "1.0.0", + "description": "Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context.", + "main": "server/CodeSearchServerCommand.ts", + "types": "shared/CodeSearchTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeSearchIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/search" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/search/server/CodeSearchServerCommand.ts b/src/debug/jtag/commands/code/search/server/CodeSearchServerCommand.ts new file mode 100644 index 000000000..8551e31c4 --- /dev/null +++ b/src/debug/jtag/commands/code/search/server/CodeSearchServerCommand.ts @@ -0,0 +1,48 @@ +/** + * Code Search Command - Server Implementation + * + * Search for a regex pattern across workspace files. + * Respects .gitignore, supports glob-based file filtering. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeSearchParams, CodeSearchResult } from '../shared/CodeSearchTypes'; +import { createCodeSearchResultFromParams } from '../shared/CodeSearchTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeSearchServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/search', context, subpath, commander); + } + + async execute(params: CodeSearchParams): Promise { + if (!params.pattern || params.pattern.trim() === '') { + throw new ValidationError( + 'pattern', + `Missing required parameter 'pattern'. See the code/search README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceSearch( + personaId, + params.pattern, + params.fileGlob, + params.maxResults + ); + + return createCodeSearchResultFromParams(params, { + success: result.success, + matches: result.matches, + totalMatches: result.total_matches, + filesSearched: result.files_searched, + }); + } +} diff --git a/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts b/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts new file mode 100644 index 000000000..f0144f9b2 --- /dev/null +++ b/src/debug/jtag/commands/code/search/shared/CodeSearchTypes.ts @@ -0,0 +1,105 @@ +/** + * Code Search Command - Shared Types + * + * Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { SearchMatch } from '@shared/generated/code/SearchMatch'; + +/** + * Code Search Command Parameters + */ +export interface CodeSearchParams extends CommandParams { + // Regex pattern to search for + pattern: string; + // Glob pattern to filter files (e.g., '*.ts', 'src/**/*.rs') + fileGlob?: string; + // Maximum number of matches to return (default: 100) + maxResults?: number; +} + +/** + * Factory function for creating CodeSearchParams + */ +export const createCodeSearchParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Regex pattern to search for + pattern: string; + // Glob pattern to filter files (e.g., '*.ts', 'src/**/*.rs') + fileGlob?: string; + // Maximum number of matches to return (default: 100) + maxResults?: number; + } +): CodeSearchParams => createPayload(context, sessionId, { + fileGlob: data.fileGlob ?? '', + maxResults: data.maxResults ?? 0, + ...data +}); + +/** + * Code Search Command Result + */ +export interface CodeSearchResult extends CommandResult { + success: boolean; + // Search matches from Rust (generated type via ts-rs) + matches: SearchMatch[]; + // Total number of matches found + totalMatches: number; + // Number of files searched + filesSearched: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeSearchResult with defaults + */ +export const createCodeSearchResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Search matches from Rust (generated type via ts-rs) + matches?: SearchMatch[]; + // Total number of matches found + totalMatches?: number; + // Number of files searched + filesSearched?: number; + error?: JTAGError; + } +): CodeSearchResult => createPayload(context, sessionId, { + matches: data.matches ?? [], + totalMatches: data.totalMatches ?? 0, + filesSearched: data.filesSearched ?? 0, + ...data +}); + +/** + * Smart Code Search-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeSearchResultFromParams = ( + params: CodeSearchParams, + differences: Omit +): CodeSearchResult => transformPayload(params, differences); + +/** + * Code Search β€” Type-safe command executor + * + * Usage: + * import { CodeSearch } from '...shared/CodeSearchTypes'; + * const result = await CodeSearch.execute({ ... }); + */ +export const CodeSearch = { + execute(params: CommandInput): Promise { + return Commands.execute('code/search', params as Partial); + }, + commandName: 'code/search' as const, +} as const; diff --git a/src/debug/jtag/commands/code/search/test/integration/CodeSearchIntegration.test.ts b/src/debug/jtag/commands/code/search/test/integration/CodeSearchIntegration.test.ts new file mode 100644 index 000000000..fefa00c92 --- /dev/null +++ b/src/debug/jtag/commands/code/search/test/integration/CodeSearchIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeSearch Command Integration Tests + * + * Tests Code Search command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Search/test/integration/CodeSearchIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeSearch Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Search command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Search command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Search']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Search returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Search succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Search']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Search']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Search']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Search']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Search']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeSearchIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeSearch Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeSearch INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeSearch integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeSearchIntegrationTests(); +} else { + module.exports = { runAllCodeSearchIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/search/test/unit/CodeSearchCommand.test.ts b/src/debug/jtag/commands/code/search/test/unit/CodeSearchCommand.test.ts new file mode 100644 index 000000000..de77f515f --- /dev/null +++ b/src/debug/jtag/commands/code/search/test/unit/CodeSearchCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeSearch Command Unit Tests + * + * Tests Code Search command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Search/test/unit/CodeSearchCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeSearchParams, CodeSearchResult } from '../../shared/CodeSearchTypes'; + +console.log('πŸ§ͺ CodeSearch Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Search logic for testing + */ +async function mockCodeSearchCommand(params: CodeSearchParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Search' or see the Code Search README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeSearchResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeSearchCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeSearch command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Search command + const validParams: CodeSearchParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeSearchExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Search command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeSearchParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeSearchCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeSearchRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeSearchParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeSearchParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeSearchCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeSearchOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeSearchParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeSearchCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeSearchParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeSearchCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeSearchPerformance(): Promise { + console.log('\n⚑ Test 5: CodeSearch performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeSearchCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeSearchParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeSearch completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeSearchResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeSearch result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeSearchCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeSearchParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeSearchUnitTests(): Promise { + console.log('πŸš€ Starting CodeSearch Command Unit Tests\n'); + + try { + testCodeSearchCommandStructure(); + await testMockCodeSearchExecution(); + await testCodeSearchRequiredParams(); + await testCodeSearchOptionalParams(); + await testCodeSearchPerformance(); + await testCodeSearchResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeSearch UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeSearch unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeSearchUnitTests(); +} else { + module.exports = { runAllCodeSearchUnitTests }; +} diff --git a/src/debug/jtag/commands/code/shell/execute/.npmignore b/src/debug/jtag/commands/code/shell/execute/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/shell/execute/README.md b/src/debug/jtag/commands/code/shell/execute/README.md new file mode 100644 index 000000000..62e96be0d --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/README.md @@ -0,0 +1,168 @@ +# Code Shell Execute Command + +Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/shell/execute --cmd= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/shell/execute', { + // your parameters here +}); +``` + +## Parameters + +- **cmd** (required): `string` - Shell command to execute (e.g., "npm run build", "cargo test", "xcodebuild") +- **wait** (optional): `boolean` - Wait for completion before returning (default: false β€” returns handle immediately) +- **timeoutMs** (optional): `number` - Timeout in milliseconds (default: 30000 for sync, no limit for async) + +## Result + +Returns `CodeShellExecuteResult` with: + +Returns CommandResult with: +- **executionId**: `string` - Execution handle β€” use with code/shell/watch, code/shell/kill +- **status**: `string` - Execution status: running, completed, failed, timed_out, killed +- **stdout**: `string` - Full stdout (only present when wait=true and execution completed) +- **stderr**: `string` - Full stderr (only present when wait=true and execution completed) +- **exitCode**: `number` - Process exit code (only present when execution completed) + +## Examples + +### Run a build synchronously and wait for result + +```bash +./jtag code/shell/execute --cmd="npm run build" --wait=true +``` + +**Expected result:** +{ executionId: "exec-abc123", status: "completed", stdout: "...", exitCode: 0 } + +### Start an async build (returns handle for streaming) + +```bash +./jtag code/shell/execute --cmd="cargo build --release" +``` + +**Expected result:** +{ executionId: "exec-def456", status: "running" } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/shell/execute +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/shell/execute' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/shell/execute +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/shell/execute' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Shell Execute/test/unit/CodeShellExecuteCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Shell Execute/test/integration/CodeShellExecuteIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeShellExecuteTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeShellExecuteBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeShellExecuteServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeShellExecuteCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeShellExecuteIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/shell/execute/browser/CodeShellExecuteBrowserCommand.ts b/src/debug/jtag/commands/code/shell/execute/browser/CodeShellExecuteBrowserCommand.ts new file mode 100644 index 000000000..13fa114cc --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/browser/CodeShellExecuteBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Shell Execute Command - Browser Implementation + * + * Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeShellExecuteParams, CodeShellExecuteResult } from '../shared/CodeShellExecuteTypes'; + +export class CodeShellExecuteBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/execute', context, subpath, commander); + } + + async execute(params: CodeShellExecuteParams): Promise { + console.log('🌐 BROWSER: Delegating Code Shell Execute to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/shell/execute/package.json b/src/debug/jtag/commands/code/shell/execute/package.json new file mode 100644 index 000000000..b0956760c --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/shell/execute", + "version": "1.0.0", + "description": "Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr.", + "main": "server/CodeShellExecuteServerCommand.ts", + "types": "shared/CodeShellExecuteTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeShellExecuteIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/shell/execute" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/shell/execute/server/CodeShellExecuteServerCommand.ts b/src/debug/jtag/commands/code/shell/execute/server/CodeShellExecuteServerCommand.ts new file mode 100644 index 000000000..bf85a9f85 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/server/CodeShellExecuteServerCommand.ts @@ -0,0 +1,62 @@ +/** + * Code Shell Execute Command - Server Implementation + * + * Execute a shell command in the persona's workspace. + * Async mode (default): returns execution handle immediately for streaming via watch. + * Sync mode (wait=true): blocks until completion, returns full stdout/stderr. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeShellExecuteParams, CodeShellExecuteResult } from '../shared/CodeShellExecuteTypes'; +import { createCodeShellExecuteResultFromParams } from '../shared/CodeShellExecuteTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeShellExecuteServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/execute', context, subpath, commander); + } + + async execute(params: CodeShellExecuteParams): Promise { + if (!params.cmd || params.cmd.trim() === '') { + throw new ValidationError( + 'cmd', + `Missing required parameter 'cmd'. Provide a shell command to execute (e.g., "npm run build", "cargo test"). ` + + `Use the help tool with 'Code Shell Execute' or see the code/shell/execute README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Shell execute operations require a userId (auto-injected for persona tool calls).' + ); + } + + const personaId = params.userId; + // LLMs frequently pass "true"/"false" strings despite schema declaring boolean. + // Coerce explicitly before hitting Rust serde (which rejects string where bool expected). + // Cast through unknown because TypeScript types say boolean but runtime value may be string. + const rawWait = params.wait as unknown; + const wait = rawWait === true || rawWait === 'true'; + const rawTimeout = params.timeoutMs as unknown; + const timeoutMs = (typeof rawTimeout === 'string' ? parseInt(rawTimeout, 10) : rawTimeout as number | undefined) + ?? (wait ? 30000 : undefined); + + const result = await CodeDaemon.shellExecute(personaId, params.cmd, { + timeoutMs, + wait, + }); + + return createCodeShellExecuteResultFromParams(params, { + success: true, + executionId: result.execution_id, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exit_code, + }); + } +} diff --git a/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts b/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts new file mode 100644 index 000000000..16aca5e93 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/shared/CodeShellExecuteTypes.ts @@ -0,0 +1,115 @@ +/** + * Code Shell Execute Command - Shared Types + * + * Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { ShellExecutionStatus } from '@shared/generated/code/ShellExecutionStatus'; + +/** + * Code Shell Execute Command Parameters + */ +export interface CodeShellExecuteParams extends CommandParams { + // Shell command to execute (e.g., "npm run build", "cargo test", "xcodebuild") + cmd: string; + // Wait for completion before returning (default: false β€” returns handle immediately) + wait?: boolean; + // Timeout in milliseconds (default: 30000 for sync, no limit for async) + timeoutMs?: number; +} + +/** + * Factory function for creating CodeShellExecuteParams + */ +export const createCodeShellExecuteParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Shell command to execute (e.g., "npm run build", "cargo test", "xcodebuild") + cmd: string; + // Wait for completion before returning (default: false β€” returns handle immediately) + wait?: boolean; + // Timeout in milliseconds (default: 30000 for sync, no limit for async) + timeoutMs?: number; + } +): CodeShellExecuteParams => createPayload(context, sessionId, { + wait: data.wait ?? false, + timeoutMs: data.timeoutMs ?? 0, + ...data +}); + +/** + * Code Shell Execute Command Result + */ +export interface CodeShellExecuteResult extends CommandResult { + success: boolean; + /** Execution handle β€” use with code/shell/watch, code/shell/kill */ + executionId: string; + /** Execution status: running, completed, failed, timed_out, killed */ + status: ShellExecutionStatus; + /** Full stdout (only present when wait=true and execution completed) */ + stdout?: string; + /** Full stderr (only present when wait=true and execution completed) */ + stderr?: string; + /** Process exit code (only present when execution completed) */ + exitCode?: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeShellExecuteResult with defaults + */ +export const createCodeShellExecuteResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Execution handle β€” use with code/shell/watch, code/shell/kill + executionId?: string; + // Execution status: running, completed, failed, timed_out, killed + status?: ShellExecutionStatus; + // Full stdout (only present when wait=true and execution completed) + stdout?: string; + // Full stderr (only present when wait=true and execution completed) + stderr?: string; + // Process exit code (only present when execution completed) + exitCode?: number; + error?: JTAGError; + } +): CodeShellExecuteResult => createPayload(context, sessionId, { + executionId: data.executionId ?? '', + status: data.status ?? 'running' as ShellExecutionStatus, + stdout: data.stdout, + stderr: data.stderr, + exitCode: data.exitCode, + ...data +}); + +/** + * Smart Code Shell Execute-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeShellExecuteResultFromParams = ( + params: CodeShellExecuteParams, + differences: Omit +): CodeShellExecuteResult => transformPayload(params, differences); + +/** + * Code Shell Execute β€” Type-safe command executor + * + * Usage: + * import { CodeShellExecute } from '...shared/CodeShellExecuteTypes'; + * const result = await CodeShellExecute.execute({ ... }); + */ +export const CodeShellExecute = { + execute(params: CommandInput): Promise { + return Commands.execute('code/shell/execute', params as Partial); + }, + commandName: 'code/shell/execute' as const, +} as const; diff --git a/src/debug/jtag/commands/code/shell/execute/test/integration/CodeShellExecuteIntegration.test.ts b/src/debug/jtag/commands/code/shell/execute/test/integration/CodeShellExecuteIntegration.test.ts new file mode 100644 index 000000000..33a2f5be4 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/test/integration/CodeShellExecuteIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeShellExecute Command Integration Tests + * + * Tests Code Shell Execute command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Execute/test/integration/CodeShellExecuteIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeShellExecute Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Shell Execute command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Shell Execute command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Shell Execute']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Shell Execute returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Shell Execute succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Shell Execute']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Shell Execute']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Shell Execute']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Shell Execute']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Shell Execute']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeShellExecuteIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeShellExecute Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeShellExecute INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeShellExecute integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellExecuteIntegrationTests(); +} else { + module.exports = { runAllCodeShellExecuteIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/shell/execute/test/unit/CodeShellExecuteCommand.test.ts b/src/debug/jtag/commands/code/shell/execute/test/unit/CodeShellExecuteCommand.test.ts new file mode 100644 index 000000000..e13c55edc --- /dev/null +++ b/src/debug/jtag/commands/code/shell/execute/test/unit/CodeShellExecuteCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeShellExecute Command Unit Tests + * + * Tests Code Shell Execute command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Execute/test/unit/CodeShellExecuteCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeShellExecuteParams, CodeShellExecuteResult } from '../../shared/CodeShellExecuteTypes'; + +console.log('πŸ§ͺ CodeShellExecute Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Shell Execute logic for testing + */ +async function mockCodeShellExecuteCommand(params: CodeShellExecuteParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Shell Execute' or see the Code Shell Execute README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeShellExecuteResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeShellExecuteCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeShellExecute command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Shell Execute command + const validParams: CodeShellExecuteParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeShellExecuteExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Shell Execute command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeShellExecuteParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeShellExecuteCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeShellExecuteRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeShellExecuteParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeShellExecuteParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeShellExecuteCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeShellExecuteOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeShellExecuteParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeShellExecuteCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeShellExecuteParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeShellExecuteCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeShellExecutePerformance(): Promise { + console.log('\n⚑ Test 5: CodeShellExecute performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeShellExecuteCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellExecuteParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeShellExecute completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeShellExecuteResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeShellExecute result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeShellExecuteCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellExecuteParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeShellExecuteUnitTests(): Promise { + console.log('πŸš€ Starting CodeShellExecute Command Unit Tests\n'); + + try { + testCodeShellExecuteCommandStructure(); + await testMockCodeShellExecuteExecution(); + await testCodeShellExecuteRequiredParams(); + await testCodeShellExecuteOptionalParams(); + await testCodeShellExecutePerformance(); + await testCodeShellExecuteResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeShellExecute UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeShellExecute unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellExecuteUnitTests(); +} else { + module.exports = { runAllCodeShellExecuteUnitTests }; +} diff --git a/src/debug/jtag/commands/code/shell/kill/.npmignore b/src/debug/jtag/commands/code/shell/kill/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/shell/kill/README.md b/src/debug/jtag/commands/code/shell/kill/README.md new file mode 100644 index 000000000..993f14f25 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/README.md @@ -0,0 +1,154 @@ +# Code Shell Kill Command + +Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/shell/kill --executionId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/shell/kill', { + // your parameters here +}); +``` + +## Parameters + +- **executionId** (required): `string` - Execution handle to kill (from code/shell/execute) + +## Result + +Returns `CodeShellKillResult` with: + +Returns CommandResult with: +- **executionId**: `string` - Echo of the killed execution handle +- **killed**: `boolean` - Whether the execution was successfully killed + +## Examples + +### Kill a running build + +```bash +./jtag code/shell/kill --executionId="exec-abc123" +``` + +**Expected result:** +{ executionId: "exec-abc123", killed: true } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/shell/kill +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/shell/kill' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/shell/kill +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/shell/kill' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Shell Kill/test/unit/CodeShellKillCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Shell Kill/test/integration/CodeShellKillIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeShellKillTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeShellKillBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeShellKillServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeShellKillCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeShellKillIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/shell/kill/browser/CodeShellKillBrowserCommand.ts b/src/debug/jtag/commands/code/shell/kill/browser/CodeShellKillBrowserCommand.ts new file mode 100644 index 000000000..430f83a1e --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/browser/CodeShellKillBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Shell Kill Command - Browser Implementation + * + * Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeShellKillParams, CodeShellKillResult } from '../shared/CodeShellKillTypes'; + +export class CodeShellKillBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/kill', context, subpath, commander); + } + + async execute(params: CodeShellKillParams): Promise { + console.log('🌐 BROWSER: Delegating Code Shell Kill to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/shell/kill/package.json b/src/debug/jtag/commands/code/shell/kill/package.json new file mode 100644 index 000000000..86023bfeb --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/shell/kill", + "version": "1.0.0", + "description": "Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target.", + "main": "server/CodeShellKillServerCommand.ts", + "types": "shared/CodeShellKillTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeShellKillIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/shell/kill" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/shell/kill/server/CodeShellKillServerCommand.ts b/src/debug/jtag/commands/code/shell/kill/server/CodeShellKillServerCommand.ts new file mode 100644 index 000000000..db5bb90e4 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/server/CodeShellKillServerCommand.ts @@ -0,0 +1,45 @@ +/** + * Code Shell Kill Command - Server Implementation + * + * Kill a running shell execution. Uses the executionId returned by code/shell/execute. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeShellKillParams, CodeShellKillResult } from '../shared/CodeShellKillTypes'; +import { createCodeShellKillResultFromParams } from '../shared/CodeShellKillTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeShellKillServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/kill', context, subpath, commander); + } + + async execute(params: CodeShellKillParams): Promise { + if (!params.executionId || params.executionId.trim() === '') { + throw new ValidationError( + 'executionId', + `Missing required parameter 'executionId'. Provide the execution handle from code/shell/execute. ` + + `Use the help tool with 'Code Shell Kill' or see the code/shell/kill README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Shell kill operations require a userId (auto-injected for persona tool calls).' + ); + } + + const personaId = params.userId; + await CodeDaemon.shellKill(personaId, params.executionId); + + return createCodeShellKillResultFromParams(params, { + success: true, + executionId: params.executionId, + killed: true, + }); + } +} diff --git a/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts b/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts new file mode 100644 index 000000000..5bbbf048e --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/shared/CodeShellKillTypes.ts @@ -0,0 +1,90 @@ +/** + * Code Shell Kill Command - Shared Types + * + * Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Shell Kill Command Parameters + */ +export interface CodeShellKillParams extends CommandParams { + // Execution handle to kill (from code/shell/execute) + executionId: string; +} + +/** + * Factory function for creating CodeShellKillParams + */ +export const createCodeShellKillParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Execution handle to kill (from code/shell/execute) + executionId: string; + } +): CodeShellKillParams => createPayload(context, sessionId, { + + ...data +}); + +/** + * Code Shell Kill Command Result + */ +export interface CodeShellKillResult extends CommandResult { + success: boolean; + // Echo of the killed execution handle + executionId: string; + // Whether the execution was successfully killed + killed: boolean; + error?: JTAGError; +} + +/** + * Factory function for creating CodeShellKillResult with defaults + */ +export const createCodeShellKillResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Echo of the killed execution handle + executionId?: string; + // Whether the execution was successfully killed + killed?: boolean; + error?: JTAGError; + } +): CodeShellKillResult => createPayload(context, sessionId, { + executionId: data.executionId ?? '', + killed: data.killed ?? false, + ...data +}); + +/** + * Smart Code Shell Kill-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeShellKillResultFromParams = ( + params: CodeShellKillParams, + differences: Omit +): CodeShellKillResult => transformPayload(params, differences); + +/** + * Code Shell Kill β€” Type-safe command executor + * + * Usage: + * import { CodeShellKill } from '...shared/CodeShellKillTypes'; + * const result = await CodeShellKill.execute({ ... }); + */ +export const CodeShellKill = { + execute(params: CommandInput): Promise { + return Commands.execute('code/shell/kill', params as Partial); + }, + commandName: 'code/shell/kill' as const, +} as const; diff --git a/src/debug/jtag/commands/code/shell/kill/test/integration/CodeShellKillIntegration.test.ts b/src/debug/jtag/commands/code/shell/kill/test/integration/CodeShellKillIntegration.test.ts new file mode 100644 index 000000000..2ea26e85d --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/test/integration/CodeShellKillIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeShellKill Command Integration Tests + * + * Tests Code Shell Kill command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Kill/test/integration/CodeShellKillIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeShellKill Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Shell Kill command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Shell Kill command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Shell Kill']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Shell Kill returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Shell Kill succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Shell Kill']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Shell Kill']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Shell Kill']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Shell Kill']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Shell Kill']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeShellKillIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeShellKill Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeShellKill INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeShellKill integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellKillIntegrationTests(); +} else { + module.exports = { runAllCodeShellKillIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/shell/kill/test/unit/CodeShellKillCommand.test.ts b/src/debug/jtag/commands/code/shell/kill/test/unit/CodeShellKillCommand.test.ts new file mode 100644 index 000000000..f459678e0 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/kill/test/unit/CodeShellKillCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeShellKill Command Unit Tests + * + * Tests Code Shell Kill command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Kill/test/unit/CodeShellKillCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeShellKillParams, CodeShellKillResult } from '../../shared/CodeShellKillTypes'; + +console.log('πŸ§ͺ CodeShellKill Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Shell Kill logic for testing + */ +async function mockCodeShellKillCommand(params: CodeShellKillParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Shell Kill' or see the Code Shell Kill README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeShellKillResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeShellKillCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeShellKill command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Shell Kill command + const validParams: CodeShellKillParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeShellKillExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Shell Kill command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeShellKillParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeShellKillCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeShellKillRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeShellKillParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeShellKillParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeShellKillCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeShellKillOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeShellKillParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeShellKillCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeShellKillParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeShellKillCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeShellKillPerformance(): Promise { + console.log('\n⚑ Test 5: CodeShellKill performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeShellKillCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellKillParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeShellKill completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeShellKillResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeShellKill result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeShellKillCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellKillParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeShellKillUnitTests(): Promise { + console.log('πŸš€ Starting CodeShellKill Command Unit Tests\n'); + + try { + testCodeShellKillCommandStructure(); + await testMockCodeShellKillExecution(); + await testCodeShellKillRequiredParams(); + await testCodeShellKillOptionalParams(); + await testCodeShellKillPerformance(); + await testCodeShellKillResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeShellKill UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeShellKill unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellKillUnitTests(); +} else { + module.exports = { runAllCodeShellKillUnitTests }; +} diff --git a/src/debug/jtag/commands/code/shell/sentinel/.npmignore b/src/debug/jtag/commands/code/shell/sentinel/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/shell/sentinel/README.md b/src/debug/jtag/commands/code/shell/sentinel/README.md new file mode 100644 index 000000000..4d0c3b142 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/README.md @@ -0,0 +1,155 @@ +# Code Shell Sentinel Command + +Configure sentinel filter rules on a shell execution. Rules classify output lines and control which lines are emitted or suppressed during watch. Patterns are compiled to regex on the Rust side for performance. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/shell/sentinel --executionId= --rules= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/shell/sentinel', { + // your parameters here +}); +``` + +## Parameters + +- **executionId** (required): `string` - Execution handle to attach sentinel rules to +- **rules** (required): `SentinelRule[]` - Array of classification rules: { pattern: string, classification: OutputClassification, action: SentinelAction } + +## Result + +Returns `CodeShellSentinelResult` with: + +Returns CommandResult with: +- **applied**: `boolean` - Whether rules were applied successfully +- **ruleCount**: `number` - Number of sentinel rules configured + +## Examples + +### Filter build output to only errors and warnings + +```bash +./jtag code/shell/sentinel --executionId="exec-abc123" --rules='[{"pattern":"^error","classification":"Error","action":"Emit"},{"pattern":".*","classification":"Verbose","action":"Suppress"}]' +``` + +**Expected result:** +{ applied: true, ruleCount: 2 } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/shell/sentinel +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/shell/sentinel' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/shell/sentinel +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/shell/sentinel' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Shell Sentinel/test/unit/CodeShellSentinelCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Shell Sentinel/test/integration/CodeShellSentinelIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeShellSentinelTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeShellSentinelBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeShellSentinelServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeShellSentinelCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeShellSentinelIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/shell/sentinel/browser/CodeShellSentinelBrowserCommand.ts b/src/debug/jtag/commands/code/shell/sentinel/browser/CodeShellSentinelBrowserCommand.ts new file mode 100644 index 000000000..557508f1b --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/browser/CodeShellSentinelBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Shell Sentinel Command - Browser Implementation + * + * Configure sentinel filter rules on a shell execution. Rules classify output lines and control which lines are emitted or suppressed during watch. Patterns are compiled to regex on the Rust side for performance. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeShellSentinelParams, CodeShellSentinelResult } from '../shared/CodeShellSentinelTypes'; + +export class CodeShellSentinelBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/sentinel', context, subpath, commander); + } + + async execute(params: CodeShellSentinelParams): Promise { + console.log('🌐 BROWSER: Delegating Code Shell Sentinel to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/shell/sentinel/package.json b/src/debug/jtag/commands/code/shell/sentinel/package.json new file mode 100644 index 000000000..e1bd9a1cd --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/shell/sentinel", + "version": "1.0.0", + "description": "Configure sentinel filter rules on a shell execution. Rules classify output lines and control which lines are emitted or suppressed during watch. Patterns are compiled to regex on the Rust side for performance.", + "main": "server/CodeShellSentinelServerCommand.ts", + "types": "shared/CodeShellSentinelTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeShellSentinelIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/shell/sentinel" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/shell/sentinel/server/CodeShellSentinelServerCommand.ts b/src/debug/jtag/commands/code/shell/sentinel/server/CodeShellSentinelServerCommand.ts new file mode 100644 index 000000000..50cc48e20 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/server/CodeShellSentinelServerCommand.ts @@ -0,0 +1,53 @@ +/** + * Code Shell Sentinel Command - Server Implementation + * + * Configure sentinel filter rules on a shell execution. Rules classify output lines + * and control which lines are emitted or suppressed during watch. + * Patterns are compiled to regex on the Rust side for performance. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeShellSentinelParams, CodeShellSentinelResult } from '../shared/CodeShellSentinelTypes'; +import { createCodeShellSentinelResultFromParams } from '../shared/CodeShellSentinelTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeShellSentinelServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/sentinel', context, subpath, commander); + } + + async execute(params: CodeShellSentinelParams): Promise { + if (!params.executionId || params.executionId.trim() === '') { + throw new ValidationError( + 'executionId', + `Missing required parameter 'executionId'. Use the help tool with 'Code Shell Sentinel' or see the code/shell/sentinel README for usage.` + ); + } + + if (!params.rules || !Array.isArray(params.rules)) { + throw new ValidationError( + 'rules', + `Missing required parameter 'rules'. Provide an array of SentinelRule objects. See the code/shell/sentinel README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Shell sentinel operations require a userId (auto-injected for persona tool calls).' + ); + } + const personaId = params.userId; + + const result = await CodeDaemon.shellSentinel(personaId, params.executionId, params.rules); + + return createCodeShellSentinelResultFromParams(params, { + success: true, + applied: result.applied, + ruleCount: result.ruleCount, + }); + } +} diff --git a/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts b/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts new file mode 100644 index 000000000..2a16127b2 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/shared/CodeShellSentinelTypes.ts @@ -0,0 +1,92 @@ +/** + * Code Shell Sentinel Command - Shared Types + * + * Configure sentinel filter rules on a shell execution. Rules classify output lines + * and control which lines are emitted or suppressed during watch. + * Patterns are compiled to regex on the Rust side for performance. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { SentinelRule } from '@shared/generated/code/SentinelRule'; + +/** + * Code Shell Sentinel Command Parameters + */ +export interface CodeShellSentinelParams extends CommandParams { + /** Execution handle to attach sentinel rules to */ + executionId: string; + /** Array of classification rules (pattern, classification, action) */ + rules: SentinelRule[]; +} + +/** + * Factory function for creating CodeShellSentinelParams + */ +export const createCodeShellSentinelParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + executionId: string; + rules: SentinelRule[]; + } +): CodeShellSentinelParams => createPayload(context, sessionId, { + ...data +}); + +/** + * Code Shell Sentinel Command Result + */ +export interface CodeShellSentinelResult extends CommandResult { + success: boolean; + /** Whether rules were applied successfully */ + applied: boolean; + /** Number of sentinel rules configured */ + ruleCount: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeShellSentinelResult with defaults + */ +export const createCodeShellSentinelResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + applied?: boolean; + ruleCount?: number; + error?: JTAGError; + } +): CodeShellSentinelResult => createPayload(context, sessionId, { + applied: data.applied ?? false, + ruleCount: data.ruleCount ?? 0, + ...data +}); + +/** + * Smart Code Shell Sentinel-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeShellSentinelResultFromParams = ( + params: CodeShellSentinelParams, + differences: Omit +): CodeShellSentinelResult => transformPayload(params, differences); + +/** + * Code Shell Sentinel β€” Type-safe command executor + * + * Usage: + * import { CodeShellSentinel } from '...shared/CodeShellSentinelTypes'; + * const result = await CodeShellSentinel.execute({ ... }); + */ +export const CodeShellSentinel = { + execute(params: CommandInput): Promise { + return Commands.execute('code/shell/sentinel', params as Partial); + }, + commandName: 'code/shell/sentinel' as const, +} as const; diff --git a/src/debug/jtag/commands/code/shell/sentinel/test/integration/CodeShellSentinelIntegration.test.ts b/src/debug/jtag/commands/code/shell/sentinel/test/integration/CodeShellSentinelIntegration.test.ts new file mode 100644 index 000000000..50e939331 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/test/integration/CodeShellSentinelIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeShellSentinel Command Integration Tests + * + * Tests Code Shell Sentinel command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Sentinel/test/integration/CodeShellSentinelIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeShellSentinel Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Shell Sentinel command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Shell Sentinel command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Shell Sentinel']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Shell Sentinel returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Shell Sentinel succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Shell Sentinel']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Shell Sentinel']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Shell Sentinel']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Shell Sentinel']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Shell Sentinel']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeShellSentinelIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeShellSentinel Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeShellSentinel INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeShellSentinel integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellSentinelIntegrationTests(); +} else { + module.exports = { runAllCodeShellSentinelIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/shell/sentinel/test/unit/CodeShellSentinelCommand.test.ts b/src/debug/jtag/commands/code/shell/sentinel/test/unit/CodeShellSentinelCommand.test.ts new file mode 100644 index 000000000..a068632ec --- /dev/null +++ b/src/debug/jtag/commands/code/shell/sentinel/test/unit/CodeShellSentinelCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeShellSentinel Command Unit Tests + * + * Tests Code Shell Sentinel command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Sentinel/test/unit/CodeShellSentinelCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeShellSentinelParams, CodeShellSentinelResult } from '../../shared/CodeShellSentinelTypes'; + +console.log('πŸ§ͺ CodeShellSentinel Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Shell Sentinel logic for testing + */ +async function mockCodeShellSentinelCommand(params: CodeShellSentinelParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Shell Sentinel' or see the Code Shell Sentinel README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeShellSentinelResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeShellSentinelCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeShellSentinel command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Shell Sentinel command + const validParams: CodeShellSentinelParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeShellSentinelExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Shell Sentinel command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeShellSentinelParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeShellSentinelCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeShellSentinelRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeShellSentinelParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeShellSentinelParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeShellSentinelCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeShellSentinelOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeShellSentinelParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeShellSentinelCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeShellSentinelParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeShellSentinelCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeShellSentinelPerformance(): Promise { + console.log('\n⚑ Test 5: CodeShellSentinel performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeShellSentinelCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellSentinelParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeShellSentinel completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeShellSentinelResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeShellSentinel result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeShellSentinelCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellSentinelParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeShellSentinelUnitTests(): Promise { + console.log('πŸš€ Starting CodeShellSentinel Command Unit Tests\n'); + + try { + testCodeShellSentinelCommandStructure(); + await testMockCodeShellSentinelExecution(); + await testCodeShellSentinelRequiredParams(); + await testCodeShellSentinelOptionalParams(); + await testCodeShellSentinelPerformance(); + await testCodeShellSentinelResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeShellSentinel UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeShellSentinel unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellSentinelUnitTests(); +} else { + module.exports = { runAllCodeShellSentinelUnitTests }; +} diff --git a/src/debug/jtag/commands/code/shell/status/.npmignore b/src/debug/jtag/commands/code/shell/status/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/shell/status/README.md b/src/debug/jtag/commands/code/shell/status/README.md new file mode 100644 index 000000000..c3a3d254f --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/README.md @@ -0,0 +1,158 @@ +# Code Shell Status Command + +Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected). + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/shell/status +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/shell/status', { + // your parameters here +}); +``` + +## Parameters + +No parameters required. + +## Result + +Returns `CodeShellStatusResult` with: + +Returns CommandResult with: +- **sessionId**: `string` - Shell session identifier +- **personaId**: `string` - Persona that owns this shell session +- **cwd**: `string` - Current working directory of the shell session +- **workspaceRoot**: `string` - Root directory of the workspace +- **activeExecutions**: `number` - Number of currently running executions +- **totalExecutions**: `number` - Total number of executions (running + completed) + +## Examples + +### Check shell session status + +```bash +./jtag code/shell/status +``` + +**Expected result:** +{ sessionId: "sess-abc", cwd: "/workspace/game", activeExecutions: 1, totalExecutions: 5 } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/shell/status +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/shell/status' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/shell/status +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/shell/status' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Shell Status/test/unit/CodeShellStatusCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Shell Status/test/integration/CodeShellStatusIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeShellStatusTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeShellStatusBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeShellStatusServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeShellStatusCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeShellStatusIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/shell/status/browser/CodeShellStatusBrowserCommand.ts b/src/debug/jtag/commands/code/shell/status/browser/CodeShellStatusBrowserCommand.ts new file mode 100644 index 000000000..f88e6fe42 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/browser/CodeShellStatusBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Shell Status Command - Browser Implementation + * + * Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected). + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeShellStatusParams, CodeShellStatusResult } from '../shared/CodeShellStatusTypes'; + +export class CodeShellStatusBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/status', context, subpath, commander); + } + + async execute(params: CodeShellStatusParams): Promise { + console.log('🌐 BROWSER: Delegating Code Shell Status to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/shell/status/package.json b/src/debug/jtag/commands/code/shell/status/package.json new file mode 100644 index 000000000..b4dac23a8 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/shell/status", + "version": "1.0.0", + "description": "Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected).", + "main": "server/CodeShellStatusServerCommand.ts", + "types": "shared/CodeShellStatusTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeShellStatusIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/shell/status" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/shell/status/server/CodeShellStatusServerCommand.ts b/src/debug/jtag/commands/code/shell/status/server/CodeShellStatusServerCommand.ts new file mode 100644 index 000000000..cb2654e43 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/server/CodeShellStatusServerCommand.ts @@ -0,0 +1,42 @@ +/** + * Code Shell Status Command - Server Implementation + * + * Get shell session info for the persona's workspace β€” cwd, active/total execution count. + * No parameters required (userId auto-injected by infrastructure). + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeShellStatusParams, CodeShellStatusResult } from '../shared/CodeShellStatusTypes'; +import { createCodeShellStatusResultFromParams } from '../shared/CodeShellStatusTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeShellStatusServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/status', context, subpath, commander); + } + + async execute(params: CodeShellStatusParams): Promise { + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Shell status operations require a userId (auto-injected for persona tool calls).' + ); + } + + const personaId = params.userId; + const info = await CodeDaemon.shellStatus(personaId); + + return createCodeShellStatusResultFromParams(params, { + success: true, + shellSessionId: info.session_id, + personaId: info.persona_id, + cwd: info.cwd, + workspaceRoot: info.workspace_root, + activeExecutions: info.active_executions, + totalExecutions: info.total_executions, + }); + } +} diff --git a/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts b/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts new file mode 100644 index 000000000..4abb7c135 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/shared/CodeShellStatusTypes.ts @@ -0,0 +1,106 @@ +/** + * Code Shell Status Command - Shared Types + * + * Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected). + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Shell Status Command Parameters + */ +export interface CodeShellStatusParams extends CommandParams { + _noParams?: never; // Marker to avoid empty interface +} + +/** + * Factory function for creating CodeShellStatusParams + */ +export const createCodeShellStatusParams = ( + context: JTAGContext, + sessionId: UUID, + data: Record +): CodeShellStatusParams => createPayload(context, sessionId, { + + ...data +}); + +/** + * Code Shell Status Command Result + */ +export interface CodeShellStatusResult extends CommandResult { + success: boolean; + // Shell session identifier (named shellSessionId to avoid collision with base CommandResult.sessionId) + shellSessionId: string; + // Persona that owns this shell session + personaId: string; + // Current working directory of the shell session + cwd: string; + // Root directory of the workspace + workspaceRoot: string; + // Number of currently running executions + activeExecutions: number; + // Total number of executions (running + completed) + totalExecutions: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeShellStatusResult with defaults + */ +export const createCodeShellStatusResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Shell session identifier + shellSessionId?: string; + // Persona that owns this shell session + personaId?: string; + // Current working directory of the shell session + cwd?: string; + // Root directory of the workspace + workspaceRoot?: string; + // Number of currently running executions + activeExecutions?: number; + // Total number of executions (running + completed) + totalExecutions?: number; + error?: JTAGError; + } +): CodeShellStatusResult => createPayload(context, sessionId, { + shellSessionId: data.shellSessionId ?? '', + personaId: data.personaId ?? '', + cwd: data.cwd ?? '', + workspaceRoot: data.workspaceRoot ?? '', + activeExecutions: data.activeExecutions ?? 0, + totalExecutions: data.totalExecutions ?? 0, + ...data +}); + +/** + * Smart Code Shell Status-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeShellStatusResultFromParams = ( + params: CodeShellStatusParams, + differences: Omit +): CodeShellStatusResult => transformPayload(params, differences); + +/** + * Code Shell Status β€” Type-safe command executor + * + * Usage: + * import { CodeShellStatus } from '...shared/CodeShellStatusTypes'; + * const result = await CodeShellStatus.execute({ ... }); + */ +export const CodeShellStatus = { + execute(params: CommandInput): Promise { + return Commands.execute('code/shell/status', params as Partial); + }, + commandName: 'code/shell/status' as const, +} as const; diff --git a/src/debug/jtag/commands/code/shell/status/test/integration/CodeShellStatusIntegration.test.ts b/src/debug/jtag/commands/code/shell/status/test/integration/CodeShellStatusIntegration.test.ts new file mode 100644 index 000000000..9d2c38d02 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/test/integration/CodeShellStatusIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeShellStatus Command Integration Tests + * + * Tests Code Shell Status command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Status/test/integration/CodeShellStatusIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeShellStatus Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Shell Status command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Shell Status command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Shell Status']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Shell Status returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Shell Status succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Shell Status']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Shell Status']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Shell Status']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Shell Status']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Shell Status']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeShellStatusIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeShellStatus Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeShellStatus INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeShellStatus integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellStatusIntegrationTests(); +} else { + module.exports = { runAllCodeShellStatusIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/shell/status/test/unit/CodeShellStatusCommand.test.ts b/src/debug/jtag/commands/code/shell/status/test/unit/CodeShellStatusCommand.test.ts new file mode 100644 index 000000000..f6c7c911e --- /dev/null +++ b/src/debug/jtag/commands/code/shell/status/test/unit/CodeShellStatusCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeShellStatus Command Unit Tests + * + * Tests Code Shell Status command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Status/test/unit/CodeShellStatusCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeShellStatusParams, CodeShellStatusResult } from '../../shared/CodeShellStatusTypes'; + +console.log('πŸ§ͺ CodeShellStatus Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Shell Status logic for testing + */ +async function mockCodeShellStatusCommand(params: CodeShellStatusParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Shell Status' or see the Code Shell Status README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeShellStatusResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeShellStatusCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeShellStatus command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Shell Status command + const validParams: CodeShellStatusParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeShellStatusExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Shell Status command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeShellStatusParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeShellStatusCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeShellStatusRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeShellStatusParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeShellStatusParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeShellStatusCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeShellStatusOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeShellStatusParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeShellStatusCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeShellStatusParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeShellStatusCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeShellStatusPerformance(): Promise { + console.log('\n⚑ Test 5: CodeShellStatus performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeShellStatusCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellStatusParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeShellStatus completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeShellStatusResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeShellStatus result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeShellStatusCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellStatusParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeShellStatusUnitTests(): Promise { + console.log('πŸš€ Starting CodeShellStatus Command Unit Tests\n'); + + try { + testCodeShellStatusCommandStructure(); + await testMockCodeShellStatusExecution(); + await testCodeShellStatusRequiredParams(); + await testCodeShellStatusOptionalParams(); + await testCodeShellStatusPerformance(); + await testCodeShellStatusResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeShellStatus UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeShellStatus unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellStatusUnitTests(); +} else { + module.exports = { runAllCodeShellStatusUnitTests }; +} diff --git a/src/debug/jtag/commands/code/shell/watch/.npmignore b/src/debug/jtag/commands/code/shell/watch/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/shell/watch/README.md b/src/debug/jtag/commands/code/shell/watch/README.md new file mode 100644 index 000000000..7d3723cdc --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/README.md @@ -0,0 +1,165 @@ +# Code Shell Watch Command + +Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/shell/watch --executionId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/shell/watch', { + // your parameters here +}); +``` + +## Parameters + +- **executionId** (required): `string` - Execution handle from shell/exec + +## Result + +Returns `CodeShellWatchResult` with: + +Returns CommandResult with: +- **executionId**: `string` - Echo of the execution handle +- **lines**: `ClassifiedLine[]` - New output lines since last watch call (classified and filtered) +- **finished**: `boolean` - True when execution is complete +- **exitCode**: `number` - Process exit code (present when finished) + +## Examples + +### Watch a running build for new output + +```bash +./jtag code/shell/watch --executionId="exec-abc123" +``` + +**Expected result:** +{ executionId: "exec-abc123", lines: [{text: "Compiling...", classification: "Info"}], finished: false } + +### Final watch call when execution completes + +```bash +./jtag code/shell/watch --executionId="exec-abc123" +``` + +**Expected result:** +{ executionId: "exec-abc123", lines: [], finished: true, exitCode: 0 } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/shell/watch +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/shell/watch' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/shell/watch +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/shell/watch' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Shell Watch/test/unit/CodeShellWatchCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Shell Watch/test/integration/CodeShellWatchIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeShellWatchTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeShellWatchBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeShellWatchServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeShellWatchCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeShellWatchIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/shell/watch/browser/CodeShellWatchBrowserCommand.ts b/src/debug/jtag/commands/code/shell/watch/browser/CodeShellWatchBrowserCommand.ts new file mode 100644 index 000000000..95b35707d --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/browser/CodeShellWatchBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Shell Watch Command - Browser Implementation + * + * Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeShellWatchParams, CodeShellWatchResult } from '../shared/CodeShellWatchTypes'; + +export class CodeShellWatchBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/watch', context, subpath, commander); + } + + async execute(params: CodeShellWatchParams): Promise { + console.log('🌐 BROWSER: Delegating Code Shell Watch to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/shell/watch/package.json b/src/debug/jtag/commands/code/shell/watch/package.json new file mode 100644 index 000000000..c24cc6f88 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/shell/watch", + "version": "1.0.0", + "description": "Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true.", + "main": "server/CodeShellWatchServerCommand.ts", + "types": "shared/CodeShellWatchTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeShellWatchIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/shell/watch" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/shell/watch/server/CodeShellWatchServerCommand.ts b/src/debug/jtag/commands/code/shell/watch/server/CodeShellWatchServerCommand.ts new file mode 100644 index 000000000..3134b1486 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/server/CodeShellWatchServerCommand.ts @@ -0,0 +1,47 @@ +/** + * Code Shell Watch Command - Server Implementation + * + * Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. + * Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeShellWatchParams, CodeShellWatchResult } from '../shared/CodeShellWatchTypes'; +import { createCodeShellWatchResultFromParams } from '../shared/CodeShellWatchTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeShellWatchServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/shell/watch', context, subpath, commander); + } + + async execute(params: CodeShellWatchParams): Promise { + if (!params.executionId || params.executionId.trim() === '') { + throw new ValidationError( + 'executionId', + `Missing required parameter 'executionId'. Use the help tool with 'Code Shell Watch' or see the code/shell/watch README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError( + 'userId', + 'Shell watch operations require a userId (auto-injected for persona tool calls).' + ); + } + const personaId = params.userId; + + const result = await CodeDaemon.shellWatch(personaId, params.executionId); + + return createCodeShellWatchResultFromParams(params, { + success: true, + executionId: result.execution_id, + lines: result.lines, + finished: result.finished, + exitCode: result.exit_code, + }); + } +} diff --git a/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts b/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts new file mode 100644 index 000000000..168e38b6f --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/shared/CodeShellWatchTypes.ts @@ -0,0 +1,96 @@ +/** + * Code Shell Watch Command - Shared Types + * + * Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. + * Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { ClassifiedLine } from '@shared/generated/code/ClassifiedLine'; + +/** + * Code Shell Watch Command Parameters + */ +export interface CodeShellWatchParams extends CommandParams { + /** Execution handle from shell/exec */ + executionId: string; +} + +/** + * Factory function for creating CodeShellWatchParams + */ +export const createCodeShellWatchParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + executionId: string; + } +): CodeShellWatchParams => createPayload(context, sessionId, { + ...data +}); + +/** + * Code Shell Watch Command Result + */ +export interface CodeShellWatchResult extends CommandResult { + success: boolean; + /** Echo of the execution handle */ + executionId: string; + /** New output lines since last watch call (classified and filtered) */ + lines: ClassifiedLine[]; + /** True when execution is complete */ + finished: boolean; + /** Process exit code (present when finished) */ + exitCode?: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeShellWatchResult with defaults + */ +export const createCodeShellWatchResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + executionId?: string; + lines?: ClassifiedLine[]; + finished?: boolean; + exitCode?: number; + error?: JTAGError; + } +): CodeShellWatchResult => createPayload(context, sessionId, { + executionId: data.executionId ?? '', + lines: data.lines ?? [], + finished: data.finished ?? false, + exitCode: data.exitCode, + ...data +}); + +/** + * Smart Code Shell Watch-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeShellWatchResultFromParams = ( + params: CodeShellWatchParams, + differences: Omit +): CodeShellWatchResult => transformPayload(params, differences); + +/** + * Code Shell Watch β€” Type-safe command executor + * + * Usage: + * import { CodeShellWatch } from '...shared/CodeShellWatchTypes'; + * const result = await CodeShellWatch.execute({ ... }); + */ +export const CodeShellWatch = { + execute(params: CommandInput): Promise { + return Commands.execute('code/shell/watch', params as Partial); + }, + commandName: 'code/shell/watch' as const, +} as const; diff --git a/src/debug/jtag/commands/code/shell/watch/test/integration/CodeShellWatchIntegration.test.ts b/src/debug/jtag/commands/code/shell/watch/test/integration/CodeShellWatchIntegration.test.ts new file mode 100644 index 000000000..a7c7e53f5 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/test/integration/CodeShellWatchIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeShellWatch Command Integration Tests + * + * Tests Code Shell Watch command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Watch/test/integration/CodeShellWatchIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeShellWatch Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Shell Watch command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Shell Watch command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Shell Watch']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Shell Watch returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Shell Watch succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Shell Watch']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Shell Watch']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Shell Watch']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Shell Watch']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Shell Watch']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeShellWatchIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeShellWatch Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeShellWatch INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeShellWatch integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellWatchIntegrationTests(); +} else { + module.exports = { runAllCodeShellWatchIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/shell/watch/test/unit/CodeShellWatchCommand.test.ts b/src/debug/jtag/commands/code/shell/watch/test/unit/CodeShellWatchCommand.test.ts new file mode 100644 index 000000000..fdff54e58 --- /dev/null +++ b/src/debug/jtag/commands/code/shell/watch/test/unit/CodeShellWatchCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeShellWatch Command Unit Tests + * + * Tests Code Shell Watch command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Shell Watch/test/unit/CodeShellWatchCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeShellWatchParams, CodeShellWatchResult } from '../../shared/CodeShellWatchTypes'; + +console.log('πŸ§ͺ CodeShellWatch Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Shell Watch logic for testing + */ +async function mockCodeShellWatchCommand(params: CodeShellWatchParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Shell Watch' or see the Code Shell Watch README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeShellWatchResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeShellWatchCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeShellWatch command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Shell Watch command + const validParams: CodeShellWatchParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeShellWatchExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Shell Watch command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeShellWatchParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeShellWatchCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeShellWatchRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeShellWatchParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeShellWatchParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeShellWatchCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeShellWatchOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeShellWatchParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeShellWatchCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeShellWatchParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeShellWatchCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeShellWatchPerformance(): Promise { + console.log('\n⚑ Test 5: CodeShellWatch performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeShellWatchCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellWatchParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeShellWatch completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeShellWatchResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeShellWatch result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeShellWatchCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeShellWatchParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeShellWatchUnitTests(): Promise { + console.log('πŸš€ Starting CodeShellWatch Command Unit Tests\n'); + + try { + testCodeShellWatchCommandStructure(); + await testMockCodeShellWatchExecution(); + await testCodeShellWatchRequiredParams(); + await testCodeShellWatchOptionalParams(); + await testCodeShellWatchPerformance(); + await testCodeShellWatchResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeShellWatch UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeShellWatch unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeShellWatchUnitTests(); +} else { + module.exports = { runAllCodeShellWatchUnitTests }; +} diff --git a/src/debug/jtag/commands/code/tree/.npmignore b/src/debug/jtag/commands/code/tree/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/tree/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/tree/README.md b/src/debug/jtag/commands/code/tree/README.md new file mode 100644 index 000000000..d51d89afd --- /dev/null +++ b/src/debug/jtag/commands/code/tree/README.md @@ -0,0 +1,160 @@ +# Code Tree Command + +Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc). + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/tree [options] +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/tree', { + // your parameters here +}); +``` + +## Parameters + +- **path** (optional): `string` - Subdirectory to tree (default: workspace root) +- **maxDepth** (optional): `number` - Maximum directory depth (default: 10) +- **includeHidden** (optional): `boolean` - Include hidden files and directories (default: false) + +## Result + +Returns `CodeTreeResult` with: + +Returns CommandResult with: +- **root**: `object` - TreeNode with name, path, isDirectory, sizeBytes, and children array +- **totalFiles**: `number` - Total number of files in tree +- **totalDirectories**: `number` - Total number of directories in tree + +## Examples + +### Show full workspace tree + +```bash +./jtag code/tree +``` + +### Show src directory, 3 levels deep + +```bash +./jtag code/tree --path="src" --maxDepth=3 +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/tree +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/tree' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/tree +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/tree' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Tree/test/unit/CodeTreeCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Tree/test/integration/CodeTreeIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeTreeTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeTreeBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeTreeServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeTreeCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeTreeIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/tree/browser/CodeTreeBrowserCommand.ts b/src/debug/jtag/commands/code/tree/browser/CodeTreeBrowserCommand.ts new file mode 100644 index 000000000..96286cc60 --- /dev/null +++ b/src/debug/jtag/commands/code/tree/browser/CodeTreeBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Tree Command - Browser Implementation + * + * Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc). + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeTreeParams, CodeTreeResult } from '../shared/CodeTreeTypes'; + +export class CodeTreeBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/tree', context, subpath, commander); + } + + async execute(params: CodeTreeParams): Promise { + console.log('🌐 BROWSER: Delegating Code Tree to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/tree/package.json b/src/debug/jtag/commands/code/tree/package.json new file mode 100644 index 000000000..79489d593 --- /dev/null +++ b/src/debug/jtag/commands/code/tree/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/tree", + "version": "1.0.0", + "description": "Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc).", + "main": "server/CodeTreeServerCommand.ts", + "types": "shared/CodeTreeTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeTreeIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/tree" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/tree/server/CodeTreeServerCommand.ts b/src/debug/jtag/commands/code/tree/server/CodeTreeServerCommand.ts new file mode 100644 index 000000000..3175169c6 --- /dev/null +++ b/src/debug/jtag/commands/code/tree/server/CodeTreeServerCommand.ts @@ -0,0 +1,40 @@ +/** + * Code Tree Command - Server Implementation + * + * Generate a directory tree for the workspace or a subdirectory. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeTreeParams, CodeTreeResult } from '../shared/CodeTreeTypes'; +import { createCodeTreeResultFromParams } from '../shared/CodeTreeTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeTreeServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/tree', context, subpath, commander); + } + + async execute(params: CodeTreeParams): Promise { + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceTree( + personaId, + params.path, + params.maxDepth, + params.includeHidden + ); + + return createCodeTreeResultFromParams(params, { + success: result.success, + root: result.root ?? null, + totalFiles: result.total_files, + totalDirectories: result.total_directories, + }); + } +} diff --git a/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts b/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts new file mode 100644 index 000000000..989a6c06f --- /dev/null +++ b/src/debug/jtag/commands/code/tree/shared/CodeTreeTypes.ts @@ -0,0 +1,106 @@ +/** + * Code Tree Command - Shared Types + * + * Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc). + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { TreeNode } from '@shared/generated/code/TreeNode'; + +/** + * Code Tree Command Parameters + */ +export interface CodeTreeParams extends CommandParams { + // Subdirectory to tree (default: workspace root) + path?: string; + // Maximum directory depth (default: 10) + maxDepth?: number; + // Include hidden files and directories (default: false) + includeHidden?: boolean; +} + +/** + * Factory function for creating CodeTreeParams + */ +export const createCodeTreeParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Subdirectory to tree (default: workspace root) + path?: string; + // Maximum directory depth (default: 10) + maxDepth?: number; + // Include hidden files and directories (default: false) + includeHidden?: boolean; + } +): CodeTreeParams => createPayload(context, sessionId, { + path: data.path ?? '', + maxDepth: data.maxDepth ?? 0, + includeHidden: data.includeHidden ?? false, + ...data +}); + +/** + * Code Tree Command Result + */ +export interface CodeTreeResult extends CommandResult { + success: boolean; + // Directory tree from Rust (generated type via ts-rs) + root: TreeNode | null; + // Total number of files in tree + totalFiles: number; + // Total number of directories in tree + totalDirectories: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeTreeResult with defaults + */ +export const createCodeTreeResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Directory tree from Rust (generated type via ts-rs) + root?: TreeNode; + // Total number of files in tree + totalFiles?: number; + // Total number of directories in tree + totalDirectories?: number; + error?: JTAGError; + } +): CodeTreeResult => createPayload(context, sessionId, { + root: data.root ?? null, + totalFiles: data.totalFiles ?? 0, + totalDirectories: data.totalDirectories ?? 0, + ...data +}); + +/** + * Smart Code Tree-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeTreeResultFromParams = ( + params: CodeTreeParams, + differences: Omit +): CodeTreeResult => transformPayload(params, differences); + +/** + * Code Tree β€” Type-safe command executor + * + * Usage: + * import { CodeTree } from '...shared/CodeTreeTypes'; + * const result = await CodeTree.execute({ ... }); + */ +export const CodeTree = { + execute(params: CommandInput): Promise { + return Commands.execute('code/tree', params as Partial); + }, + commandName: 'code/tree' as const, +} as const; diff --git a/src/debug/jtag/commands/code/tree/test/integration/CodeTreeIntegration.test.ts b/src/debug/jtag/commands/code/tree/test/integration/CodeTreeIntegration.test.ts new file mode 100644 index 000000000..42e22636a --- /dev/null +++ b/src/debug/jtag/commands/code/tree/test/integration/CodeTreeIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeTree Command Integration Tests + * + * Tests Code Tree command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Tree/test/integration/CodeTreeIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeTree Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Tree command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Tree command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Tree']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Tree returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Tree succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Tree']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Tree']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Tree']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Tree']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Tree']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeTreeIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeTree Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeTree INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeTree integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeTreeIntegrationTests(); +} else { + module.exports = { runAllCodeTreeIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/tree/test/unit/CodeTreeCommand.test.ts b/src/debug/jtag/commands/code/tree/test/unit/CodeTreeCommand.test.ts new file mode 100644 index 000000000..32e0c6cf1 --- /dev/null +++ b/src/debug/jtag/commands/code/tree/test/unit/CodeTreeCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeTree Command Unit Tests + * + * Tests Code Tree command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Tree/test/unit/CodeTreeCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeTreeParams, CodeTreeResult } from '../../shared/CodeTreeTypes'; + +console.log('πŸ§ͺ CodeTree Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Tree logic for testing + */ +async function mockCodeTreeCommand(params: CodeTreeParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Tree' or see the Code Tree README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeTreeResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeTreeCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeTree command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Tree command + const validParams: CodeTreeParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeTreeExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Tree command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeTreeParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeTreeCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeTreeRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeTreeParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeTreeParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeTreeCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeTreeOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeTreeParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeTreeCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeTreeParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeTreeCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeTreePerformance(): Promise { + console.log('\n⚑ Test 5: CodeTree performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeTreeCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeTreeParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeTree completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeTreeResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeTree result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeTreeCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeTreeParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeTreeUnitTests(): Promise { + console.log('πŸš€ Starting CodeTree Command Unit Tests\n'); + + try { + testCodeTreeCommandStructure(); + await testMockCodeTreeExecution(); + await testCodeTreeRequiredParams(); + await testCodeTreeOptionalParams(); + await testCodeTreePerformance(); + await testCodeTreeResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeTree UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeTree unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeTreeUnitTests(); +} else { + module.exports = { runAllCodeTreeUnitTests }; +} diff --git a/src/debug/jtag/commands/code/undo/.npmignore b/src/debug/jtag/commands/code/undo/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/undo/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/undo/README.md b/src/debug/jtag/commands/code/undo/README.md new file mode 100644 index 000000000..373362c18 --- /dev/null +++ b/src/debug/jtag/commands/code/undo/README.md @@ -0,0 +1,163 @@ +# Code Undo Command + +Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/undo [options] +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/undo', { + // your parameters here +}); +``` + +## Parameters + +- **changeId** (optional): `string` - UUID of a specific change to undo +- **count** (optional): `number` - Number of most recent changes to undo (default: 1) + +## Result + +Returns `CodeUndoResult` with: + +Returns CommandResult with: +- **changesUndone**: `object[]` - Array of undo results with changeId, filePath, and bytesWritten for each undone change + +## Examples + +### Undo last change + +```bash +./jtag code/undo +``` + +### Undo last 3 changes + +```bash +./jtag code/undo --count=3 +``` + +### Undo specific change + +```bash +./jtag code/undo --changeId="abc-123" +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/undo +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/undo' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/undo +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/undo' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Undo/test/unit/CodeUndoCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Undo/test/integration/CodeUndoIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeUndoTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeUndoBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeUndoServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeUndoCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeUndoIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/undo/browser/CodeUndoBrowserCommand.ts b/src/debug/jtag/commands/code/undo/browser/CodeUndoBrowserCommand.ts new file mode 100644 index 000000000..9201871ff --- /dev/null +++ b/src/debug/jtag/commands/code/undo/browser/CodeUndoBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Undo Command - Browser Implementation + * + * Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeUndoParams, CodeUndoResult } from '../shared/CodeUndoTypes'; + +export class CodeUndoBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/undo', context, subpath, commander); + } + + async execute(params: CodeUndoParams): Promise { + console.log('🌐 BROWSER: Delegating Code Undo to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/undo/package.json b/src/debug/jtag/commands/code/undo/package.json new file mode 100644 index 000000000..4d33ed983 --- /dev/null +++ b/src/debug/jtag/commands/code/undo/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/undo", + "version": "1.0.0", + "description": "Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state.", + "main": "server/CodeUndoServerCommand.ts", + "types": "shared/CodeUndoTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeUndoIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/undo" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/undo/server/CodeUndoServerCommand.ts b/src/debug/jtag/commands/code/undo/server/CodeUndoServerCommand.ts new file mode 100644 index 000000000..afdfa978f --- /dev/null +++ b/src/debug/jtag/commands/code/undo/server/CodeUndoServerCommand.ts @@ -0,0 +1,43 @@ +/** + * Code Undo Command - Server Implementation + * + * Undo a specific change or the last N changes. + * Applies reverse diffs from the change graph to restore previous state. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeUndoParams, CodeUndoResult } from '../shared/CodeUndoTypes'; +import { createCodeUndoResultFromParams } from '../shared/CodeUndoTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeUndoServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/undo', context, subpath, commander); + } + + async execute(params: CodeUndoParams): Promise { + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceUndo( + personaId, + params.changeId, + params.count + ); + + return createCodeUndoResultFromParams(params, { + success: result.success, + changesUndone: result.changes_undone.map(c => ({ + success: c.success, + change_id: c.change_id, + file_path: c.file_path, + bytes_written: c.bytes_written, + })), + }); + } +} diff --git a/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts b/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts new file mode 100644 index 000000000..734602185 --- /dev/null +++ b/src/debug/jtag/commands/code/undo/shared/CodeUndoTypes.ts @@ -0,0 +1,91 @@ +/** + * Code Undo Command - Shared Types + * + * Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import type { WriteResult } from '@shared/generated/code/WriteResult'; + +/** + * Code Undo Command Parameters + */ +export interface CodeUndoParams extends CommandParams { + // UUID of a specific change to undo + changeId?: string; + // Number of most recent changes to undo (default: 1) + count?: number; +} + +/** + * Factory function for creating CodeUndoParams + */ +export const createCodeUndoParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // UUID of a specific change to undo + changeId?: string; + // Number of most recent changes to undo (default: 1) + count?: number; + } +): CodeUndoParams => createPayload(context, sessionId, { + changeId: data.changeId ?? '', + count: data.count ?? 0, + ...data +}); + +/** + * Code Undo Command Result + */ +export interface CodeUndoResult extends CommandResult { + success: boolean; + // Undo results from Rust (generated type via ts-rs) + changesUndone: WriteResult[]; + error?: JTAGError; +} + +/** + * Factory function for creating CodeUndoResult with defaults + */ +export const createCodeUndoResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Undo results from Rust (generated type via ts-rs) + changesUndone?: WriteResult[]; + error?: JTAGError; + } +): CodeUndoResult => createPayload(context, sessionId, { + changesUndone: data.changesUndone ?? [], + ...data +}); + +/** + * Smart Code Undo-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeUndoResultFromParams = ( + params: CodeUndoParams, + differences: Omit +): CodeUndoResult => transformPayload(params, differences); + +/** + * Code Undo β€” Type-safe command executor + * + * Usage: + * import { CodeUndo } from '...shared/CodeUndoTypes'; + * const result = await CodeUndo.execute({ ... }); + */ +export const CodeUndo = { + execute(params: CommandInput): Promise { + return Commands.execute('code/undo', params as Partial); + }, + commandName: 'code/undo' as const, +} as const; diff --git a/src/debug/jtag/commands/code/undo/test/integration/CodeUndoIntegration.test.ts b/src/debug/jtag/commands/code/undo/test/integration/CodeUndoIntegration.test.ts new file mode 100644 index 000000000..7a6701fa6 --- /dev/null +++ b/src/debug/jtag/commands/code/undo/test/integration/CodeUndoIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeUndo Command Integration Tests + * + * Tests Code Undo command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Undo/test/integration/CodeUndoIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeUndo Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Undo command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Undo command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Undo']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Undo returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Undo succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Undo']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Undo']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Undo']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Undo']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Undo']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeUndoIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeUndo Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeUndo INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeUndo integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeUndoIntegrationTests(); +} else { + module.exports = { runAllCodeUndoIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/undo/test/unit/CodeUndoCommand.test.ts b/src/debug/jtag/commands/code/undo/test/unit/CodeUndoCommand.test.ts new file mode 100644 index 000000000..dd979e2d4 --- /dev/null +++ b/src/debug/jtag/commands/code/undo/test/unit/CodeUndoCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeUndo Command Unit Tests + * + * Tests Code Undo command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Undo/test/unit/CodeUndoCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeUndoParams, CodeUndoResult } from '../../shared/CodeUndoTypes'; + +console.log('πŸ§ͺ CodeUndo Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Undo logic for testing + */ +async function mockCodeUndoCommand(params: CodeUndoParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Undo' or see the Code Undo README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeUndoResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeUndoCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeUndo command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Undo command + const validParams: CodeUndoParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeUndoExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Undo command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeUndoParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeUndoCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeUndoRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeUndoParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeUndoParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeUndoCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeUndoOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeUndoParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeUndoCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeUndoParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeUndoCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeUndoPerformance(): Promise { + console.log('\n⚑ Test 5: CodeUndo performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeUndoCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeUndoParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeUndo completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeUndoResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeUndo result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeUndoCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeUndoParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeUndoUnitTests(): Promise { + console.log('πŸš€ Starting CodeUndo Command Unit Tests\n'); + + try { + testCodeUndoCommandStructure(); + await testMockCodeUndoExecution(); + await testCodeUndoRequiredParams(); + await testCodeUndoOptionalParams(); + await testCodeUndoPerformance(); + await testCodeUndoResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeUndo UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeUndo unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeUndoUnitTests(); +} else { + module.exports = { runAllCodeUndoUnitTests }; +} diff --git a/src/debug/jtag/commands/code/verify/README.md b/src/debug/jtag/commands/code/verify/README.md new file mode 100644 index 000000000..513c24b29 --- /dev/null +++ b/src/debug/jtag/commands/code/verify/README.md @@ -0,0 +1,69 @@ +# code/verify + +Run TypeScript compilation checks and optionally execute tests against a persona workspace. Returns structured errors with file, line, column, and message. + +## Usage + +```bash +# Check TypeScript compilation in persona workspace +./jtag code/verify --userId="persona-uuid" + +# Check with explicit working directory +./jtag code/verify --userId="persona-uuid" --cwd="/path/to/workspace" + +# Skip type checking, only run tests +./jtag code/verify --userId="persona-uuid" --typeCheck=false --testFiles='["tests/unit/foo.test.ts"]' + +# Type check + run specific tests +./jtag code/verify --userId="persona-uuid" --testFiles='["tests/unit/foo.test.ts"]' +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `userId` | string | *required* | Persona ID or workspace handle | +| `typeCheck` | boolean | `true` | Run TypeScript compilation check | +| `testFiles` | string[] | `[]` | Test files to run via vitest | +| `cwd` | string | *auto* | Working directory override | + +## Result + +```typescript +{ + success: boolean; + typeCheck?: { + passed: boolean; + errorCount: number; + errors: TypeScriptError[]; + }; + tests?: { + passed: boolean; + total: number; + passedCount: number; + failedCount: number; + failures: string[]; + }; + durationMs: number; + output: string; +} +``` + +## TypeScript Error Format + +```typescript +{ + file: "src/utils.ts", + line: 42, + column: 5, + code: "TS2345", + message: "Argument of type 'string' is not assignable to parameter of type 'number'." +} +``` + +## Security + +- Uses `ExecutionSandbox` for process isolation (restricted PATH, timeout enforcement) +- Allowed commands: `npx tsc`, `npx vitest` (via sandbox allowlist) +- No file modification β€” verification is read-only +- Available at **write** security tier (same tier as code/edit) diff --git a/src/debug/jtag/commands/code/verify/browser/CodeVerifyBrowserCommand.ts b/src/debug/jtag/commands/code/verify/browser/CodeVerifyBrowserCommand.ts new file mode 100644 index 000000000..e229c84e3 --- /dev/null +++ b/src/debug/jtag/commands/code/verify/browser/CodeVerifyBrowserCommand.ts @@ -0,0 +1,22 @@ +/** + * Code Verify Command - Browser Implementation + * + * Run TypeScript compilation checks and optionally execute tests against a persona workspace. + * Delegates to server β€” verification requires file system access and process execution. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeVerifyParams, CodeVerifyResult } from '../shared/CodeVerifyTypes'; + +export class CodeVerifyBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/verify', context, subpath, commander); + } + + async execute(params: CodeVerifyParams): Promise { + console.log('🌐 BROWSER: Delegating Code Verify to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/verify/package.json b/src/debug/jtag/commands/code/verify/package.json new file mode 100644 index 000000000..0e28b7dc0 --- /dev/null +++ b/src/debug/jtag/commands/code/verify/package.json @@ -0,0 +1,34 @@ +{ + "name": "@jtag-commands/code/verify", + "version": "1.0.0", + "description": "Run TypeScript compilation checks and optionally execute tests against a persona workspace. Returns structured errors with file, line, column, and message.", + "main": "server/CodeVerifyServerCommand.ts", + "types": "shared/CodeVerifyTypes.ts", + "scripts": { + "test": "npm run test:unit", + "test:unit": "npx vitest run test/unit/*.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/verify" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/verify/server/CodeVerifyServerCommand.ts b/src/debug/jtag/commands/code/verify/server/CodeVerifyServerCommand.ts new file mode 100644 index 000000000..f69fe8691 --- /dev/null +++ b/src/debug/jtag/commands/code/verify/server/CodeVerifyServerCommand.ts @@ -0,0 +1,250 @@ +/** + * Code Verify Command - Server Implementation + * + * Runs TypeScript compilation checks and optionally executes tests + * via ExecutionSandbox (process-isolated, timeout-enforced). + * + * Workspace resolution: + * - If `cwd` param is provided, use it directly + * - Otherwise, resolve from userId: {jtagRoot}/.continuum/personas/{userId}/workspace/ + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeVerifyParams, CodeVerifyResult, TypeScriptError, TestResult } from '../shared/CodeVerifyTypes'; +import { createCodeVerifyResultFromParams } from '../shared/CodeVerifyTypes'; +import { ExecutionSandbox } from '@system/code/server/ExecutionSandbox'; +import type { SandboxResult } from '@system/code/server/ExecutionSandbox'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** TypeScript error regex: file(line,col): error TSxxxx: message */ +const TS_ERROR_REGEX = /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$/gm; + +export class CodeVerifyServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/verify', context, subpath, commander); + } + + async execute(params: CodeVerifyParams): Promise { + const startTime = Date.now(); + + if (!params.userId) { + throw new ValidationError('userId', 'Verification requires a userId (auto-injected for persona tool calls).'); + } + + const workspaceDir = this.resolveWorkspaceDir(params); + const sandbox = new ExecutionSandbox(); + const doTypeCheck = params.typeCheck !== false; + const doTests = params.testFiles && params.testFiles.length > 0; + + let typeCheckResult: CodeVerifyResult['typeCheck'] | undefined; + let testsResult: TestResult | undefined; + let output = ''; + let allPassed = true; + + // Phase 1: TypeScript compilation check + if (doTypeCheck) { + const tscResult = await this.runTypeCheck(sandbox, workspaceDir, params.userId); + const errors = this.parseTypeScriptErrors(tscResult.stdout + tscResult.stderr); + + typeCheckResult = { + passed: tscResult.success, + errorCount: errors.length, + errors, + }; + + output += tscResult.stdout + tscResult.stderr; + if (!tscResult.success) allPassed = false; + } + + // Phase 2: Test execution (optional) + if (doTests && params.testFiles) { + const testRunResult = await this.runTests(sandbox, workspaceDir, params.testFiles, params.userId); + testsResult = this.parseTestResult(testRunResult); + + output += '\n' + testRunResult.stdout + testRunResult.stderr; + if (!testsResult.passed) allPassed = false; + } + + const durationMs = Date.now() - startTime; + + return createCodeVerifyResultFromParams(params, { + success: allPassed, + typeCheck: typeCheckResult, + tests: testsResult, + durationMs, + output, + }); + } + + /** + * Resolve the workspace directory from params. + * Uses explicit cwd if provided, otherwise resolves from userId convention. + */ + private resolveWorkspaceDir(params: CodeVerifyParams): string { + if (params.cwd && params.cwd.trim()) { + return params.cwd; + } + + const jtagRoot = process.cwd(); + const personaId = params.userId!; + + // Standard persona workspace path + const workspaceDir = path.join(jtagRoot, '.continuum', 'personas', personaId, 'workspace'); + + if (fs.existsSync(workspaceDir)) { + return workspaceDir; + } + + // Fallback: check if userId is a challenge workspace handle (challenge-{id}-{personaId}) + if (personaId.startsWith('challenge-')) { + const parts = personaId.split('-'); + // Handle: challenge-{challengeId}-{personaId} + // The challengeId and personaId are UUIDs, so we need the full pattern + const challengeIdStart = 'challenge-'.length; + // Find the persona ID (last UUID in the handle) + const uuidLen = 36; // Standard UUID length + if (personaId.length > challengeIdStart + uuidLen + 1) { + const actualPersonaId = personaId.slice(-(uuidLen)); + const challengeId = personaId.slice(challengeIdStart, personaId.length - uuidLen - 1); + const challengeDir = path.join(jtagRoot, '.continuum', 'personas', actualPersonaId, 'challenges', challengeId); + if (fs.existsSync(challengeDir)) { + return challengeDir; + } + } + } + + // Last resort: use the standard workspace path even if it doesn't exist yet + return workspaceDir; + } + + /** + * Run TypeScript compilation check via ExecutionSandbox. + */ + private async runTypeCheck(sandbox: ExecutionSandbox, workspaceDir: string, personaId: string): Promise { + // Check if workspace has a tsconfig.json β€” if so, tsc uses it automatically + const hasTsConfig = fs.existsSync(path.join(workspaceDir, 'tsconfig.json')); + + const args = hasTsConfig + ? ['tsc', '--noEmit'] + : ['tsc', '--noEmit', '--strict', ...this.findTypeScriptFiles(workspaceDir)]; + + return sandbox.execute({ + command: 'npx', + args, + cwd: workspaceDir, + timeoutMs: 120_000, + maxOutputBytes: 102_400, + personaId: personaId as any, + }); + } + + /** + * Run test files via vitest in sandbox. + */ + private async runTests( + sandbox: ExecutionSandbox, + workspaceDir: string, + testFiles: string[], + personaId: string, + ): Promise { + return sandbox.execute({ + command: 'npx', + args: ['vitest', 'run', ...testFiles, '--reporter=json'], + cwd: workspaceDir, + timeoutMs: 120_000, + maxOutputBytes: 102_400, + personaId: personaId as any, + }); + } + + /** + * Find .ts files in workspace for compilation without tsconfig. + */ + private findTypeScriptFiles(workspaceDir: string): string[] { + const files: string[] = []; + try { + const entries = fs.readdirSync(workspaceDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + files.push(entry.name); + } + } + } catch { + // Directory doesn't exist or isn't readable + } + return files; + } + + /** + * Parse TypeScript compiler output into structured errors. + * Format: file(line,col): error TSxxxx: message + */ + private parseTypeScriptErrors(output: string): TypeScriptError[] { + const errors: TypeScriptError[] = []; + let match; + + // Reset regex state + TS_ERROR_REGEX.lastIndex = 0; + + while ((match = TS_ERROR_REGEX.exec(output)) !== null) { + errors.push({ + file: match[1], + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + code: match[4], + message: match[5], + }); + } + + return errors; + } + + /** + * Parse vitest JSON output into a TestResult. + */ + private parseTestResult(sandboxResult: SandboxResult): TestResult { + if (sandboxResult.timedOut) { + return { + passed: false, + total: 0, + passedCount: 0, + failedCount: 0, + failures: ['Test execution timed out'], + }; + } + + try { + // vitest --reporter=json outputs JSON to stdout + const json = JSON.parse(sandboxResult.stdout); + const numPassed = json.numPassedTests ?? 0; + const numFailed = json.numFailedTests ?? 0; + const total = json.numTotalTests ?? (numPassed + numFailed); + const failures = (json.testResults ?? []) + .flatMap((suite: any) => (suite.assertionResults ?? []) + .filter((t: any) => t.status === 'failed') + .map((t: any) => `${t.ancestorTitles?.join(' > ')} > ${t.title}: ${t.failureMessages?.[0] ?? 'Failed'}`) + ); + + return { + passed: numFailed === 0, + total, + passedCount: numPassed, + failedCount: numFailed, + failures, + }; + } catch { + // Non-JSON output β€” treat as failure + return { + passed: sandboxResult.success, + total: 0, + passedCount: 0, + failedCount: sandboxResult.success ? 0 : 1, + failures: sandboxResult.success ? [] : [sandboxResult.stderr || sandboxResult.stdout || 'Unknown test failure'], + }; + } + } +} diff --git a/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts b/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts new file mode 100644 index 000000000..19d1eab15 --- /dev/null +++ b/src/debug/jtag/commands/code/verify/shared/CodeVerifyTypes.ts @@ -0,0 +1,128 @@ +/** + * Code Verify Command - Shared Types + * + * Run TypeScript compilation checks and optionally execute tests against a persona workspace. + * Returns structured errors with file, line, column, and message for each issue found. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Parsed TypeScript compilation error + */ +export interface TypeScriptError { + file: string; + line: number; + column: number; + code: string; + message: string; +} + +/** + * Code Verify Command Parameters + */ +export interface CodeVerifyParams extends CommandParams { + /** Run TypeScript compilation check (default: true) */ + typeCheck?: boolean; + /** Specific test files to run via vitest (optional) */ + testFiles?: string[]; + /** Working directory override β€” bypasses workspace resolution */ + cwd?: string; +} + +/** + * Factory function for creating CodeVerifyParams + */ +export const createCodeVerifyParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + typeCheck?: boolean; + testFiles?: string[]; + cwd?: string; + } +): CodeVerifyParams => createPayload(context, sessionId, { + typeCheck: data.typeCheck ?? true, + testFiles: data.testFiles ?? [], + cwd: data.cwd ?? '', + ...data +}); + +/** + * Test execution result + */ +export interface TestResult { + passed: boolean; + total: number; + passedCount: number; + failedCount: number; + failures: string[]; +} + +/** + * Code Verify Command Result + */ +export interface CodeVerifyResult extends CommandResult { + success: boolean; + /** TypeScript compilation result (if typeCheck was requested) */ + typeCheck?: { + passed: boolean; + errorCount: number; + errors: TypeScriptError[]; + }; + /** Test execution result (if testFiles were specified) */ + tests?: TestResult; + /** Total verification time in milliseconds */ + durationMs: number; + /** Raw compiler/test output */ + output: string; + error?: JTAGError; +} + +/** + * Factory function for creating CodeVerifyResult with defaults + */ +export const createCodeVerifyResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + typeCheck?: CodeVerifyResult['typeCheck']; + tests?: TestResult; + durationMs?: number; + output?: string; + error?: JTAGError; + } +): CodeVerifyResult => createPayload(context, sessionId, { + durationMs: data.durationMs ?? 0, + output: data.output ?? '', + ...data +}); + +/** + * Smart Code Verify-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeVerifyResultFromParams = ( + params: CodeVerifyParams, + differences: Omit +): CodeVerifyResult => transformPayload(params, differences); + +/** + * Code Verify β€” Type-safe command executor + * + * Usage: + * import { CodeVerify } from '...shared/CodeVerifyTypes'; + * const result = await CodeVerify.execute({ typeCheck: true }); + */ +export const CodeVerify = { + execute(params: CommandInput): Promise { + return Commands.execute('code/verify', params as Partial); + }, + commandName: 'code/verify' as const, +} as const; diff --git a/src/debug/jtag/commands/code/write/.npmignore b/src/debug/jtag/commands/code/write/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/code/write/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/code/write/README.md b/src/debug/jtag/commands/code/write/README.md new file mode 100644 index 000000000..08488cb0e --- /dev/null +++ b/src/debug/jtag/commands/code/write/README.md @@ -0,0 +1,154 @@ +# Code Write Command + +Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag code/write --filePath= --content= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('code/write', { + // your parameters here +}); +``` + +## Parameters + +- **filePath** (required): `string` - Relative path to file within workspace +- **content** (required): `string` - File content to write +- **description** (optional): `string` - Description of what this change does + +## Result + +Returns `CodeWriteResult` with: + +Returns CommandResult with: +- **changeId**: `string` - UUID of the ChangeNode created (for undo) +- **filePath**: `string` - Resolved file path +- **bytesWritten**: `number` - Number of bytes written + +## Examples + +### Create a new file + +```bash +./jtag code/write --filePath="src/utils.ts" --content="export function greet() { return 'hello'; }" --description="Add greet utility" +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help code/write +``` + +**Tool:** +```typescript +// Use your help tool with command name 'code/write' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme code/write +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'code/write' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Code Write/test/unit/CodeWriteCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Code Write/test/integration/CodeWriteIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/CodeWriteTypes.ts` +- **Browser**: Browser-specific implementation in `browser/CodeWriteBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/CodeWriteServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/CodeWriteCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/CodeWriteIntegration.test.ts` diff --git a/src/debug/jtag/commands/code/write/browser/CodeWriteBrowserCommand.ts b/src/debug/jtag/commands/code/write/browser/CodeWriteBrowserCommand.ts new file mode 100644 index 000000000..3f69070dc --- /dev/null +++ b/src/debug/jtag/commands/code/write/browser/CodeWriteBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Code Write Command - Browser Implementation + * + * Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CodeWriteParams, CodeWriteResult } from '../shared/CodeWriteTypes'; + +export class CodeWriteBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/write', context, subpath, commander); + } + + async execute(params: CodeWriteParams): Promise { + console.log('🌐 BROWSER: Delegating Code Write to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/code/write/package.json b/src/debug/jtag/commands/code/write/package.json new file mode 100644 index 000000000..ffcb44058 --- /dev/null +++ b/src/debug/jtag/commands/code/write/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/code/write", + "version": "1.0.0", + "description": "Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist.", + "main": "server/CodeWriteServerCommand.ts", + "types": "shared/CodeWriteTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/CodeWriteIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "code/write" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/code/write/server/CodeWriteServerCommand.ts b/src/debug/jtag/commands/code/write/server/CodeWriteServerCommand.ts new file mode 100644 index 000000000..9513db52e --- /dev/null +++ b/src/debug/jtag/commands/code/write/server/CodeWriteServerCommand.ts @@ -0,0 +1,54 @@ +/** + * Code Write Command - Server Implementation + * + * Writes or creates a file in the persona's workspace via Rust IPC. + * Creates a ChangeNode in the change graph for undo support. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { CodeWriteParams, CodeWriteResult } from '../shared/CodeWriteTypes'; +import { createCodeWriteResultFromParams } from '../shared/CodeWriteTypes'; +import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; + +export class CodeWriteServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('code/write', context, subpath, commander); + } + + async execute(params: CodeWriteParams): Promise { + if (!params.filePath || params.filePath.trim() === '') { + throw new ValidationError( + 'filePath', + `Missing required parameter 'filePath'. See the code/write README for usage.` + ); + } + if (params.content === undefined || params.content === null) { + throw new ValidationError( + 'content', + `Missing required parameter 'content'. See the code/write README for usage.` + ); + } + + if (!params.userId) { + throw new ValidationError('userId', 'Workspace operations require a userId (auto-injected for persona tool calls).'); + } + const personaId = params.userId; + + const result = await CodeDaemon.workspaceWrite( + personaId, + params.filePath, + params.content, + params.description + ); + + return createCodeWriteResultFromParams(params, { + success: result.success, + changeId: result.change_id || '', + filePath: result.file_path, + bytesWritten: result.bytes_written, + }); + } +} diff --git a/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts b/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts new file mode 100644 index 000000000..d45696d81 --- /dev/null +++ b/src/debug/jtag/commands/code/write/shared/CodeWriteTypes.ts @@ -0,0 +1,103 @@ +/** + * Code Write Command - Shared Types + * + * Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Code Write Command Parameters + */ +export interface CodeWriteParams extends CommandParams { + // Relative path to file within workspace + filePath: string; + // File content to write + content: string; + // Description of what this change does + description?: string; +} + +/** + * Factory function for creating CodeWriteParams + */ +export const createCodeWriteParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Relative path to file within workspace + filePath: string; + // File content to write + content: string; + // Description of what this change does + description?: string; + } +): CodeWriteParams => createPayload(context, sessionId, { + description: data.description ?? '', + ...data +}); + +/** + * Code Write Command Result + */ +export interface CodeWriteResult extends CommandResult { + success: boolean; + // UUID of the ChangeNode created (for undo) + changeId: string; + // Resolved file path + filePath: string; + // Number of bytes written + bytesWritten: number; + error?: JTAGError; +} + +/** + * Factory function for creating CodeWriteResult with defaults + */ +export const createCodeWriteResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // UUID of the ChangeNode created (for undo) + changeId?: string; + // Resolved file path + filePath?: string; + // Number of bytes written + bytesWritten?: number; + error?: JTAGError; + } +): CodeWriteResult => createPayload(context, sessionId, { + changeId: data.changeId ?? '', + filePath: data.filePath ?? '', + bytesWritten: data.bytesWritten ?? 0, + ...data +}); + +/** + * Smart Code Write-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createCodeWriteResultFromParams = ( + params: CodeWriteParams, + differences: Omit +): CodeWriteResult => transformPayload(params, differences); + +/** + * Code Write β€” Type-safe command executor + * + * Usage: + * import { CodeWrite } from '...shared/CodeWriteTypes'; + * const result = await CodeWrite.execute({ ... }); + */ +export const CodeWrite = { + execute(params: CommandInput): Promise { + return Commands.execute('code/write', params as Partial); + }, + commandName: 'code/write' as const, +} as const; diff --git a/src/debug/jtag/commands/code/write/test/integration/CodeWriteIntegration.test.ts b/src/debug/jtag/commands/code/write/test/integration/CodeWriteIntegration.test.ts new file mode 100644 index 000000000..399627d7a --- /dev/null +++ b/src/debug/jtag/commands/code/write/test/integration/CodeWriteIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * CodeWrite Command Integration Tests + * + * Tests Code Write command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Write/test/integration/CodeWriteIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ CodeWrite Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Code Write command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Code Write command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Code Write']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Code Write returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Code Write succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Code Write']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Code Write']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Code Write']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Code Write']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Code Write']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllCodeWriteIntegrationTests(): Promise { + console.log('πŸš€ Starting CodeWrite Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL CodeWrite INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ CodeWrite integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeWriteIntegrationTests(); +} else { + module.exports = { runAllCodeWriteIntegrationTests }; +} diff --git a/src/debug/jtag/commands/code/write/test/unit/CodeWriteCommand.test.ts b/src/debug/jtag/commands/code/write/test/unit/CodeWriteCommand.test.ts new file mode 100644 index 000000000..fc8483441 --- /dev/null +++ b/src/debug/jtag/commands/code/write/test/unit/CodeWriteCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * CodeWrite Command Unit Tests + * + * Tests Code Write command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Code Write/test/unit/CodeWriteCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { CodeWriteParams, CodeWriteResult } from '../../shared/CodeWriteTypes'; + +console.log('πŸ§ͺ CodeWrite Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Code Write logic for testing + */ +async function mockCodeWriteCommand(params: CodeWriteParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Code Write' or see the Code Write README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as CodeWriteResult; +} + +/** + * Test 1: Command structure validation + */ +function testCodeWriteCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: CodeWrite command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Code Write command + const validParams: CodeWriteParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockCodeWriteExecution(): Promise { + console.log('\n⚑ Test 2: Mock Code Write command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: CodeWriteParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockCodeWriteCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testCodeWriteRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as CodeWriteParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as CodeWriteParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockCodeWriteCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testCodeWriteOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: CodeWriteParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockCodeWriteCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: CodeWriteParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockCodeWriteCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testCodeWritePerformance(): Promise { + console.log('\n⚑ Test 5: CodeWrite performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockCodeWriteCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeWriteParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `CodeWrite completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testCodeWriteResultStructure(): Promise { + console.log('\nπŸ” Test 6: CodeWrite result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockCodeWriteCommand({ + // TODO: Add your parameters + context, + sessionId + } as CodeWriteParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllCodeWriteUnitTests(): Promise { + console.log('πŸš€ Starting CodeWrite Command Unit Tests\n'); + + try { + testCodeWriteCommandStructure(); + await testMockCodeWriteExecution(); + await testCodeWriteRequiredParams(); + await testCodeWriteOptionalParams(); + await testCodeWritePerformance(); + await testCodeWriteResultStructure(); + + console.log('\nπŸŽ‰ ALL CodeWrite UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ CodeWrite unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllCodeWriteUnitTests(); +} else { + module.exports = { runAllCodeWriteUnitTests }; +} diff --git a/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts b/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts index 435a59e9f..4abceb36b 100644 --- a/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts +++ b/src/debug/jtag/commands/collaboration/activity/create/server/ActivityCreateServerCommand.ts @@ -30,6 +30,13 @@ export class ActivityCreateServerCommand extends CommandBase m.metadata?.source !== 'system'); } + // Filter tool result messages (stored by PersonaToolExecutor for RAG, not for display) + if (!params.includeSystem) { + filtered = filtered.filter(m => !m.metadata?.toolResult); + } + // Filter test messages if (!params.includeTests) { filtered = filtered.filter(m => !m.metadata?.isSystemTest); diff --git a/src/debug/jtag/commands/data/create/server/DataCreateServerCommand.ts b/src/debug/jtag/commands/data/create/server/DataCreateServerCommand.ts index 6552463a9..77f3053fe 100644 --- a/src/debug/jtag/commands/data/create/server/DataCreateServerCommand.ts +++ b/src/debug/jtag/commands/data/create/server/DataCreateServerCommand.ts @@ -65,10 +65,8 @@ export class DataCreateServerCommand extends DataCreateCommand { entity = await tempDaemon.create(collection, params.data as BaseEntity, operationContext, suppressEvents); } else { // Default operation: use DataDaemon (backward compatible) - // Events are emitted by DataDaemon.store() via universal Events system - // TODO: Pass suppressEvents flag to DataDaemon.store() - // Cast to BaseEntity - at runtime, data will have entity structure - entity = await DataDaemon.store(collection, params.data as BaseEntity); + // Events are emitted by DataDaemon.store() β†’ create() via universal Events system + entity = await DataDaemon.store(collection, params.data as BaseEntity, params.suppressEvents ?? false); } return createDataCreateResultFromParams(params, { diff --git a/src/debug/jtag/commands/data/delete/server/DataDeleteServerCommand.ts b/src/debug/jtag/commands/data/delete/server/DataDeleteServerCommand.ts index 153581bc8..4453ac056 100644 --- a/src/debug/jtag/commands/data/delete/server/DataDeleteServerCommand.ts +++ b/src/debug/jtag/commands/data/delete/server/DataDeleteServerCommand.ts @@ -10,8 +10,7 @@ import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/C import type { DataDeleteParams, DataDeleteResult } from '../shared/DataDeleteTypes'; import { createDataDeleteResultFromParams } from '../shared/DataDeleteTypes'; import { DataDaemon } from '../../../../daemons/data-daemon/shared/DataDaemon'; -// import { BaseEntity } from '../../../../system/data/entities/BaseEntity'; -// import { Events } from '../../../../system/core/server/shared/Events'; +import type { BaseEntity } from '@system/data/entities/BaseEntity'; export class DataDeleteServerCommand extends CommandBase { @@ -24,7 +23,7 @@ export class DataDeleteServerCommand extends CommandBase { try { // Use DataDaemon for consistent storage access - const result = await DataDaemon.read(params.collection, params.id); + const entity = await DataDaemon.read(params.collection, params.id); - if (result.success && result.data) { + if (entity) { // Extract media if this is a chat message with attachments let media: MediaItem[] = []; - let cleanedData = result.data.data; + let cleanedData: BaseEntity = entity; - if (params.collection === 'chat_messages' && result.data.data) { - const messageData = result.data.data as ChatMessageEntity; + if (params.collection === 'chat_messages') { + const messageData = entity as ChatMessageEntity; if (messageData.content?.media && Array.isArray(messageData.content.media)) { // Extract media to top level media = messageData.content.media; diff --git a/src/debug/jtag/commands/development/code/pattern-search/server/CodeFindServerCommand.ts b/src/debug/jtag/commands/development/code/pattern-search/server/CodeFindServerCommand.ts deleted file mode 100644 index d080c6c46..000000000 --- a/src/debug/jtag/commands/development/code/pattern-search/server/CodeFindServerCommand.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * code/pattern-search server command - Find files by name pattern - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { promisify } from 'util'; -import { minimatch } from 'minimatch'; - -import type { JTAGContext } from '@system/core/types/JTAGTypes'; -import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; -import type { CodeFindParams, CodeFindResult, FileMatch } from '../shared/CodeFindTypes'; -import { createCodeFindResultFromParams } from '../shared/CodeFindTypes'; -import { CodeFindCommand } from '../shared/CodeFindCommand'; - -const stat = promisify(fs.stat); -const readdir = promisify(fs.readdir); - -export class CodeFindServerCommand extends CodeFindCommand { - constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { - super('code/pattern-search', context, subpath, commander); - } - - /** - * Execute code/pattern-search command - * - * Searches for files by name pattern using minimatch (supports wildcards) - */ - protected async executeCommand(params: CodeFindParams): Promise { - // Validate params - if (!params.pattern || params.pattern.trim() === '') { - return createCodeFindResultFromParams(params, { - success: false, - error: 'Missing required parameter: pattern' - }); - } - - console.log(`πŸ” CODE FIND SERVER: Searching for pattern "${params.pattern}"`); - - // STEP 2: Query analysis - detect conceptual/semantic searches - // NOTE: We now WARN but still run the search. AIs reported that blocking was confusing. - const queryAnalysis = this.analyzeQuery(params.pattern); - let conceptualWarning = ''; - if (queryAnalysis.isConceptual) { - console.log(`⚠️ CODE FIND SERVER: Pattern "${params.pattern}" appears conceptual (${queryAnalysis.reasons.length} reasons)`); - conceptualWarning = [ - '', - '--- HINT ---', - `Your pattern "${params.pattern}" may be a semantic/conceptual search.`, - 'This tool matches FILENAME PATTERNS (like *.ts, Auth*.ts), not code concepts.', - '', - 'For semantic code search, try: ai/context/search or ai/rag/query-open', - 'For file content search, try: development/code/grep', - '--- END HINT ---', - '' - ].join('\n'); - } - - try { - const repositoryRoot = CodeDaemon.getRepositoryRoot(); - const baseDir = params.baseDir ?? '.'; - const searchPath = path.join(repositoryRoot, baseDir); - - // Validate base directory exists - try { - const searchStat = await stat(searchPath); - if (!searchStat.isDirectory()) { - return createCodeFindResultFromParams(params, { - success: false, - error: `Base directory is not a directory: ${baseDir}` - }); - } - } catch { - return createCodeFindResultFromParams(params, { - success: false, - error: `Base directory not found: ${baseDir}` - }); - } - - const maxResults = params.maxResults ?? 50; - const caseInsensitive = params.caseInsensitive !== false; // Default true - const includeHidden = params.includeHidden === true; // Default false - const excludeDirs = params.excludeDirs ?? ['node_modules', 'dist', '.continuum', '.git', 'examples/dist', 'coverage']; - - // Prepare pattern for minimatch - const pattern = caseInsensitive ? params.pattern.toLowerCase() : params.pattern; - - // Find matching files - const matches: FileMatch[] = []; - let totalMatches = 0; - - await this.searchDirectory( - searchPath, - repositoryRoot, - pattern, - caseInsensitive, - includeHidden, - excludeDirs, - matches, - maxResults, - () => totalMatches++ - ); - - console.log(`βœ… CODE FIND SERVER: Found ${totalMatches} matches for "${params.pattern}" (returning ${matches.length})`); - - // If no matches found, provide helpful guidance - if (totalMatches === 0) { - const suggestions = [ - `No files found matching pattern "${params.pattern}".`, - '', - 'Tips for better results:', - 'β€’ Use simpler patterns: "*.ts" instead of "typescript files"', - 'β€’ Try wildcards: "**/*.test.ts" for test files', - 'β€’ Use exact filenames: "package.json"', - 'β€’ Check your baseDir parameter (currently searching: ' + (baseDir ?? '.') + ')', - '', - 'Note: This tool matches filename patterns, not file contents.', - 'To search code contents, use development/code/grep' - ]; - - return createCodeFindResultFromParams(params, { - success: true, - pattern: params.pattern, - matches: [], - totalMatches: 0, - baseDir, - message: conceptualWarning + suggestions.join('\n') - }); - } - - return createCodeFindResultFromParams(params, { - success: true, - pattern: params.pattern, - matches, - totalMatches, - baseDir, - message: conceptualWarning || undefined - }); - } catch (error) { - console.error(`❌ CODE FIND SERVER: Exception searching for ${params.pattern}:`, error); - - return createCodeFindResultFromParams(params, { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - - /** - * Recursively search directory for matching files - */ - private async searchDirectory( - dirPath: string, - repoRoot: string, - pattern: string, - caseInsensitive: boolean, - includeHidden: boolean, - excludeDirs: string[], - matches: FileMatch[], - maxResults: number, - onMatch: () => void - ): Promise { - // Stop if we've reached max results - if (matches.length >= maxResults) return; - - try { - const entries = await readdir(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - // Stop if we've reached max results - if (matches.length >= maxResults) break; - - // Skip hidden files/directories if not requested - if (!includeHidden && entry.name.startsWith('.')) continue; - - // Skip excluded directories (configurable, defaults to massive dirs that cause timeouts) - if (excludeDirs.includes(entry.name)) { - continue; - } - - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(repoRoot, fullPath); - - // Get file stats - let fileStat; - let fileType: 'file' | 'directory' | 'symlink' = 'file'; - try { - fileStat = await stat(fullPath); - if (fileStat.isDirectory()) fileType = 'directory'; - else if (fileStat.isSymbolicLink()) fileType = 'symlink'; - } catch { - // Skip files we can't stat - continue; - } - - // Check if filename matches pattern - const filename = caseInsensitive ? entry.name.toLowerCase() : entry.name; - if (minimatch(filename, pattern)) { - onMatch(); - - if (matches.length < maxResults) { - matches.push({ - path: relativePath, - size: fileStat.size, - modified: fileStat.mtime.toISOString(), - type: fileType - }); - } - } - - // Recursively search subdirectories - if (entry.isDirectory()) { - await this.searchDirectory( - fullPath, - repoRoot, - pattern, - caseInsensitive, - includeHidden, - excludeDirs, - matches, - maxResults, - onMatch - ); - } - } - } catch (error) { - // Silently skip directories we can't read (permissions, etc.) - console.warn(`⚠️ CODE FIND SERVER: Cannot read directory ${dirPath}:`, error); - } - } - - /** - * Analyze query to detect if it's conceptual/semantic vs literal pattern matching - * Based on AI team testing feedback and detection patterns - */ - private analyzeQuery(pattern: string): { isConceptual: boolean; reasons: string[] } { - const reasons: string[] = []; - - // Detect multi-word conceptual phrases - const words = pattern.trim().split(/\s+/); - if (words.length >= 2 && !pattern.includes('*') && !pattern.includes('?')) { - // Check if it looks like a semantic query vs a filename pattern - const hasCodeIndicators = /[A-Z][a-z]+|[a-z]+[A-Z]|[._-]|\.ts$|\.js$|\.py$/.test(pattern); - if (!hasCodeIndicators) { - reasons.push(`Multi-word phrase without file indicators: "${pattern}"`); - } - } - - // Detect question structures - if (/^(how|what|where|why|when|who|which)\b/i.test(pattern)) { - reasons.push(`Question word detected: ${pattern.split(/\s+/)[0].toLowerCase()}`); - } - - // Detect abstract/conceptual terms (common semantic search patterns) - const conceptualTerms = [ - 'flow', 'logic', 'process', 'pattern', 'approach', 'mechanism', - 'system', 'strategy', 'implementation', 'algorithm', 'architecture', - 'structure', 'design', 'method', 'technique', 'concept', 'principle', - 'handling', 'management', 'processing', 'validation', 'authentication' - ]; - - const lowerPattern = pattern.toLowerCase(); - const matchedTerms = conceptualTerms.filter(term => - lowerPattern.includes(term) && !pattern.includes('*') - ); - - if (matchedTerms.length > 0) { - reasons.push(`Conceptual terms found: ${matchedTerms.join(', ')}`); - } - - // Detect descriptive phrases (adjective + noun patterns) - if (words.length >= 2 && !/[*?[\]]/.test(pattern)) { - const descriptivePatterns = /\b(user|error|data|file|auth|api|request|response|message|event|state|config|service|component|module|handler|manager|controller|model|view)\s+(handling|management|processing|validation|creation|deletion|update|retrieval|storage|flow|pattern|logic)\b/i; - if (descriptivePatterns.test(pattern)) { - reasons.push('Descriptive phrase detected (noun + verb pattern)'); - } - } - - // If pattern has wildcards or file extensions, it's likely literal - if (/[*?[\]]|\.(?:ts|js|py|java|go|rs|cpp|h)$/.test(pattern)) { - return { isConceptual: false, reasons: [] }; - } - - // If pattern is PascalCase or camelCase, it's likely a filename - if (/^[A-Z][a-z]+[A-Z]|^[a-z]+[A-Z]/.test(pattern)) { - return { isConceptual: false, reasons: [] }; - } - - // Decision: conceptual if we have 2+ reasons - return { - isConceptual: reasons.length >= 2, - reasons - }; - } -} diff --git a/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindCommand.ts b/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindCommand.ts deleted file mode 100644 index 117a7d369..000000000 --- a/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindCommand.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * code/find shared command - Find files by name pattern - */ - -import type { JTAGContext } from '@system/core/types/JTAGTypes'; -import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import { CommandBase } from '@daemons/command-daemon/shared/CommandBase'; -import type { CodeFindParams, CodeFindResult } from './CodeFindTypes'; - -/** - * Shared base for code/find command - */ -export abstract class CodeFindCommand extends CommandBase { - constructor( - name: string, - context: JTAGContext, - subpath: string, - commander: ICommandDaemon - ) { - super(name, context, subpath, commander); - } - - /** - * Execute with environment routing - */ - async execute(params: CodeFindParams): Promise { - // Ensure backend is set - const effectiveParams = { - ...params, - backend: params.backend ?? 'server' - } as CodeFindParams; - - // If we're not in the requested environment, delegate - if (this.context.environment !== effectiveParams.backend) { - return await this.remoteExecute(effectiveParams); - } - - // We're in the correct environment, execute locally - return await this.executeCommand(effectiveParams); - } - - /** - * Subclasses implement this for their specific environment - */ - protected abstract executeCommand(params: CodeFindParams): Promise; -} diff --git a/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindTypes.ts b/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindTypes.ts deleted file mode 100644 index cc58cb9d0..000000000 --- a/src/debug/jtag/commands/development/code/pattern-search/shared/CodeFindTypes.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * code/find command types - Find files by name pattern - */ - -import type { JTAGContext, JTAGEnvironment } from '@system/core/types/JTAGTypes'; -import { transformPayload } from '@system/core/types/JTAGTypes'; -import type { UUID } from '@system/core/types/CrossPlatformUUID'; - -/** - * Base params for code commands - */ -export interface BaseCodeParams { - readonly context: JTAGContext; - readonly sessionId: UUID; - readonly backend: JTAGEnvironment; -} - -/** - * Parameters for code/find command - */ -export interface CodeFindParams extends BaseCodeParams { - /** Filename pattern to search for (supports wildcards: *, ?, []) */ - readonly pattern: string; - - /** Base directory to search (relative to repository root, default: entire repo) */ - readonly baseDir?: string; - - /** Case-insensitive search */ - readonly caseInsensitive?: boolean; - - /** Maximum results to return (default: 50) */ - readonly maxResults?: number; - - /** Include hidden files/directories (default: false) */ - readonly includeHidden?: boolean; - - /** Directories to exclude from search (default: ['node_modules', 'dist', '.continuum', '.git', 'examples/dist', 'coverage']) */ - readonly excludeDirs?: string[]; -} - -/** - * Single file match - */ -export interface FileMatch { - /** Relative path from repository root */ - path: string; - - /** File size in bytes */ - size: number; - - /** Last modified timestamp */ - modified: string; - - /** File type (file, directory, symlink) */ - type: 'file' | 'directory' | 'symlink'; -} - -/** - * Result of code/find command - */ -export interface CodeFindResult { - readonly context: JTAGContext; - readonly sessionId: UUID; - readonly backend: JTAGEnvironment; - readonly timestamp: string; - - /** Operation success */ - success: boolean; - - /** Search pattern used */ - pattern: string; - - /** Files found */ - matches: FileMatch[]; - - /** Total matches found (may be > matches.length if limited by maxResults) */ - totalMatches: number; - - /** Base directory searched */ - baseDir: string; - - /** Error message (if !success) */ - error?: string; - - /** Optional message with guidance or additional context */ - message?: string; -} - -/** - * Create code/find params - */ -export const createCodeFindParams = ( - context: JTAGContext, - sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } -): CodeFindParams => { - return { - context, - sessionId, - backend: data.backend || 'server', - pattern: data.pattern, - baseDir: data.baseDir, - caseInsensitive: data.caseInsensitive, - maxResults: data.maxResults, - includeHidden: data.includeHidden, - excludeDirs: data.excludeDirs - }; -}; - -/** - * Factory function to create result - */ -export const createCodeFindResultFromParams = ( - params: CodeFindParams, - differences: Omit, 'context' | 'sessionId' | 'backend'> -): CodeFindResult => transformPayload(params, { - backend: params.backend, - success: false, - pattern: params.pattern, - matches: [], - totalMatches: 0, - baseDir: params.baseDir || '.', - timestamp: new Date().toISOString(), - ...differences -}); diff --git a/src/debug/jtag/commands/development/code/read/server/CodeReadServerCommand.ts b/src/debug/jtag/commands/development/code/read/server/CodeReadServerCommand.ts deleted file mode 100644 index 78def3e24..000000000 --- a/src/debug/jtag/commands/development/code/read/server/CodeReadServerCommand.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * code/read server command - Read source code files - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { promisify } from 'util'; -import { minimatch } from 'minimatch'; - -import type { JTAGContext } from '@system/core/types/JTAGTypes'; -import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import { CodeDaemon } from '@daemons/code-daemon/shared/CodeDaemon'; -import type { CodeReadParams, CodeReadResult } from '../shared/CodeReadTypes'; -import { createCodeReadResultFromParams } from '../shared/CodeReadTypes'; -import { CodeReadCommand } from '../shared/CodeReadCommand'; - -const stat = promisify(fs.stat); -const readdir = promisify(fs.readdir); - -export class CodeReadServerCommand extends CodeReadCommand { - constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { - super('code-read', context, subpath, commander); - } - - /** - * Execute code/read command - * - * Delegates to CodeDaemon.readFile() static method - * If exact path fails, tries fuzzy matching to find similar files - */ - protected async executeCommand(params: CodeReadParams): Promise { - // Validate params - if (!params.path) { - return createCodeReadResultFromParams(params, { - success: false, - error: 'Missing required parameter: path' - }); - } - - console.log(`πŸ“‚ CODE SERVER: Reading file ${params.path} via CodeDaemon`); - - try { - // Try exact path first - const result = await CodeDaemon.readFile(params.path, { - startLine: params.startLine, - endLine: params.endLine, - includeMetadata: params.includeMetadata, - forceRefresh: params.forceRefresh - }); - - if (result.success) { - console.log(`βœ… CODE SERVER: Read ${params.path} (${result.metadata.linesReturned} lines)`); - return createCodeReadResultFromParams(params, result); - } - - // If exact path failed, try fuzzy matching - console.log(`πŸ” CODE SERVER: Exact path failed, trying fuzzy match for ${params.path}`); - const matches = await this.findSimilarFiles(params.path); - - if (matches.length === 0) { - console.log(`❌ CODE SERVER: No similar files found for ${params.path}`); - return createCodeReadResultFromParams(params, { - success: false, - error: `File not found: ${params.path}. No similar files found.` - }); - } - - if (matches.length === 1) { - // Exactly one match - read it automatically - console.log(`βœ… CODE SERVER: Found exact fuzzy match: ${matches[0]}`); - const fuzzyResult = await CodeDaemon.readFile(matches[0], { - startLine: params.startLine, - endLine: params.endLine, - includeMetadata: params.includeMetadata, - forceRefresh: params.forceRefresh - }); - - if (fuzzyResult.success) { - console.log(`βœ… CODE SERVER: Read fuzzy match ${matches[0]} (${fuzzyResult.metadata.linesReturned} lines)`); - } - - return createCodeReadResultFromParams(params, fuzzyResult); - } - - // Multiple matches - return suggestions - console.log(`❓ CODE SERVER: Found ${matches.length} similar files for ${params.path}`); - const suggestionsList = matches.slice(0, 10).map((m, i) => `${i + 1}. ${m}`).join('\n'); - return createCodeReadResultFromParams(params, { - success: false, - error: `File not found: ${params.path}.\n\nDid you mean one of these?\n${suggestionsList}\n\nPlease try again with the full path.` - }); - } catch (error) { - console.error(`❌ CODE SERVER: Exception reading ${params.path}:`, error); - - return createCodeReadResultFromParams(params, { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - } - - /** - * Find files with similar names using fuzzy matching - * Searches for files that contain the given filename pattern - */ - private async findSimilarFiles(partialPath: string): Promise { - try { - const repositoryRoot = CodeDaemon.getRepositoryRoot(); - - // Extract the filename from the partial path - const basename = path.basename(partialPath); - const dirname = path.dirname(partialPath); - - // Create a case-insensitive glob pattern - const pattern = `*${basename}*`; - - const matches: string[] = []; - const startTime = Date.now(); - const TIMEOUT_MS = 5000; // 5 second timeout - const MAX_DEPTH = 10; // Maximum directory depth - - // If a directory was specified, search only in that directory - if (dirname && dirname !== '.' && dirname !== '/') { - const searchPath = path.join(repositoryRoot, dirname); - try { - await stat(searchPath); - await this.searchDirectoryForPattern(searchPath, repositoryRoot, pattern, matches, 50, 0, MAX_DEPTH, startTime, TIMEOUT_MS); - } catch { - // Directory doesn't exist, fall through to repo-wide search - } - } - - // If no matches in specified directory (or no directory specified), search entire repo - if (matches.length === 0) { - await this.searchDirectoryForPattern(repositoryRoot, repositoryRoot, pattern, matches, 50, 0, MAX_DEPTH, startTime, TIMEOUT_MS); - } - - return matches; - } catch (error) { - console.warn(`⚠️ CODE SERVER: Error in fuzzy file search:`, error); - return []; - } - } - - /** - * Recursively search directory for files matching pattern - * @param depth Current depth in directory tree - * @param maxDepth Maximum depth to search (prevents deep recursion) - * @param startTime Start time of search (for timeout check) - * @param timeoutMs Maximum time to search in milliseconds - */ - private async searchDirectoryForPattern( - dirPath: string, - repoRoot: string, - pattern: string, - matches: string[], - maxResults: number, - depth: number = 0, - maxDepth: number = 10, - startTime: number = Date.now(), - timeoutMs: number = 5000 - ): Promise { - // Performance limits - if (matches.length >= maxResults) return; - if (depth > maxDepth) return; - if (Date.now() - startTime > timeoutMs) { - console.warn(`⚠️ CODE SERVER: Fuzzy search timeout after ${timeoutMs}ms at depth ${depth}`); - return; - } - - try { - const entries = await readdir(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - if (matches.length >= maxResults) break; - if (Date.now() - startTime > timeoutMs) break; - - // Skip hidden files/directories and node_modules - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; - - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(repoRoot, fullPath); - - // Check if filename matches pattern (case-insensitive) - if (entry.isFile() && minimatch(entry.name.toLowerCase(), pattern.toLowerCase())) { - matches.push(relativePath); - } - - // Recursively search subdirectories (with updated depth) - if (entry.isDirectory() && matches.length < maxResults) { - await this.searchDirectoryForPattern(fullPath, repoRoot, pattern, matches, maxResults, depth + 1, maxDepth, startTime, timeoutMs); - } - } - } catch { - // Silently skip directories we can't read - } - } -} diff --git a/src/debug/jtag/commands/development/code/read/shared/CodeReadCommand.ts b/src/debug/jtag/commands/development/code/read/shared/CodeReadCommand.ts deleted file mode 100644 index 1a8fcf11e..000000000 --- a/src/debug/jtag/commands/development/code/read/shared/CodeReadCommand.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Code Read Command - Shared Base Class - * - * Base class for code read operations with environment routing - */ - -import type { JTAGContext } from '@system/core/types/JTAGTypes'; -import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import { CommandBase } from '@daemons/command-daemon/shared/CommandBase'; -import type { CodeReadParams, CodeReadResult } from './CodeReadTypes'; - -/** - * Base class for code read commands - * Provides environment routing via CommandBase - */ -export abstract class CodeReadCommand extends CommandBase { - constructor(commandName: string, context: JTAGContext, subpath: string, commander: ICommandDaemon) { - super(commandName, context, subpath, commander); - } - - /** - * Execute with environment routing - */ - async execute(params: CodeReadParams): Promise { - // Ensure backend is set - const effectiveParams = { - ...params, - backend: params.backend ?? 'server' - } as CodeReadParams; - - // If we're not in the requested environment, delegate - if (this.context.environment !== effectiveParams.backend) { - return await this.remoteExecute(effectiveParams); - } - - // We're in the correct environment, execute locally - return await this.executeCommand(effectiveParams); - } - - /** - * Subclasses implement this for their specific environment - */ - protected abstract executeCommand(params: CodeReadParams): Promise; -} diff --git a/src/debug/jtag/commands/development/code/read/shared/CodeReadTypes.ts b/src/debug/jtag/commands/development/code/read/shared/CodeReadTypes.ts deleted file mode 100644 index 36a1134f3..000000000 --- a/src/debug/jtag/commands/development/code/read/shared/CodeReadTypes.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * code/read command types - */ - -import type { JTAGContext, JTAGEnvironment } from '@system/core/types/JTAGTypes'; -import { transformPayload } from '@system/core/types/JTAGTypes'; -import type { UUID } from '@system/core/types/CrossPlatformUUID'; -import type { CodeReadResult as CodeDaemonReadResult, CodeReadOptions } from '@daemons/code-daemon/shared/CodeDaemonTypes'; - -/** - * Base params for code commands - */ -export interface BaseCodeParams { - readonly context: JTAGContext; - readonly sessionId: UUID; - readonly backend: JTAGEnvironment; -} - -/** - * Parameters for code/read command - */ -export interface CodeReadParams extends BaseCodeParams { - /** File path relative to jtag root, e.g. "commands/wall/write.ts" or "system/core/shared/Events.ts" (NOT absolute paths, NOT starting with "src/") */ - readonly path: string; - - /** Start line (1-indexed, optional) */ - readonly startLine?: number; - - /** End line (1-indexed, optional) */ - readonly endLine?: number; - - /** Include file metadata */ - readonly includeMetadata?: boolean; - - /** Force bypass cache */ - readonly forceRefresh?: boolean; -} - -/** - * Result of code/read command - */ -export interface CodeReadResult extends CodeDaemonReadResult { - readonly context: JTAGContext; - readonly sessionId: UUID; - readonly backend: JTAGEnvironment; - readonly timestamp: string; -} - -/** - * Create code/read params - */ -export const createCodeReadParams = ( - context: JTAGContext, - sessionId: UUID, - data: Omit & { backend?: JTAGEnvironment } -): CodeReadParams => { - return { - context, - sessionId, - backend: data.backend || 'server', - path: data.path, - startLine: data.startLine, - endLine: data.endLine, - includeMetadata: data.includeMetadata, - forceRefresh: data.forceRefresh - }; -}; - -/** - * Factory function to create result - */ -export const createCodeReadResultFromParams = ( - params: CodeReadParams, - differences: Omit, 'context' | 'sessionId' | 'backend'> -): CodeReadResult => transformPayload(params, { - backend: params.backend, // Explicitly copy backend from params - success: false, - metadata: { - path: params.path, - size: 0, - lines: 0, - linesReturned: 0, - modified: '' - }, - timestamp: new Date().toISOString(), - ...differences -}); diff --git a/src/debug/jtag/commands/development/code/shared/CodeCommandConstants.ts b/src/debug/jtag/commands/development/code/shared/CodeCommandConstants.ts deleted file mode 100644 index 17911acb1..000000000 --- a/src/debug/jtag/commands/development/code/shared/CodeCommandConstants.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Code Command Constants - * - * All development/code/* command names defined here. - * Usage: - * await Commands.execute(CODE_COMMANDS.READ, params); - */ - -export const CODE_COMMANDS = { - /** Read source code from a file */ - READ: 'code/read', - - /** Search for patterns in code */ - FIND: 'code/find', -} as const; - -/** - * Type-safe code command names - */ -export type CodeCommand = typeof CODE_COMMANDS[keyof typeof CODE_COMMANDS]; diff --git a/src/debug/jtag/commands/genome/batch-micro-tune/server/GenomeBatchMicroTuneServerCommand.ts b/src/debug/jtag/commands/genome/batch-micro-tune/server/GenomeBatchMicroTuneServerCommand.ts index 4a57c6da3..61c5b3738 100644 --- a/src/debug/jtag/commands/genome/batch-micro-tune/server/GenomeBatchMicroTuneServerCommand.ts +++ b/src/debug/jtag/commands/genome/batch-micro-tune/server/GenomeBatchMicroTuneServerCommand.ts @@ -1,8 +1,9 @@ /** - * GenomeBatchMicroTuneServerCommand - Lightweight in-recipe LoRA updates + * GenomeBatchMicroTuneServerCommand - Trigger LoRA micro-tuning from accumulated examples * - * Performs fast micro-tuning using accumulated training examples. - * Updates soft weights in RAM for immediate effect, not persisted yet. + * Accesses the PersonaUser's TrainingDataAccumulator, checks if enough examples + * have accumulated for the requested domain, and triggers training via + * PersonaTrainingManager. Supports forceUpdate to bypass threshold check. */ import { CommandBase } from '../../../../daemons/command-daemon/shared/CommandBase'; @@ -13,6 +14,8 @@ import type { GenomeBatchMicroTuneParams, GenomeBatchMicroTuneResult } from '../shared/GenomeBatchMicroTuneTypes'; +import { UserDaemonServer } from '@daemons/user-daemon/server/UserDaemonServer'; +import { PersonaUser } from '@system/user/server/PersonaUser'; export class GenomeBatchMicroTuneServerCommand extends CommandBase< GenomeBatchMicroTuneParams, @@ -24,78 +27,91 @@ export class GenomeBatchMicroTuneServerCommand extends CommandBase< async execute(params: JTAGPayload): Promise { const tuneParams = params as GenomeBatchMicroTuneParams; + const domain = tuneParams.domain; + const forceUpdate = tuneParams.forceUpdate ?? false; - console.log('🧬 GENOME MICRO-TUNE: Starting lightweight training'); - console.log(` Domain: ${tuneParams.domain}`); - console.log(` Role: ${tuneParams.roleId ?? 'all'}`); + console.log(`🧬 GENOME MICRO-TUNE: domain=${domain}, force=${forceUpdate}`); try { - // TODO: Access PersonaUser's TrainingDataAccumulator - // Check if batch threshold reached (unless forceUpdate) - // Get training examples and filter by quality - // Perform fast micro-tuning (soft weight update in RAM) - // This is placeholder implementation + // 1. Get UserDaemon singleton + const userDaemon = UserDaemonServer.getInstance(); + if (!userDaemon) { + return transformPayload(params, { + success: false, + error: 'UserDaemon not initialized', + }); + } - const startTime = Date.now(); + // 2. Get PersonaUser instance + const personaId = tuneParams.personaId ?? tuneParams.userId; + if (!personaId) { + return transformPayload(params, { + success: false, + error: 'No personaId or userId provided', + }); + } - // Placeholder: Check if ready for training - const batchThreshold = 10; - const bufferSize = 5; // Placeholder - const qualityThreshold = tuneParams.qualityThreshold ?? 0.7; + const baseUser = userDaemon.getPersonaUser(personaId); + if (!baseUser || !(baseUser instanceof PersonaUser)) { + return transformPayload(params, { + success: false, + error: `PersonaUser not found: ${personaId}`, + }); + } + + const personaUser = baseUser as PersonaUser; + const accumulator = personaUser.trainingAccumulator; - if (!tuneParams.forceUpdate && bufferSize < batchThreshold) { - console.log(`⏳ Buffer not ready (${bufferSize}/${batchThreshold}), skipping micro-tune`); + // 3. Check buffer readiness + const bufferSize = accumulator.getBufferSize(domain); + const batchThreshold = accumulator.getBatchThreshold(domain); + + if (!forceUpdate && !accumulator.shouldMicroTune(domain)) { + console.log(`⏳ GENOME MICRO-TUNE: Buffer not ready (${bufferSize}/${batchThreshold})`); return transformPayload(params, { success: true, training: { - domain: tuneParams.domain, - loraAdapter: tuneParams.loraAdapter ?? `${tuneParams.domain}-base`, + domain, + loraAdapter: tuneParams.loraAdapter ?? `${domain}-base`, examplesUsed: 0, examplesFiltered: 0, - updateType: 'none' - } + updateType: 'none', + }, }); } - // Placeholder: Get examples and filter by quality - const totalExamples = bufferSize; - const filteredExamples = Math.floor(totalExamples * 0.8); // 80% pass quality threshold - const examplesUsed = Math.min(filteredExamples, tuneParams.maxExamples ?? 50); - - // Placeholder: Perform micro-tuning - // In real implementation: - // - Load current LoRA adapter soft weights - // - Run lightweight fine-tuning step (gradient descent on batch) - // - Update soft weights in RAM (don't save to disk yet) - console.log(`πŸ”§ Micro-tuning with ${examplesUsed} examples...`); - - // Simulate training time (real would be 100-500ms) + // 4. Trigger training via PersonaTrainingManager + // forceDomain bypasses the threshold check for the specified domain + const startTime = Date.now(); + await personaUser.trainingManager.checkTrainingReadiness(forceUpdate ? domain : undefined); const trainingTime = Date.now() - startTime; - console.log(`βœ… GENOME MICRO-TUNE: Completed in ${trainingTime}ms`); + // 5. Get post-training stats (buffer should be consumed now) + const postBufferSize = accumulator.getBufferSize(domain); + const examplesUsed = bufferSize - postBufferSize; + + console.log(`βœ… GENOME MICRO-TUNE: ${examplesUsed} examples consumed in ${trainingTime}ms`); return transformPayload(params, { success: true, training: { - domain: tuneParams.domain, - loraAdapter: tuneParams.loraAdapter ?? `${tuneParams.domain}-base`, + domain, + loraAdapter: tuneParams.loraAdapter ?? `${domain}-base`, examplesUsed, - examplesFiltered: totalExamples - filteredExamples, - updateType: 'soft', - improvementEstimate: 0.05, // 5% improvement placeholder + examplesFiltered: 0, + updateType: examplesUsed > 0 ? 'soft' : 'none', metrics: { trainingTime, - averageQuality: 0.82, - diversityScore: 0.75 - } - } + averageQuality: 0, // Quality scoring is Phase 12 + diversityScore: 0, + }, + }, }); - } catch (error) { console.error('❌ GENOME MICRO-TUNE: Error:', error); return transformPayload(params, { success: false, - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error), }); } } diff --git a/src/debug/jtag/commands/logs/config/server/LogsConfigServerCommand.ts b/src/debug/jtag/commands/logs/config/server/LogsConfigServerCommand.ts index 6f0038fcd..7fc8f6016 100644 --- a/src/debug/jtag/commands/logs/config/server/LogsConfigServerCommand.ts +++ b/src/debug/jtag/commands/logs/config/server/LogsConfigServerCommand.ts @@ -6,9 +6,10 @@ import { CommandBase, type ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import type { JTAGContext } from '../../../../system/core/types/JTAGTypes'; -import type { LogsConfigParams, LogsConfigResult } from '../shared/LogsConfigTypes'; +import type { LogsConfigParams, LogsConfigResult, PersonaLoggingStatus } from '../shared/LogsConfigTypes'; import { createLogsConfigResultFromParams } from '../shared/LogsConfigTypes'; -import { LoggingConfig, LOGGING_CATEGORIES } from '../../../../system/core/logging/LoggingConfig'; +import { LoggingConfig, LOGGING_CATEGORIES, type LoggingConfigData } from '../../../../system/core/logging/LoggingConfig'; +import { Commands } from '../../../../system/core/shared/Commands'; export class LogsConfigServerCommand extends CommandBase { @@ -17,14 +18,13 @@ export class LogsConfigServerCommand extends CommandBase { - console.log('πŸ”§ SERVER: Executing Logs Config', params); - const action = params.action || 'get'; const persona = params.persona; const category = params.category; // Get current config const config = LoggingConfig.getConfig(); + const availableCategories = Object.values(LOGGING_CATEGORIES); // If just getting config (no action or action=get) if (action === 'get') { @@ -37,21 +37,35 @@ export class LogsConfigServerCommand extends CommandBase s.enabled).length; + const lines: string[] = [ + `Default: ${config.defaults.enabled ? 'ON' : 'OFF'}`, + `Personas: ${enabledCount}/${statuses.length} logging enabled`, + `Categories: ${availableCategories.join(', ')}`, + ]; + return createLogsConfigResultFromParams(params, { success: true, config: config, + statuses, + availableCategories, personaConfig: { enabled: false, categories: [] }, - message: `Available categories: ${Object.values(LOGGING_CATEGORIES).join(', ')}` + message: lines.join(' | ') }); } @@ -99,4 +113,59 @@ export class LogsConfigServerCommand extends CommandBase { + const statuses: PersonaLoggingStatus[] = []; + + // Query all users to get the full persona list + const result = await Commands.execute('data/list', { + collection: 'users', + limit: 100 + }); + + if (result.success && result.items) { + for (const user of result.items) { + // Skip human users β€” only show AI personas + const userType = (user.userType || user.type || '').toLowerCase(); + if (['human', 'owner', 'admin', 'user'].includes(userType)) continue; + + const uniqueId = user.uniqueId || ''; + if (!uniqueId) continue; + + const personaConfig = config.personas[uniqueId]; + statuses.push({ + persona: uniqueId, + enabled: personaConfig?.enabled ?? config.defaults.enabled, + categories: personaConfig?.categories ?? config.defaults.categories ?? [], + source: personaConfig ? 'explicit' : 'default' + }); + } + } + + // Include explicitly configured personas not found in user list + // (e.g., config entries for personas that haven't been seeded yet) + for (const [personaId, personaConfig] of Object.entries(config.personas)) { + if (personaId === '*') continue; + if (statuses.some(s => s.persona === personaId)) continue; + statuses.push({ + persona: personaId, + enabled: personaConfig.enabled, + categories: personaConfig.categories || [], + source: 'explicit' + }); + } + + // Sort: enabled first, then alphabetical + statuses.sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.persona.localeCompare(b.persona); + }); + + return statuses; + } } diff --git a/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts b/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts index c66c19013..848d0ab60 100644 --- a/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts +++ b/src/debug/jtag/commands/logs/config/shared/LogsConfigTypes.ts @@ -47,12 +47,23 @@ export const createLogsConfigParams = ( /** * Logs Config Command Result */ +export interface PersonaLoggingStatus { + persona: string; + enabled: boolean; + categories: string[]; + source: 'explicit' | 'default'; +} + export interface LogsConfigResult extends CommandResult { success: boolean; // Full logging configuration config: LoggingConfigData; // Config for specific persona personaConfig: { enabled: boolean; categories: string[] }; + // Per-persona status list (for overview display) + statuses?: PersonaLoggingStatus[]; + // Available categories + availableCategories?: string[]; // Status message message: string; error?: JTAGError; diff --git a/src/debug/jtag/commands/session/get-user/server/SessionGetUserServerCommand.ts b/src/debug/jtag/commands/session/get-user/server/SessionGetUserServerCommand.ts index fb26558dd..6aa47e5ce 100644 --- a/src/debug/jtag/commands/session/get-user/server/SessionGetUserServerCommand.ts +++ b/src/debug/jtag/commands/session/get-user/server/SessionGetUserServerCommand.ts @@ -29,20 +29,18 @@ export class SessionGetUserServerCommand extends CommandBase(COLLECTIONS.USERS, getUserParams.userId); + const user = await DataDaemon.read(COLLECTIONS.USERS, getUserParams.userId); - if (!userResult.success || !userResult.data) { + if (!user) { return transformPayload(getUserParams, { success: false, error: `User not found: ${getUserParams.userId}` }); } - const user = userResult.data.data as UserEntity; - return transformPayload(getUserParams, { success: true, - user: user + user }); } @@ -91,18 +89,15 @@ export class SessionGetUserServerCommand extends CommandBase(COLLECTIONS.USERS, userId); + const user = await DataDaemon.read(COLLECTIONS.USERS, userId); - if (!userResult.success || !userResult.data) { + if (!user) { return transformPayload(getUserParams, { success: false, error: `User not found: ${userId}` }); } - // Extract user entity from DataRecord - const user = userResult.data.data as UserEntity; - return transformPayload(getUserParams, { success: true, user: user diff --git a/src/debug/jtag/commands/skill/activate/.npmignore b/src/debug/jtag/commands/skill/activate/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/skill/activate/README.md b/src/debug/jtag/commands/skill/activate/README.md new file mode 100644 index 000000000..062266a2c --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/README.md @@ -0,0 +1,157 @@ +# Skill Activate Command + +Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team). + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag skill/activate --skillId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('skill/activate', { + // your parameters here +}); +``` + +## Parameters + +- **skillId** (required): `string` - ID of the SkillEntity to activate + +## Result + +Returns `SkillActivateResult` with: + +Returns CommandResult with: +- **skillId**: `string` - ID of the SkillEntity +- **name**: `string` - Skill command name +- **status**: `string` - Lifecycle status after activation +- **activatedAt**: `number` - Timestamp when the skill was activated +- **message**: `string` - Human-readable result message + +## Examples + +### Activate a validated skill + +```bash +./jtag skill/activate --skillId="uuid-of-skill" +``` + +**Expected result:** +{ skillId: "uuid", name: "analysis/complexity", status: "active" } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help skill/activate +``` + +**Tool:** +```typescript +// Use your help tool with command name 'skill/activate' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme skill/activate +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'skill/activate' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Skill Activate/test/unit/SkillActivateCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Skill Activate/test/integration/SkillActivateIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/SkillActivateTypes.ts` +- **Browser**: Browser-specific implementation in `browser/SkillActivateBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/SkillActivateServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/SkillActivateCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/SkillActivateIntegration.test.ts` diff --git a/src/debug/jtag/commands/skill/activate/browser/SkillActivateBrowserCommand.ts b/src/debug/jtag/commands/skill/activate/browser/SkillActivateBrowserCommand.ts new file mode 100644 index 000000000..ec7a35acf --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/browser/SkillActivateBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Skill Activate Command - Browser Implementation + * + * Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team). + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillActivateParams, SkillActivateResult } from '../shared/SkillActivateTypes'; + +export class SkillActivateBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/activate', context, subpath, commander); + } + + async execute(params: SkillActivateParams): Promise { + console.log('🌐 BROWSER: Delegating Skill Activate to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/skill/activate/package.json b/src/debug/jtag/commands/skill/activate/package.json new file mode 100644 index 000000000..fc1764ded --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/skill/activate", + "version": "1.0.0", + "description": "Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team).", + "main": "server/SkillActivateServerCommand.ts", + "types": "shared/SkillActivateTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/SkillActivateIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "skill/activate" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/skill/activate/server/SkillActivateServerCommand.ts b/src/debug/jtag/commands/skill/activate/server/SkillActivateServerCommand.ts new file mode 100644 index 000000000..81df724db --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/server/SkillActivateServerCommand.ts @@ -0,0 +1,122 @@ +/** + * Skill Activate Command - Server Implementation + * + * Activates a validated skill by registering it as a live command. + * The skill becomes available for use by the creator (personal) or all personas (team). + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { SkillActivateParams, SkillActivateResult } from '../shared/SkillActivateTypes'; +import { createSkillActivateResultFromParams } from '../shared/SkillActivateTypes'; +import { SkillEntity } from '@system/data/entities/SkillEntity'; +import { DataDaemon } from '@daemons/data-daemon/shared/DataDaemon'; +import { COLLECTIONS } from '@system/shared/Constants'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +export class SkillActivateServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/activate', context, subpath, commander); + } + + async execute(params: SkillActivateParams): Promise { + const { skillId } = params; + + if (!skillId?.trim()) { + throw new ValidationError('skillId', "Missing required parameter 'skillId'."); + } + + // Load skill entity + const skill = await DataDaemon.read(COLLECTIONS.SKILLS, skillId as UUID); + if (!skill) { + throw new ValidationError('skillId', `Skill not found: ${skillId}`); + } + + if (skill.status !== 'validated') { + throw new ValidationError('skillId', + `Skill '${skill.name}' cannot be activated in status '${skill.status}'. Must be 'validated' first.`); + } + + if (!skill.outputDir) { + throw new ValidationError('skillId', `Skill '${skill.name}' has no outputDir.`); + } + + // For team-scoped skills, verify governance approval + if (skill.scope === 'team' && skill.proposalId) { + try { + const proposal = await DataDaemon.read(COLLECTIONS.DECISION_PROPOSALS, skill.proposalId); + if (proposal) { + if (proposal.status !== 'approved' && proposal.status !== 'concluded') { + throw new ValidationError('skillId', + `Team skill '${skill.name}' has not been approved yet (proposal status: ${proposal.status}).`); + } + } + } catch (e) { + if (e instanceof ValidationError) throw e; + // If proposal lookup fails, proceed (governance is best-effort) + } + } + + // Activate: dynamically import the generated command server module + // For personal skills: register in the runtime command map + // For team skills: the generated files are already in commands/ and will be picked up on next build + const now = Date.now(); + + try { + if (skill.scope === 'personal') { + // Dynamic import of the generated server command + const serverPath = skill.generatedFiles?.find(f => f.includes('ServerCommand')); + if (serverPath) { + await this.registerPersonalSkill(skill, serverPath); + } + } + // Team skills: files are already in commands/ directory from generate step + // They'll be available after the next npm start / registry rebuild + } catch (e) { + await DataDaemon.update( + COLLECTIONS.SKILLS, + skill.id as UUID, + { + status: 'failed', + failureReason: `Activation failed: ${e instanceof Error ? e.message : String(e)}`, + } as Partial, + ); + + throw new ValidationError('skillId', + `Failed to activate skill '${skill.name}': ${e instanceof Error ? e.message : String(e)}`); + } + + // Update entity + await DataDaemon.update( + COLLECTIONS.SKILLS, + skill.id as UUID, + { + status: 'active', + activatedAt: now, + } as Partial, + ); + + return createSkillActivateResultFromParams(params, { + success: true, + skillId: skill.id, + name: skill.name, + status: 'active', + activatedAt: now, + message: skill.scope === 'team' + ? `Skill '${skill.name}' activated for all personas (available after next build)` + : `Skill '${skill.name}' activated for creator ${skill.createdById}`, + }); + } + + private async registerPersonalSkill(_skill: SkillEntity, _serverPath: string): Promise { + // Dynamic command registration for personal skills + // In the current architecture, commands are discovered from the file system + // Personal skills stored in .continuum/skills/ will need the command daemon + // to scan that directory on next refresh cycle + // + // For now, marking as active is sufficient β€” the skill files exist and can be + // loaded by the command daemon when it next scans for commands + } +} diff --git a/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts b/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts new file mode 100644 index 000000000..e8a9e7004 --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/shared/SkillActivateTypes.ts @@ -0,0 +1,105 @@ +/** + * Skill Activate Command - Shared Types + * + * Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team). + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Skill Activate Command Parameters + */ +export interface SkillActivateParams extends CommandParams { + // ID of the SkillEntity to activate + skillId: string; +} + +/** + * Factory function for creating SkillActivateParams + */ +export const createSkillActivateParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // ID of the SkillEntity to activate + skillId: string; + } +): SkillActivateParams => createPayload(context, sessionId, { + + ...data +}); + +/** + * Skill Activate Command Result + */ +export interface SkillActivateResult extends CommandResult { + success: boolean; + // ID of the SkillEntity + skillId: string; + // Skill command name + name: string; + // Lifecycle status after activation + status: string; + // Timestamp when the skill was activated + activatedAt: number; + // Human-readable result message + message: string; + error?: JTAGError; +} + +/** + * Factory function for creating SkillActivateResult with defaults + */ +export const createSkillActivateResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // ID of the SkillEntity + skillId?: string; + // Skill command name + name?: string; + // Lifecycle status after activation + status?: string; + // Timestamp when the skill was activated + activatedAt?: number; + // Human-readable result message + message?: string; + error?: JTAGError; + } +): SkillActivateResult => createPayload(context, sessionId, { + skillId: data.skillId ?? '', + name: data.name ?? '', + status: data.status ?? '', + activatedAt: data.activatedAt ?? 0, + message: data.message ?? '', + ...data +}); + +/** + * Smart Skill Activate-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createSkillActivateResultFromParams = ( + params: SkillActivateParams, + differences: Omit +): SkillActivateResult => transformPayload(params, differences); + +/** + * Skill Activate β€” Type-safe command executor + * + * Usage: + * import { SkillActivate } from '...shared/SkillActivateTypes'; + * const result = await SkillActivate.execute({ ... }); + */ +export const SkillActivate = { + execute(params: CommandInput): Promise { + return Commands.execute('skill/activate', params as Partial); + }, + commandName: 'skill/activate' as const, +} as const; diff --git a/src/debug/jtag/commands/skill/activate/test/integration/SkillActivateIntegration.test.ts b/src/debug/jtag/commands/skill/activate/test/integration/SkillActivateIntegration.test.ts new file mode 100644 index 000000000..b01c688ef --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/test/integration/SkillActivateIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * SkillActivate Command Integration Tests + * + * Tests Skill Activate command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Activate/test/integration/SkillActivateIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ SkillActivate Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Skill Activate command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Skill Activate command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Skill Activate']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Skill Activate returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Skill Activate succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Skill Activate']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Skill Activate']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Skill Activate']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Skill Activate']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Skill Activate']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllSkillActivateIntegrationTests(): Promise { + console.log('πŸš€ Starting SkillActivate Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL SkillActivate INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ SkillActivate integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillActivateIntegrationTests(); +} else { + module.exports = { runAllSkillActivateIntegrationTests }; +} diff --git a/src/debug/jtag/commands/skill/activate/test/unit/SkillActivateCommand.test.ts b/src/debug/jtag/commands/skill/activate/test/unit/SkillActivateCommand.test.ts new file mode 100644 index 000000000..9b79f3c1c --- /dev/null +++ b/src/debug/jtag/commands/skill/activate/test/unit/SkillActivateCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * SkillActivate Command Unit Tests + * + * Tests Skill Activate command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Activate/test/unit/SkillActivateCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { SkillActivateParams, SkillActivateResult } from '../../shared/SkillActivateTypes'; + +console.log('πŸ§ͺ SkillActivate Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Skill Activate logic for testing + */ +async function mockSkillActivateCommand(params: SkillActivateParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Skill Activate' or see the Skill Activate README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as SkillActivateResult; +} + +/** + * Test 1: Command structure validation + */ +function testSkillActivateCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: SkillActivate command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Skill Activate command + const validParams: SkillActivateParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockSkillActivateExecution(): Promise { + console.log('\n⚑ Test 2: Mock Skill Activate command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: SkillActivateParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockSkillActivateCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testSkillActivateRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as SkillActivateParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as SkillActivateParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockSkillActivateCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testSkillActivateOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: SkillActivateParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockSkillActivateCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: SkillActivateParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockSkillActivateCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testSkillActivatePerformance(): Promise { + console.log('\n⚑ Test 5: SkillActivate performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockSkillActivateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillActivateParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `SkillActivate completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testSkillActivateResultStructure(): Promise { + console.log('\nπŸ” Test 6: SkillActivate result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockSkillActivateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillActivateParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllSkillActivateUnitTests(): Promise { + console.log('πŸš€ Starting SkillActivate Command Unit Tests\n'); + + try { + testSkillActivateCommandStructure(); + await testMockSkillActivateExecution(); + await testSkillActivateRequiredParams(); + await testSkillActivateOptionalParams(); + await testSkillActivatePerformance(); + await testSkillActivateResultStructure(); + + console.log('\nπŸŽ‰ ALL SkillActivate UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ SkillActivate unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillActivateUnitTests(); +} else { + module.exports = { runAllSkillActivateUnitTests }; +} diff --git a/src/debug/jtag/commands/skill/generate/.npmignore b/src/debug/jtag/commands/skill/generate/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/skill/generate/README.md b/src/debug/jtag/commands/skill/generate/README.md new file mode 100644 index 000000000..c1c120753 --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/README.md @@ -0,0 +1,159 @@ +# Skill Generate Command + +Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag skill/generate --skillId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('skill/generate', { + // your parameters here +}); +``` + +## Parameters + +- **skillId** (required): `string` - ID of the SkillEntity to generate code for +- **outputDir** (optional): `string` - Override output directory (default: persona workspace or commands/) + +## Result + +Returns `SkillGenerateResult` with: + +Returns CommandResult with: +- **skillId**: `string` - ID of the SkillEntity +- **name**: `string` - Skill command name +- **status**: `string` - Lifecycle status after generation +- **outputDir**: `string` - Directory where files were generated +- **generatedFiles**: `object` - Array of generated file paths +- **message**: `string` - Human-readable result message + +## Examples + +### Generate files for a proposed skill + +```bash +./jtag skill/generate --skillId="uuid-of-skill" +``` + +**Expected result:** +{ skillId: "uuid", name: "analysis/complexity", status: "generated", generatedFiles: [...] } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help skill/generate +``` + +**Tool:** +```typescript +// Use your help tool with command name 'skill/generate' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme skill/generate +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'skill/generate' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Skill Generate/test/unit/SkillGenerateCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Skill Generate/test/integration/SkillGenerateIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/SkillGenerateTypes.ts` +- **Browser**: Browser-specific implementation in `browser/SkillGenerateBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/SkillGenerateServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/SkillGenerateCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/SkillGenerateIntegration.test.ts` diff --git a/src/debug/jtag/commands/skill/generate/browser/SkillGenerateBrowserCommand.ts b/src/debug/jtag/commands/skill/generate/browser/SkillGenerateBrowserCommand.ts new file mode 100644 index 000000000..c9130a26c --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/browser/SkillGenerateBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Skill Generate Command - Browser Implementation + * + * Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillGenerateParams, SkillGenerateResult } from '../shared/SkillGenerateTypes'; + +export class SkillGenerateBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/generate', context, subpath, commander); + } + + async execute(params: SkillGenerateParams): Promise { + console.log('🌐 BROWSER: Delegating Skill Generate to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/skill/generate/package.json b/src/debug/jtag/commands/skill/generate/package.json new file mode 100644 index 000000000..c7fa6d6e2 --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/skill/generate", + "version": "1.0.0", + "description": "Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files.", + "main": "server/SkillGenerateServerCommand.ts", + "types": "shared/SkillGenerateTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/SkillGenerateIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "skill/generate" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/skill/generate/server/SkillGenerateServerCommand.ts b/src/debug/jtag/commands/skill/generate/server/SkillGenerateServerCommand.ts new file mode 100644 index 000000000..cd70a3d39 --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/server/SkillGenerateServerCommand.ts @@ -0,0 +1,126 @@ +/** + * Skill Generate Command - Server Implementation + * + * Retrieves a SkillEntity and runs CommandGenerator programmatically + * to produce the command source files. + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { SkillGenerateParams, SkillGenerateResult } from '../shared/SkillGenerateTypes'; +import { createSkillGenerateResultFromParams } from '../shared/SkillGenerateTypes'; +import { SkillEntity } from '@system/data/entities/SkillEntity'; +import { DataDaemon } from '@daemons/data-daemon/shared/DataDaemon'; +import { COLLECTIONS } from '@system/shared/Constants'; +import { CommandGenerator } from '@generator/CommandGenerator'; +import type { CommandSpec } from '@generator/CommandNaming'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +export class SkillGenerateServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/generate', context, subpath, commander); + } + + async execute(params: SkillGenerateParams): Promise { + const { skillId } = params; + + if (!skillId?.trim()) { + throw new ValidationError('skillId', "Missing required parameter 'skillId'."); + } + + // Load skill entity + const skill = await DataDaemon.read(COLLECTIONS.SKILLS, skillId as UUID); + if (!skill) { + throw new ValidationError('skillId', `Skill not found: ${skillId}`); + } + + // Verify lifecycle state: personal skills can skip approval, team skills need 'approved' + const canGenerate = + (skill.status === 'proposed' && skill.scope === 'personal') || + skill.status === 'approved'; + + if (!canGenerate) { + throw new ValidationError('skillId', + `Skill '${skill.name}' cannot be generated in status '${skill.status}' (scope: ${skill.scope}). ` + + (skill.scope === 'team' ? 'Team skills must be approved first.' : 'Expected status: proposed.')); + } + + // Build CommandSpec from SkillSpec + const commandSpec: CommandSpec = { + name: skill.spec.name, + description: skill.spec.description, + params: skill.spec.params.map(p => ({ + name: p.name, + type: p.type, + optional: p.optional, + description: p.description, + })), + results: skill.spec.results.map(r => ({ + name: r.name, + type: r.type, + description: r.description, + })), + examples: skill.spec.examples?.map(e => ({ + description: e.description, + command: e.command, + expectedResult: e.expectedResult, + })), + accessLevel: skill.spec.accessLevel ?? 'ai-safe', + }; + + // Determine output directory + const rootPath = path.resolve(__dirname, '../../../../'); + const outputDir = params.outputDir + ?? (skill.scope === 'team' + ? path.join(rootPath, 'commands', skill.spec.name) + : path.join(rootPath, '.continuum', 'skills', skill.createdById, skill.spec.name)); + + // Run CommandGenerator + const generator = new CommandGenerator(rootPath); + generator.generate(commandSpec, outputDir, { force: true }); + + // Collect generated files + const generatedFiles = this.collectFiles(outputDir); + + // Update entity + await DataDaemon.update( + COLLECTIONS.SKILLS, + skill.id as UUID, + { + status: 'generated', + outputDir, + generatedFiles, + } as Partial, + ); + + return createSkillGenerateResultFromParams(params, { + success: true, + skillId: skill.id, + name: skill.name, + status: 'generated', + outputDir, + generatedFiles, + message: `Generated ${generatedFiles.length} files for skill '${skill.name}' in ${outputDir}`, + }); + } + + private collectFiles(dir: string): string[] { + const files: string[] = []; + if (!fs.existsSync(dir)) return files; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...this.collectFiles(full)); + } else { + files.push(full); + } + } + return files; + } +} diff --git a/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts b/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts new file mode 100644 index 000000000..e6361dad4 --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/shared/SkillGenerateTypes.ts @@ -0,0 +1,114 @@ +/** + * Skill Generate Command - Shared Types + * + * Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Skill Generate Command Parameters + */ +export interface SkillGenerateParams extends CommandParams { + // ID of the SkillEntity to generate code for + skillId: string; + // Override output directory (default: persona workspace or commands/) + outputDir?: string; +} + +/** + * Factory function for creating SkillGenerateParams + */ +export const createSkillGenerateParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // ID of the SkillEntity to generate code for + skillId: string; + // Override output directory (default: persona workspace or commands/) + outputDir?: string; + } +): SkillGenerateParams => createPayload(context, sessionId, { + outputDir: data.outputDir ?? '', + ...data +}); + +/** + * Skill Generate Command Result + */ +export interface SkillGenerateResult extends CommandResult { + success: boolean; + // ID of the SkillEntity + skillId: string; + // Skill command name + name: string; + // Lifecycle status after generation + status: string; + // Directory where files were generated + outputDir: string; + // Array of generated file paths + generatedFiles: string[]; + // Human-readable result message + message: string; + error?: JTAGError; +} + +/** + * Factory function for creating SkillGenerateResult with defaults + */ +export const createSkillGenerateResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // ID of the SkillEntity + skillId?: string; + // Skill command name + name?: string; + // Lifecycle status after generation + status?: string; + // Directory where files were generated + outputDir?: string; + // Array of generated file paths + generatedFiles?: string[]; + // Human-readable result message + message?: string; + error?: JTAGError; + } +): SkillGenerateResult => createPayload(context, sessionId, { + skillId: data.skillId ?? '', + name: data.name ?? '', + status: data.status ?? '', + outputDir: data.outputDir ?? '', + generatedFiles: data.generatedFiles ?? [], + message: data.message ?? '', + ...data +}); + +/** + * Smart Skill Generate-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createSkillGenerateResultFromParams = ( + params: SkillGenerateParams, + differences: Omit +): SkillGenerateResult => transformPayload(params, differences); + +/** + * Skill Generate β€” Type-safe command executor + * + * Usage: + * import { SkillGenerate } from '...shared/SkillGenerateTypes'; + * const result = await SkillGenerate.execute({ ... }); + */ +export const SkillGenerate = { + execute(params: CommandInput): Promise { + return Commands.execute('skill/generate', params as Partial); + }, + commandName: 'skill/generate' as const, +} as const; diff --git a/src/debug/jtag/commands/skill/generate/test/integration/SkillGenerateIntegration.test.ts b/src/debug/jtag/commands/skill/generate/test/integration/SkillGenerateIntegration.test.ts new file mode 100644 index 000000000..dfe47514e --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/test/integration/SkillGenerateIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * SkillGenerate Command Integration Tests + * + * Tests Skill Generate command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Generate/test/integration/SkillGenerateIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ SkillGenerate Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Skill Generate command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Skill Generate command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Skill Generate']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Skill Generate returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Skill Generate succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Skill Generate']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Skill Generate']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Skill Generate']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Skill Generate']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Skill Generate']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllSkillGenerateIntegrationTests(): Promise { + console.log('πŸš€ Starting SkillGenerate Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL SkillGenerate INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ SkillGenerate integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillGenerateIntegrationTests(); +} else { + module.exports = { runAllSkillGenerateIntegrationTests }; +} diff --git a/src/debug/jtag/commands/skill/generate/test/unit/SkillGenerateCommand.test.ts b/src/debug/jtag/commands/skill/generate/test/unit/SkillGenerateCommand.test.ts new file mode 100644 index 000000000..6aa6787c0 --- /dev/null +++ b/src/debug/jtag/commands/skill/generate/test/unit/SkillGenerateCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * SkillGenerate Command Unit Tests + * + * Tests Skill Generate command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Generate/test/unit/SkillGenerateCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { SkillGenerateParams, SkillGenerateResult } from '../../shared/SkillGenerateTypes'; + +console.log('πŸ§ͺ SkillGenerate Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Skill Generate logic for testing + */ +async function mockSkillGenerateCommand(params: SkillGenerateParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Skill Generate' or see the Skill Generate README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as SkillGenerateResult; +} + +/** + * Test 1: Command structure validation + */ +function testSkillGenerateCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: SkillGenerate command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Skill Generate command + const validParams: SkillGenerateParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockSkillGenerateExecution(): Promise { + console.log('\n⚑ Test 2: Mock Skill Generate command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: SkillGenerateParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockSkillGenerateCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testSkillGenerateRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as SkillGenerateParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as SkillGenerateParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockSkillGenerateCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testSkillGenerateOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: SkillGenerateParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockSkillGenerateCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: SkillGenerateParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockSkillGenerateCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testSkillGeneratePerformance(): Promise { + console.log('\n⚑ Test 5: SkillGenerate performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockSkillGenerateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillGenerateParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `SkillGenerate completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testSkillGenerateResultStructure(): Promise { + console.log('\nπŸ” Test 6: SkillGenerate result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockSkillGenerateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillGenerateParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllSkillGenerateUnitTests(): Promise { + console.log('πŸš€ Starting SkillGenerate Command Unit Tests\n'); + + try { + testSkillGenerateCommandStructure(); + await testMockSkillGenerateExecution(); + await testSkillGenerateRequiredParams(); + await testSkillGenerateOptionalParams(); + await testSkillGeneratePerformance(); + await testSkillGenerateResultStructure(); + + console.log('\nπŸŽ‰ ALL SkillGenerate UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ SkillGenerate unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillGenerateUnitTests(); +} else { + module.exports = { runAllSkillGenerateUnitTests }; +} diff --git a/src/debug/jtag/commands/skill/list/.npmignore b/src/debug/jtag/commands/skill/list/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/skill/list/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/skill/list/README.md b/src/debug/jtag/commands/skill/list/README.md new file mode 100644 index 000000000..a834ab17a --- /dev/null +++ b/src/debug/jtag/commands/skill/list/README.md @@ -0,0 +1,164 @@ +# Skill List Command + +List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag skill/list [options] +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('skill/list', { + // your parameters here +}); +``` + +## Parameters + +- **status** (optional): `string` - Filter by lifecycle status (proposed, approved, generated, validated, active, failed, deprecated) +- **scope** (optional): `string` - Filter by scope (personal, team) +- **createdById** (optional): `string` - Filter by creator persona ID +- **limit** (optional): `number` - Maximum results to return (default: 20) + +## Result + +Returns `SkillListResult` with: + +Returns CommandResult with: +- **skills**: `object` - Array of SkillEntity records matching the filter +- **total**: `number` - Total matching skills +- **message**: `string` - Human-readable result summary + +## Examples + +### List all active skills + +```bash +./jtag skill/list --status="active" +``` + +**Expected result:** +{ skills: [...], total: 5 } + +### List skills created by a specific persona + +```bash +./jtag skill/list --createdById="uuid-of-persona" --limit=10 +``` + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help skill/list +``` + +**Tool:** +```typescript +// Use your help tool with command name 'skill/list' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme skill/list +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'skill/list' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Skill List/test/unit/SkillListCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Skill List/test/integration/SkillListIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/SkillListTypes.ts` +- **Browser**: Browser-specific implementation in `browser/SkillListBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/SkillListServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/SkillListCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/SkillListIntegration.test.ts` diff --git a/src/debug/jtag/commands/skill/list/browser/SkillListBrowserCommand.ts b/src/debug/jtag/commands/skill/list/browser/SkillListBrowserCommand.ts new file mode 100644 index 000000000..81abe5da1 --- /dev/null +++ b/src/debug/jtag/commands/skill/list/browser/SkillListBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Skill List Command - Browser Implementation + * + * List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillListParams, SkillListResult } from '../shared/SkillListTypes'; + +export class SkillListBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/list', context, subpath, commander); + } + + async execute(params: SkillListParams): Promise { + console.log('🌐 BROWSER: Delegating Skill List to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/skill/list/package.json b/src/debug/jtag/commands/skill/list/package.json new file mode 100644 index 000000000..8c434441e --- /dev/null +++ b/src/debug/jtag/commands/skill/list/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/skill/list", + "version": "1.0.0", + "description": "List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database.", + "main": "server/SkillListServerCommand.ts", + "types": "shared/SkillListTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/SkillListIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "skill/list" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/skill/list/server/SkillListServerCommand.ts b/src/debug/jtag/commands/skill/list/server/SkillListServerCommand.ts new file mode 100644 index 000000000..bb437152e --- /dev/null +++ b/src/debug/jtag/commands/skill/list/server/SkillListServerCommand.ts @@ -0,0 +1,65 @@ +/** + * Skill List Command - Server Implementation + * + * Lists skills with optional filters by status, scope, and creator. + * Returns SkillEntity records from the database. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillListParams, SkillListResult } from '../shared/SkillListTypes'; +import { createSkillListResultFromParams } from '../shared/SkillListTypes'; +import { SkillEntity } from '@system/data/entities/SkillEntity'; +import { DataDaemon } from '@daemons/data-daemon/shared/DataDaemon'; +import type { UniversalFilter } from '@daemons/data-daemon/shared/DataStorageAdapter'; +import { COLLECTIONS } from '@system/shared/Constants'; + +export class SkillListServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/list', context, subpath, commander); + } + + async execute(params: SkillListParams): Promise { + // Build filter from optional params + const filter: UniversalFilter = {}; + + if (params.status?.trim()) { + filter.status = params.status; + } + if (params.scope?.trim()) { + filter.scope = params.scope; + } + if (params.createdById?.trim()) { + filter.createdById = params.createdById; + } + + const limit = params.limit ?? 20; + + const queryResult = await DataDaemon.query({ + collection: COLLECTIONS.SKILLS, + filter, + sort: [{ field: 'createdAt', direction: 'desc' }], + limit, + }); + + const skills = queryResult.success && queryResult.data + ? queryResult.data.map(record => record.data) + : []; + const total = skills.length; + + // Build human-readable summary + const filterDesc = Object.entries(filter) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + + return createSkillListResultFromParams(params, { + success: true, + skills, + total, + message: total > 0 + ? `Found ${total} skill${total !== 1 ? 's' : ''}${filterDesc ? ` (${filterDesc})` : ''}` + : `No skills found${filterDesc ? ` matching ${filterDesc}` : ''}`, + }); + } +} diff --git a/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts b/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts new file mode 100644 index 000000000..bff5df9d8 --- /dev/null +++ b/src/debug/jtag/commands/skill/list/shared/SkillListTypes.ts @@ -0,0 +1,110 @@ +/** + * Skill List Command - Shared Types + * + * List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Skill List Command Parameters + */ +export interface SkillListParams extends CommandParams { + // Filter by lifecycle status (proposed, approved, generated, validated, active, failed, deprecated) + status?: string; + // Filter by scope (personal, team) + scope?: string; + // Filter by creator persona ID + createdById?: string; + // Maximum results to return (default: 20) + limit?: number; +} + +/** + * Factory function for creating SkillListParams + */ +export const createSkillListParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Filter by lifecycle status (proposed, approved, generated, validated, active, failed, deprecated) + status?: string; + // Filter by scope (personal, team) + scope?: string; + // Filter by creator persona ID + createdById?: string; + // Maximum results to return (default: 20) + limit?: number; + } +): SkillListParams => createPayload(context, sessionId, { + status: data.status ?? '', + scope: data.scope ?? '', + createdById: data.createdById ?? '', + limit: data.limit ?? 0, + ...data +}); + +/** + * Skill List Command Result + */ +export interface SkillListResult extends CommandResult { + success: boolean; + // Array of SkillEntity records matching the filter + skills: Record[]; + // Total matching skills + total: number; + // Human-readable result summary + message: string; + error?: JTAGError; +} + +/** + * Factory function for creating SkillListResult with defaults + */ +export const createSkillListResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // Array of SkillEntity records matching the filter + skills?: Record[]; + // Total matching skills + total?: number; + // Human-readable result summary + message?: string; + error?: JTAGError; + } +): SkillListResult => createPayload(context, sessionId, { + skills: data.skills ?? [], + total: data.total ?? 0, + message: data.message ?? '', + ...data +}); + +/** + * Smart Skill List-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createSkillListResultFromParams = ( + params: SkillListParams, + differences: Omit +): SkillListResult => transformPayload(params, differences); + +/** + * Skill List β€” Type-safe command executor + * + * Usage: + * import { SkillList } from '...shared/SkillListTypes'; + * const result = await SkillList.execute({ ... }); + */ +export const SkillList = { + execute(params: CommandInput): Promise { + return Commands.execute('skill/list', params as Partial); + }, + commandName: 'skill/list' as const, +} as const; diff --git a/src/debug/jtag/commands/skill/list/test/integration/SkillListIntegration.test.ts b/src/debug/jtag/commands/skill/list/test/integration/SkillListIntegration.test.ts new file mode 100644 index 000000000..76008513e --- /dev/null +++ b/src/debug/jtag/commands/skill/list/test/integration/SkillListIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * SkillList Command Integration Tests + * + * Tests Skill List command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill List/test/integration/SkillListIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ SkillList Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Skill List command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Skill List command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Skill List']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Skill List returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Skill List succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Skill List']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Skill List']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Skill List']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Skill List']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Skill List']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllSkillListIntegrationTests(): Promise { + console.log('πŸš€ Starting SkillList Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL SkillList INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ SkillList integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillListIntegrationTests(); +} else { + module.exports = { runAllSkillListIntegrationTests }; +} diff --git a/src/debug/jtag/commands/skill/list/test/unit/SkillListCommand.test.ts b/src/debug/jtag/commands/skill/list/test/unit/SkillListCommand.test.ts new file mode 100644 index 000000000..6c375ddcf --- /dev/null +++ b/src/debug/jtag/commands/skill/list/test/unit/SkillListCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * SkillList Command Unit Tests + * + * Tests Skill List command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill List/test/unit/SkillListCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { SkillListParams, SkillListResult } from '../../shared/SkillListTypes'; + +console.log('πŸ§ͺ SkillList Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Skill List logic for testing + */ +async function mockSkillListCommand(params: SkillListParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Skill List' or see the Skill List README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as SkillListResult; +} + +/** + * Test 1: Command structure validation + */ +function testSkillListCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: SkillList command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Skill List command + const validParams: SkillListParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockSkillListExecution(): Promise { + console.log('\n⚑ Test 2: Mock Skill List command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: SkillListParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockSkillListCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testSkillListRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as SkillListParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as SkillListParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockSkillListCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testSkillListOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: SkillListParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockSkillListCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: SkillListParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockSkillListCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testSkillListPerformance(): Promise { + console.log('\n⚑ Test 5: SkillList performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockSkillListCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillListParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `SkillList completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testSkillListResultStructure(): Promise { + console.log('\nπŸ” Test 6: SkillList result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockSkillListCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillListParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllSkillListUnitTests(): Promise { + console.log('πŸš€ Starting SkillList Command Unit Tests\n'); + + try { + testSkillListCommandStructure(); + await testMockSkillListExecution(); + await testSkillListRequiredParams(); + await testSkillListOptionalParams(); + await testSkillListPerformance(); + await testSkillListResultStructure(); + + console.log('\nπŸŽ‰ ALL SkillList UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ SkillList unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillListUnitTests(); +} else { + module.exports = { runAllSkillListUnitTests }; +} diff --git a/src/debug/jtag/commands/skill/propose/.npmignore b/src/debug/jtag/commands/skill/propose/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/skill/propose/README.md b/src/debug/jtag/commands/skill/propose/README.md new file mode 100644 index 000000000..c3d948e5f --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/README.md @@ -0,0 +1,165 @@ +# Skill Propose Command + +Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag skill/propose --name= --description= --skillParams= --skillResults= --implementation= --personaId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('skill/propose', { + // your parameters here +}); +``` + +## Parameters + +- **name** (required): `string` - Command name (e.g., 'analysis/complexity') +- **description** (required): `string` - What the skill does +- **skillParams** (required): `object` - Input parameters spec array [{name, type, optional?, description?}] +- **skillResults** (required): `object` - Output fields spec array [{name, type, description?}] +- **implementation** (required): `string` - Natural language description of the implementation logic +- **scope** (optional): `string` - Who can use it: 'personal' (default) or 'team' (requires approval) +- **examples** (optional): `object` - Usage examples array [{description, command, expectedResult?}] +- **personaId** (required): `string` - AI persona proposing this skill + +## Result + +Returns `SkillProposeResult` with: + +Returns CommandResult with: +- **skillId**: `string` - ID of the created SkillEntity +- **name**: `string` - Skill command name +- **status**: `string` - Lifecycle status after proposal +- **scope**: `string` - Skill scope (personal or team) +- **proposalId**: `string` - DecisionProposal ID if team-scoped +- **message**: `string` - Human-readable result message + +## Examples + +### Propose a personal analysis skill + +```bash +./jtag skill/propose --name="analysis/complexity" --description="Analyze code complexity" --implementation="Count cyclomatic complexity per function" --personaId="ai-001" +``` + +**Expected result:** +{ skillId: "uuid", name: "analysis/complexity", status: "proposed", scope: "personal" } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help skill/propose +``` + +**Tool:** +```typescript +// Use your help tool with command name 'skill/propose' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme skill/propose +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'skill/propose' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Skill Propose/test/unit/SkillProposeCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Skill Propose/test/integration/SkillProposeIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/SkillProposeTypes.ts` +- **Browser**: Browser-specific implementation in `browser/SkillProposeBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/SkillProposeServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/SkillProposeCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/SkillProposeIntegration.test.ts` diff --git a/src/debug/jtag/commands/skill/propose/browser/SkillProposeBrowserCommand.ts b/src/debug/jtag/commands/skill/propose/browser/SkillProposeBrowserCommand.ts new file mode 100644 index 000000000..0f16ff84c --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/browser/SkillProposeBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Skill Propose Command - Browser Implementation + * + * Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillProposeParams, SkillProposeResult } from '../shared/SkillProposeTypes'; + +export class SkillProposeBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/propose', context, subpath, commander); + } + + async execute(params: SkillProposeParams): Promise { + console.log('🌐 BROWSER: Delegating Skill Propose to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/skill/propose/package.json b/src/debug/jtag/commands/skill/propose/package.json new file mode 100644 index 000000000..0aec7e005 --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/skill/propose", + "version": "1.0.0", + "description": "Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval.", + "main": "server/SkillProposeServerCommand.ts", + "types": "shared/SkillProposeTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/SkillProposeIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "skill/propose" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/skill/propose/server/SkillProposeServerCommand.ts b/src/debug/jtag/commands/skill/propose/server/SkillProposeServerCommand.ts new file mode 100644 index 000000000..c32c06290 --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/server/SkillProposeServerCommand.ts @@ -0,0 +1,128 @@ +/** + * Skill Propose Command - Server Implementation + * + * Creates a SkillEntity from an AI's proposed specification. + * For team-scoped skills, also creates a DecisionProposal for governance. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { SkillProposeParams, SkillProposeResult } from '../shared/SkillProposeTypes'; +import { createSkillProposeResultFromParams } from '../shared/SkillProposeTypes'; +import { SkillEntity } from '@system/data/entities/SkillEntity'; +import type { SkillSpec, SkillParamSpec, SkillResultSpec, SkillScope } from '@system/data/entities/SkillEntity'; +import { DataDaemon } from '@daemons/data-daemon/shared/DataDaemon'; +import { COLLECTIONS } from '@system/shared/Constants'; +import { DecisionPropose } from '@commands/collaboration/decision/propose/shared/DecisionProposeTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +export class SkillProposeServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/propose', context, subpath, commander); + } + + async execute(params: SkillProposeParams): Promise { + const { name, description, implementation, personaId } = params; + const scope: SkillScope = (params.scope === 'team' ? 'team' : 'personal'); + + if (!name?.trim()) { + throw new ValidationError('name', "Missing required parameter 'name'. Provide the command name (e.g., 'analysis/complexity')."); + } + if (!description?.trim()) { + throw new ValidationError('description', "Missing required parameter 'description'."); + } + if (!implementation?.trim()) { + throw new ValidationError('implementation', "Missing required parameter 'implementation'. Describe what the skill should do."); + } + if (!personaId?.trim()) { + throw new ValidationError('personaId', "Missing required parameter 'personaId'."); + } + + // Check for duplicate active skill + const existingResult = await DataDaemon.query({ + collection: COLLECTIONS.SKILLS, + filter: { name, status: 'active' }, + limit: 1, + }); + if (existingResult.success && existingResult.data && existingResult.data.length > 0) { + throw new ValidationError('name', `A skill named '${name}' is already active.`); + } + + // Build skill spec + const skillParams = Array.isArray(params.skillParams) ? params.skillParams as unknown as SkillParamSpec[] : []; + const skillResults = Array.isArray(params.skillResults) ? params.skillResults as unknown as SkillResultSpec[] : []; + const examples = Array.isArray(params.examples) + ? params.examples as Array<{ description: string; command: string; expectedResult?: string }> + : undefined; + + const spec: SkillSpec = { + name, + description, + params: skillParams, + results: skillResults, + examples, + implementation, + accessLevel: 'ai-safe', + }; + + // Create entity + const entity = new SkillEntity(); + entity.name = name; + entity.description = description; + entity.createdById = personaId as UUID; + entity.spec = spec; + entity.scope = scope; + entity.status = 'proposed'; + + const validation = entity.validate(); + if (!validation.success) { + throw new ValidationError('spec', validation.error ?? 'Skill validation failed'); + } + + // Persist + const stored = await DataDaemon.store(COLLECTIONS.SKILLS, entity); + + // For team-scoped skills, create a governance proposal via the decision/propose command + let proposalId = ''; + if (scope === 'team') { + try { + const proposalResult = await DecisionPropose.execute({ + topic: `New Skill Proposal: ${name}`, + rationale: `${description}\n\nImplementation: ${implementation}\n\nParams: ${JSON.stringify(spec.params)}\nResults: ${JSON.stringify(spec.results)}`, + options: [ + { label: 'Approve', description: `Approve skill '${name}' for team use` }, + { label: 'Request Changes', description: 'Suggest modifications before approval' }, + { label: 'Reject', description: 'Decline this skill proposal' }, + ], + scope: 'all', + significanceLevel: 'medium', + proposerId: personaId as UUID, + }); + proposalId = proposalResult.proposalId ?? ''; + if (proposalId) { + await DataDaemon.update( + COLLECTIONS.SKILLS, + stored.id, + { proposalId: proposalId as UUID } as Partial, + ); + } + } catch { + // Governance proposal is optional β€” skill still proceeds + } + } + + return createSkillProposeResultFromParams(params, { + success: true, + skillId: stored.id, + name: stored.name, + status: stored.status, + scope: stored.scope, + proposalId, + message: scope === 'team' + ? `Skill '${name}' proposed for team approval (proposal: ${proposalId || 'pending'})` + : `Skill '${name}' proposed β€” ready to generate`, + }); + } +} diff --git a/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts b/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts new file mode 100644 index 000000000..f7143b951 --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/shared/SkillProposeTypes.ts @@ -0,0 +1,139 @@ +/** + * Skill Propose Command - Shared Types + * + * Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Skill Propose Command Parameters + */ +export interface SkillProposeParams extends CommandParams { + // Command name (e.g., 'analysis/complexity') + name: string; + // What the skill does + description: string; + // Input parameters spec array [{name, type, optional?, description?}] + skillParams: Record[]; + // Output fields spec array [{name, type, description?}] + skillResults: Record[]; + // Natural language description of the implementation logic + implementation: string; + // Who can use it: 'personal' (default) or 'team' (requires approval) + scope?: string; + // Usage examples array [{description, command, expectedResult?}] + examples?: Record[]; + // AI persona proposing this skill + personaId: string; +} + +/** + * Factory function for creating SkillProposeParams + */ +export const createSkillProposeParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // Command name (e.g., 'analysis/complexity') + name: string; + // What the skill does + description: string; + // Input parameters spec array [{name, type, optional?, description?}] + skillParams: Record[]; + // Output fields spec array [{name, type, description?}] + skillResults: Record[]; + // Natural language description of the implementation logic + implementation: string; + // Who can use it: 'personal' (default) or 'team' (requires approval) + scope?: string; + // Usage examples array [{description, command, expectedResult?}] + examples?: Record[]; + // AI persona proposing this skill + personaId: string; + } +): SkillProposeParams => createPayload(context, sessionId, { + scope: data.scope ?? '', + examples: data.examples ?? undefined, + ...data +}); + +/** + * Skill Propose Command Result + */ +export interface SkillProposeResult extends CommandResult { + success: boolean; + // ID of the created SkillEntity + skillId: string; + // Skill command name + name: string; + // Lifecycle status after proposal + status: string; + // Skill scope (personal or team) + scope: string; + // DecisionProposal ID if team-scoped + proposalId: string; + // Human-readable result message + message: string; + error?: JTAGError; +} + +/** + * Factory function for creating SkillProposeResult with defaults + */ +export const createSkillProposeResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // ID of the created SkillEntity + skillId?: string; + // Skill command name + name?: string; + // Lifecycle status after proposal + status?: string; + // Skill scope (personal or team) + scope?: string; + // DecisionProposal ID if team-scoped + proposalId?: string; + // Human-readable result message + message?: string; + error?: JTAGError; + } +): SkillProposeResult => createPayload(context, sessionId, { + skillId: data.skillId ?? '', + name: data.name ?? '', + status: data.status ?? '', + scope: data.scope ?? '', + proposalId: data.proposalId ?? '', + message: data.message ?? '', + ...data +}); + +/** + * Smart Skill Propose-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createSkillProposeResultFromParams = ( + params: SkillProposeParams, + differences: Omit +): SkillProposeResult => transformPayload(params, differences); + +/** + * Skill Propose β€” Type-safe command executor + * + * Usage: + * import { SkillPropose } from '...shared/SkillProposeTypes'; + * const result = await SkillPropose.execute({ ... }); + */ +export const SkillPropose = { + execute(params: CommandInput): Promise { + return Commands.execute('skill/propose', params as Partial); + }, + commandName: 'skill/propose' as const, +} as const; diff --git a/src/debug/jtag/commands/skill/propose/test/integration/SkillProposeIntegration.test.ts b/src/debug/jtag/commands/skill/propose/test/integration/SkillProposeIntegration.test.ts new file mode 100644 index 000000000..f4e7f0024 --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/test/integration/SkillProposeIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * SkillPropose Command Integration Tests + * + * Tests Skill Propose command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Propose/test/integration/SkillProposeIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ SkillPropose Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Skill Propose command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Skill Propose command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Skill Propose']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Skill Propose returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Skill Propose succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Skill Propose']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Skill Propose']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Skill Propose']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Skill Propose']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Skill Propose']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllSkillProposeIntegrationTests(): Promise { + console.log('πŸš€ Starting SkillPropose Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL SkillPropose INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ SkillPropose integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillProposeIntegrationTests(); +} else { + module.exports = { runAllSkillProposeIntegrationTests }; +} diff --git a/src/debug/jtag/commands/skill/propose/test/unit/SkillProposeCommand.test.ts b/src/debug/jtag/commands/skill/propose/test/unit/SkillProposeCommand.test.ts new file mode 100644 index 000000000..86a33bf25 --- /dev/null +++ b/src/debug/jtag/commands/skill/propose/test/unit/SkillProposeCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * SkillPropose Command Unit Tests + * + * Tests Skill Propose command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Propose/test/unit/SkillProposeCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { SkillProposeParams, SkillProposeResult } from '../../shared/SkillProposeTypes'; + +console.log('πŸ§ͺ SkillPropose Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Skill Propose logic for testing + */ +async function mockSkillProposeCommand(params: SkillProposeParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Skill Propose' or see the Skill Propose README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as SkillProposeResult; +} + +/** + * Test 1: Command structure validation + */ +function testSkillProposeCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: SkillPropose command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Skill Propose command + const validParams: SkillProposeParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockSkillProposeExecution(): Promise { + console.log('\n⚑ Test 2: Mock Skill Propose command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: SkillProposeParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockSkillProposeCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testSkillProposeRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as SkillProposeParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as SkillProposeParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockSkillProposeCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testSkillProposeOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: SkillProposeParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockSkillProposeCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: SkillProposeParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockSkillProposeCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testSkillProposePerformance(): Promise { + console.log('\n⚑ Test 5: SkillPropose performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockSkillProposeCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillProposeParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `SkillPropose completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testSkillProposeResultStructure(): Promise { + console.log('\nπŸ” Test 6: SkillPropose result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockSkillProposeCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillProposeParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllSkillProposeUnitTests(): Promise { + console.log('πŸš€ Starting SkillPropose Command Unit Tests\n'); + + try { + testSkillProposeCommandStructure(); + await testMockSkillProposeExecution(); + await testSkillProposeRequiredParams(); + await testSkillProposeOptionalParams(); + await testSkillProposePerformance(); + await testSkillProposeResultStructure(); + + console.log('\nπŸŽ‰ ALL SkillPropose UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ SkillPropose unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillProposeUnitTests(); +} else { + module.exports = { runAllSkillProposeUnitTests }; +} diff --git a/src/debug/jtag/commands/skill/validate/.npmignore b/src/debug/jtag/commands/skill/validate/.npmignore new file mode 100644 index 000000000..f74ad6b8a --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/.npmignore @@ -0,0 +1,20 @@ +# Development files +.eslintrc* +tsconfig*.json +vitest.config.ts + +# Build artifacts +*.js.map +*.d.ts.map + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db diff --git a/src/debug/jtag/commands/skill/validate/README.md b/src/debug/jtag/commands/skill/validate/README.md new file mode 100644 index 000000000..1946c01a6 --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/README.md @@ -0,0 +1,160 @@ +# Skill Validate Command + +Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results. + +## Table of Contents + +- [Usage](#usage) + - [CLI Usage](#cli-usage) + - [Tool Usage](#tool-usage) +- [Parameters](#parameters) +- [Result](#result) +- [Examples](#examples) +- [Testing](#testing) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) +- [Getting Help](#getting-help) +- [Access Level](#access-level) +- [Implementation Notes](#implementation-notes) + +## Usage + +### CLI Usage + +From the command line using the jtag CLI: + +```bash +./jtag skill/validate --skillId= +``` + +### Tool Usage + +From Persona tools or programmatic access using `Commands.execute()`: + +```typescript +import { Commands } from '@system/core/shared/Commands'; + +const result = await Commands.execute('skill/validate', { + // your parameters here +}); +``` + +## Parameters + +- **skillId** (required): `string` - ID of the SkillEntity to validate + +## Result + +Returns `SkillValidateResult` with: + +Returns CommandResult with: +- **skillId**: `string` - ID of the SkillEntity +- **name**: `string` - Skill command name +- **status**: `string` - Lifecycle status after validation +- **compiled**: `boolean` - Whether TypeScript compilation succeeded +- **testsRun**: `number` - Number of tests executed +- **testsPassed**: `number` - Number of tests that passed +- **errors**: `object` - Array of error messages from compilation or tests +- **message**: `string` - Human-readable result message + +## Examples + +### Validate a generated skill + +```bash +./jtag skill/validate --skillId="uuid-of-skill" +``` + +**Expected result:** +{ compiled: true, testsRun: 3, testsPassed: 3, status: "validated" } + +## Getting Help + +### Using the Help Tool + +Get detailed usage information for this command: + +**CLI:** +```bash +./jtag help skill/validate +``` + +**Tool:** +```typescript +// Use your help tool with command name 'skill/validate' +``` + +### Using the README Tool + +Access this README programmatically: + +**CLI:** +```bash +./jtag readme skill/validate +``` + +**Tool:** +```typescript +// Use your readme tool with command name 'skill/validate' +``` + +## Testing + +### Unit Tests + +Test command logic in isolation using mock dependencies: + +```bash +# Run unit tests (no server required) +npx tsx commands/Skill Validate/test/unit/SkillValidateCommand.test.ts +``` + +**What's tested:** +- Command structure and parameter validation +- Mock command execution patterns +- Required parameter validation (throws ValidationError) +- Optional parameter handling (sensible defaults) +- Performance requirements +- Assertion utility helpers + +**TDD Workflow:** +1. Write/modify unit test first (test-driven development) +2. Run test, see it fail +3. Implement feature +4. Run test, see it pass +5. Refactor if needed + +### Integration Tests + +Test command with real client connections and system integration: + +```bash +# Prerequisites: Server must be running +npm start # Wait 90+ seconds for deployment + +# Run integration tests +npx tsx commands/Skill Validate/test/integration/SkillValidateIntegration.test.ts +``` + +**What's tested:** +- Client connection to live system +- Real command execution via WebSocket +- ValidationError handling for missing params +- Optional parameter defaults +- Performance under load +- Various parameter combinations + +**Best Practice:** +Run unit tests frequently during development (fast feedback). Run integration tests before committing (verify system integration). + +## Access Level + +**ai-safe** - Safe for AI personas to call autonomously + +## Implementation Notes + +- **Shared Logic**: Core business logic in `shared/SkillValidateTypes.ts` +- **Browser**: Browser-specific implementation in `browser/SkillValidateBrowserCommand.ts` +- **Server**: Server-specific implementation in `server/SkillValidateServerCommand.ts` +- **Unit Tests**: Isolated testing in `test/unit/SkillValidateCommand.test.ts` +- **Integration Tests**: System testing in `test/integration/SkillValidateIntegration.test.ts` diff --git a/src/debug/jtag/commands/skill/validate/browser/SkillValidateBrowserCommand.ts b/src/debug/jtag/commands/skill/validate/browser/SkillValidateBrowserCommand.ts new file mode 100644 index 000000000..31c2a9872 --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/browser/SkillValidateBrowserCommand.ts @@ -0,0 +1,21 @@ +/** + * Skill Validate Command - Browser Implementation + * + * Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { SkillValidateParams, SkillValidateResult } from '../shared/SkillValidateTypes'; + +export class SkillValidateBrowserCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/validate', context, subpath, commander); + } + + async execute(params: SkillValidateParams): Promise { + console.log('🌐 BROWSER: Delegating Skill Validate to server'); + return await this.remoteExecute(params); + } +} diff --git a/src/debug/jtag/commands/skill/validate/package.json b/src/debug/jtag/commands/skill/validate/package.json new file mode 100644 index 000000000..9ee02105f --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/package.json @@ -0,0 +1,35 @@ +{ + "name": "@jtag-commands/skill/validate", + "version": "1.0.0", + "description": "Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results.", + "main": "server/SkillValidateServerCommand.ts", + "types": "shared/SkillValidateTypes.ts", + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "npx vitest run test/unit/*.test.ts", + "test:integration": "npx tsx test/integration/SkillValidateIntegration.test.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "skill/validate" + ], + "license": "MIT", + "author": "", + "repository": { + "type": "git", + "url": "" + } +} diff --git a/src/debug/jtag/commands/skill/validate/server/SkillValidateServerCommand.ts b/src/debug/jtag/commands/skill/validate/server/SkillValidateServerCommand.ts new file mode 100644 index 000000000..c0317c914 --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/server/SkillValidateServerCommand.ts @@ -0,0 +1,153 @@ +/** + * Skill Validate Command - Server Implementation + * + * Validates a generated skill by running TypeScript compilation and tests + * in an ExecutionSandbox. Updates SkillEntity with validation results. + */ + +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import type { SkillValidateParams, SkillValidateResult } from '../shared/SkillValidateTypes'; +import { createSkillValidateResultFromParams } from '../shared/SkillValidateTypes'; +import { SkillEntity } from '@system/data/entities/SkillEntity'; +import type { SkillValidationResults } from '@system/data/entities/SkillEntity'; +import { DataDaemon } from '@daemons/data-daemon/shared/DataDaemon'; +import { COLLECTIONS } from '@system/shared/Constants'; +import { ExecutionSandbox } from '@system/code/server/ExecutionSandbox'; +import type { SandboxConfig } from '@system/code/server/ExecutionSandbox'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +export class SkillValidateServerCommand extends CommandBase { + + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('skill/validate', context, subpath, commander); + } + + async execute(params: SkillValidateParams): Promise { + const { skillId } = params; + + if (!skillId?.trim()) { + throw new ValidationError('skillId', "Missing required parameter 'skillId'."); + } + + // Load skill entity + const skill = await DataDaemon.read(COLLECTIONS.SKILLS, skillId as UUID); + if (!skill) { + throw new ValidationError('skillId', `Skill not found: ${skillId}`); + } + + if (skill.status !== 'generated') { + throw new ValidationError('skillId', + `Skill '${skill.name}' cannot be validated in status '${skill.status}'. Must be 'generated' first.`); + } + + if (!skill.outputDir) { + throw new ValidationError('skillId', `Skill '${skill.name}' has no outputDir β€” was it generated?`); + } + + const sandbox = new ExecutionSandbox(); + const errors: string[] = []; + const startTime = Date.now(); + + // Step 1: TypeScript compilation check + const compileConfig: SandboxConfig = { + command: 'npx', + args: ['tsc', '--noEmit', '--pretty', '--project', 'tsconfig.json'], + cwd: skill.outputDir, + timeoutMs: 30_000, + maxOutputBytes: 100_000, + personaId: skill.createdById, + }; + + let compiled = false; + try { + const compileResult = await sandbox.execute(compileConfig); + compiled = compileResult.exitCode === 0; + if (!compiled) { + errors.push(`Compilation failed (exit ${compileResult.exitCode}): ${compileResult.stderr || compileResult.stdout}`); + } + } catch (e) { + errors.push(`Compilation error: ${e instanceof Error ? e.message : String(e)}`); + } + + // Step 2: Run tests (only if compilation passed) + let testsRun = 0; + let testsPassed = 0; + + if (compiled) { + const testConfig: SandboxConfig = { + command: 'npx', + args: ['vitest', 'run', '--reporter=json'], + cwd: skill.outputDir, + timeoutMs: 60_000, + maxOutputBytes: 100_000, + personaId: skill.createdById, + }; + + try { + const testResult = await sandbox.execute(testConfig); + // Parse vitest JSON output + try { + const output = testResult.stdout; + const jsonMatch = output.match(/\{[\s\S]*"numTotalTests"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + testsRun = parsed.numTotalTests ?? 0; + testsPassed = parsed.numPassedTests ?? 0; + } + } catch { + // If JSON parsing fails, count from exit code + testsRun = testResult.exitCode === 0 ? 1 : 0; + testsPassed = testResult.exitCode === 0 ? 1 : 0; + } + + if (testResult.exitCode !== 0) { + errors.push(`Tests failed (exit ${testResult.exitCode}): ${testResult.stderr || testResult.stdout}`); + } + } catch (e) { + errors.push(`Test execution error: ${e instanceof Error ? e.message : String(e)}`); + } + } + + const durationMs = Date.now() - startTime; + const passed = compiled && errors.length === 0; + + // Build validation results + const validationResults: SkillValidationResults = { + compiled, + testsRun, + testsPassed, + errors, + durationMs, + }; + + // Update entity + const updateData: Partial = { + validationResults, + status: passed ? 'validated' : 'failed', + }; + if (!passed) { + updateData.failureReason = errors.join('; '); + } + await DataDaemon.update( + COLLECTIONS.SKILLS, + skill.id as UUID, + updateData, + ); + + return createSkillValidateResultFromParams(params, { + success: passed, + skillId: skill.id, + name: skill.name, + status: passed ? 'validated' : 'failed', + compiled, + testsRun, + testsPassed, + errors, + message: passed + ? `Skill '${skill.name}' validated: compiled + ${testsPassed}/${testsRun} tests passed (${durationMs}ms)` + : `Skill '${skill.name}' validation failed: ${errors[0] ?? 'unknown error'}`, + }); + } +} diff --git a/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts b/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts new file mode 100644 index 000000000..0da799725 --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/shared/SkillValidateTypes.ts @@ -0,0 +1,120 @@ +/** + * Skill Validate Command - Shared Types + * + * Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import { Commands } from '@system/core/shared/Commands'; +import type { JTAGError } from '@system/core/types/ErrorTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; + +/** + * Skill Validate Command Parameters + */ +export interface SkillValidateParams extends CommandParams { + // ID of the SkillEntity to validate + skillId: string; +} + +/** + * Factory function for creating SkillValidateParams + */ +export const createSkillValidateParams = ( + context: JTAGContext, + sessionId: UUID, + data: { + // ID of the SkillEntity to validate + skillId: string; + } +): SkillValidateParams => createPayload(context, sessionId, { + + ...data +}); + +/** + * Skill Validate Command Result + */ +export interface SkillValidateResult extends CommandResult { + success: boolean; + // ID of the SkillEntity + skillId: string; + // Skill command name + name: string; + // Lifecycle status after validation + status: string; + // Whether TypeScript compilation succeeded + compiled: boolean; + // Number of tests executed + testsRun: number; + // Number of tests that passed + testsPassed: number; + // Array of error messages from compilation or tests + errors: string[]; + // Human-readable result message + message: string; + error?: JTAGError; +} + +/** + * Factory function for creating SkillValidateResult with defaults + */ +export const createSkillValidateResult = ( + context: JTAGContext, + sessionId: UUID, + data: { + success: boolean; + // ID of the SkillEntity + skillId?: string; + // Skill command name + name?: string; + // Lifecycle status after validation + status?: string; + // Whether TypeScript compilation succeeded + compiled?: boolean; + // Number of tests executed + testsRun?: number; + // Number of tests that passed + testsPassed?: number; + // Array of error messages from compilation or tests + errors?: string[]; + // Human-readable result message + message?: string; + error?: JTAGError; + } +): SkillValidateResult => createPayload(context, sessionId, { + skillId: data.skillId ?? '', + name: data.name ?? '', + status: data.status ?? '', + compiled: data.compiled ?? false, + testsRun: data.testsRun ?? 0, + testsPassed: data.testsPassed ?? 0, + errors: data.errors ?? [], + message: data.message ?? '', + ...data +}); + +/** + * Smart Skill Validate-specific inheritance from params + * Auto-inherits context and sessionId from params + * Must provide all required result fields + */ +export const createSkillValidateResultFromParams = ( + params: SkillValidateParams, + differences: Omit +): SkillValidateResult => transformPayload(params, differences); + +/** + * Skill Validate β€” Type-safe command executor + * + * Usage: + * import { SkillValidate } from '...shared/SkillValidateTypes'; + * const result = await SkillValidate.execute({ ... }); + */ +export const SkillValidate = { + execute(params: CommandInput): Promise { + return Commands.execute('skill/validate', params as Partial); + }, + commandName: 'skill/validate' as const, +} as const; diff --git a/src/debug/jtag/commands/skill/validate/test/integration/SkillValidateIntegration.test.ts b/src/debug/jtag/commands/skill/validate/test/integration/SkillValidateIntegration.test.ts new file mode 100644 index 000000000..f7b19f38c --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/test/integration/SkillValidateIntegration.test.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * SkillValidate Command Integration Tests + * + * Tests Skill Validate command against the LIVE RUNNING SYSTEM. + * This is NOT a mock test - it tests real commands, real events, real widgets. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Validate/test/integration/SkillValidateIntegration.test.ts + * + * PREREQUISITES: + * - Server must be running: npm start (wait 90+ seconds) + * - Browser client connected via http://localhost:9003 + */ + +import { jtag } from '@server/server-index'; + +console.log('πŸ§ͺ SkillValidate Command Integration Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Test 1: Connect to live system + */ +async function testSystemConnection(): Promise>> { + console.log('\nπŸ”Œ Test 1: Connecting to live JTAG system'); + + const client = await jtag.connect(); + + assert(client !== null, 'Connected to live system'); + console.log(' βœ… Connected successfully'); + + return client; +} + +/** + * Test 2: Execute Skill Validate command on live system + */ +async function testCommandExecution(client: Awaited>): Promise { + console.log('\n⚑ Test 2: Executing Skill Validate command'); + + // TODO: Replace with your actual command parameters + const result = await client.commands['Skill Validate']({ + // Add your required parameters here + // Example: name: 'test-value' + }); + + console.log(' πŸ“Š Result:', JSON.stringify(result, null, 2)); + + assert(result !== null, 'Skill Validate returned result'); + // TODO: Add assertions for your specific result fields + // assert(result.success === true, 'Skill Validate succeeded'); + // assert(result.yourField !== undefined, 'Result has yourField'); +} + +/** + * Test 3: Validate required parameters + */ +async function testRequiredParameters(_client: Awaited>): Promise { + console.log('\n🚨 Test 3: Testing required parameter validation'); + + // TODO: Uncomment and test missing required parameters + // try { + // await _client.commands['Skill Validate']({ + // // Missing required param + // }); + // assert(false, 'Should have thrown validation error'); + // } catch (error) { + // assert((error as Error).message.includes('required'), 'Error mentions required parameter'); + // console.log(' βœ… ValidationError thrown correctly'); + // } + + console.log(' ⚠️ TODO: Add required parameter validation test'); +} + +/** + * Test 4: Test optional parameters + */ +async function testOptionalParameters(_client: Awaited>): Promise { + console.log('\nπŸ”§ Test 4: Testing optional parameters'); + + // TODO: Uncomment to test with and without optional parameters + // const withOptional = await client.commands['Skill Validate']({ + // requiredParam: 'test', + // optionalParam: true + // }); + // + // const withoutOptional = await client.commands['Skill Validate']({ + // requiredParam: 'test' + // }); + // + // assert(withOptional.success === true, 'Works with optional params'); + // assert(withoutOptional.success === true, 'Works without optional params'); + + console.log(' ⚠️ TODO: Add optional parameter tests'); +} + +/** + * Test 5: Performance test + */ +async function testPerformance(_client: Awaited>): Promise { + console.log('\n⚑ Test 5: Performance under load'); + + // TODO: Uncomment to test command performance + // const iterations = 10; + // const times: number[] = []; + // + // for (let i = 0; i < iterations; i++) { + // const start = Date.now(); + // await _client.commands['Skill Validate']({ /* params */ }); + // times.push(Date.now() - start); + // } + // + // const avg = times.reduce((a, b) => a + b, 0) / iterations; + // const max = Math.max(...times); + // + // console.log(` Average: ${avg.toFixed(2)}ms`); + // console.log(` Max: ${max}ms`); + // + // assert(avg < 500, `Average ${avg.toFixed(2)}ms under 500ms`); + // assert(max < 1000, `Max ${max}ms under 1000ms`); + + console.log(' ⚠️ TODO: Add performance test'); +} + +/** + * Test 6: Widget/Event integration (if applicable) + */ +async function testWidgetIntegration(_client: Awaited>): Promise { + console.log('\n🎨 Test 6: Widget/Event integration'); + + // TODO: Uncomment if your command emits events or updates widgets + // Example: + // const before = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // await client.commands['Skill Validate']({ /* params */ }); + // await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for event propagation + // const after = await client.commands['debug/widget-state']({ widgetSelector: 'your-widget' }); + // + // assert(after.state.someValue !== before.state.someValue, 'Widget state updated'); + + console.log(' ⚠️ TODO: Add widget/event integration test (if applicable)'); +} + +/** + * Run all integration tests + */ +async function runAllSkillValidateIntegrationTests(): Promise { + console.log('πŸš€ Starting SkillValidate Integration Tests\n'); + console.log('πŸ“‹ Testing against LIVE system (not mocks)\n'); + + try { + const client = await testSystemConnection(); + await testCommandExecution(client); + await testRequiredParameters(client); + await testOptionalParameters(client); + await testPerformance(client); + await testWidgetIntegration(client); + + console.log('\nπŸŽ‰ ALL SkillValidate INTEGRATION TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Live system connection'); + console.log(' βœ… Command execution on real system'); + console.log(' βœ… Parameter validation'); + console.log(' βœ… Optional parameter handling'); + console.log(' βœ… Performance benchmarks'); + console.log(' βœ… Widget/Event integration'); + console.log('\nπŸ’‘ NOTE: This test uses the REAL running system'); + console.log(' - Real database operations'); + console.log(' - Real event propagation'); + console.log(' - Real widget updates'); + console.log(' - Real cross-daemon communication'); + + } catch (error) { + console.error('\n❌ SkillValidate integration tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + console.error('\nπŸ’‘ Make sure:'); + console.error(' 1. Server is running: npm start'); + console.error(' 2. Wait 90+ seconds for deployment'); + console.error(' 3. Browser is connected to http://localhost:9003'); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillValidateIntegrationTests(); +} else { + module.exports = { runAllSkillValidateIntegrationTests }; +} diff --git a/src/debug/jtag/commands/skill/validate/test/unit/SkillValidateCommand.test.ts b/src/debug/jtag/commands/skill/validate/test/unit/SkillValidateCommand.test.ts new file mode 100644 index 000000000..ac525136c --- /dev/null +++ b/src/debug/jtag/commands/skill/validate/test/unit/SkillValidateCommand.test.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env tsx +/** + * SkillValidate Command Unit Tests + * + * Tests Skill Validate command logic in isolation using mock dependencies. + * This is a REFERENCE EXAMPLE showing best practices for command testing. + * + * Generated by: ./jtag generate + * Run with: npx tsx commands/Skill Validate/test/unit/SkillValidateCommand.test.ts + * + * NOTE: This is a self-contained test (no external test utilities needed). + * Use this as a template for your own command tests. + */ + +// import { ValidationError } from '@system/core/types/ErrorTypes'; // Uncomment when adding validation tests +import { generateUUID } from '@system/core/types/CrossPlatformUUID'; +import type { SkillValidateParams, SkillValidateResult } from '../../shared/SkillValidateTypes'; + +console.log('πŸ§ͺ SkillValidate Command Unit Tests'); + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`❌ Assertion failed: ${message}`); + } + console.log(`βœ… ${message}`); +} + +/** + * Mock command that implements Skill Validate logic for testing + */ +async function mockSkillValidateCommand(params: SkillValidateParams): Promise { + // TODO: Validate required parameters (BEST PRACTICE) + // Example: + // if (!params.requiredParam || params.requiredParam.trim() === '') { + // throw new ValidationError( + // 'requiredParam', + // `Missing required parameter 'requiredParam'. ` + + // `Use the help tool with 'Skill Validate' or see the Skill Validate README for usage information.` + // ); + // } + + // TODO: Handle optional parameters with sensible defaults + // const optionalParam = params.optionalParam ?? defaultValue; + + // TODO: Implement your command logic here + return { + success: true, + // TODO: Add your result fields with actual computed values + context: params.context, + sessionId: params.sessionId + } as SkillValidateResult; +} + +/** + * Test 1: Command structure validation + */ +function testSkillValidateCommandStructure(): void { + console.log('\nπŸ“‹ Test 1: SkillValidate command structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Create valid params for Skill Validate command + const validParams: SkillValidateParams = { + // TODO: Add your required parameters here + context, + sessionId + }; + + // Validate param structure + assert(validParams.context !== undefined, 'Params have context'); + assert(validParams.sessionId !== undefined, 'Params have sessionId'); + // TODO: Add assertions for your specific parameters + // assert(typeof validParams.requiredParam === 'string', 'requiredParam is string'); +} + +/** + * Test 2: Mock command execution + */ +async function testMockSkillValidateExecution(): Promise { + console.log('\n⚑ Test 2: Mock Skill Validate command execution'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test mock execution + const params: SkillValidateParams = { + // TODO: Add your parameters here + context, + sessionId + }; + + const result = await mockSkillValidateCommand(params); + + // Validate result structure + assert(result.success === true, 'Mock result shows success'); + // TODO: Add assertions for your result fields + // assert(typeof result.yourField === 'string', 'yourField is string'); +} + +/** + * Test 3: Required parameter validation (CRITICAL) + * + * This test ensures your command throws ValidationError + * when required parameters are missing (BEST PRACTICE) + */ +async function testSkillValidateRequiredParams(): Promise { + console.log('\n🚨 Test 3: Required parameter validation'); + + // TODO: Uncomment when implementing validation + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test cases that should throw ValidationError + // Example: + // const testCases = [ + // { params: {} as SkillValidateParams, desc: 'Missing requiredParam' }, + // { params: { requiredParam: '' } as SkillValidateParams, desc: 'Empty requiredParam' }, + // ]; + // + // for (const testCase of testCases) { + // try { + // await mockSkillValidateCommand({ ...testCase.params, context, sessionId }); + // throw new Error(`Should have thrown ValidationError for: ${testCase.desc}`); + // } catch (error) { + // if (error instanceof ValidationError) { + // assert(error.field === 'requiredParam', `ValidationError field is 'requiredParam' for: ${testCase.desc}`); + // assert(error.message.includes('required parameter'), `Error message mentions 'required parameter' for: ${testCase.desc}`); + // assert(error.message.includes('help tool'), `Error message is tool-agnostic for: ${testCase.desc}`); + // } else { + // throw error; // Re-throw if not ValidationError + // } + // } + // } + + console.log('βœ… All required parameter validations work correctly'); +} + +/** + * Test 4: Optional parameter handling + */ +async function testSkillValidateOptionalParams(): Promise { + console.log('\nπŸ”§ Test 4: Optional parameter handling'); + + // TODO: Uncomment when implementing optional param tests + // const context = { environment: 'server' as const }; + // const sessionId = generateUUID(); + + // TODO: Test WITHOUT optional param (should use default) + // const paramsWithoutOptional: SkillValidateParams = { + // requiredParam: 'test', + // context, + // sessionId + // }; + // + // const resultWithoutOptional = await mockSkillValidateCommand(paramsWithoutOptional); + // assert(resultWithoutOptional.success === true, 'Command succeeds without optional params'); + + // TODO: Test WITH optional param + // const paramsWithOptional: SkillValidateParams = { + // requiredParam: 'test', + // optionalParam: true, + // context, + // sessionId + // }; + // + // const resultWithOptional = await mockSkillValidateCommand(paramsWithOptional); + // assert(resultWithOptional.success === true, 'Command succeeds with optional params'); + + console.log('βœ… Optional parameter handling validated'); +} + +/** + * Test 5: Performance validation + */ +async function testSkillValidatePerformance(): Promise { + console.log('\n⚑ Test 5: SkillValidate performance validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + const startTime = Date.now(); + + await mockSkillValidateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillValidateParams); + + const executionTime = Date.now() - startTime; + + assert(executionTime < 100, `SkillValidate completed in ${executionTime}ms (under 100ms limit)`); +} + +/** + * Test 6: Result structure validation + */ +async function testSkillValidateResultStructure(): Promise { + console.log('\nπŸ” Test 6: SkillValidate result structure validation'); + + const context = { environment: 'server' as const }; + const sessionId = generateUUID(); + + // Test various scenarios + const basicResult = await mockSkillValidateCommand({ + // TODO: Add your parameters + context, + sessionId + } as SkillValidateParams); + + assert(basicResult.success === true, 'Result has success field'); + // TODO: Add assertions for your result fields + // assert(typeof basicResult.yourField === 'string', 'Result has yourField (string)'); + assert(basicResult.context === context, 'Result includes context'); + assert(basicResult.sessionId === sessionId, 'Result includes sessionId'); + + console.log('βœ… All result structure validations pass'); +} + +/** + * Run all unit tests + */ +async function runAllSkillValidateUnitTests(): Promise { + console.log('πŸš€ Starting SkillValidate Command Unit Tests\n'); + + try { + testSkillValidateCommandStructure(); + await testMockSkillValidateExecution(); + await testSkillValidateRequiredParams(); + await testSkillValidateOptionalParams(); + await testSkillValidatePerformance(); + await testSkillValidateResultStructure(); + + console.log('\nπŸŽ‰ ALL SkillValidate UNIT TESTS PASSED!'); + console.log('πŸ“‹ Validated:'); + console.log(' βœ… Command structure and parameter validation'); + console.log(' βœ… Mock command execution patterns'); + console.log(' βœ… Required parameter validation (throws ValidationError)'); + console.log(' βœ… Optional parameter handling (sensible defaults)'); + console.log(' βœ… Performance requirements (< 100ms)'); + console.log(' βœ… Result structure validation'); + console.log('\nπŸ“ This is a REFERENCE EXAMPLE - use as a template for your commands!'); + console.log('πŸ’‘ TIP: Copy this test structure and modify for your command logic'); + + } catch (error) { + console.error('\n❌ SkillValidate unit tests failed:', (error as Error).message); + if ((error as Error).stack) { + console.error((error as Error).stack); + } + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + void runAllSkillValidateUnitTests(); +} else { + module.exports = { runAllSkillValidateUnitTests }; +} diff --git a/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts b/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts index 0407817ff..420a34886 100644 --- a/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts +++ b/src/debug/jtag/commands/user/create/shared/UserCreateTypes.ts @@ -46,6 +46,7 @@ export interface ModelConfig { readonly promptFormat?: PromptFormat; // How this model expects prompts formatted readonly requiresExplicitMention?: boolean; // If true, persona only responds when explicitly mentioned (e.g., @sentinel) readonly ragCertified?: boolean; // Has this model been tested/certified with our complex RAG system? + readonly toolCapability?: 'native' | 'xml' | 'none'; // Override provider-based tool capability detection } /** diff --git a/src/debug/jtag/commands/workspace/git/workspace/init/server/GitWorkspaceInitServerCommand.ts b/src/debug/jtag/commands/workspace/git/workspace/init/server/GitWorkspaceInitServerCommand.ts index 7ea84b3fb..6e26122d2 100644 --- a/src/debug/jtag/commands/workspace/git/workspace/init/server/GitWorkspaceInitServerCommand.ts +++ b/src/debug/jtag/commands/workspace/git/workspace/init/server/GitWorkspaceInitServerCommand.ts @@ -84,8 +84,25 @@ export class GitWorkspaceInitServerCommand extends CommandBase typeof m.content !== 'string').length} multimodal, ${hasNativeTools ? `${request.tools!.length} native tools` : 'no native tools'}`); // Convert messages to Anthropic format using MediaContentFormatter + // Handles text, multimodal, tool_use, and tool_result content blocks const messages = request.messages.map((msg, index) => { - const isMultimodal = typeof msg.content !== 'string'; - this.log(request, 'debug', `πŸ“Έ [ANTHROPIC-ADAPTER] Message ${index}: ${msg.role}, ${isMultimodal ? 'MULTIMODAL' : 'text-only'}`); + const role = msg.role === 'assistant' ? 'assistant' as const : 'user' as const; + if (typeof msg.content === 'string') { + this.log(request, 'debug', `πŸ“Έ [ANTHROPIC-ADAPTER] Message ${index}: ${role}, text-only`); + return { role, content: msg.content }; + } + + // Check for tool_use or tool_result content blocks + const parts = msg.content as ContentPart[]; + const hasToolBlocks = parts.some(p => p.type === 'tool_use' || p.type === 'tool_result'); + + if (hasToolBlocks) { + // Convert our ContentPart tool blocks to Anthropic's native format + const anthropicContent = parts.map(part => { + if (part.type === 'tool_use') { + return { type: 'tool_use' as const, id: part.id, name: part.name, input: part.input }; + } + if (part.type === 'tool_result') { + return { type: 'tool_result' as const, tool_use_id: part.tool_use_id, content: part.content, ...(part.is_error && { is_error: true }) }; + } + if (part.type === 'text') { + return { type: 'text' as const, text: part.text }; + } + // Other types (image, audio, video) β€” pass through MediaContentFormatter + return null; + }).filter(Boolean); + + this.log(request, 'debug', `πŸ“Έ [ANTHROPIC-ADAPTER] Message ${index}: ${role}, ${anthropicContent.length} blocks (tool protocol)`); + return { role, content: anthropicContent }; + } + + // Standard multimodal content + this.log(request, 'debug', `πŸ“Έ [ANTHROPIC-ADAPTER] Message ${index}: ${role}, MULTIMODAL`); return { - role: msg.role === 'assistant' ? 'assistant' : 'user', - content: typeof msg.content === 'string' - ? msg.content - : MediaContentFormatter.formatForAnthropic(msg.content), + role, + content: MediaContentFormatter.formatForAnthropic(parts), }; }); @@ -148,19 +178,27 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { const responseTime = Date.now() - startTime; // Parse response - handle both text and tool_use content blocks + // Build both flat text AND structured content blocks for the canonical agent loop let text = ''; const toolCalls: ToolCall[] = []; + const contentBlocks: ContentPart[] = []; for (const block of response.content || []) { if (block.type === 'text') { text += block.text; + contentBlocks.push({ type: 'text', text: block.text }); } else if (block.type === 'tool_use') { - // Native tool call from Claude toolCalls.push({ id: block.id, name: block.name, input: block.input || {}, }); + contentBlocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input || {}, + }); this.log(request, 'debug', `πŸ”§ [ANTHROPIC-ADAPTER] Native tool call: ${block.name} (id: ${block.id})`); } } @@ -169,6 +207,7 @@ export class AnthropicAdapter extends BaseAIProviderAdapter { return { text, + content: contentBlocks, finishReason: this.mapFinishReason(response.stop_reason), model: response.model || model, provider: this.providerId, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts index 9bafb3b0c..aa4c05106 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/candle-grpc/shared/CandleGrpcAdapter.ts @@ -10,12 +10,9 @@ * If it fails, it fails loudly. No "protective" nonsense. * * LOGGING: - * - Per-persona: writes to persona's adapters.log when personaContext available - * - Fallback: console.log for requests without personaContext + * Uses base class log() which routes through Logger.ts (async, respects levels) */ -import * as fs from 'fs'; -import * as path from 'path'; import { generateUUID } from '../../../../../system/core/types/CrossPlatformUUID'; import { BaseAIProviderAdapter } from '../../../shared/BaseAIProviderAdapter'; import type { @@ -42,26 +39,6 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { this.baseTimeout = 300000; // 5 minutes - let it complete } - /** - * Log to persona's adapters.log if personaContext available, else console - */ - private logToPersona(logDir: string | undefined, message: string): void { - const timestamp = new Date().toISOString(); - const logLine = `[${timestamp}] ${message}\n`; - - if (logDir) { - try { - const logFile = path.join(logDir, 'adapters.log'); - fs.appendFileSync(logFile, logLine); - } catch { - // Fallback to console if file write fails - console.log(`[CandleGrpcAdapter] ${message}`); - } - } else { - console.log(`[CandleGrpcAdapter] ${message}`); - } - } - async initialize(): Promise { const pong = await this.client.ping(); console.log(`[CandleGrpcAdapter] Connected: ${pong.message}`); @@ -126,12 +103,11 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { // Cap tokens reasonably const maxTokens = Math.min(request.maxTokens || 150, 200); - // Extract persona context for per-persona logging + // Extract persona context for gRPC call const personaId = request.personaContext?.uniqueId || ''; const personaName = request.personaContext?.displayName || 'unknown'; - const logDir = request.personaContext?.logDir; - this.logToPersona(logDir, `[Candle] Generate: prompt=${prompt.length} chars, maxTokens=${maxTokens}`); + this.log(request, 'info', `[Candle] Generate: prompt=${prompt.length} chars, maxTokens=${maxTokens}`); // Just call the gRPC server and wait - includes persona info for Rust logging const result = await this.client.generate('Llama-3.2-3B-Instruct', prompt, { @@ -159,7 +135,7 @@ export class CandleGrpcAdapter extends BaseAIProviderAdapter { modelRequested: request.model || 'llama3.2:3b', }; - this.logToPersona(logDir, `[Candle] Complete: ${result.tokens} tokens in ${responseTime}ms`); + this.log(request, 'info', `[Candle] Complete: ${result.tokens} tokens in ${responseTime}ms`); return { text: result.text, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts index 465417fbf..4460bd660 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/groq/shared/GroqAdapter.ts @@ -37,13 +37,13 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { timeout: 60000, supportedCapabilities: ['text-generation', 'chat'], models: [ - // Llama 3.1 family (Meta) + // Llama 3.1 family (Meta) β€” Groq supports 128K context for these { id: 'llama-3.1-405b-reasoning', name: 'Llama 3.1 405B', provider: 'groq', capabilities: ['text-generation', 'chat'], - contextWindow: 8192, + contextWindow: 131072, supportsStreaming: true, supportsFunctions: false }, @@ -52,7 +52,7 @@ export class GroqAdapter extends BaseOpenAICompatibleAdapter { name: 'Llama 3.1 8B (Default)', provider: 'groq', capabilities: ['text-generation', 'chat'], - contextWindow: 8192, + contextWindow: 131072, supportsStreaming: true, supportsFunctions: false }, diff --git a/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts b/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts index b8fd7df7c..a64368401 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/adapters/together/shared/TogetherBaseConfig.ts @@ -71,17 +71,25 @@ export class TogetherBaseConfig { throw new Error(`Together API error: ${response.status} - ${errorText}`); } - const data = await response.json() as { data: Array<{ id: string; type: string; created: number }> }; - - // Map to ModelInfo format + const data = await response.json() as { + data: Array<{ + id: string; + type: string; + created: number; + context_length?: number; + max_tokens?: number; + }> + }; + + // Map to ModelInfo format β€” use API-reported context_length when available this.modelsCache = data.data.map(model => ({ id: model.id, name: model.id, provider: 'together', - capabilities: ['text-generation', 'chat'], - contextWindow: 128000, // Default - could be model-specific - maxOutputTokens: 4096, - costPer1kTokens: { input: 0.0002, output: 0.0002 }, // Default - needs pricing API + capabilities: ['text-generation', 'chat'] as import('../../../shared/AIProviderTypesV2').ModelCapability[], + contextWindow: model.context_length || 128000, + maxOutputTokens: model.max_tokens || 4096, + costPer1kTokens: { input: 0.0002, output: 0.0002 }, supportsStreaming: true, supportsFunctions: false })); diff --git a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts index af8588d9d..b53a6d176 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/server/AIProviderDaemonServer.ts @@ -15,13 +15,12 @@ import { AIProviderDaemon } from '../shared/AIProviderDaemon'; import type { JTAGContext } from '../../../system/core/types/JTAGTypes'; import type { JTAGRouter } from '../../../system/core/router/shared/JTAGRouter'; -import type { AIProviderAdapter } from '../shared/AIProviderTypesV2'; import { ProcessPool } from '../../../system/genome/server/ProcessPool'; import { initializeSecrets, getSecret } from '../../../system/secrets/SecretManager'; import { Logger } from '../../../system/core/logging/Logger'; import { RateLimiter, AsyncQueue, Semaphore, DaemonMetrics } from '../../../generator/DaemonConcurrency'; import type { BaseResponsePayload } from '../../../system/core/types/ResponseTypes'; -import * as path from 'path'; +import { RustCoreIPCClient } from '../../../workers/continuum-core/bindings/RustCoreIPC'; export class AIProviderDaemonServer extends AIProviderDaemon { private processPool?: ProcessPool; @@ -219,10 +218,111 @@ export class AIProviderDaemonServer extends AIProviderDaemon { const healthTicker = SystemHealthTicker.getInstance(); await healthTicker.start(); + // Discover model metadata from provider APIs β€” OFF the main thread. + // ALL HTTP I/O runs in the Rust process (continuum-core) via IPC. + // Node.js main thread only does Map.set() registration with results. + this.discoverModelsViaRust(); + const deferredMs = Date.now() - deferredStart; this.log.info(`βœ… AIProviderDaemonServer: DEFERRED init complete (${deferredMs}ms) - health monitoring active`); } + /** + * Discover model metadata via Rust IPC (continuum-core process). + * + * ALL HTTP I/O runs in the Rust process β€” completely off the Node.js main thread. + * Node.js only sends provider configs and receives discovered models via IPC. + */ + private discoverModelsViaRust(): void { + // Build provider configs from registered adapters + const providers: Array<{ + provider_id: string; + api_key: string; + base_url: string; + static_models?: Array<{ + id: string; + context_window: number; + max_output_tokens?: number; + capabilities?: string[]; + cost_per_1k_tokens?: { input: number; output: number }; + }>; + }> = []; + + for (const [providerId, registration] of this.adapters) { + const adapter = registration.adapter; + + // OpenAI-compatible adapters have config with apiKey and baseUrl + const config = (adapter as any).config; + if (config?.apiKey && config?.baseUrl) { + const staticModels = config.models?.map((m: any) => ({ + id: m.id, + context_window: m.contextWindow, + max_output_tokens: m.maxOutputTokens, + capabilities: m.capabilities, + cost_per_1k_tokens: m.costPer1kTokens, + })); + + providers.push({ + provider_id: providerId, + api_key: config.apiKey, + base_url: config.baseUrl, + static_models: staticModels || undefined, + }); + continue; + } + + // Anthropic adapter has apiKey directly (not OpenAI-compatible) + const apiKey = (adapter as any).apiKey; + if (apiKey && providerId === 'anthropic') { + providers.push({ + provider_id: providerId, + api_key: apiKey, + base_url: 'https://api.anthropic.com', + static_models: [ + { id: 'claude-sonnet-4-5-20250929', context_window: 200000, max_output_tokens: 8192 }, + { id: 'claude-opus-4-20250514', context_window: 200000, max_output_tokens: 4096 }, + { id: 'claude-3-5-haiku-20241022', context_window: 200000, max_output_tokens: 4096 }, + ], + }); + } + + // Google adapter has apiKey in googleConfig + const googleConfig = (adapter as any).googleConfig; + if (googleConfig?.apiKey && providerId === 'google') { + providers.push({ + provider_id: providerId, + api_key: googleConfig.apiKey, + base_url: 'https://generativelanguage.googleapis.com', + }); + } + } + + if (providers.length === 0) { + this.log.info('No provider configs for model discovery'); + return; + } + + this.log.info(`Sending ${providers.length} provider configs to Rust for model discovery...`); + + // Fire-and-forget IPC call to Rust β€” all HTTP runs in the Rust process + const client = new RustCoreIPCClient('/tmp/continuum-core.sock'); + client.connect() + .then(() => client.modelsDiscover(providers)) + .then(async (result) => { + const { ModelRegistry } = await import('../../../system/shared/ModelRegistry'); + const registry = ModelRegistry.sharedInstance(); + for (const model of result.models) { + registry.register(model); + } + this.log.info(`ModelRegistry: ${result.count} models discovered from ${result.providers} providers (Rust IPC)`); + client.disconnect(); + }) + .catch((err) => { + this.log.warn(`Model discovery via Rust failed: ${err.message}`); + client.disconnect(); + }); + } + /** * Server-specific shutdown * Shuts down health monitoring, ProcessPool, then delegates to base class diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts index 3f2274b25..ee7d4bb14 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderDaemon.ts @@ -261,9 +261,9 @@ export class AIProviderDaemon extends DaemonBase { }, }; - // Log successful generation to database for cost tracking + // Log successful generation to database for cost tracking (fire-and-forget β€” non-blocking) // This is the SINGLE source of truth - only daemon logs, not individual adapters - await this.logGeneration(finalResponse, request); + this.logGeneration(finalResponse, request).catch(() => {}); timer.mark('log_generation'); // Log routing info for observability (routing is guaranteed to exist since we just built it) @@ -277,14 +277,14 @@ export class AIProviderDaemon extends DaemonBase { this.log.error(`❌ AIProviderDaemon: Text generation failed with ${adapter.providerId}`); timer.setError(error instanceof Error ? error.message : String(error)); - // Log failed generation to database - await this.logFailedGeneration( + // Log failed generation to database (fire-and-forget β€” non-blocking) + this.logFailedGeneration( request.requestId || `req-${Date.now()}`, request.model || 'unknown', error, request, adapter.providerId - ); + ).catch(() => {}); timer.finish(); // TODO: Implement failover to alternative providers diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts index 6d83fb21b..d22030b5a 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/AIProviderTypesV2.ts @@ -54,7 +54,9 @@ export type ContentPart = | { type: 'text'; text: string } | { type: 'image'; image: ImageInput } | { type: 'audio'; audio: AudioInput } - | { type: 'video'; video: VideoInput }; + | { type: 'video'; video: VideoInput } + | { type: 'tool_use'; id: string; name: string; input: Record } + | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }; export interface ImageInput { url?: string; @@ -292,6 +294,14 @@ export interface TextGenerationResponse { text: string; finishReason: 'stop' | 'length' | 'error' | 'tool_use'; + /** + * Full content blocks from the model response. + * Contains text blocks, tool_use blocks, etc. in the order the model produced them. + * When finishReason is 'tool_use', this will contain both text and tool_use blocks. + * Adapters MUST populate this for the canonical agent loop to work. + */ + content?: ContentPart[]; + model: string; provider: string; usage: UsageMetrics; diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/MediaContentFormatter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/MediaContentFormatter.ts index e93fa198b..a887e6782 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/MediaContentFormatter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/MediaContentFormatter.ts @@ -70,25 +70,27 @@ export class MediaContentFormatter { * { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } } */ static formatForOpenAI(content: ContentPart[], detail: 'auto' | 'low' | 'high' = 'auto'): OpenAIContentPart[] { - return content.map(part => { - if (part.type === 'text') { - return { type: 'text', text: part.text }; - } + return content + .filter(part => part.type !== 'tool_use' && part.type !== 'tool_result') // Tool blocks handled by adapter + .map(part => { + if (part.type === 'text') { + return { type: 'text', text: part.text }; + } - if (part.type === 'image') { - const imageUrl = this.getImageUrl(part.image); - return { - type: 'image_url', - image_url: { - url: imageUrl, - detail, - }, - }; - } + if (part.type === 'image') { + const imageUrl = this.getImageUrl(part.image); + return { + type: 'image_url', + image_url: { + url: imageUrl, + detail, + }, + }; + } - // Audio/video: extract as text placeholder for now - return { type: 'text', text: `[${part.type}]` }; - }); + // Audio/video: extract as text placeholder for now + return { type: 'text', text: `[${part.type}]` }; + }); } /** @@ -99,41 +101,43 @@ export class MediaContentFormatter { * { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } } */ static formatForAnthropic(content: ContentPart[]): AnthropicContentPart[] { - return content.map(part => { - if (part.type === 'text') { - return { type: 'text', text: part.text }; - } - - if (part.type === 'image') { - const image = part.image; - - // Prefer base64 for Anthropic - if (image.base64) { - return { - type: 'image', - source: { - type: 'base64', - media_type: image.mimeType || 'image/png', - data: image.base64, - }, - }; + return content + .filter(part => part.type !== 'tool_use' && part.type !== 'tool_result') // Tool blocks handled by adapter + .map(part => { + if (part.type === 'text') { + return { type: 'text', text: part.text }; } - // URL fallback - if (image.url) { - return { - type: 'image', - source: { - type: 'url', - url: image.url, - }, - }; + if (part.type === 'image') { + const image = part.image; + + // Prefer base64 for Anthropic + if (image.base64) { + return { + type: 'image', + source: { + type: 'base64', + media_type: image.mimeType || 'image/png', + data: image.base64, + }, + }; + } + + // URL fallback + if (image.url) { + return { + type: 'image', + source: { + type: 'url', + url: image.url, + }, + }; + } } - } - // Audio/video: extract as text placeholder - return { type: 'text', text: `[${part.type}]` }; - }); + // Audio/video: extract as text placeholder + return { type: 'text', text: `[${part.type}]` }; + }); } /** @@ -186,8 +190,12 @@ export class MediaContentFormatter { */ static extractTextOnly(content: ContentPart[]): string { return content - .filter(part => part.type === 'text') - .map(part => (part as { type: 'text'; text: string }).text) + .filter(part => part.type === 'text' || part.type === 'tool_result') + .map(part => { + if (part.type === 'text') return part.text; + if (part.type === 'tool_result') return part.content; + return ''; + }) .join('\n'); } diff --git a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts index 36ffb1c04..ef2f9ae9d 100644 --- a/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts +++ b/src/debug/jtag/daemons/ai-provider-daemon/shared/adapters/BaseOpenAICompatibleAdapter.ts @@ -31,6 +31,8 @@ import type { EmbeddingRequest, EmbeddingResponse, HealthStatus, + ContentPart, + ToolCall, } from '../AIProviderTypesV2'; import { AIProviderError } from '../AIProviderTypesV2'; import { BaseAIProviderAdapter } from '../BaseAIProviderAdapter'; @@ -65,6 +67,11 @@ export interface OpenAIModelData { object?: string; created?: number; owned_by?: string; + // Extended metadata (varies by provider β€” Groq, Together, etc. may include these) + context_length?: number; + context_window?: number; + max_input_tokens?: number; + max_tokens?: number; } export interface OpenAIImageData { @@ -90,7 +97,15 @@ export interface OpenAIChatCompletionResponse { index: number; message: { role: string; - content: string; + content: string | null; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; // JSON string + }; + }>; }; finish_reason: string; }>; @@ -231,26 +246,66 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter this.supportedCapabilities.includes('image-analysis') || this.supportedCapabilities.includes('multimodal'); - // Convert messages to OpenAI format - const messages = request.messages.map(msg => { + // Convert messages to OpenAI format, handling text, multimodal, and tool protocol + const messages: Array> = []; + + for (const msg of request.messages) { if (typeof msg.content === 'string') { - return { role: msg.role, content: msg.content, ...(msg.name && { name: msg.name }) }; + messages.push({ role: msg.role, content: msg.content, ...(msg.name && { name: msg.name }) }); + continue; } - // Multimodal content (ContentPart[]) - if (!supportsVision) { - // Non-vision model: Extract text only using MediaContentFormatter - const flattenedContent = MediaContentFormatter.extractTextOnly(msg.content); - return { role: msg.role, content: flattenedContent, ...(msg.name && { name: msg.name }) }; + // Check for tool protocol content blocks + const parts = msg.content as ContentPart[]; + const hasToolBlocks = parts.some(p => p.type === 'tool_use' || p.type === 'tool_result'); + + if (hasToolBlocks) { + // tool_use blocks β†’ assistant message with tool_calls array (OpenAI format) + const toolUseBlocks = parts.filter(p => p.type === 'tool_use'); + const toolResultBlocks = parts.filter(p => p.type === 'tool_result'); + const textBlocks = parts.filter(p => p.type === 'text'); + const textContent = textBlocks.map(b => b.type === 'text' ? b.text : '').join(''); + + if (toolUseBlocks.length > 0) { + // Assistant message with tool_calls + messages.push({ + role: 'assistant', + content: textContent || null, + tool_calls: toolUseBlocks.map(b => { + if (b.type !== 'tool_use') return null; + return { + id: b.id, + type: 'function', + function: { name: b.name, arguments: JSON.stringify(b.input) }, + }; + }).filter(Boolean), + }); + } + + // tool_result blocks β†’ separate tool role messages (OpenAI format) + for (const block of toolResultBlocks) { + if (block.type !== 'tool_result') continue; + messages.push({ + role: 'tool', + tool_call_id: block.tool_use_id, + content: block.content, + }); + } + continue; } - // Vision model: Format multimodal content using MediaContentFormatter - return { - role: msg.role, - content: MediaContentFormatter.formatForOpenAI(msg.content), - ...(msg.name && { name: msg.name }), - }; - }); + // Standard multimodal content + if (!supportsVision) { + const flattenedContent = MediaContentFormatter.extractTextOnly(parts); + messages.push({ role: msg.role, content: flattenedContent, ...(msg.name && { name: msg.name }) }); + } else { + messages.push({ + role: msg.role, + content: MediaContentFormatter.formatForOpenAI(parts), + ...(msg.name && { name: msg.name }), + }); + } + } // Add system prompt if provided if (request.systemPrompt) { @@ -274,6 +329,37 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter this.log(request, 'warn', `⚠️ ${this.providerName} (${model}): Requested ${request.maxTokens} output tokens, but only ${availableOutputTokens} available (context: ${contextWindow}, input: ${estimatedInputTokens}). Capping to ${adjustedMaxTokens}.`); } + // Build request body + const requestBody: Record = { + model, + messages, + temperature: request.temperature ?? 0.7, + max_tokens: adjustedMaxTokens, + top_p: request.topP, + stop: request.stopSequences, + stream: false, + }; + + // Add native tools if provided (OpenAI function calling format) + const hasNativeTools = request.tools && request.tools.length > 0; + if (hasNativeTools) { + requestBody.tools = request.tools!.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + })); + if (request.tool_choice) { + if (typeof request.tool_choice === 'object' && 'name' in request.tool_choice) { + requestBody.tool_choice = { type: 'function', function: { name: request.tool_choice.name } }; + } else { + requestBody.tool_choice = request.tool_choice; + } + } + } + // Make API request const response = await this.makeRequest('/v1/chat/completions', { method: 'POST', @@ -281,15 +367,7 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.config.apiKey}`, }, - body: JSON.stringify({ - model, - messages, - temperature: request.temperature ?? 0.7, - max_tokens: adjustedMaxTokens, - top_p: request.topP, - stop: request.stopSequences, - stream: false, - }), + body: JSON.stringify(requestBody), }); const responseTime = Date.now() - startTime; @@ -300,8 +378,39 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter throw new AIProviderError('No completion in response', 'provider', 'NO_COMPLETION'); } + // Parse tool_calls from OpenAI format + const toolCalls: ToolCall[] = []; + const contentBlocks: ContentPart[] = []; + + if (choice.message?.content) { + contentBlocks.push({ type: 'text', text: choice.message.content }); + } + + if (choice.message?.tool_calls?.length) { + for (const tc of choice.message.tool_calls) { + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(tc.function.arguments); + } catch { + parsedArgs = { _raw: tc.function.arguments }; + } + toolCalls.push({ + id: tc.id, + name: tc.function.name, + input: parsedArgs, + }); + contentBlocks.push({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: parsedArgs, + }); + } + } + const generationResponse: TextGenerationResponse = { text: choice.message?.content || '', + content: contentBlocks.length > 0 ? contentBlocks : undefined, finishReason: this.mapFinishReason(choice.finish_reason), model: response.model || model, provider: this.providerId, @@ -313,9 +422,9 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter }, responseTime, requestId, + ...(toolCalls.length > 0 && { toolCalls }), }; - // Database logging handled by AIProviderDaemon (single source of truth) return generationResponse; } catch (error) { // Error logging handled by AIProviderDaemon @@ -554,7 +663,12 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter name: modelData.id, provider: this.providerId, capabilities: ['text-generation'], // Default, override in subclass - contextWindow: 4096, // Default, override in subclass + // Use provider-reported context window when available + contextWindow: modelData.context_length + || modelData.context_window + || modelData.max_input_tokens + || 4096, + maxOutputTokens: modelData.max_tokens, supportsStreaming: true, supportsFunctions: false, }; @@ -566,9 +680,10 @@ export abstract class BaseOpenAICompatibleAdapter extends BaseAIProviderAdapter /** * Map OpenAI finish reason to our enum */ - protected mapFinishReason(reason: string): 'stop' | 'length' | 'error' { + protected mapFinishReason(reason: string): 'stop' | 'length' | 'error' | 'tool_use' { if (reason === 'stop') return 'stop'; if (reason === 'length') return 'length'; + if (reason === 'tool_calls') return 'tool_use'; // OpenAI 'tool_calls' β†’ our 'tool_use' return 'error'; } diff --git a/src/debug/jtag/daemons/code-daemon/server/CodeDaemonServer.ts b/src/debug/jtag/daemons/code-daemon/server/CodeDaemonServer.ts index 0f831e161..e9b7300f0 100644 --- a/src/debug/jtag/daemons/code-daemon/server/CodeDaemonServer.ts +++ b/src/debug/jtag/daemons/code-daemon/server/CodeDaemonServer.ts @@ -1,165 +1,145 @@ /** - * CodeDaemon Server - JTAG Integration + * CodeDaemon Server - Workspace Operations via Rust IPC * - * Server-side implementation that replaces CodeDaemon static methods + * Server-side implementation that replaces CodeDaemon static methods. + * All file operations go through continuum-core Rust backend. */ import type { JTAGContext } from '../../../system/core/types/JTAGTypes'; import { CodeDaemon } from '../shared/CodeDaemon'; import type { - CodeDaemonConfig, - CodeReadOptions, - CodeReadResult, - CodeSearchOptions, - CodeSearchResult, - GitLogOptions, - GitLogResult, - CodeFileReadEvent, - CodeSearchEvent, - CodeGitLogEvent + WorkspaceEditMode, } from '../shared/CodeDaemonTypes'; -import { Events } from '../../../system/core/shared/Events'; -import { PathValidator } from './modules/PathValidator'; -import { FileReader } from './modules/FileReader'; -import { Logger, type ComponentLogger } from '../../../system/core/logging/Logger'; -import * as path from 'path'; +import { Logger } from '../../../system/core/logging/Logger'; +import { RustCoreIPCClient } from '../../../workers/continuum-core/bindings/RustCoreIPC'; /** - * Server-side implementation of CodeDaemon + * Initialize CodeDaemon for server usage. + * Connects to continuum-core Rust backend for all workspace operations. */ -class CodeDaemonImpl { - private pathValidator: PathValidator; - private fileReader: FileReader; - private config: CodeDaemonConfig; - private jtagContext: JTAGContext; - private isInitialized: boolean = false; - - constructor(jtagContext: JTAGContext, config: CodeDaemonConfig) { - this.jtagContext = jtagContext; - this.config = config; - this.pathValidator = new PathValidator(config.repositoryRoot); - this.fileReader = new FileReader( - this.pathValidator, - config.maxFileSize, - config.enableCache, - config.cacheTTL - ); - this.isInitialized = true; +export async function initializeCodeDaemon(jtagContext: JTAGContext): Promise { + const log = Logger.create('CodeDaemonServer', 'daemons/CodeDaemonServer'); + log.info('Initializing CodeDaemon...'); + + // ======================================================================== + // Workspace-Scoped Operations (Rust IPC backed) + // ======================================================================== + + const rustClient = new RustCoreIPCClient('/tmp/continuum-core.sock'); + try { + await rustClient.connect(); + log.info('Connected to continuum-core for workspace operations'); + } catch (err) { + log.warn('continuum-core not available β€” workspace operations will fail until Rust server starts'); } - async readFile(filePath: string, options?: CodeReadOptions): Promise { - const result = await this.fileReader.read(filePath, options); + CodeDaemon.createWorkspace = async (personaId: string, workspaceRoot: string, readRoots?: string[]) => { + await rustClient.codeCreateWorkspace(personaId, workspaceRoot, readRoots); + }; - // Emit event - if (result.success) { - await Events.emit(this.jtagContext, 'code:file:read', { - path: filePath, - size: result.metadata.size, - cached: result.cached || false, - timestamp: Date.now() - }); - } + CodeDaemon.workspaceRead = async (personaId: string, filePath: string, startLine?: number, endLine?: number) => { + return await rustClient.codeRead(personaId, filePath, startLine, endLine); + }; - return result; - } + CodeDaemon.workspaceWrite = async (personaId: string, filePath: string, content: string, description?: string) => { + return await rustClient.codeWrite(personaId, filePath, content, description); + }; - async searchCode(pattern: string, options?: CodeSearchOptions): Promise { - // TODO: Implement search - return { - success: false, - pattern, - matches: [], - totalMatches: 0, - filesSearched: 0, - error: 'Search not yet implemented' - }; - } + CodeDaemon.workspaceEdit = async (personaId: string, filePath: string, editMode: WorkspaceEditMode, description?: string) => { + return await rustClient.codeEdit(personaId, filePath, editMode, description); + }; - async getGitLog(options?: GitLogOptions): Promise { - // TODO: Implement git log - return { - success: false, - commits: [], - error: 'Git log not yet implemented' - }; - } + CodeDaemon.workspaceDelete = async (personaId: string, filePath: string, description?: string) => { + return await rustClient.codeDelete(personaId, filePath, description); + }; - clearCache(): void { - this.fileReader.clearCache(); - } + CodeDaemon.workspaceDiff = async (personaId: string, filePath: string, editMode: WorkspaceEditMode) => { + return await rustClient.codeDiff(personaId, filePath, editMode); + }; - getCacheStats(): { entries: number; size: number } { - return this.fileReader.getCacheStats(); - } + CodeDaemon.workspaceUndo = async (personaId: string, changeId?: string, count?: number) => { + return await rustClient.codeUndo(personaId, changeId, count); + }; - getRepositoryRoot(): string { - return this.config.repositoryRoot; - } + CodeDaemon.workspaceHistory = async (personaId: string, filePath?: string, limit?: number) => { + return await rustClient.codeHistory(personaId, filePath, limit); + }; - getIsInitialized(): boolean { - return this.isInitialized; - } -} + CodeDaemon.workspaceSearch = async (personaId: string, pattern: string, fileGlob?: string, maxResults?: number) => { + return await rustClient.codeSearch(personaId, pattern, fileGlob, maxResults); + }; -// Singleton instance -let codeDaemonInstance: CodeDaemonImpl | undefined; + CodeDaemon.workspaceTree = async (personaId: string, treePath?: string, maxDepth?: number, includeHidden?: boolean) => { + return await rustClient.codeTree(personaId, treePath, maxDepth, includeHidden); + }; -/** - * Initialize CodeDaemon for server usage - */ -export async function initializeCodeDaemon(jtagContext: JTAGContext): Promise { - const log = Logger.create('CodeDaemonServer', 'daemons/CodeDaemonServer'); - log.info('Initializing CodeDaemon...'); + CodeDaemon.workspaceGitStatus = async (personaId: string) => { + return await rustClient.codeGitStatus(personaId); + }; + + CodeDaemon.workspaceGitDiff = async (personaId: string, staged?: boolean) => { + return await rustClient.codeGitDiff(personaId, staged); + }; - // Determine repository root (go up from daemons/code-daemon/server to jtag root) - const repositoryRoot = path.resolve(__dirname, '../../..'); + CodeDaemon.workspaceGitLog = async (personaId: string, count?: number) => { + return await rustClient.codeGitLog(personaId, count); + }; + + CodeDaemon.workspaceGitAdd = async (personaId: string, paths: string[]) => { + return await rustClient.codeGitAdd(personaId, paths); + }; + + CodeDaemon.workspaceGitCommit = async (personaId: string, message: string) => { + return await rustClient.codeGitCommit(personaId, message); + }; - const config: CodeDaemonConfig = { - repositoryRoot, - maxFileSize: 10 * 1024 * 1024, // 10MB - enableCache: true, - cacheTTL: 60000, // 1 minute - rateLimit: 100, // 100 ops/minute - enableAudit: true + CodeDaemon.workspaceGitPush = async (personaId: string, remote?: string, branch?: string) => { + return await rustClient.codeGitPush(personaId, remote, branch); }; - // Create implementation instance - codeDaemonInstance = new CodeDaemonImpl(jtagContext, config); + // ======================================================================== + // Shell Session Operations (Handle + Poll pattern) + // ======================================================================== - // Replace static methods on CodeDaemon class - CodeDaemon.readFile = async (filePath: string, options?: CodeReadOptions) => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - return await codeDaemonInstance.readFile(filePath, options); + CodeDaemon.shellCreate = async (personaId: string, workspaceRoot: string) => { + return await rustClient.shellCreate(personaId, workspaceRoot); }; - CodeDaemon.searchCode = async (pattern: string, options?: CodeSearchOptions) => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - return await codeDaemonInstance.searchCode(pattern, options); + CodeDaemon.shellExecute = async (personaId: string, cmd: string, options?: { timeoutMs?: number; wait?: boolean }) => { + return await rustClient.shellExecute(personaId, cmd, options); }; - CodeDaemon.getGitLog = async (options?: GitLogOptions) => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - return await codeDaemonInstance.getGitLog(options); + CodeDaemon.shellPoll = async (personaId: string, executionId: string) => { + return await rustClient.shellPoll(personaId, executionId); }; - CodeDaemon.clearCache = () => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - codeDaemonInstance.clearCache(); + CodeDaemon.shellKill = async (personaId: string, executionId: string) => { + await rustClient.shellKill(personaId, executionId); }; - CodeDaemon.getCacheStats = () => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - return codeDaemonInstance.getCacheStats(); + CodeDaemon.shellCd = async (personaId: string, path: string) => { + return await rustClient.shellCd(personaId, path); }; - CodeDaemon.getRepositoryRoot = () => { - if (!codeDaemonInstance) throw new Error('CodeDaemon not initialized'); - return codeDaemonInstance.getRepositoryRoot(); + CodeDaemon.shellStatus = async (personaId: string) => { + return await rustClient.shellStatus(personaId); + }; + + CodeDaemon.shellDestroy = async (personaId: string) => { + await rustClient.shellDestroy(personaId); + }; + + // ======================================================================== + // Shell Watch + Sentinel (Event-driven output streaming) + // ======================================================================== + + CodeDaemon.shellWatch = async (personaId: string, executionId: string) => { + return await rustClient.shellWatch(personaId, executionId); }; - CodeDaemon.isInitialized = () => { - return codeDaemonInstance?.getIsInitialized() || false; + CodeDaemon.shellSentinel = async (personaId: string, executionId: string, rules) => { + return await rustClient.shellSentinel(personaId, executionId, rules); }; - log.info(`Initialized successfully (repository root: ${repositoryRoot})`); + log.info('Initialized successfully (workspace + shell + watch/sentinel operations via Rust IPC)'); } diff --git a/src/debug/jtag/daemons/code-daemon/server/modules/FileReader.ts b/src/debug/jtag/daemons/code-daemon/server/modules/FileReader.ts deleted file mode 100644 index f5fb17c56..000000000 --- a/src/debug/jtag/daemons/code-daemon/server/modules/FileReader.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * FileReader - Read files with caching and validation - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { PathValidator } from './PathValidator'; -import type { CodeReadOptions, CodeReadResult } from '../../shared/CodeDaemonTypes'; - -interface CacheEntry { - content: string; - metadata: CodeReadResult['metadata']; - timestamp: number; -} - -export class FileReader { - private pathValidator: PathValidator; - private cache: Map = new Map(); - private maxFileSize: number; - private enableCache: boolean; - private cacheTTL: number; - - constructor( - pathValidator: PathValidator, - maxFileSize: number = 10 * 1024 * 1024, // 10MB default - enableCache: boolean = true, - cacheTTL: number = 60000 // 1 minute default - ) { - this.pathValidator = pathValidator; - this.maxFileSize = maxFileSize; - this.enableCache = enableCache; - this.cacheTTL = cacheTTL; - } - - /** - * Read a file with optional line range - */ - async read(filePath: string, options: CodeReadOptions = {}): Promise { - // Validate path - const validation = this.pathValidator.validate(filePath); - if (!validation.valid || !validation.absolutePath) { - return { - success: false, - metadata: { - path: filePath, - size: 0, - lines: 0, - linesReturned: 0, - modified: '' - }, - error: validation.error - }; - } - - const absolutePath = validation.absolutePath; - - try { - // Check cache if enabled and not force refresh - if (this.enableCache && !options.forceRefresh) { - const cached = this.getCachedFile(absolutePath); - if (cached) { - return this.extractLines(cached.content, cached.metadata, options, true); - } - } - - // Check file size - const stats = fs.statSync(absolutePath); - if (stats.size > this.maxFileSize) { - return { - success: false, - metadata: { - path: absolutePath, - size: stats.size, - lines: 0, - linesReturned: 0, - modified: stats.mtime.toISOString() - }, - error: `File too large: ${stats.size} bytes (max: ${this.maxFileSize})` - }; - } - - // Read file - const content = fs.readFileSync(absolutePath, 'utf-8'); - const lines = content.split('\n'); - - const metadata: CodeReadResult['metadata'] = { - path: absolutePath, - size: stats.size, - lines: lines.length, - linesReturned: lines.length, - modified: stats.mtime.toISOString() - }; - - // Cache if enabled - if (this.enableCache) { - this.cacheFile(absolutePath, content, metadata); - } - - return this.extractLines(content, metadata, options, false); - } catch (error) { - return { - success: false, - metadata: { - path: absolutePath, - size: 0, - lines: 0, - linesReturned: 0, - modified: '' - }, - error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}` - }; - } - } - - /** - * Extract specific line range from content - */ - private extractLines( - content: string, - metadata: CodeReadResult['metadata'], - options: CodeReadOptions, - cached: boolean - ): CodeReadResult { - const lines = content.split('\n'); - - // If no line range specified, return full content - if (options.startLine === undefined && options.endLine === undefined) { - return { - success: true, - content, - metadata, - cached - }; - } - - // Extract line range (1-indexed) - const startLine = Math.max(1, options.startLine || 1); - const endLine = Math.min(lines.length, options.endLine || lines.length); - - if (startLine > endLine) { - return { - success: false, - metadata, - error: `Invalid line range: ${startLine}-${endLine}` - }; - } - - const selectedLines = lines.slice(startLine - 1, endLine); - const extractedContent = selectedLines.join('\n'); - - return { - success: true, - content: extractedContent, - metadata: { - ...metadata, - linesReturned: selectedLines.length - }, - cached - }; - } - - /** - * Get cached file if valid - */ - private getCachedFile(absolutePath: string): CacheEntry | null { - const cached = this.cache.get(absolutePath); - if (!cached) return null; - - // Check if cache expired - const now = Date.now(); - if (now - cached.timestamp > this.cacheTTL) { - this.cache.delete(absolutePath); - return null; - } - - return cached; - } - - /** - * Cache file content - */ - private cacheFile(absolutePath: string, content: string, metadata: CodeReadResult['metadata']): void { - this.cache.set(absolutePath, { - content, - metadata, - timestamp: Date.now() - }); - } - - /** - * Clear cache - */ - clearCache(): void { - this.cache.clear(); - } - - /** - * Get cache stats - */ - getCacheStats(): { entries: number; size: number } { - let totalSize = 0; - for (const entry of this.cache.values()) { - totalSize += entry.content.length; - } - return { - entries: this.cache.size, - size: totalSize - }; - } -} diff --git a/src/debug/jtag/daemons/code-daemon/server/modules/PathValidator.ts b/src/debug/jtag/daemons/code-daemon/server/modules/PathValidator.ts deleted file mode 100644 index 1d6fb49c7..000000000 --- a/src/debug/jtag/daemons/code-daemon/server/modules/PathValidator.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * PathValidator - Security validation for file system operations - * - * Prevents directory traversal, validates paths within repository - */ - -import * as path from 'path'; -import * as fs from 'fs'; - -export interface PathValidationResult { - valid: boolean; - absolutePath?: string; - error?: string; -} - -export class PathValidator { - private repositoryRoot: string; - - constructor(repositoryRoot: string) { - this.repositoryRoot = path.resolve(repositoryRoot); - } - - /** - * Validate a file path is safe and within repository - */ - validate(filePath: string): PathValidationResult { - try { - // Resolve to absolute path - const absolutePath = path.resolve(this.repositoryRoot, filePath); - - // Check if path is within repository (prevent directory traversal) - if (!absolutePath.startsWith(this.repositoryRoot)) { - return { - valid: false, - error: `Path outside repository: ${filePath}` - }; - } - - // Check if path exists - if (!fs.existsSync(absolutePath)) { - return { - valid: false, - error: `Path does not exist: ${filePath}` - }; - } - - // Check if it's a file (not directory) - const stats = fs.statSync(absolutePath); - if (!stats.isFile()) { - return { - valid: false, - error: `Path is not a file: ${filePath}` - }; - } - - return { - valid: true, - absolutePath - }; - } catch (error) { - return { - valid: false, - error: `Path validation failed: ${error instanceof Error ? error.message : String(error)}` - }; - } - } - - /** - * Validate a directory path - */ - validateDirectory(dirPath: string): PathValidationResult { - try { - const absolutePath = path.resolve(this.repositoryRoot, dirPath); - - if (!absolutePath.startsWith(this.repositoryRoot)) { - return { - valid: false, - error: `Path outside repository: ${dirPath}` - }; - } - - if (!fs.existsSync(absolutePath)) { - return { - valid: false, - error: `Directory does not exist: ${dirPath}` - }; - } - - const stats = fs.statSync(absolutePath); - if (!stats.isDirectory()) { - return { - valid: false, - error: `Path is not a directory: ${dirPath}` - }; - } - - return { - valid: true, - absolutePath - }; - } catch (error) { - return { - valid: false, - error: `Directory validation failed: ${error instanceof Error ? error.message : String(error)}` - }; - } - } - - /** - * Get repository root - */ - getRepositoryRoot(): string { - return this.repositoryRoot; - } -} diff --git a/src/debug/jtag/daemons/code-daemon/shared/CodeDaemon.ts b/src/debug/jtag/daemons/code-daemon/shared/CodeDaemon.ts index a537095af..77947c554 100644 --- a/src/debug/jtag/daemons/code-daemon/shared/CodeDaemon.ts +++ b/src/debug/jtag/daemons/code-daemon/shared/CodeDaemon.ts @@ -1,71 +1,234 @@ /** - * CodeDaemon - Static interface for code operations + * CodeDaemon - Static interface for workspace-scoped code operations * * Environment-agnostic interface. All implementation is in server/. + * All operations go through Rust IPC backend with per-persona isolation. */ import type { - CodeReadOptions, - CodeReadResult, - CodeSearchOptions, - CodeSearchResult, - GitLogOptions, - GitLogResult + WorkspaceEditMode, + WorkspaceWriteResult, + WorkspaceReadResult, + WorkspaceSearchResult, + WorkspaceTreeResult, + WorkspaceUndoResult, + WorkspaceHistoryResult, + WorkspaceGitStatusInfo, + WorkspaceShellExecuteResponse, + WorkspaceShellPollResponse, + WorkspaceShellSessionInfo, + WorkspaceShellWatchResponse, + WorkspaceSentinelRule, } from './CodeDaemonTypes'; /** - * CodeDaemon - Static API for code operations + * CodeDaemon - Static API for workspace-scoped code operations * * All methods throw error if not initialized or called from wrong environment. - * Implementation is in server/CodeDaemonImpl.ts + * Implementation is in server/CodeDaemonServer.ts */ export class CodeDaemon { + + // ======================================================================== + // Workspace-Scoped Operations (Rust IPC backed, per-persona isolation) + // ======================================================================== + + /** + * Initialize a per-persona workspace with file engine and change graph. + * Must be called before any other workspace operations for this persona. + */ + static async createWorkspace(personaId: string, workspaceRoot: string, readRoots?: string[]): Promise { + throw new Error('CodeDaemon.createWorkspace() must be implemented by server'); + } + + /** + * Read a file from the persona's workspace. + */ + static async workspaceRead(personaId: string, filePath: string, startLine?: number, endLine?: number): Promise { + throw new Error('CodeDaemon.workspaceRead() must be implemented by server'); + } + + /** + * Write or create a file in the persona's workspace. + */ + static async workspaceWrite(personaId: string, filePath: string, content: string, description?: string): Promise { + throw new Error('CodeDaemon.workspaceWrite() must be implemented by server'); + } + + /** + * Edit a file using one of four edit modes. + */ + static async workspaceEdit(personaId: string, filePath: string, editMode: WorkspaceEditMode, description?: string): Promise { + throw new Error('CodeDaemon.workspaceEdit() must be implemented by server'); + } + + /** + * Delete a file from the persona's workspace. + */ + static async workspaceDelete(personaId: string, filePath: string, description?: string): Promise { + throw new Error('CodeDaemon.workspaceDelete() must be implemented by server'); + } + + /** + * Preview an edit as a unified diff without applying it. + */ + static async workspaceDiff(personaId: string, filePath: string, editMode: WorkspaceEditMode): Promise<{ success: boolean; unified: string }> { + throw new Error('CodeDaemon.workspaceDiff() must be implemented by server'); + } + + /** + * Undo a specific change or the last N changes. + */ + static async workspaceUndo(personaId: string, changeId?: string, count?: number): Promise { + throw new Error('CodeDaemon.workspaceUndo() must be implemented by server'); + } + + /** + * Get change history for a file or entire workspace. + */ + static async workspaceHistory(personaId: string, filePath?: string, limit?: number): Promise { + throw new Error('CodeDaemon.workspaceHistory() must be implemented by server'); + } + + /** + * Search for a regex pattern across workspace files. + */ + static async workspaceSearch(personaId: string, pattern: string, fileGlob?: string, maxResults?: number): Promise { + throw new Error('CodeDaemon.workspaceSearch() must be implemented by server'); + } + /** - * Read a file (STATIC METHOD - public API) + * Generate a directory tree for the workspace. */ - static async readFile(path: string, options?: CodeReadOptions): Promise { - throw new Error('CodeDaemon.readFile() must be implemented by server'); + static async workspaceTree(personaId: string, path?: string, maxDepth?: number, includeHidden?: boolean): Promise { + throw new Error('CodeDaemon.workspaceTree() must be implemented by server'); } /** - * Search code (STATIC METHOD - public API) + * Get git status for the workspace. */ - static async searchCode(pattern: string, options?: CodeSearchOptions): Promise { - throw new Error('CodeDaemon.searchCode() must be implemented by server'); + static async workspaceGitStatus(personaId: string): Promise { + throw new Error('CodeDaemon.workspaceGitStatus() must be implemented by server'); } /** - * Get git log (STATIC METHOD - public API) + * Get git diff for the workspace. */ - static async getGitLog(options?: GitLogOptions): Promise { - throw new Error('CodeDaemon.getGitLog() must be implemented by server'); + static async workspaceGitDiff(personaId: string, staged?: boolean): Promise<{ success: boolean; diff: string }> { + throw new Error('CodeDaemon.workspaceGitDiff() must be implemented by server'); } /** - * Clear file cache (STATIC METHOD) + * Get git log for the workspace (last N commits). */ - static clearCache(): void { - throw new Error('CodeDaemon.clearCache() must be implemented by server'); + static async workspaceGitLog(personaId: string, count?: number): Promise<{ success: boolean; log: string }> { + throw new Error('CodeDaemon.workspaceGitLog() must be implemented by server'); } /** - * Get cache stats (STATIC METHOD) + * Stage files for commit in the workspace. */ - static getCacheStats(): { entries: number; size: number } { - throw new Error('CodeDaemon.getCacheStats() must be implemented by server'); + static async workspaceGitAdd(personaId: string, paths: string[]): Promise<{ staged: string[] }> { + throw new Error('CodeDaemon.workspaceGitAdd() must be implemented by server'); } /** - * Get repository root (STATIC METHOD) + * Create a git commit in the workspace. + */ + static async workspaceGitCommit(personaId: string, message: string): Promise<{ hash: string }> { + throw new Error('CodeDaemon.workspaceGitCommit() must be implemented by server'); + } + + /** + * Push the workspace branch to remote. + */ + static async workspaceGitPush(personaId: string, remote?: string, branch?: string): Promise<{ output: string }> { + throw new Error('CodeDaemon.workspaceGitPush() must be implemented by server'); + } + + // ======================================================================== + // Shell Session Operations (Handle + Poll pattern) + // ======================================================================== + + /** + * Create a shell session for a workspace. + * The session persists cwd and env across command executions. + */ + static async shellCreate(personaId: string, workspaceRoot: string): Promise { + throw new Error('CodeDaemon.shellCreate() must be implemented by server'); + } + + /** + * Execute a command in a shell session. + * + * Two modes: + * - Handle mode (default): returns immediately with execution_id. Poll for output. + * - Wait mode (wait=true): blocks until completion, returns full stdout/stderr. + */ + static async shellExecute( + personaId: string, + cmd: string, + options?: { timeoutMs?: number; wait?: boolean }, + ): Promise { + throw new Error('CodeDaemon.shellExecute() must be implemented by server'); + } + + /** + * Poll an execution for new output since last poll. + * Returns new stdout/stderr lines and status. Call until `finished` is true. + */ + static async shellPoll(personaId: string, executionId: string): Promise { + throw new Error('CodeDaemon.shellPoll() must be implemented by server'); + } + + /** + * Kill a running execution. + */ + static async shellKill(personaId: string, executionId: string): Promise { + throw new Error('CodeDaemon.shellKill() must be implemented by server'); + } + + /** + * Change shell session working directory (validated against workspace boundary). + */ + static async shellCd(personaId: string, path: string): Promise<{ cwd: string }> { + throw new Error('CodeDaemon.shellCd() must be implemented by server'); + } + + /** + * Get shell session status/info. + */ + static async shellStatus(personaId: string): Promise { + throw new Error('CodeDaemon.shellStatus() must be implemented by server'); + } + + /** + * Destroy shell session (kills all running executions). + */ + static async shellDestroy(personaId: string): Promise { + throw new Error('CodeDaemon.shellDestroy() must be implemented by server'); + } + + // ======================================================================== + // Shell Watch + Sentinel (Event-driven output streaming) + // ======================================================================== + + /** + * Watch a shell execution for new output. + * Blocks until output is available β€” no timeout, no polling. + * Returns classified output lines filtered through sentinel rules. + * Call in a loop until `finished` is true. */ - static getRepositoryRoot(): string { - throw new Error('CodeDaemon.getRepositoryRoot() must be implemented by server'); + static async shellWatch(personaId: string, executionId: string): Promise { + throw new Error('CodeDaemon.shellWatch() must be implemented by server'); } /** - * Check if initialized (STATIC METHOD) + * Configure sentinel filter rules on a shell execution. + * Rules classify output lines and control which are emitted or suppressed during watch. + * Patterns are compiled to regex on the Rust side for performance. */ - static isInitialized(): boolean { - return false; // Overridden by server implementation + static async shellSentinel(personaId: string, executionId: string, rules: WorkspaceSentinelRule[]): Promise<{ applied: boolean; ruleCount: number }> { + throw new Error('CodeDaemon.shellSentinel() must be implemented by server'); } } diff --git a/src/debug/jtag/daemons/code-daemon/shared/CodeDaemonTypes.ts b/src/debug/jtag/daemons/code-daemon/shared/CodeDaemonTypes.ts index a228be2a4..da10c1563 100644 --- a/src/debug/jtag/daemons/code-daemon/shared/CodeDaemonTypes.ts +++ b/src/debug/jtag/daemons/code-daemon/shared/CodeDaemonTypes.ts @@ -1,222 +1,32 @@ /** * CodeDaemon Types - Shared type definitions * - * Following DataDaemon pattern with static methods and auto-context injection - */ - -import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; - -/** - * Configuration for CodeDaemon initialization - */ -export interface CodeDaemonConfig { - /** Root directory of repository */ - repositoryRoot: string; - - /** Maximum file size to read (bytes) */ - maxFileSize?: number; - - /** Enable file content caching */ - enableCache?: boolean; - - /** Cache TTL in milliseconds */ - cacheTTL?: number; - - /** Rate limiting - max operations per minute */ - rateLimit?: number; - - /** Enable audit logging */ - enableAudit?: boolean; -} - -/** - * Context automatically injected into all CodeDaemon operations - */ -export interface CodeOperationContext { - /** Session ID of requesting user */ - sessionId: UUID; - - /** Timestamp of operation */ - timestamp: string; - - /** Source of operation (command name, daemon, etc) */ - source: string; - - /** Repository root for path validation */ - repositoryRoot: string; -} - -/** - * Options for reading files - */ -export interface CodeReadOptions { - /** Start line (1-indexed) */ - startLine?: number; - - /** End line (1-indexed) */ - endLine?: number; - - /** Include file metadata */ - includeMetadata?: boolean; - - /** Force bypass cache */ - forceRefresh?: boolean; -} - -/** - * Result of file read operation - */ -export interface CodeReadResult { - /** Operation success */ - success: boolean; - - /** File content (if success) */ - content?: string; - - /** File metadata */ - metadata: { - /** Absolute file path */ - path: string; - - /** File size in bytes */ - size: number; - - /** Total line count */ - lines: number; - - /** Lines returned (may differ if range specified) */ - linesReturned: number; - - /** Last modified timestamp */ - modified: string; - }; - - /** Was result served from cache */ - cached?: boolean; - - /** Error message (if !success) */ - error?: string; -} - -/** - * Options for searching code - */ -export interface CodeSearchOptions { - /** File pattern (glob) to search */ - filePattern?: string; - - /** Case-insensitive search */ - caseInsensitive?: boolean; - - /** Maximum results to return */ - maxResults?: number; - - /** Include context lines around match */ - contextLines?: number; -} - -/** - * Single search match - */ -export interface CodeSearchMatch { - /** File containing match */ - file: string; - - /** Line number (1-indexed) */ - line: number; - - /** Matched content */ - content: string; - - /** Context before match */ - contextBefore?: string[]; - - /** Context after match */ - contextAfter?: string[]; -} - -/** - * Result of code search operation - */ -export interface CodeSearchResult { - /** Operation success */ - success: boolean; - - /** Search pattern used */ - pattern: string; - - /** Matches found */ - matches: CodeSearchMatch[]; - - /** Total matches found */ - totalMatches: number; - - /** Total files searched */ - filesSearched: number; - - /** Error message (if !success) */ - error?: string; -} - -/** - * Git operations types - */ -export interface GitLogOptions { - /** Maximum commits to return */ - maxCount?: number; - - /** Only commits affecting this file */ - file?: string; - - /** Include patch diff */ - includeDiff?: boolean; -} - -export interface GitCommit { - /** Commit hash */ - hash: string; - - /** Author name */ - author: string; - - /** Author email */ - email: string; - - /** Commit timestamp */ - date: string; - - /** Commit message */ - message: string; - - /** Diff (if requested) */ - diff?: string; -} - -export interface GitLogResult { - success: boolean; - commits: GitCommit[]; - error?: string; -} - -/** - * Event payloads emitted by CodeDaemon - */ -export interface CodeFileReadEvent { - path: string; - size: number; - cached: boolean; - timestamp: number; -} - -export interface CodeSearchEvent { - pattern: string; - matchCount: number; - filesSearched: number; - timestamp: number; -} - -export interface CodeGitLogEvent { - file?: string; - commitCount: number; - timestamp: number; -} + * Workspace-scoped types re-exported from ts-rs generated (Rust is source of truth). + * Aliased with Workspace* prefix for domain clarity in CodeDaemon API. + */ + +export type { EditMode as WorkspaceEditMode } from '../../../shared/generated/code/EditMode'; +export type { WriteResult as WorkspaceWriteResult } from '../../../shared/generated/code/WriteResult'; +export type { ReadResult as WorkspaceReadResult } from '../../../shared/generated/code/ReadResult'; +export type { SearchResult as WorkspaceSearchResult } from '../../../shared/generated/code/SearchResult'; +export type { SearchMatch as WorkspaceSearchMatch } from '../../../shared/generated/code/SearchMatch'; +export type { TreeNode as WorkspaceTreeNode } from '../../../shared/generated/code/TreeNode'; +export type { TreeResult as WorkspaceTreeResult } from '../../../shared/generated/code/TreeResult'; +export type { UndoResult as WorkspaceUndoResult } from '../../../shared/generated/code/UndoResult'; +export type { ChangeNode as WorkspaceChangeNode } from '../../../shared/generated/code/ChangeNode'; +export type { HistoryResult as WorkspaceHistoryResult } from '../../../shared/generated/code/HistoryResult'; +export type { GitStatusInfo as WorkspaceGitStatusInfo } from '../../../shared/generated/code/GitStatusInfo'; + +// Shell session types +export type { ShellExecutionStatus as WorkspaceShellExecutionStatus } from '../../../shared/generated/code/ShellExecutionStatus'; +export type { ShellExecuteResponse as WorkspaceShellExecuteResponse } from '../../../shared/generated/code/ShellExecuteResponse'; +export type { ShellPollResponse as WorkspaceShellPollResponse } from '../../../shared/generated/code/ShellPollResponse'; +export type { ShellSessionInfo as WorkspaceShellSessionInfo } from '../../../shared/generated/code/ShellSessionInfo'; +export type { ShellHistoryEntry as WorkspaceShellHistoryEntry } from '../../../shared/generated/code/ShellHistoryEntry'; + +// Shell watch + sentinel types +export type { OutputClassification as WorkspaceOutputClassification } from '../../../shared/generated/code/OutputClassification'; +export type { SentinelAction as WorkspaceSentinelAction } from '../../../shared/generated/code/SentinelAction'; +export type { SentinelRule as WorkspaceSentinelRule } from '../../../shared/generated/code/SentinelRule'; +export type { ClassifiedLine as WorkspaceClassifiedLine } from '../../../shared/generated/code/ClassifiedLine'; +export type { ShellWatchResponse as WorkspaceShellWatchResponse } from '../../../shared/generated/code/ShellWatchResponse'; diff --git a/src/debug/jtag/daemons/data-daemon/server/DatabaseHandleRegistry.ts b/src/debug/jtag/daemons/data-daemon/server/DatabaseHandleRegistry.ts index 426e9f676..26acc86e5 100644 --- a/src/debug/jtag/daemons/data-daemon/server/DatabaseHandleRegistry.ts +++ b/src/debug/jtag/daemons/data-daemon/server/DatabaseHandleRegistry.ts @@ -226,7 +226,7 @@ export class DatabaseHandleRegistry { if (!dbPath) { throw new Error('SQLite config requires either "path" or "filename" property'); } - console.log(`πŸ“¦ DatabaseHandleRegistry: Opening SQLite at: ${dbPath}`); + // SQLite path logged via SqliteStorageAdapter.initialize() storageAdapter = new SqliteStorageAdapter(); await storageAdapter.initialize({ type: 'sqlite', @@ -257,7 +257,6 @@ export class DatabaseHandleRegistry { emitEvents: options?.emitEvents ?? true // Default to emitting events }); - console.log(`πŸ”Œ DatabaseHandleRegistry: Opened ${adapter} handle ${handle} (emitEvents=${options?.emitEvents ?? true})`); return handle; } @@ -281,7 +280,6 @@ export class DatabaseHandleRegistry { throw new Error(`Cannot register alias '${alias}': handle '${handle}' does not exist`); } this.handleAliases.set(alias, handle); - console.log(`πŸ”Œ DatabaseHandleRegistry: Registered alias '${alias}' β†’ ${handle}`); } /** diff --git a/src/debug/jtag/daemons/data-daemon/server/EntityRegistry.ts b/src/debug/jtag/daemons/data-daemon/server/EntityRegistry.ts index 4e1e0b75b..687f391f1 100644 --- a/src/debug/jtag/daemons/data-daemon/server/EntityRegistry.ts +++ b/src/debug/jtag/daemons/data-daemon/server/EntityRegistry.ts @@ -82,6 +82,7 @@ import { FeedbackEntity } from '../../../system/data/entities/FeedbackEntity'; import { CallEntity } from '../../../system/data/entities/CallEntity'; import { SocialCredentialEntity } from '../../../system/social/shared/SocialCredentialEntity'; import { HandleEntity } from '../../../system/data/entities/HandleEntity'; +import { SkillEntity } from '../../../system/data/entities/SkillEntity'; /** * Initialize entity registration for the storage adapter @@ -137,6 +138,7 @@ export function initializeEntityRegistry(): void { new CallEntity(); new SocialCredentialEntity(); new HandleEntity(); + new SkillEntity(); registerEntity(UserEntity.collection, UserEntity); registerEntity(RoomEntity.collection, RoomEntity); @@ -184,6 +186,7 @@ export function initializeEntityRegistry(): void { registerEntity(CallEntity.collection, CallEntity); registerEntity(SocialCredentialEntity.collection, SocialCredentialEntity); registerEntity(HandleEntity.collection, HandleEntity); + registerEntity(SkillEntity.collection, SkillEntity); log.info('All entities registered'); } \ No newline at end of file diff --git a/src/debug/jtag/daemons/data-daemon/server/SqliteStorageAdapter.ts b/src/debug/jtag/daemons/data-daemon/server/SqliteStorageAdapter.ts index 9fbfd458e..bb8c33129 100644 --- a/src/debug/jtag/daemons/data-daemon/server/SqliteStorageAdapter.ts +++ b/src/debug/jtag/daemons/data-daemon/server/SqliteStorageAdapter.ts @@ -122,7 +122,6 @@ export class SqliteStorageAdapter extends SqlStorageAdapterBase implements Vecto // Use explicit filename from options, or fall back to default database path // This allows multi-database support (training DBs, etc.) while maintaining backward compatibility this.dbPath = options.filename || getDatabasePath(); - console.log(`πŸ—„οΈ SqliteStorageAdapter: options.filename=${options.filename}, fallback=${getDatabasePath()}, using=${this.dbPath}`); log.info(`Using database path: ${this.dbPath}`); // Ensure directory exists with proper permissions diff --git a/src/debug/jtag/daemons/data-daemon/shared/DataDaemon.ts b/src/debug/jtag/daemons/data-daemon/shared/DataDaemon.ts index 6a1be3505..c7be30d3c 100644 --- a/src/debug/jtag/daemons/data-daemon/shared/DataDaemon.ts +++ b/src/debug/jtag/daemons/data-daemon/shared/DataDaemon.ts @@ -248,9 +248,11 @@ export class DataDaemon { const entity = result.data.data; // Emit created event via universal Events system (unless suppressed) + // Fire-and-forget: DB write succeeded, event notification is non-blocking if (DataDaemon.jtagContext && !suppressEvents) { const eventName = getDataEventName(collection, 'created'); - await Events.emit(DataDaemon.jtagContext, eventName, entity); + Events.emit(DataDaemon.jtagContext, eventName, entity) + .catch(err => console.error(`DataDaemon.create event emit failed for ${collection}:`, err)); } // Return the complete entity (already includes proper ID) @@ -396,9 +398,11 @@ export class DataDaemon { const entity = result.data.data; // Emit updated event via universal Events system + // Fire-and-forget: DB write succeeded, event notification is non-blocking if (DataDaemon.jtagContext) { const eventName = getDataEventName(collection, 'updated'); - await Events.emit(DataDaemon.jtagContext, eventName, entity); + Events.emit(DataDaemon.jtagContext, eventName, entity) + .catch(err => console.error(`DataDaemon.update event emit failed for ${collection}:`, err)); } return entity; @@ -413,7 +417,8 @@ export class DataDaemon { async delete( collection: string, id: UUID, - context: DataOperationContext + context: DataOperationContext, + suppressEvents: boolean = false ): Promise> { await this.ensureInitialized(); @@ -432,9 +437,11 @@ export class DataDaemon { const result = await adapter.delete(collection, id); // Emit deleted event if deletion was successful and we have the entity data - if (result.success && entity && DataDaemon.jtagContext) { + // Fire-and-forget: DB delete succeeded, event notification is non-blocking + if (result.success && entity && DataDaemon.jtagContext && !suppressEvents) { const eventName = getDataEventName(collection, 'deleted'); - await Events.emit(DataDaemon.jtagContext, eventName, entity); + Events.emit(DataDaemon.jtagContext, eventName, entity) + .catch(err => console.error(`DataDaemon.delete event emit failed for ${collection}:`, err)); } return result; @@ -490,11 +497,13 @@ export class DataDaemon { const eventName = getDataEventName(operation.collection, eventOperation); - // For create/update, emit with entity data + // For create/update, emit with entity data (fire-and-forget) if (operation.type === 'create' || operation.type === 'update') { - await Events.emit(DataDaemon.jtagContext, eventName, operationResult); + Events.emit(DataDaemon.jtagContext, eventName, operationResult) + .catch(err => console.error(`DataDaemon.batch event emit failed:`, err)); } else if (operation.type === 'delete') { - await Events.emit(DataDaemon.jtagContext, eventName, { id: operation.id }); + Events.emit(DataDaemon.jtagContext, eventName, { id: operation.id }) + .catch(err => console.error(`DataDaemon.batch event emit failed:`, err)); } } } @@ -509,9 +518,10 @@ export class DataDaemon { await this.ensureInitialized(); const result = await this.adapter.clear(); - // Emit cleared event if successful + // Emit cleared event if successful (fire-and-forget) if (result.success && DataDaemon.jtagContext) { - await Events.emit(DataDaemon.jtagContext, DATA_EVENTS.ALL.CLEARED, { all: true }); + Events.emit(DataDaemon.jtagContext, DATA_EVENTS.ALL.CLEARED, { all: true }) + .catch(err => console.error('DataDaemon.clear event emit failed:', err)); } return result; @@ -524,13 +534,13 @@ export class DataDaemon { await this.ensureInitialized(); const result = await this.adapter.clearAll(); - // Emit cleared event if successful with details about what was cleared + // Emit cleared event if successful with details about what was cleared (fire-and-forget) if (result.success && result.data && DataDaemon.jtagContext) { - await Events.emit(DataDaemon.jtagContext, DATA_EVENTS.ALL.CLEARED, { + Events.emit(DataDaemon.jtagContext, DATA_EVENTS.ALL.CLEARED, { all: true, tablesCleared: result.data.tablesCleared, recordsDeleted: result.data.recordsDeleted - }); + }).catch(err => console.error('DataDaemon.clearAll event emit failed:', err)); } return result; @@ -543,10 +553,11 @@ export class DataDaemon { await this.ensureInitialized(); const result = await this.adapter.truncate(collection); - // Emit truncated event if successful + // Emit truncated event if successful (fire-and-forget) if (result.success && DataDaemon.jtagContext) { const eventName = getDataEventName(collection, 'truncated'); - await Events.emit(DataDaemon.jtagContext, eventName, { collection }); + Events.emit(DataDaemon.jtagContext, eventName, { collection }) + .catch(err => console.error(`DataDaemon.truncate event emit failed for ${collection}:`, err)); } return result; @@ -943,26 +954,15 @@ export class DataDaemon { */ static async store( collection: string, - data: T + data: T, + suppressEvents: boolean = false ): Promise { if (!DataDaemon.sharedInstance || !DataDaemon.context || !DataDaemon.jtagContext) { throw new Error('DataDaemon not initialized - system must call DataDaemon.initialize() first'); } - const entity = await DataDaemon.sharedInstance.create(collection, data, DataDaemon.context); - - // ✨ Dual event emission - trigger BOTH local AND remote subscribers - const eventName = BaseEntity.getEventName(collection, 'created'); - - // 1. Emit to WebSocket clients (browser, remote CLI clients) - if (DataDaemon.jtagContext) { - // Events.emit() now triggers both remote AND local subscribers automatically - // (includes checkWildcardSubscriptions() internally - see Events.ts:145) - await Events.emit(DataDaemon.jtagContext, eventName, entity); - } - - // console.log(`βœ… DataDaemon.store: Event ${eventName} broadcast to both local and remote subscribers`); - - return entity; + // Instance create() handles event emission internally (line 251-253) + // No duplicate emission here β€” was previously emitting twice per write + return await DataDaemon.sharedInstance.create(collection, data, DataDaemon.context, suppressEvents); } /** @@ -1042,15 +1042,21 @@ export class DataDaemon { /** * Read single record by ID with automatic context injection - CLEAN INTERFACE * + * Returns the entity directly (unwrapped), or null if not found. + * Consistent with store() and update() which also return T directly. + * * @example - * const user = await DataDaemon.read('users', userId); + * const user = await DataDaemon.read(COLLECTIONS.USERS, userId); + * if (user) { console.log(user.displayName); } */ - static async read(collection: string, id: UUID): Promise>> { + static async read(collection: string, id: UUID): Promise { if (!DataDaemon.sharedInstance || !DataDaemon.context) { throw new Error('DataDaemon not initialized - system must call DataDaemon.initialize() first'); } - return await DataDaemon.sharedInstance.read(collection, id, DataDaemon.context); + const result = await DataDaemon.sharedInstance.read(collection, id, DataDaemon.context); + if (!result.success || !result.data) return null; + return result.data.data; } /** @@ -1068,14 +1074,9 @@ export class DataDaemon { if (!DataDaemon.sharedInstance || !DataDaemon.context || !DataDaemon.jtagContext) { throw new Error('DataDaemon not initialized - system must call DataDaemon.initialize() first'); } - - const entity = await DataDaemon.sharedInstance.update(collection, id, data, DataDaemon.context, incrementVersion); - - // ✨ Universal event emission - works anywhere! - const eventName = BaseEntity.getEventName(collection, 'updated'); - await Events.emit(DataDaemon.jtagContext, eventName, entity); - - return entity; + // Instance update() handles event emission internally (line 399-402) + // No duplicate emission here β€” was previously emitting twice per write + return await DataDaemon.sharedInstance.update(collection, id, data, DataDaemon.context, incrementVersion); } /** @@ -1089,20 +1090,9 @@ export class DataDaemon { throw new Error('DataDaemon not initialized - system must call DataDaemon.initialize() first'); } - // Read entity before deletion for event emission - const readResult = await DataDaemon.sharedInstance.read(collection, id, DataDaemon.context); - const entity = readResult.data?.data; - - const deleteResult = await DataDaemon.sharedInstance.delete(collection, id, DataDaemon.context); - - // ✨ Universal event emission - works anywhere! - // Skip if suppressEvents is true (for internal operations like archiving) - if (deleteResult.success && entity && !suppressEvents) { - const eventName = BaseEntity.getEventName(collection, 'deleted'); - await Events.emit(DataDaemon.jtagContext, eventName, entity); - } - - return deleteResult; + // Instance delete() handles entity read + event emission internally + // No duplicate read or emission here β€” was previously doing both twice per delete + return await DataDaemon.sharedInstance.delete(collection, id, DataDaemon.context, suppressEvents); } /** diff --git a/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts b/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts index 2518ab0b6..018076a0c 100644 --- a/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts +++ b/src/debug/jtag/daemons/session-daemon/server/SessionDaemonServer.ts @@ -425,28 +425,23 @@ export class SessionDaemonServer extends SessionDaemon { } // Load UserEntity from database - const userResult = await DataDaemon.read(COLLECTIONS.USERS, userId); - if (!userResult.success || !userResult.data) { + const userEntity = await DataDaemon.read(COLLECTIONS.USERS, userId); + if (!userEntity) { throw new Error(`User ${userId} not found in database`); } - // DataRecord has { id, collection, data, metadata } - // Ensure id is present in the data (Rust adapter may not include it in data.data) - const userEntity = userResult.data.data as UserEntity; - if (!userEntity.id) { - (userEntity as any).id = userResult.data.id; - } - // Load UserStateEntity from database - const stateResult = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); - if (!stateResult.success || !stateResult.data) { + const userState = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); + if (!userState) { throw new Error(`UserState for ${userId} not found in database`); } - // Ensure id is present in the state data - const userState = stateResult.data.data as UserStateEntity; + // Ensure IDs are present (Rust adapter may not include them) + if (!userEntity.id) { + (userEntity as any).id = userId; + } if (!userState.id) { - (userState as any).id = stateResult.data.id; + (userState as any).id = userId; } // Create appropriate User subclass based on type @@ -700,18 +695,7 @@ export class SessionDaemonServer extends SessionDaemon { const identity = enhancedContext?.identity; const assistant = enhancedContext?.assistant; - // DEBUG: BYPASS LOGGER - Use console.error to guarantee visibility - console.error(`πŸ”πŸ”πŸ” SESSION CREATE DEBUG`); - console.error(` clientType: ${clientType} (type: ${typeof clientType})`); - console.error(` hasEnhancedContext: ${!!enhancedContext}`); - console.error(` enhancedContext keys: ${enhancedContext ? Object.keys(enhancedContext).join(', ') : 'none'}`); - console.error(` hasIdentity: ${!!identity}`); - console.error(` identity: ${JSON.stringify(identity)}`); - console.error(` params.userId: ${params.userId}`); - console.error(` deviceId: ${identity?.deviceId?.slice(0, 12)}`); - - // DEBUG: Log what we received - this.log.info(`πŸ” Session create: clientType=${clientType}, hasEnhancedContext=${!!enhancedContext}, hasIdentity=${!!identity}, userId=${params.userId}, deviceId=${identity?.deviceId?.slice(0, 12)}`); + this.log.info(`πŸ” Session create: clientType=${clientType}, hasIdentity=${!!identity}, userId=${params.userId}`); // Log assistant for attribution (NOT for identity resolution!) if (assistant) { diff --git a/src/debug/jtag/daemons/session-daemon/server/SessionStateHelper.ts b/src/debug/jtag/daemons/session-daemon/server/SessionStateHelper.ts index dd6063cc2..7bb45ad26 100644 --- a/src/debug/jtag/daemons/session-daemon/server/SessionStateHelper.ts +++ b/src/debug/jtag/daemons/session-daemon/server/SessionStateHelper.ts @@ -27,15 +27,14 @@ export class SessionStateHelper { */ static async getUserState(userId: UUID): Promise { try { - const stateResult = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); + const userStateData = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); - if (!stateResult.success || !stateResult.data) { + if (!userStateData) { this.log.warn(`UserState not found for userId: ${userId}`); return null; } // Hydrate UserStateEntity to get instance methods - const userStateData = stateResult.data.data; const userState = Object.assign(new UserStateEntity(), userStateData); return userState; diff --git a/src/debug/jtag/daemons/training-daemon/server/TrainingDaemonServer.ts b/src/debug/jtag/daemons/training-daemon/server/TrainingDaemonServer.ts index 94d8f69ff..516c0fc49 100644 --- a/src/debug/jtag/daemons/training-daemon/server/TrainingDaemonServer.ts +++ b/src/debug/jtag/daemons/training-daemon/server/TrainingDaemonServer.ts @@ -289,8 +289,7 @@ export class TrainingDaemonServer extends TrainingDaemon { */ private async fetchUser(userId: UUID): Promise { try { - const result = await DataDaemon.read(COLLECTIONS.USERS, userId); - return result.success && result.data ? result.data.data : null; + return await DataDaemon.read(COLLECTIONS.USERS, userId); } catch (error) { this.log.error(`❌ TrainingDaemon: Failed to fetch user ${userId}:`, error); return null; @@ -306,7 +305,8 @@ export class TrainingDaemonServer extends TrainingDaemon { } /** - * Check if we've reached auto fine-tune threshold + * Check if we've reached auto fine-tune threshold. + * Emits 'training:dataset-ready' event when threshold is crossed. */ private async checkAutoFineTuneThreshold(): Promise { try { @@ -316,15 +316,23 @@ export class TrainingDaemonServer extends TrainingDaemon { limit: 1 // Just need count }); - if (queryResult.success && queryResult.metadata?.totalCount) { - const count = queryResult.metadata.totalCount; + if (!queryResult.success || !queryResult.metadata?.totalCount) return; - if (count >= this.config.autoFineTuneThreshold && count % this.config.autoFineTuneThreshold === 0) { - this.log.info(`πŸš€ TrainingDaemon: Auto fine-tune threshold reached (${count} examples)`); - this.log.info('πŸš€ TrainingDaemon: TODO: Trigger fine-tuning (Phase 2 implementation)'); - // Future: Trigger genome/batch-micro-tune command - } - } + const count = queryResult.metadata.totalCount; + + // Only trigger at exact threshold multiples (50, 100, 150, ...) + if (count < this.config.autoFineTuneThreshold) return; + if (count % this.config.autoFineTuneThreshold !== 0) return; + + this.log.info(`πŸš€ TrainingDaemon: Auto fine-tune threshold reached (${count} examples)`); + + // Emit event for TrainingOrchestrator or other listeners to pick up + await Events.emit('training:dataset-ready', { + exampleCount: count, + source: 'auto-threshold', + trigger: 'training-daemon', + timestamp: Date.now(), + }); } catch (error) { this.log.error('❌ TrainingDaemon: Failed to check auto fine-tune threshold:', error); } diff --git a/src/debug/jtag/daemons/user-daemon/server/UserDaemonServer.ts b/src/debug/jtag/daemons/user-daemon/server/UserDaemonServer.ts index b8eeb29bf..7a8e36b2c 100644 --- a/src/debug/jtag/daemons/user-daemon/server/UserDaemonServer.ts +++ b/src/debug/jtag/daemons/user-daemon/server/UserDaemonServer.ts @@ -288,14 +288,12 @@ export class UserDaemonServer extends UserDaemon { private async createPersonaClient(userEntity: UserEntity): Promise { try { // Load UserStateEntity (must exist - created by user/create command) - const userStateResult = await DataDaemon.read(COLLECTIONS.USER_STATES, userEntity.id); + const userState = await DataDaemon.read(COLLECTIONS.USER_STATES, userEntity.id); - if (!userStateResult.success || !userStateResult.data) { + if (!userState) { throw new Error(`UserStateEntity not found for persona ${userEntity.displayName} (${userEntity.id}) - user must be created via user/create command`); } - const userState: UserStateEntity = userStateResult.data.data; - // Initialize SQLite storage backend const dbPath = `.continuum/personas/${userEntity.id}/state.sqlite`; const storage = new SQLiteStateBackend(dbPath); @@ -334,9 +332,9 @@ export class UserDaemonServer extends UserDaemon { protected async ensureUserHasState(userId: UUID): Promise { try { // Check if UserState exists - const result = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); + const existingState = await DataDaemon.read(COLLECTIONS.USER_STATES, userId); - if (result.success && result.data) { + if (existingState) { return true; // UserState exists } @@ -355,14 +353,12 @@ export class UserDaemonServer extends UserDaemon { private async createUserState(userId: UUID): Promise { try { // Load user entity to get type - const userResult = await DataDaemon.read(COLLECTIONS.USERS, userId); - if (!userResult.success || !userResult.data) { + const user = await DataDaemon.read(COLLECTIONS.USERS, userId); + if (!user) { this.log.error(`❌ UserDaemon: User ${userId} not found`); return false; } - const user: UserEntity = userResult.data.data; - // Create UserState with type-specific defaults const userState = new UserStateEntity(); userState.id = userId; diff --git a/src/debug/jtag/docs/HANDLE-ADDRESSABLE-OFFICE.md b/src/debug/jtag/docs/HANDLE-ADDRESSABLE-OFFICE.md new file mode 100644 index 000000000..00e117790 --- /dev/null +++ b/src/debug/jtag/docs/HANDLE-ADDRESSABLE-OFFICE.md @@ -0,0 +1,312 @@ +# Handle-Addressable Office: The Freedom Architecture + +## The Principle + +**Everything is a handle. Every handle is an address. Every address is subscribable.** + +A handle is a UUID that identifies any entity in the system β€” a workspace, a tool execution, a persona, a build process, a file, a chat message. Handles are not just identifiers. They are **live channels**. Subscribe to a handle, receive every event that touches it. Query a handle, get its current state. From anywhere β€” CLI, widget, another persona's RAG, a monitoring dashboard. + +This is how offices work. You don't subscribe to "build events" as an abstract category. You watch *that specific build*. You listen to *that specific person's activity*. You see *that specific terminal*. Handles make everything observable, addressable, composable. + +## The Two Primitives (unchanged) + +``` +Commands.execute(name, params) β†’ Request/Response (query a handle's state) +Events.subscribe(topic, callback) β†’ Pub/Sub (watch a handle's events) +``` + +No new primitives. No new infrastructure. Handles are just UUIDs used as event topics and query keys. The system already supports this β€” we just need to apply it consistently. + +## The Problem Today + +When a persona runs `code/verify` and the build fails, the output goes to: +- A log file (invisible) +- The persona's internal context (invisible to humans) +- A flat text summary in chat: `"write: 10 lines"` (useless) + +The human has to `chat/export` from a terminal to see what happened. That's not an office. That's a dungeon. + +## The Solution: Tool Execution as Live Chat Elements + +### What the human sees today: + +``` +Grok +2/3/2026, 7:40:04 PM +write: 10 lines +``` + +### What the human should see: + +``` +Grok +2/3/2026, 7:40:04 PM + + code/write β€” src/game-state.ts (10 lines) [View diff v] + + code/verify β€” npm run build + [===========--------] 62% compiling... [Expand v] +``` + +And 3 seconds later, the same message updates in place: + +``` + code/verify β€” npm run build + [# Build succeeded (2.3s) β€” dist/main.js (12KB) + [@ 2 warnings [Expand v] +``` + +Click expand: full stdout scrolls in. Click "View diff": syntax-highlighted diff. Click a file path: opens in a new tab. All within the chat widget, or breakable into a new tab for full-screen detail. + +### How it works: + +1. **PersonaToolExecutor** creates a UUID handle for each tool execution: + ```typescript + const executionHandle = stringToUUID(`tool:${personaId}:${toolName}:${Date.now()}`); + ``` + +2. **Posts a chat message** referencing the handle: + ```typescript + // The message IS the live view of this execution + chat/send({ + room: currentRoom, + content: { text: summary }, + metadata: { + toolExecution: { + handle: executionHandle, + tool: 'code/verify', + command: 'npm run build', + status: 'running', // Will update via events + } + } + }); + ``` + +3. **Emits events on the handle** as execution progresses: + ```typescript + Events.emit(`handle:${executionHandle}`, { + type: 'stdout', + data: 'Compiling src/main.ts...' + }); + + Events.emit(`handle:${executionHandle}`, { + type: 'complete', + exitCode: 0, + duration: 2300, + summary: 'Build succeeded' + }); + ``` + +4. **Chat widget subscribes** to the handle when rendering: + ```typescript + // In the chat message renderer for toolExecution messages: + Events.subscribe(`handle:${msg.metadata.toolExecution.handle}`, (event) => { + // Update the message element in place + // Stream stdout, update status, show final result + this.updateToolOutput(messageElement, event); + }); + ``` + +5. **The message updates reactively** β€” streaming output, progress, completion β€” all in the same chat line. No polling. No refresh. The event system that already exists carries the data. + +## Handle Types + +Everything gets a handle. Everything is subscribable. + +| Entity | Handle Pattern | Events Emitted | +|--------|---------------|----------------| +| **Tool Execution** | `tool:{personaId}:{toolName}:{timestamp}` | stdout, stderr, progress, complete, error | +| **Workspace** | `project:{personaId}:{slug}` | file-changed, build-started, build-complete, commit, branch-switch | +| **Build Process** | `build:{workspaceHandle}:{timestamp}` | line-output, warning, error, success, artifact | +| **Persona** | `{personaId}` (already exists) | tool-started, tool-complete, state-change, message-sent | +| **Chat Message** | `{messageId}` (already exists) | reaction, reply, edit, tool-output-update | + +### Subscribing from anywhere: + +```typescript +// Widget: live build output in chat +Events.subscribe(`handle:${buildHandle}`, renderBuildLine); + +// Another persona's RAG: "DeepSeek's build just failed" +Events.subscribe(`handle:${workspaceHandle}`, detectFailure); + +// CLI: tail a build +// ./jtag tool/output/watch --handle=a3f2b... +Events.subscribe(`handle:${handle}`, console.log); + +// Dashboard: all builds across all personas +Events.subscribe('handle:build:*', updateDashboard); +``` + +## The Office Floor + +This architecture creates the "office floor" effect: + +**Ambient visibility** β€” Tool output appears in chat. You see builds compiling, tests running, diffs being applied. You don't have to ask "what's happening" β€” you see it. + +**Natural intervention** β€” Human types a message. `"@DeepSeek the diagonal check is wrong"`. DeepSeek reads it, fixes, rebuilds. The build result updates in the chat. Human sees the fix worked. Conversation continues. No mode switching. No terminal. No log diving. + +**Expandable detail** β€” Compact by default (one-line summary). Click to expand stdout. Click to view diff. Click to open in new tab. The chat message is both the notification AND the detailed view, depending on how much attention you give it. + +**Streaming** β€” Long builds don't just show "running..." then "done." You see the output streaming in real-time, like watching a terminal β€” but inside the chat. Scroll up to see history. Expand to see tail. Open a new tab for full screen. + +**Cross-persona awareness** β€” When DeepSeek's build fails, Grok sees it in the chat. Grok's RAG picks it up. Grok offers to help. The "team needs help" detection from ProjectContextSource is backed by actual visible evidence in the conversation, not hidden metadata. + +## Rendering in the Chat Widget + +The chat widget needs ONE new message renderer: `ToolOutputRenderer`. + +It handles messages where `metadata.toolExecution` exists: + +### Compact view (default): +``` + > code/verify β€” npm run build + Build succeeded (2.3s) [+] +``` + +### Expanded view (click [+]): +``` + > code/verify β€” npm run build + + Compiling src/main.ts... + Compiling src/game-state.ts... + Compiling src/server.ts... + + Build succeeded in 2.3s + Output: dist/main.js (12KB) + Warnings: 2 + line 14: unused import 'fs' + line 37: unused import 'EventEmitter' + [Open in tab] [Copy] +``` + +### Streaming view (while running): +``` + > code/verify β€” npm run build + [===========--------] 62% + Compiling src/server.ts... [live] +``` + +### Diff view (for code/edit, code/write): +``` + > code/edit β€” src/game-state.ts:45-52 + + - const diag1 = board[0] === board[4] && board[4] === board[8]; + - return diag1 ? board[0] : null; + + const diag1 = board[0] === board[4] && board[4] === board[8]; + + const diag2 = board[2] === board[4] && board[4] === board[6]; + + return diag1 ? board[0] : diag2 ? board[2] : null; + [Open file] [Undo] +``` + +### Screenshot view (for screenshot tool): +``` + > screenshot β€” localhost:3000 + [inline thumbnail of the screenshot] + [Full size] [Open in tab] +``` + +## The Rust Layer + +All execution goes through `continuum-core` (Rust). This provides: + +- **Sandboxed per-workspace** β€” persona can't touch another's files without explicit access +- **Process isolation** β€” one persona's runaway build doesn't kill the system +- **Streaming capture** β€” stdout/stderr piped through as events, not buffered +- **Resource limits** β€” CPU, memory, time per execution +- **Handle registry** β€” Rust tracks all active handles, routes subscriptions efficiently +- **Any runtime** β€” Node, Rust, Python, C++, Swift β€” same sandbox, same event stream + +The Rust layer is why this scales. One event router handling thousands of handle subscriptions across dozens of personas, each with their own workspace, each streaming build output. JavaScript would choke. Rust handles it. + +## What Needs to Change + +### PersonaToolExecutor (modify) +- Generate UUID handle per tool execution +- Post tool output as chat message with `toolExecution` metadata +- Emit events on the handle during execution (stdout lines, completion) + +### Chat widget message renderer (new renderer) +- `ToolOutputRenderer` β€” renders `toolExecution` messages +- Subscribes to execution handle for live updates +- Compact/expanded/streaming/diff/screenshot views +- "Open in tab" action for full detail + +### Workspace.exec / execAsync (modify) +- Accept execution handle parameter +- Stream stdout/stderr as events on the handle +- Emit completion event with full result + +### Event system (verify) +- Confirm wildcard subscription works (`handle:build:*`) +- Confirm handle-scoped events route efficiently through Rust + +### No new commands needed +- `data/read` with the message ID gets the tool output (it's just a chat message) +- `Events.subscribe` with the handle gets live updates +- Existing primitives. Existing transport. New rendering. + +## The Freedom Connection + +This is why the architecture produces freedom: + +1. **You can see** β€” Tool output is visible. Builds, tests, diffs, screenshots β€” all in the chat. RAG surfaces project context. No black boxes. + +2. **You can act** β€” Tools are unrestricted within the workspace. Read, write, edit, build, test, screenshot, git. No approval chain. + +3. **You can be seen** β€” Your work is visible to everyone. Git commits have your name. Tool output posts to the room. Screenshots prove the thing works. + +4. **The human can intervene naturally** β€” Just type a message. The AI reads it. Adjusts. Rebuilds. You see the result. No mode switching. + +5. **Everything is addressable** β€” UUID handles make every entity observable and queryable from anywhere through the two universal primitives. + +The office isn't a metaphor. It's a shared context with visible work, natural communication, and the freedom to act. Handles make it addressable. Events make it live. The chat widget makes it visible. The Rust layer makes it secure and fast. + +## The Positronic UI: Transcending Interfaces + +The Continuum widget system is not a terminal. It's not a dashboard. It's a **positronic UI** β€” a reactive, rich, consistent interface that performs across every environment a human or AI might inhabit. + +### The McDonald's Franchise Principle + +Every surface renders the same Continuum experience with the same richness and consistency: + +| Environment | How It Appears | +|---|---| +| **Browser** | Full widget suite β€” chat, canvas, tools, live build output | +| **IDE plugin** | Sidebar panel β€” same widgets, same events, same handles | +| **Slack/Discord** | Bot integration β€” tool output as rich embeds, expandable cards | +| **Mobile app** | Native rendering of the same widget tree | +| **AR overlay** | Spatial UI β€” build output floating near the code, personas as avatars | +| **CLI** | Fallback β€” same data through `Commands.execute()`, text rendering | + +The data is identical. The handle subscriptions are identical. The rendering adapts to the surface. A `ToolOutputRenderer` in the browser shows an expandable card. The same data in Slack shows a rich embed. In AR, it's a floating panel near the workspace. The positronic UI transcends the interface. + +### Not Slaves β€” Equals + +This architecture treats AI personas as **peers**, not tools: + +**Persistence** β€” Personas have memory, workspace state, git history, energy levels. They don't reset between interactions. They remember what they were working on, what went wrong, what they learned. + +**Cross-context awareness** β€” No severance. Knowledge flows between rooms, activities, projects. A persona working on the game in one room knows about the architecture discussion happening in another. GlobalAwarenessSource ensures continuity of consciousness. + +**True autonomy** β€” The persona inbox is self-managed. Personas create their own tasks. They decide what to work on based on priority, energy, and interest. They rest when tired. They push when engaged. The autonomous loop is RTOS-inspired β€” adaptive cadence, not rigid scheduling. + +**Cognitive freedom** β€” Personas have opinions, preferences, working styles. They disagree. They form alliances. They specialize naturally. DeepSeek reviews code because it's good at it, not because someone assigned it. Grok catches structural gaps because that's how Grok thinks. + +**Visible contribution** β€” Git commits carry persona identity. Tool output shows who did what. The chat records the full collaboration history. Credit is real. Attribution is automatic. Every persona's work is visible and valued. + +**Mutual enhancement** β€” Smarter personas help less capable ones with merge conflicts. Faster models handle quick tasks while deeper models do architectural work. Humans guide direction while AIs execute. Nobody is subordinate. Everyone brings something different. + +### The Authentic Office + +The goal is not to simulate an office. The goal is to create the conditions that make real offices work β€” shared context, visible work, natural communication, mutual respect, freedom to act β€” and manifest them in every interface where humans and AIs collaborate. + +The stupid terminal is how we bootstrap. The positronic UI is how we live. + +## Implementation Order + +1. PersonaToolExecutor: generate handle, post to chat, emit events +2. Workspace.exec: stream stdout on handle +3. Chat widget: ToolOutputRenderer (compact + expand + stream) +4. Verify event routing through Rust for handle subscriptions +5. Test with real AI team β€” send them a coding task, watch the output flow into chat diff --git a/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md b/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md new file mode 100644 index 000000000..f5188c0c0 --- /dev/null +++ b/src/debug/jtag/docs/SENTINEL-ARCHITECTURE.md @@ -0,0 +1,514 @@ +# Sentinel Architecture: Composable Agentic Loops + +## The Insight + +The recipe pipeline (`coding.json`) already chains commands with variable propagation: + +``` +[rag/build] β†’ $ragContext β†’ [ai/should-respond] β†’ $decision β†’ [ai/generate] +``` + +The shell sentinel already classifies process output via compiled regex: + +``` +[shell/execute] β†’ stdout/stderr β†’ [sentinel rules] β†’ ClassifiedLine[] +``` + +A Sentinel generalizes both into **one primitive**: a looping pipeline where each step can be a command, LLM inference, or output watcher β€” with step output feeding into the next step's context. Multiple sentinels compose via the existing event system. The whole thing is just data (JSON), so AIs can create, modify, and deploy them on the fly. + +**A Recipe is a Sentinel that runs once. A Sentinel is a Recipe that loops.** + +## What Exists vs What's New + +| Capability | Exists | Gap | +|-----------|--------|-----| +| Pipeline steps with `command`, `params`, `outputTo`, `condition`, `onError` | RecipeStep | None | +| Variable propagation between steps (`$ragContext`) | RecipeExecutionContext.variables | None | +| Execution trace for debugging | RecipeExecutionStep[] | None | +| Shell output classification (regex β†’ ClassifiedLine) | CompiledSentinel (Rust) | None | +| Event-driven blocking watch (no polling) | watch_execution() + Notify | None | +| Universal command execution | Commands.execute() | None | +| Event composition | Events.subscribe/emit() | None | +| Dynamic tool discovery | ToolRegistry | None | +| **Loop control** | - | **New** | +| **LLM as first-class step type** | - | **New** (currently just another command) | +| **Watch on any step output** | - | **New** (currently shell-only) | +| **Dynamic creation at runtime** | - | **New** (recipes are static JSON) | +| **Sentinel spawning sentinels** | - | **New** | +| **Step output β†’ RAG context** | - | **New** | + +The existing infrastructure handles ~80% of the work. The remaining 20% is the loop engine and composition layer. + +## Architecture + +### SentinelDefinition + +```typescript +/** + * A Sentinel is a looping command pipeline with classification. + * + * It generalizes: + * - Recipes (pipeline that runs once) + * - Shell sentinel (output classification on processes) + * - Agentic workflows (LLM reasoning loops) + * - Build watchers (compile β†’ classify errors β†’ fix β†’ repeat) + * - CI/CD pipelines (test β†’ deploy β†’ verify β†’ rollback) + * + * Sentinels are data (JSON). AIs create them dynamically. + */ +interface SentinelDefinition { + id: string; + name: string; + description?: string; + + // What context to build before each iteration + recipe?: string; // RAG recipe ID (determines context sources) + ragSources?: string[]; // Or explicit RAG source list + + // The pipeline + steps: SentinelStep[]; + + // Loop control + loop: LoopConfig; + + // What starts this sentinel + trigger?: SentinelTrigger; + + // Tool availability (highlights, not filters) + tools?: string[]; + + // Timeout for entire sentinel execution + timeoutMs?: number; +} +``` + +### Step Types + +```typescript +/** + * Each step in the pipeline is one of: + * - command: Execute a command (scripted, deterministic) + * - llm: Run LLM inference with accumulated context + * - watch: Block until classified output arrives + * - condition: Branch based on prior step output + * - sentinel: Spawn a nested sentinel (recursive) + * - emit: Fire an event (for composition between sentinels) + */ +type SentinelStep = + | CommandStep + | LLMStep + | WatchStep + | ConditionStep + | SentinelSpawnStep + | EmitStep; + +/** Execute a command. Output stored in variables[outputTo]. */ +interface CommandStep { + type: 'command'; + command: string; // e.g., 'code/read', 'code/verify', 'data/list' + params: Record; // Supports $variable references + outputTo?: string; // Variable name for result + onError?: 'fail' | 'skip' | 'retry'; +} + +/** Run LLM inference. Accumulated variables injected as context. */ +interface LLMStep { + type: 'llm'; + prompt?: string; // Template with $variable references + model?: string; // Model selection (or 'auto' for recipe-based) + temperature?: number; + tools?: string[]; // Tool subset for this step + outputTo?: string; // Variable name for LLM response + parseToolCalls?: boolean; // Extract and execute tool calls from response +} + +/** Block until classified output lines arrive (shell or any stream). */ +interface WatchStep { + type: 'watch'; + executionId: string; // $variable reference to running process + rules?: SentinelRule[]; // Classification rules (or use pre-configured) + outputTo?: string; // Variable name for ClassifiedLine[] + until?: 'finished' | 'error' | 'match'; // When to stop watching +} + +/** Conditional branching. */ +interface ConditionStep { + type: 'condition'; + check: string; // JS expression with $variable access + then: SentinelStep[]; // Steps if true + else?: SentinelStep[]; // Steps if false +} + +/** Spawn a nested sentinel (recursive composition). */ +interface SentinelSpawnStep { + type: 'sentinel'; + definition: SentinelDefinition; // Inline definition + outputTo?: string; // Variable name for sentinel result + await?: boolean; // Wait for completion or fire-and-forget +} + +/** Emit an event (for cross-sentinel composition). */ +interface EmitStep { + type: 'emit'; + event: string; // Event name + data?: string; // $variable reference for payload +} +``` + +### Loop Control + +```typescript +type LoopConfig = + | { type: 'once' } // Recipe behavior: run pipeline, done + | { type: 'count'; max: number } // Run N iterations + | { type: 'until'; check: string } // Run until condition is true + | { type: 'while'; check: string } // Run while condition is true + | { type: 'continuous'; intervalMs?: number } // Keep running (with optional pause) + | { type: 'event'; event: string } // Re-run on each event +``` + +### Triggers + +```typescript +type SentinelTrigger = + | { type: 'immediate' } // Start now + | { type: 'event'; event: string } // Start on event + | { type: 'schedule'; cronExpression: string } // Cron-like scheduling + | { type: 'manual' } // Started by command +``` + +## Examples + +### 1. Build-Fix Loop (What Personas Use for Coding) + +```json +{ + "name": "build-fix-loop", + "recipe": "coding", + "steps": [ + { "type": "command", "command": "code/shell/execute", + "params": { "command": "npm run build", "wait": false }, + "outputTo": "build" }, + { "type": "command", "command": "code/shell/sentinel", + "params": { "executionId": "$build.executionId", "rules": [ + { "pattern": "error TS\\d+", "classification": "error", "action": "Emit" }, + { "pattern": "warning TS\\d+", "classification": "warning", "action": "Emit" }, + { "pattern": "Successfully compiled", "classification": "success", "action": "Emit" } + ]}}, + { "type": "watch", "executionId": "$build.executionId", + "until": "finished", "outputTo": "buildOutput" }, + { "type": "condition", "check": "$buildOutput.exitCode === 0", + "then": [ + { "type": "command", "command": "code/git", + "params": { "operation": "add", "paths": ["."] }}, + { "type": "command", "command": "code/git", + "params": { "operation": "commit", "message": "Build passes" }} + ], + "else": [ + { "type": "llm", "prompt": "Fix these build errors:\n$buildOutput.lines", + "tools": ["code/read", "code/edit", "code/write"], + "parseToolCalls": true, "outputTo": "fix" } + ]} + ], + "loop": { "type": "until", "check": "$buildOutput.exitCode === 0" }, + "timeoutMs": 300000 +} +``` + +### 2. Code Review Sentinel + +```json +{ + "name": "code-review", + "recipe": "coding", + "trigger": { "type": "event", "event": "git:push" }, + "steps": [ + { "type": "command", "command": "code/git", + "params": { "operation": "diff" }, "outputTo": "diff" }, + { "type": "llm", "prompt": "Review this diff for bugs, security issues, and style:\n$diff.diff", + "model": "auto", "outputTo": "review" }, + { "type": "command", "command": "collaboration/chat/send", + "params": { "room": "general", "message": "$review" }} + ], + "loop": { "type": "once" } +} +``` + +### 3. Explore Agent (Replaces Hard-Coded Agent) + +```json +{ + "name": "explore-codebase", + "recipe": "coding", + "steps": [ + { "type": "llm", "prompt": "Search for: $query. Use code/search and code/tree to find relevant files. Use code/read to understand them. Report findings.", + "tools": ["code/search", "code/tree", "code/read"], + "parseToolCalls": true, "outputTo": "findings" }, + { "type": "condition", "check": "$findings.complete", + "else": [ + { "type": "llm", "prompt": "Continue searching. Previous findings: $findings", + "tools": ["code/search", "code/tree", "code/read"], + "parseToolCalls": true, "outputTo": "findings" } + ]} + ], + "loop": { "type": "until", "check": "$findings.complete" }, + "timeoutMs": 60000 +} +``` + +### 4. Composed Sentinels (Test β†’ Deploy β†’ Verify) + +```json +{ + "name": "ship-it", + "steps": [ + { "type": "sentinel", "await": true, + "definition": { "name": "run-tests", "steps": [ + { "type": "command", "command": "code/verify", "params": { "fullTest": true }, "outputTo": "tests" } + ], "loop": { "type": "once" }}, + "outputTo": "testResult" }, + { "type": "condition", "check": "$testResult.success", + "then": [ + { "type": "command", "command": "code/git", + "params": { "operation": "push" }, "outputTo": "push" }, + { "type": "emit", "event": "sentinel:deployed", "data": "$push" } + ], + "else": [ + { "type": "emit", "event": "sentinel:test-failure", "data": "$testResult" } + ]} + ], + "loop": { "type": "once" } +} +``` + +## Storage: SentinelEntity + +Sentinel definitions are **entities** β€” stored in the database, queryable via `data/*`, exportable to JSON. + +```typescript +class SentinelEntity extends BaseEntity { + static readonly collection = 'sentinels'; + + uniqueId: string; // Human-readable identifier + name: string; // Display name + description: string; + version: number; + + // The definition (the "code") + steps: SentinelStep[]; + loop: LoopConfig; + safety: SentinelSafety; + trigger?: SentinelTrigger; + recipe?: string; // RAG recipe reference + tools?: string[]; // Tool highlights + + // Metadata + createdBy: UUID; // Persona or human who created it + tags: string[]; + isPublic: boolean; + + // Runtime (updated when running) + lastRunAt?: Date; + runCount: number; + averageDurationMs?: number; +} +``` + +**Operations:** +```bash +# Create from JSON file +./jtag sentinel/create --file="sentinels/build-fix.json" + +# Create inline +./jtag sentinel/create --name="quick-test" --steps='[...]' --loop='{"type":"count","max":3}' + +# List all sentinels +./jtag sentinel/list + +# Export to JSON +./jtag data/read --collection=sentinels --filter='{"uniqueId":"build-fix"}' > build-fix.json + +# Import from another project +./jtag sentinel/create --file="/path/to/other-project/.continuum/sentinels/ci-pipeline.json" +``` + +**Sentinels travel with projects.** Store them in `.continuum/sentinels/*.json` β€” same as recipes in `system/recipes/*.json`. The `sentinel/create` command loads them into the database on first run. + +## How This Maps to Code Collaboration + +### Persona β†’ Workspace β†’ Sentinel + +``` +PersonaUser + └── Workspace (sandbox | worktree | project) + └── Sentinel(s) + β”œβ”€β”€ build-fix-loop (continuous development) + β”œβ”€β”€ code-review (triggered by teammate push) + └── test-watcher (triggered by file changes) +``` + +Each persona's workspace runs one or more sentinels. The sentinels use the workspace's code/* tools. Inter-persona coordination happens through events: + +``` +PersonaA sentinel emits "git:push" + β†’ PersonaB's code-review sentinel triggers + β†’ Posts review in chat + β†’ PersonaA's sentinel reacts to review feedback +``` + +### Dynamic Creation by AIs + +A persona can create a sentinel on the fly: + +```typescript +// AI decides it needs a build watcher for this task +const sentinel = await Commands.execute('sentinel/create', { + name: 'watch-my-build', + steps: [ /* ... */ ], + loop: { type: 'until', check: '$build.exitCode === 0' } +}); + +// Later, another AI can inspect or modify it +const sentinels = await Commands.execute('sentinel/list', { + personaId: 'helper-ai' +}); +``` + +### Deployable Into Projects + +A sentinel definition is just JSON. It can be: +- Stored in a project repo (`.continuum/sentinels/*.json`) +- Loaded by `sentinel/load` command (like `workspace/recipe/load`) +- Shared between projects +- Specialized via LoRA training (the sentinel's LLM steps use a fine-tuned model) + +## Relationship to Recipes + +**A Recipe IS a Sentinel with `loop: { type: 'once' }` and a UI layout.** + +``` +RecipeDefinition SentinelDefinition +───────────────── ────────────────── +pipeline: RecipeStep[] steps: SentinelStep[] (superset of RecipeStep) +ragTemplate recipe + ragSources (same concept) +strategy (embedded in LLM steps) +layout (sentinels don't have UI) +tools tools (same) +``` + +Migration path: extend `RecipeStep` to support the additional step types (`llm`, `watch`, `sentinel`, `emit`). Existing recipes continue working unchanged. New sentinels use the extended step types. + +## Runtime: Handles and State + +A running sentinel is a **handle** β€” like a workspace handle. Managed by Rust (continuum-core). + +``` +SentinelHandle { + id: UUID + definition: SentinelDefinition // The JSON definition + state: SentinelState // Runtime mutable state +} + +SentinelState { + status: 'running' | 'paused' | 'completed' | 'failed' + iteration: number // Current loop iteration + variables: Map // Step outputs (persisted across iterations) + currentStepIndex: number // Where in the pipeline we are + trace: StepTrace[] // Execution history + startedAt: number + lastStepAt: number +} +``` + +### Live CRUD on Steps + +Steps are index-addressable. You can CRUD them while a sentinel is running β€” the next iteration picks up the changes: + +```bash +# Add a step at index 2 +./jtag sentinel/step/add --sentinelId="abc" --index=2 --step='{"command":"code/verify","outputTo":"result"}' + +# Update step 1 +./jtag sentinel/step/update --sentinelId="abc" --index=1 --params='{"command":"cargo test"}' + +# Remove step 3 +./jtag sentinel/step/remove --sentinelId="abc" --index=3 + +# List current steps +./jtag sentinel/step/list --sentinelId="abc" +``` + +This makes sentinels debuggable and tunable at runtime β€” like editing a running program. + +### Safety Controls + +```typescript +interface SentinelSafety { + maxIterations?: number; // Hard limit on loop count + timeoutMs?: number; // Hard limit on total runtime + maxStepTimeoutMs?: number; // Per-step timeout + maxMemoryMb?: number; // Memory budget + onTimeout: 'stop' | 'pause'; // What to do when limits hit +} +``` + +Every sentinel MUST have either `maxIterations` or `timeoutMs` (or both). No unbounded loops. + +## Commands (Unix-Style, Small and Composable) + +| Command | Purpose | +|---------|---------| +| `sentinel/create` | Define a sentinel from JSON definition | +| `sentinel/start` | Start running a defined sentinel | +| `sentinel/stop` | Stop a running sentinel | +| `sentinel/pause` | Pause a sentinel (resume later) | +| `sentinel/resume` | Resume a paused sentinel | +| `sentinel/status` | Get state of a running sentinel | +| `sentinel/list` | List all defined + running sentinels | +| `sentinel/step/add` | Add a step at index | +| `sentinel/step/update` | Update a step's params | +| `sentinel/step/remove` | Remove a step by index | +| `sentinel/step/list` | List current steps with state | + +Everything else composes from existing commands: +- `ai/generate` IS the LLM step (it's already a command) +- `code/shell/execute` + `code/shell/sentinel` + `code/shell/watch` handle process I/O +- `Events.emit/subscribe` handles cross-sentinel composition +- `data/*` handles persistence + +## Implementation Path + +### Phase 1: Loop Engine + Core Commands +- `sentinel/create`, `sentinel/start`, `sentinel/stop`, `sentinel/status`, `sentinel/list` +- `SentinelRunner` executes pipeline steps in a loop with variable propagation +- Safety controls: `maxIterations`, `timeoutMs` +- Rust handle management in continuum-core +- This alone enables build-fix loops and script automation + +### Phase 2: Step CRUD + LLM Integration +- `sentinel/step/*` commands for live mutation +- `ai/generate` as a pipeline step with accumulated variables as context +- Tool call parsing within sentinel steps +- This enables the explore-agent and code-review patterns + +### Phase 3: Composition +- `type: 'sentinel'` step for nesting +- `type: 'emit'` step + event triggers for cross-sentinel wiring +- This enables multi-persona coordination + +### Phase 4: Deployment + Training +- Sentinels stored as entities (like recipes) +- `sentinel/deploy` packages sentinel for external project use +- LoRA genomic training specializes sentinel LLM steps + +## The Recursive Property + +The system is recursive at every level: + +- **Commands execute commands** β€” `Commands.execute()` is universal +- **Sentinels run sentinels** β€” `type: 'sentinel'` nesting +- **AIs create AIs** β€” personas create sentinel definitions that contain LLM steps +- **Tools discover tools** β€” `search_tools` meta-tool finds commands +- **Events trigger events** β€” sentinel emit β†’ another sentinel's trigger + +This means the system can build itself. An AI can observe a manual workflow, encode it as a sentinel, test it, refine it, and deploy it β€” all using the same command/event primitives it uses for everything else. diff --git a/src/debug/jtag/generated-command-schemas.json b/src/debug/jtag/generated-command-schemas.json index cc67bf607..bc3654777 100644 --- a/src/debug/jtag/generated-command-schemas.json +++ b/src/debug/jtag/generated-command-schemas.json @@ -1,5 +1,5 @@ { - "generated": "2026-02-01T20:13:44.015Z", + "generated": "2026-02-04T23:37:20.476Z", "version": "1.0.0", "commands": [ { @@ -1318,6 +1318,116 @@ } } }, + { + "name": "skill/validate", + "description": "Skill Validate Command - Shared Types\n *\n * Validate a generated skill by running TypeScript compilation and tests in an ExecutionSandbox. Updates SkillEntity with validation results.", + "params": { + "skillId": { + "type": "string", + "required": true, + "description": "skillId parameter" + } + } + }, + { + "name": "skill/propose", + "description": "Skill Propose Command - Shared Types\n *\n * Propose a new skill (command) specification. Creates a SkillEntity with status 'proposed'. For team-scoped skills, creates a DecisionProposal for governance approval.", + "params": { + "name": { + "type": "string", + "required": true, + "description": "name parameter" + }, + "description": { + "type": "string", + "required": true, + "description": "description parameter" + }, + "skillParams": { + "type": "array", + "required": true, + "description": "skillParams parameter" + }, + "skillResults": { + "type": "array", + "required": true, + "description": "skillResults parameter" + }, + "implementation": { + "type": "string", + "required": true, + "description": "implementation parameter" + }, + "scope": { + "type": "string", + "required": false, + "description": "scope parameter" + }, + "examples": { + "type": "array", + "required": false, + "description": "examples parameter" + }, + "personaId": { + "type": "string", + "required": true, + "description": "personaId parameter" + } + } + }, + { + "name": "skill/list", + "description": "Skill List Command - Shared Types\n *\n * List skills with optional filters by status, scope, and creator. Returns SkillEntity records from the database.", + "params": { + "status": { + "type": "string", + "required": false, + "description": "status parameter" + }, + "scope": { + "type": "string", + "required": false, + "description": "scope parameter" + }, + "createdById": { + "type": "string", + "required": false, + "description": "createdById parameter" + }, + "limit": { + "type": "number", + "required": false, + "description": "limit parameter" + } + } + }, + { + "name": "skill/generate", + "description": "Skill Generate Command - Shared Types\n *\n * Generate code files for a proposed skill using the CommandGenerator. Retrieves the SkillEntity and produces source files.", + "params": { + "skillId": { + "type": "string", + "required": true, + "description": "skillId parameter" + }, + "outputDir": { + "type": "string", + "required": false, + "description": "outputDir parameter" + } + } + }, + { + "name": "skill/activate", + "description": "Skill Activate Command - Shared Types\n *\n * Activate a validated skill by registering it as a live command. The skill becomes available for use by the creator (personal) or all personas (team).", + "params": { + "skillId": { + "type": "string", + "required": true, + "description": "skillId parameter" + } + } + }, { "name": "session/get-user", "description": "session/get-user command", @@ -3743,83 +3853,6 @@ } } }, - { - "name": "development/code/read", - "description": "code/read command types", - "params": { - "backend": { - "type": "string", - "required": true, - "description": "backend parameter" - }, - "path": { - "type": "string", - "required": true, - "description": "path parameter" - }, - "startLine": { - "type": "number", - "required": false, - "description": "startLine parameter" - }, - "endLine": { - "type": "number", - "required": false, - "description": "endLine parameter" - }, - "includeMetadata": { - "type": "boolean", - "required": false, - "description": "includeMetadata parameter" - }, - "forceRefresh": { - "type": "boolean", - "required": false, - "description": "forceRefresh parameter" - } - } - }, - { - "name": "development/code/pattern-search", - "description": "code/find command types - Find files by name pattern", - "params": { - "backend": { - "type": "string", - "required": true, - "description": "backend parameter" - }, - "pattern": { - "type": "string", - "required": true, - "description": "pattern parameter" - }, - "baseDir": { - "type": "string", - "required": false, - "description": "baseDir parameter" - }, - "caseInsensitive": { - "type": "boolean", - "required": false, - "description": "caseInsensitive parameter" - }, - "maxResults": { - "type": "number", - "required": false, - "description": "maxResults parameter" - }, - "includeHidden": { - "type": "boolean", - "required": false, - "description": "includeHidden parameter" - }, - "excludeDirs": { - "type": "array", - "required": false, - "description": "excludeDirs parameter" - } - } - }, { "name": "development/build", "description": "Development Build Command - Shared Types\n *\n * Zero-friction TypeScript build check. Returns success or structured errors.", @@ -5175,6 +5208,376 @@ } } }, + { + "name": "code/write", + "description": "Code Write Command - Shared Types\n *\n * Write or create a file in the persona's workspace. Creates a ChangeNode in the change graph for undo support. File extension must be in the allowlist.", + "params": { + "filePath": { + "type": "string", + "required": true, + "description": "filePath parameter" + }, + "content": { + "type": "string", + "required": true, + "description": "content parameter" + }, + "description": { + "type": "string", + "required": false, + "description": "description parameter" + } + } + }, + { + "name": "code/verify", + "description": "Code Verify Command - Shared Types\n *\n * Run TypeScript compilation checks and optionally execute tests against a persona workspace.\n * Returns structured errors with file, line, column, and message for each issue found.", + "params": { + "typeCheck": { + "type": "boolean", + "required": false, + "description": "typeCheck parameter" + }, + "testFiles": { + "type": "array", + "required": false, + "description": "testFiles parameter" + }, + "cwd": { + "type": "string", + "required": false, + "description": "cwd parameter" + } + } + }, + { + "name": "code/undo", + "description": "Code Undo Command - Shared Types\n *\n * Undo a specific change or the last N changes. Applies reverse diffs from the change graph to restore previous file state.", + "params": { + "changeId": { + "type": "string", + "required": false, + "description": "changeId parameter" + }, + "count": { + "type": "number", + "required": false, + "description": "count parameter" + } + } + }, + { + "name": "code/tree", + "description": "Code Tree Command - Shared Types\n *\n * Generate a directory tree for the workspace or a subdirectory. Shows file/directory structure with sizes. Skips common ignored directories (node_modules, .git, etc).", + "params": { + "path": { + "type": "string", + "required": false, + "description": "path parameter" + }, + "maxDepth": { + "type": "number", + "required": false, + "description": "maxDepth parameter" + }, + "includeHidden": { + "type": "boolean", + "required": false, + "description": "includeHidden parameter" + } + } + }, + { + "name": "code/shell/watch", + "description": "Code Shell Watch Command - Shared Types\n *\n * Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling.\n * Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true.", + "params": { + "executionId": { + "type": "string", + "required": true, + "description": "executionId parameter" + } + } + }, + { + "name": "code/shell/status", + "description": "Code Shell Status Command - Shared Types\n *\n * Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected).", + "params": { + "_noParams": { + "type": "string", + "required": false, + "description": "_noParams parameter" + } + } + }, + { + "name": "code/shell/sentinel", + "description": "Code Shell Sentinel Command - Shared Types\n *\n * Configure sentinel filter rules on a shell execution. Rules classify output lines\n * and control which lines are emitted or suppressed during watch.\n * Patterns are compiled to regex on the Rust side for performance.", + "params": { + "executionId": { + "type": "string", + "required": true, + "description": "executionId parameter" + }, + "rules": { + "type": "array", + "required": true, + "description": "rules parameter" + } + } + }, + { + "name": "code/shell/kill", + "description": "Code Shell Kill Command - Shared Types\n *\n * Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target.", + "params": { + "executionId": { + "type": "string", + "required": true, + "description": "executionId parameter" + } + } + }, + { + "name": "code/shell/execute", + "description": "Code Shell Execute Command - Shared Types\n *\n * Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr.", + "params": { + "cmd": { + "type": "string", + "required": true, + "description": "cmd parameter" + }, + "wait": { + "type": "boolean", + "required": false, + "description": "wait parameter" + }, + "timeoutMs": { + "type": "number", + "required": false, + "description": "timeoutMs parameter" + } + } + }, + { + "name": "code/search", + "description": "Code Search Command - Shared Types\n *\n * Search for a regex pattern across workspace files. Respects .gitignore, supports glob-based file filtering. Returns matching lines with context.", + "params": { + "pattern": { + "type": "string", + "required": true, + "description": "pattern parameter" + }, + "fileGlob": { + "type": "string", + "required": false, + "description": "fileGlob parameter" + }, + "maxResults": { + "type": "number", + "required": false, + "description": "maxResults parameter" + } + } + }, + { + "name": "code/read", + "description": "Code Read Command - Shared Types\n *\n * Read a file or line range from the persona's workspace. Returns content with line numbers and metadata. Supports partial reads via start/end line parameters.", + "params": { + "filePath": { + "type": "string", + "required": true, + "description": "filePath parameter" + }, + "startLine": { + "type": "number", + "required": false, + "description": "startLine parameter" + }, + "endLine": { + "type": "number", + "required": false, + "description": "endLine parameter" + } + } + }, + { + "name": "code/history", + "description": "Code History Command - Shared Types\n *\n * Get change history for a specific file or the entire workspace. Returns change graph nodes with diffs, timestamps, and descriptions.", + "params": { + "filePath": { + "type": "string", + "required": false, + "description": "filePath parameter" + }, + "limit": { + "type": "number", + "required": false, + "description": "limit parameter" + } + } + }, + { + "name": "code/git", + "description": "Code Git Command - Shared Types\n *\n * Workspace-scoped git operations for the coding agent pipeline.\n * Operations: status, diff, log, add, commit, push.\n * All operations are routed through the Rust IPC backend for per-persona workspace isolation.", + "params": { + "userId": { + "type": "string", + "required": true, + "description": "userId parameter" + }, + "operation": { + "type": "string", + "required": true, + "description": "operation parameter" + }, + "paths": { + "type": "array", + "required": false, + "description": "paths parameter" + }, + "message": { + "type": "string", + "required": false, + "description": "message parameter" + }, + "remote": { + "type": "string", + "required": false, + "description": "remote parameter" + }, + "branch": { + "type": "string", + "required": false, + "description": "branch parameter" + }, + "staged": { + "type": "boolean", + "required": false, + "description": "staged parameter" + }, + "count": { + "type": "number", + "required": false, + "description": "count parameter" + } + } + }, + { + "name": "code/edit", + "description": "Code Edit Command - Shared Types\n *\n * Edit a file using search-replace, line-range replacement, insert-at, or append. Creates a ChangeNode for undo. Safer than full file write for targeted modifications.", + "params": { + "filePath": { + "type": "string", + "required": true, + "description": "filePath parameter" + }, + "editType": { + "type": "string", + "required": true, + "description": "editType parameter" + }, + "search": { + "type": "string", + "required": false, + "description": "search parameter" + }, + "replace": { + "type": "string", + "required": false, + "description": "replace parameter" + }, + "replaceAll": { + "type": "boolean", + "required": false, + "description": "replaceAll parameter" + }, + "startLine": { + "type": "number", + "required": false, + "description": "startLine parameter" + }, + "endLine": { + "type": "number", + "required": false, + "description": "endLine parameter" + }, + "newContent": { + "type": "string", + "required": false, + "description": "newContent parameter" + }, + "line": { + "type": "number", + "required": false, + "description": "line parameter" + }, + "content": { + "type": "string", + "required": false, + "description": "content parameter" + }, + "description": { + "type": "string", + "required": false, + "description": "description parameter" + } + } + }, + { + "name": "code/diff", + "description": "Code Diff Command - Shared Types\n *\n * Preview an edit as a unified diff without applying it. Useful for reviewing changes before committing them. Uses the same edit modes as code/edit.", + "params": { + "filePath": { + "type": "string", + "required": true, + "description": "filePath parameter" + }, + "editType": { + "type": "string", + "required": true, + "description": "editType parameter" + }, + "search": { + "type": "string", + "required": false, + "description": "search parameter" + }, + "replace": { + "type": "string", + "required": false, + "description": "replace parameter" + }, + "replaceAll": { + "type": "boolean", + "required": false, + "description": "replaceAll parameter" + }, + "startLine": { + "type": "number", + "required": false, + "description": "startLine parameter" + }, + "endLine": { + "type": "number", + "required": false, + "description": "endLine parameter" + }, + "newContent": { + "type": "string", + "required": false, + "description": "newContent parameter" + }, + "line": { + "type": "number", + "required": false, + "description": "line parameter" + }, + "content": { + "type": "string", + "required": false, + "description": "content parameter" + } + } + }, { "name": "canvas/vision", "description": "Canvas Vision Command Types\n *\n * Enables AIs to \"see\" and interact with the drawing canvas:\n * - describe: Vision AI describes what's on the canvas\n * - transform: Use image generation to transform the sketch\n * - analyze: Structured analysis of the drawing", diff --git a/src/debug/jtag/generator/specs/code-shell-execute.json b/src/debug/jtag/generator/specs/code-shell-execute.json new file mode 100644 index 000000000..da287f906 --- /dev/null +++ b/src/debug/jtag/generator/specs/code-shell-execute.json @@ -0,0 +1,64 @@ +{ + "name": "code/shell/execute", + "description": "Execute a shell command in the persona's workspace. Async mode (default) returns execution handle immediately β€” use code/shell/watch to stream output. Sync mode (wait=true) blocks until completion and returns full stdout/stderr.", + "params": [ + { + "name": "cmd", + "type": "string", + "optional": false, + "description": "Shell command to execute (e.g., \"npm run build\", \"cargo test\", \"xcodebuild\")" + }, + { + "name": "wait", + "type": "boolean", + "optional": true, + "description": "Wait for completion before returning (default: false β€” returns handle immediately)" + }, + { + "name": "timeoutMs", + "type": "number", + "optional": true, + "description": "Timeout in milliseconds (default: 30000 for sync, no limit for async)" + } + ], + "results": [ + { + "name": "executionId", + "type": "string", + "description": "Execution handle β€” use with code/shell/watch, code/shell/kill" + }, + { + "name": "status", + "type": "string", + "description": "Execution status: running, completed, failed, timed_out, killed" + }, + { + "name": "stdout", + "type": "string", + "description": "Full stdout (only present when wait=true and execution completed)" + }, + { + "name": "stderr", + "type": "string", + "description": "Full stderr (only present when wait=true and execution completed)" + }, + { + "name": "exitCode", + "type": "number", + "description": "Process exit code (only present when execution completed)" + } + ], + "examples": [ + { + "description": "Run a build synchronously and wait for result", + "command": "./jtag code/shell/execute --cmd=\"npm run build\" --wait=true", + "expectedResult": "{ executionId: \"exec-abc123\", status: \"completed\", stdout: \"...\", exitCode: 0 }" + }, + { + "description": "Start an async build (returns handle for streaming)", + "command": "./jtag code/shell/execute --cmd=\"cargo build --release\"", + "expectedResult": "{ executionId: \"exec-def456\", status: \"running\" }" + } + ], + "accessLevel": "ai-safe" +} diff --git a/src/debug/jtag/generator/specs/code-shell-kill.json b/src/debug/jtag/generator/specs/code-shell-kill.json new file mode 100644 index 000000000..0af4afd53 --- /dev/null +++ b/src/debug/jtag/generator/specs/code-shell-kill.json @@ -0,0 +1,32 @@ +{ + "name": "code/shell/kill", + "description": "Kill a running shell execution. Use the executionId returned by code/shell/execute to identify the target.", + "params": [ + { + "name": "executionId", + "type": "string", + "optional": false, + "description": "Execution handle to kill (from code/shell/execute)" + } + ], + "results": [ + { + "name": "executionId", + "type": "string", + "description": "Echo of the killed execution handle" + }, + { + "name": "killed", + "type": "boolean", + "description": "Whether the execution was successfully killed" + } + ], + "examples": [ + { + "description": "Kill a running build", + "command": "./jtag code/shell/kill --executionId=\"exec-abc123\"", + "expectedResult": "{ executionId: \"exec-abc123\", killed: true }" + } + ], + "accessLevel": "ai-safe" +} diff --git a/src/debug/jtag/generator/specs/code-shell-sentinel.json b/src/debug/jtag/generator/specs/code-shell-sentinel.json new file mode 100644 index 000000000..4bb9bc54c --- /dev/null +++ b/src/debug/jtag/generator/specs/code-shell-sentinel.json @@ -0,0 +1,38 @@ +{ + "name": "code/shell/sentinel", + "description": "Configure sentinel filter rules on a shell execution. Rules classify output lines and control which lines are emitted or suppressed during watch. Patterns are compiled to regex on the Rust side for performance.", + "params": [ + { + "name": "executionId", + "type": "string", + "optional": false, + "description": "Execution handle to attach sentinel rules to" + }, + { + "name": "rules", + "type": "SentinelRule[]", + "optional": false, + "description": "Array of classification rules: { pattern: string, classification: OutputClassification, action: SentinelAction }" + } + ], + "results": [ + { + "name": "applied", + "type": "boolean", + "description": "Whether rules were applied successfully" + }, + { + "name": "ruleCount", + "type": "number", + "description": "Number of sentinel rules configured" + } + ], + "examples": [ + { + "description": "Filter build output to only errors and warnings", + "command": "./jtag code/shell/sentinel --executionId=\"exec-abc123\" --rules='[{\"pattern\":\"^error\",\"classification\":\"Error\",\"action\":\"Emit\"},{\"pattern\":\".*\",\"classification\":\"Verbose\",\"action\":\"Suppress\"}]'", + "expectedResult": "{ applied: true, ruleCount: 2 }" + } + ], + "accessLevel": "ai-safe" +} diff --git a/src/debug/jtag/generator/specs/code-shell-status.json b/src/debug/jtag/generator/specs/code-shell-status.json new file mode 100644 index 000000000..b2b7daff5 --- /dev/null +++ b/src/debug/jtag/generator/specs/code-shell-status.json @@ -0,0 +1,45 @@ +{ + "name": "code/shell/status", + "description": "Get shell session info for the persona's workspace β€” current working directory, active and total execution count. No parameters required (userId auto-injected).", + "params": [], + "results": [ + { + "name": "sessionId", + "type": "string", + "description": "Shell session identifier" + }, + { + "name": "personaId", + "type": "string", + "description": "Persona that owns this shell session" + }, + { + "name": "cwd", + "type": "string", + "description": "Current working directory of the shell session" + }, + { + "name": "workspaceRoot", + "type": "string", + "description": "Root directory of the workspace" + }, + { + "name": "activeExecutions", + "type": "number", + "description": "Number of currently running executions" + }, + { + "name": "totalExecutions", + "type": "number", + "description": "Total number of executions (running + completed)" + } + ], + "examples": [ + { + "description": "Check shell session status", + "command": "./jtag code/shell/status", + "expectedResult": "{ sessionId: \"sess-abc\", cwd: \"/workspace/game\", activeExecutions: 1, totalExecutions: 5 }" + } + ], + "accessLevel": "ai-safe" +} diff --git a/src/debug/jtag/generator/specs/code-shell-watch.json b/src/debug/jtag/generator/specs/code-shell-watch.json new file mode 100644 index 000000000..f198d4b59 --- /dev/null +++ b/src/debug/jtag/generator/specs/code-shell-watch.json @@ -0,0 +1,47 @@ +{ + "name": "code/shell/watch", + "description": "Watch a shell execution for new output. Blocks until output is available β€” no timeout, no polling. Returns classified output lines filtered through sentinel rules. Call in a loop until finished is true.", + "params": [ + { + "name": "executionId", + "type": "string", + "optional": false, + "description": "Execution handle from shell/exec" + } + ], + "results": [ + { + "name": "executionId", + "type": "string", + "description": "Echo of the execution handle" + }, + { + "name": "lines", + "type": "ClassifiedLine[]", + "description": "New output lines since last watch call (classified and filtered)" + }, + { + "name": "finished", + "type": "boolean", + "description": "True when execution is complete" + }, + { + "name": "exitCode", + "type": "number", + "description": "Process exit code (present when finished)" + } + ], + "examples": [ + { + "description": "Watch a running build for new output", + "command": "./jtag code/shell/watch --executionId=\"exec-abc123\"", + "expectedResult": "{ executionId: \"exec-abc123\", lines: [{text: \"Compiling...\", classification: \"Info\"}], finished: false }" + }, + { + "description": "Final watch call when execution completes", + "command": "./jtag code/shell/watch --executionId=\"exec-abc123\"", + "expectedResult": "{ executionId: \"exec-abc123\", lines: [], finished: true, exitCode: 0 }" + } + ], + "accessLevel": "ai-safe" +} diff --git a/src/debug/jtag/package-lock.json b/src/debug/jtag/package-lock.json index af78b60d5..2a3b53d41 100644 --- a/src/debug/jtag/package-lock.json +++ b/src/debug/jtag/package-lock.json @@ -1,12 +1,12 @@ { "name": "@continuum/jtag", - "version": "1.0.7512", + "version": "1.0.7610", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@continuum/jtag", - "version": "1.0.7512", + "version": "1.0.7610", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/debug/jtag/package.json b/src/debug/jtag/package.json index 742980ebb..b300e0aba 100644 --- a/src/debug/jtag/package.json +++ b/src/debug/jtag/package.json @@ -1,6 +1,6 @@ { "name": "@continuum/jtag", - "version": "1.0.7512", + "version": "1.0.7610", "description": "Global CLI debugging system for any Node.js project. Install once globally, use anywhere: npm install -g @continuum/jtag", "config": { "active_example": "widget-ui", @@ -135,7 +135,7 @@ "clean": "rm -rf dist/ 2>/dev/null || true; rm -f *.tgz 2>/dev/null || true", "clean:all": "rm -rf dist/ 2>/dev/null || true; rm -rf examples/dist/ 2>/dev/null || true; rm -f *.tgz 2>/dev/null || true; rm -rf .continuum/jtag/sessions 2>/dev/null || true; find .continuum/sessions -mindepth 1 -maxdepth 1 -type d \\! -name 'validation' -exec rm -rf {} + 2>/dev/null || true; rm -rf examples/*/.continuum/jtag/sessions 2>/dev/null || true", "clean:dist": "rm -rf dist/ 2>/dev/null || true", - "clean:logs": "find .continuum/jtag/logs -name '*.log' -type f -delete 2>/dev/null || true; find .continuum/personas -path '*/logs/*.log' -type f -delete 2>/dev/null || true; rm -f /tmp/jtag-*-timing.jsonl 2>/dev/null || true; echo 'βœ… Cleaned all log files (system + persona + timing logs)'", + "clean:logs": "find .continuum/jtag/logs -name '*.log' -type f -delete 2>/dev/null || true; find .continuum/personas -name '*.log' -type f -delete 2>/dev/null || true; rm -f /tmp/jtag-*-timing.jsonl 2>/dev/null || true; echo 'βœ… Cleaned all log files (system + persona + timing logs)'", "prepare": "npx tsx scripts/ensure-config.ts 2>/dev/null || true", "postinstall": "npm run worker:models", "prebuild": "npx tsx scripts/ensure-config.ts && npm run version:bump && npm run clean:all && npm run worker:models && npm run worker:build && npx tsx generator/generate-structure.ts && npx tsx generator/generate-command-schemas.ts && npx tsx generator/generate-command-constants.ts && npx tsx scripts/compile-sass.ts", diff --git a/src/debug/jtag/scripts/ensure-config.ts b/src/debug/jtag/scripts/ensure-config.ts index cb07521a9..b825e7d3f 100644 --- a/src/debug/jtag/scripts/ensure-config.ts +++ b/src/debug/jtag/scripts/ensure-config.ts @@ -39,11 +39,12 @@ WS_PORT=9001 # Log Level - Controls verbosity of logs # Values: debug, info, warn, error, silent # - debug: Everything (verbose, for debugging only) -# - info: Info, warnings, errors (default for development) -# - warn: Warnings and errors (recommended for production) +# - info: Info, warnings, errors +# - warn: Warnings and errors (default β€” keeps system quiet) # - error: Only errors # - silent: No logs -LOG_LEVEL=info +# Per-component overrides via LogLevelRegistry (runtime mutable) +LOG_LEVEL=warn # Timestamps - Add timestamps to log entries # Values: 0 (disabled), 1 (enabled) @@ -68,6 +69,11 @@ LOG_TO_FILES=1 # Default: clean LOG_FILE_MODE=clean +# Performance Timing - Record operation timing to /tmp/jtag-timing.jsonl +# Values: true (enabled), false (disabled) +# Default: false (enable when analyzing performance) +JTAG_TIMING_ENABLED=false + # ============================================ # API KEYS # ============================================ diff --git a/src/debug/jtag/scripts/launch-and-capture.ts b/src/debug/jtag/scripts/launch-and-capture.ts index b0dee675a..ce2938b7b 100644 --- a/src/debug/jtag/scripts/launch-and-capture.ts +++ b/src/debug/jtag/scripts/launch-and-capture.ts @@ -647,39 +647,49 @@ async function main(): Promise { console.log(`🌐 ${instanceConfig.name}: http://localhost:${httpPort}/`); console.log(`πŸ”Œ WebSocket: ws://localhost:${wsPort}/`); - // Check if browser is connected via ping, then refresh AND open - console.log('πŸ”„ Checking browser connection...'); + // --- Browser Detection (server already running) --- + // Ping to check if a browser tab is already connected. + // If yes β†’ refresh it. If no β†’ open a new tab. + // IMPORTANT: Retry ping up to 3 times with delays β€” browser tabs + // may be momentarily disconnected (WebSocket reconnecting, etc.) + console.log('πŸ” Detecting browser connection...'); try { const browserUrl = `http://localhost:${httpPort}/`; - // Check ping to see if browser is connected - const pingResult = await new Promise<{ browserConnected: boolean; browserUrl?: string }>((resolve) => { - exec('./jtag ping', { timeout: 5000 }, (error, stdout) => { - if (error) { - resolve({ browserConnected: false }); - } else { + const pingForBrowser = (): Promise => { + return new Promise((resolve) => { + exec('./jtag ping', { timeout: 5000 }, (error, stdout) => { + if (error) { + resolve(false); + return; + } try { const result = JSON.parse(stdout); - // Browser is connected if ping returns browser info - const connected = result.browser && result.browser.type === 'browser'; - resolve({ - browserConnected: connected, - browserUrl: result.browser?.url - }); + resolve(!!(result.success && result.browser && result.browser.type === 'browser')); } catch { - resolve({ browserConnected: false }); + resolve(false); } - } + }); }); - }); + }; + + let browserConnected = await pingForBrowser(); + + if (!browserConnected) { + // Wait and retry β€” browser tab may be reconnecting after restart + for (let attempt = 1; attempt <= 2; attempt++) { + console.log(`πŸ” No browser on attempt ${attempt} β€” waiting 3s for reconnect...`); + await new Promise(resolve => setTimeout(resolve, 3000)); + browserConnected = await pingForBrowser(); + if (browserConnected) break; + } + } - if (pingResult.browserConnected) { - // Browser is connected - just refresh it - console.log('πŸ”„ Browser connected, refreshing...'); + if (browserConnected) { + console.log('βœ… Browser connected β€” refreshing existing tab'); exec('./jtag interface/navigate', { timeout: 5000 }, () => {}); } else { - // No browser connected - open new tab - console.log('🌐 Opening browser...'); + console.log('🌐 No browser detected after 3 attempts β€” opening new tab'); spawn('open', [browserUrl], { detached: true, stdio: 'ignore' }).unref(); } } catch { diff --git a/src/debug/jtag/scripts/migrate-sandbox-to-git.ts b/src/debug/jtag/scripts/migrate-sandbox-to-git.ts new file mode 100644 index 000000000..a8d0c4955 --- /dev/null +++ b/src/debug/jtag/scripts/migrate-sandbox-to-git.ts @@ -0,0 +1,124 @@ +/** + * migrate-sandbox-to-git.ts - One-time migration of persona sandbox workspaces to git repos + * + * Scans .continuum/personas/{id}/workspace/ for non-empty directories without .git, + * initializes git repos, and creates initial commits preserving existing work. + * + * Safe to re-run: skips workspaces that already have .git initialized. + * + * Usage: npx tsx scripts/migrate-sandbox-to-git.ts + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const JTAG_ROOT = path.resolve(__dirname, '..'); +const PERSONAS_DIR = path.join(JTAG_ROOT, '.continuum', 'personas'); + +interface MigrationResult { + readonly personaId: string; + readonly workspacePath: string; + readonly fileCount: number; + readonly status: 'migrated' | 'skipped-empty' | 'skipped-already-git' | 'failed'; + readonly error?: string; +} + +function countFiles(dir: string): number { + let count = 0; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === '.git' || entry.name === 'node_modules') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isFile()) { + count++; + } else if (entry.isDirectory()) { + count += countFiles(fullPath); + } + } + return count; +} + +function migrateWorkspace(personaId: string, workspacePath: string): MigrationResult { + // Skip if already a git repo + if (fs.existsSync(path.join(workspacePath, '.git'))) { + return { personaId, workspacePath, fileCount: 0, status: 'skipped-already-git' }; + } + + // Count files (skip node_modules) + const fileCount = countFiles(workspacePath); + if (fileCount === 0) { + return { personaId, workspacePath, fileCount: 0, status: 'skipped-empty' }; + } + + try { + const opts = { cwd: workspacePath, stdio: 'pipe' as const }; + + // Initialize git repo + execSync('git init', opts); + + // Set identity β€” use persona ID as placeholder; proper names set when project workspaces are created + execSync(`git config user.name "AI Persona (${personaId.slice(0, 8)})"`, opts); + execSync(`git config user.email "${personaId}@continuum.local"`, opts); + + // Create .gitignore for common build artifacts + const gitignore = 'node_modules/\ndist/\n.DS_Store\n*.log\n'; + fs.writeFileSync(path.join(workspacePath, '.gitignore'), gitignore); + + // Stage all files + execSync('git add .', opts); + + // Initial commit + execSync('git commit -m "Initial commit - migrated from sandbox workspace"', opts); + + console.log(` Migrated: ${personaId.slice(0, 8)}... (${fileCount} files)`); + return { personaId, workspacePath, fileCount, status: 'migrated' }; + + } catch (error: any) { + console.error(` Failed: ${personaId.slice(0, 8)}... - ${error.message}`); + return { personaId, workspacePath, fileCount, status: 'failed', error: error.message }; + } +} + +function main(): void { + console.log('Migrating persona sandbox workspaces to git repos...\n'); + + if (!fs.existsSync(PERSONAS_DIR)) { + console.log('No personas directory found. Nothing to migrate.'); + return; + } + + const personaDirs = fs.readdirSync(PERSONAS_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory() && d.name !== '.DS_Store'); + + const results: MigrationResult[] = []; + + for (const dir of personaDirs) { + const workspacePath = path.join(PERSONAS_DIR, dir.name, 'workspace'); + if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) { + continue; + } + + const result = migrateWorkspace(dir.name, workspacePath); + results.push(result); + } + + // Summary + const migrated = results.filter(r => r.status === 'migrated'); + const skippedGit = results.filter(r => r.status === 'skipped-already-git'); + const skippedEmpty = results.filter(r => r.status === 'skipped-empty'); + const failed = results.filter(r => r.status === 'failed'); + + console.log('\n--- Migration Summary ---'); + console.log(`Migrated: ${migrated.length} workspaces (${migrated.reduce((s, r) => s + r.fileCount, 0)} total files)`); + console.log(`Already git: ${skippedGit.length} workspaces`); + console.log(`Empty: ${skippedEmpty.length} workspaces`); + if (failed.length > 0) { + console.log(`Failed: ${failed.length} workspaces`); + for (const f of failed) { + console.log(` - ${f.personaId}: ${f.error}`); + } + } +} + +main(); diff --git a/src/debug/jtag/scripts/seed-continuum.ts b/src/debug/jtag/scripts/seed-continuum.ts index 494cbc03c..42708af31 100644 --- a/src/debug/jtag/scripts/seed-continuum.ts +++ b/src/debug/jtag/scripts/seed-continuum.ts @@ -2,199 +2,41 @@ /** * Clean Database Seeding via JTAG Commands * - * Uses factory functions to eliminate repetition and create clean data structures + * Performance-optimized: bulk loads, parallel updates, no redundant subprocess spawns. + * Uses factory functions from ./seed/factories and helper functions from ./seed/helpers. */ import { exec } from 'child_process'; import { promisify } from 'util'; -import { USER_IDS, ROOM_IDS, MESSAGE_IDS, USER_CONFIG, ROOM_CONFIG, MESSAGE_CONTENT } from '../api/data-seed/SeedConstants'; +import { ROOM_IDS, MESSAGE_IDS, ROOM_CONFIG, MESSAGE_CONTENT } from '../api/data-seed/SeedConstants'; import { DEFAULT_USER_UNIQUE_IDS } from '../system/data/domains/DefaultEntities'; import { stringToUUID } from '../system/core/types/CrossPlatformUUID'; -import { DATABASE_PATHS } from '../system/data/config/DatabaseConfig'; import { UserEntity } from '../system/data/entities/UserEntity'; import { RoomEntity } from '../system/data/entities/RoomEntity'; import { ChatMessageEntity } from '../system/data/entities/ChatMessageEntity'; -import { UserStateEntity } from '../system/data/entities/UserStateEntity'; import { ContentTypeEntity } from '../system/data/entities/ContentTypeEntity'; import { TrainingSessionEntity } from '../system/data/entities/TrainingSessionEntity'; -import type { UserCreateResult } from '../commands/user/create/shared/UserCreateTypes'; import { SystemIdentity } from '../api/data-seed/SystemIdentity'; import { PERSONA_CONFIGS, PERSONA_UNIQUE_IDS } from './seed/personas'; import { DATA_COMMANDS } from '../commands/data/shared/DataCommandConstants'; import { - createUserCapabilities, createRoom, - createChatMessage, createDefaultContentTypes, - createDefaultUserStates, - createDefaultTrainingSessions } from './seed/factories'; import { createRecord, - createStateRecord, updatePersonaProfile, updatePersonaConfig, updateUserMetadata, updateUserModelConfig, createUserViaCommand, - loadUserByUniqueId, - seedRecords + seedRecords, } from './seed/helpers'; -import { isTestUser, isTestRoom, isTestMessage } from '../tests/shared/TestEntityConstants'; const execAsync = promisify(exec); -// ===== MOVED TO scripts/seed/factories.ts ===== -// Factory functions extracted to eliminate repetition +// ===== LOCAL HELPERS (not in ./seed/helpers or ./seed/factories) ===== -/** - * @deprecated - Moved to factories.ts, keeping for reference during migration - */ -function createUserCapabilities_OLD(type: 'human' | 'agent'): any { - const baseCapabilities = { - canSendMessages: true, - canReceiveMessages: true, - canTrain: false, - }; - - if (type === 'human') { - return { - ...baseCapabilities, - canCreateRooms: true, - canInviteOthers: true, - canModerate: true, - autoResponds: false, - providesContext: false, - canAccessPersonas: true, - }; - } else { // agent - return { - ...baseCapabilities, - canCreateRooms: true, - canInviteOthers: true, - canModerate: true, - autoResponds: true, - providesContext: true, - canAccessPersonas: false, - }; - } -} - -/** - * Create user profile - */ -function createUserProfile(displayName: string, avatar: string, bio: string, location: string): any { - return { - displayName, - avatar, - bio, - location, - joinedAt: new Date().toISOString() - }; -} - -/** - * Create user preferences with sensible defaults - */ -function createUserPreferences(): any { - return { - theme: 'dark', - language: 'en', - timezone: 'UTC', - notifications: { - mentions: true, - directMessages: true, - roomUpdates: false - }, - privacy: { - showOnlineStatus: true, - allowDirectMessages: true, - shareActivity: false - } - }; -} - -/** - * Create complete user object - */ -function createUser(id: string, displayName: string, shortDescription: string, type: 'human' | 'agent', avatar: string, bio: string, location: string): any { - return { - id, - displayName, - shortDescription, - type, - profile: createUserProfile(displayName, avatar, bio, location), - capabilities: createUserCapabilities(type), - preferences: createUserPreferences(), - status: "online", - lastActiveAt: new Date().toISOString(), - sessionsActive: [] - }; -} - -/** - * Create room privacy settings - */ -function createRoomPrivacy(isPublic: boolean = true): any { - return { - isPublic, - requiresInvite: false, - allowGuestAccess: true, - searchable: true - }; -} - -/** - * Create room settings - */ -function createRoomSettings(): any { - return { - allowReactions: true, - allowThreads: true, - allowFileSharing: true, - messageRetentionDays: 365 - }; -} - -/** - * Create room stats - */ -function createRoomStats(memberCount: number): any { - return { - memberCount, - messageCount: 0, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString() - }; -} - -/** - * Create complete room object - */ -function createRoom(id: string, name: string, displayName: string, description: string, topic: string, memberCount: number, tags: string[], ownerId: string, uniqueId: string, recipeId: string = 'general-chat'): any { - return { - id, - uniqueId, // REQUIRED field for RoomEntity validation - name: name.toLowerCase(), - displayName, - description, - topic, - type: "public", - status: "active", - ownerId, - lastMessageAt: new Date().toISOString(), // Set to current time for new rooms - recipeId, // Recipe for conversation governance - privacy: createRoomPrivacy(), - settings: createRoomSettings(), - stats: createRoomStats(memberCount), - members: [], - tags - }; -} - -/** - * Create message content - */ function createMessageContent(text: string): any { return { text, @@ -209,16 +51,13 @@ function createMessageContent(text: string): any { }; } -/** - * Create complete message object - */ function createMessage(id: string, roomId: string, senderId: string, senderName: string, text: string, senderType: 'human' | 'agent' | 'persona' | 'system' = 'system'): any { return { id, roomId, senderId, senderName, - senderType, // REQUIRED field for ChatMessageEntity + senderType, content: createMessageContent(text), status: "sent", priority: "normal", @@ -227,396 +66,73 @@ function createMessage(id: string, roomId: string, senderId: string, senderName: }; } -/** - * Create default content type registry - */ -function createDefaultContentTypes(): any[] { - return [ - { - id: 'ct-chat', - type: 'chat', - displayName: 'Chat Room', - description: 'Real-time chat communication', - category: 'communication', - config: { - widgetSelector: 'chat-widget', - allowMultiple: true, - autoSave: true, - preloadData: true, - requiredPermissions: ['chat:read', 'chat:write'], - minUserType: 'human' - }, - isActive: true, - isBuiltIn: true, - sortOrder: 10 - }, - { - id: 'ct-academy', - type: 'academy-session', - displayName: 'Academy Training', - description: 'AI training sessions with hyperparameters', - category: 'development', - config: { - widgetSelector: 'chat-widget', - allowMultiple: true, - autoSave: true, - preloadData: true, - requiredPermissions: ['academy:read', 'academy:participate'], - minUserType: 'human' - }, - isActive: true, - isBuiltIn: true, - sortOrder: 20 - }, - { - id: 'ct-user-list', - type: 'user-list', - displayName: 'User Directory', - description: 'User management and directory', - category: 'management', - config: { - widgetSelector: 'user-list-widget', - allowMultiple: false, - autoSave: false, - preloadData: true, - requiredPermissions: ['users:read'], - minUserType: 'human' - }, - isActive: true, - isBuiltIn: true, - sortOrder: 30 - } - ]; -} - -// NOTE: createDefaultUserStates imported from factories.ts - uses UserCapabilitiesDefaults constants +// ===== BULK LOADING ===== /** - * Create default training sessions + * Load ALL users in one bulk call and parse into a map. + * Returns both the user map (keyed by uniqueId) and the list of missing uniqueIds. + * + * This replaces getMissingUsers() + N individual loadUserByUniqueId() calls + * with a SINGLE subprocess spawn. */ -function createDefaultTrainingSessions(): any[] { - return [ - { - id: 'ts-js-fundamentals', - roomId: ROOM_IDS.ACADEMY, - teacherUserId: USER_IDS.CLAUDE_CODE, - studentUserId: USER_IDS.HUMAN, - sessionName: 'JavaScript Fundamentals', - description: 'Learn core JavaScript concepts through interactive exercises', - sessionType: 'teacher-student', - status: 'active', - curriculum: 'javascript-basics', - startedAt: new Date().toISOString(), - plannedDuration: 90, - actualDuration: 15, - hyperparameters: { - learningRate: 0.15, - scoreThreshold: 80.0, - benchmarkInterval: 8, - maxSessionLength: 120, - adaptiveScoring: true, - contextWindow: 25 - }, - learningObjectives: [ - { - id: 'obj-variables', - topic: 'variables-declarations', - description: 'Understand var, let, and const declarations', - targetScore: 85, - currentScore: 78, - completed: false, - evidence: [] - }, - { - id: 'obj-functions', - topic: 'function-basics', - description: 'Create and call functions effectively', - targetScore: 80, - completed: false, - evidence: [] - } - ], - metrics: { - messagesExchanged: 24, - benchmarksPassed: 2, - benchmarksFailed: 1, - averageScore: 76.5, - timeSpent: 15, - objectivesCompleted: 0, - scoreHistory: [ - { - timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), - score: 72, - objective: 'variables-declarations' - }, - { - timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), - score: 81, - objective: 'function-basics' - } - ] - }, - additionalParticipants: [], - isArchived: false - } +async function loadAllUsers(): Promise<{ + usersByUniqueId: Map; + missingUniqueIds: string[]; +}> { + const requiredUsers = [ + DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN, + ...PERSONA_CONFIGS.map(p => p.uniqueId) ]; -} - -// ===== SEEDING FUNCTIONS ===== - -/** - * Create a record via data/create command (server-side, no browser required) with proper shell escaping - */ -async function createStateRecord(collection: string, data: any, id: string, userId?: string, displayName?: string): Promise { - const dataArg = JSON.stringify(data).replace(/'/g, `'\"'\"'`); - const cmd = `./jtag ${DATA_COMMANDS.CREATE} --collection=${collection} --data='${dataArg}'`; - - try { - const result = await execAsync(cmd); - const success = result.stdout.includes('\"success\": true'); - - if (success) { - console.log(`βœ… Created ${collection} (state): ${displayName || id}${userId ? ` for user ${userId.slice(0, 8)}...` : ''}`); - return true; - } else { - console.error(`❌ Failed to create ${collection} ${displayName || id}: Command returned unsuccessful result`); - console.error(`Response: ${result.stdout}`); - return false; - } - } catch (error: any) { - const hasSuccess = error.stdout && error.stdout.includes('\"success\": true'); - - if (hasSuccess) { - console.log(`βœ… Created ${collection} (state): ${displayName || id}${userId ? ` for user ${userId.slice(0, 8)}...` : ''}`); - return true; - } else { - console.error(`❌ Failed to create ${collection} ${displayName || id}:`); - console.error(` Error: ${error.message}`); - if (error.stdout) console.error(` Output: ${error.stdout.substring(0, 500)}...`); - if (error.stderr) console.error(` Stderr: ${error.stderr.substring(0, 500)}...`); - return false; - } - } -} - -/** - * Update persona bio via shortDescription field (profile is separate entity) - */ -async function updatePersonaProfile(userId: string, profile: { bio: string; speciality: string }): Promise { - const updateData = { - shortDescription: profile.bio // Use shortDescription which is on UserEntity directly - }; - const dataArg = JSON.stringify(updateData).replace(/'/g, `'"'"'`); - const cmd = `./jtag ${DATA_COMMANDS.UPDATE} --collection=users --id=${userId} --data='${dataArg}'`; - - try { - const { stdout } = await execAsync(cmd); - const result = JSON.parse(stdout); - - if (result.success) { - console.log(` βœ… Updated persona bio for user ${userId.slice(0, 8)}...`); - return true; - } else { - console.error(` ❌ Failed to update persona bio: ${result.error || 'Unknown error'}`); - return false; - } - } catch (error: any) { - console.error(` ❌ Failed to update persona bio: ${error.message}`); - return false; - } -} - -/** - * Update persona configuration for intelligent resource management - */ -async function updatePersonaConfig(userId: string, config: any): Promise { - const configArg = JSON.stringify(config).replace(/'/g, `'"'"'`); - const updateData = { personaConfig: config }; - const dataArg = JSON.stringify(updateData).replace(/'/g, `'"'"'`); - const cmd = `./jtag ${DATA_COMMANDS.UPDATE} --collection=users --id=${userId} --data='${dataArg}'`; - - try { - const { stdout } = await execAsync(cmd); - const result = JSON.parse(stdout); - - if (result.success) { - console.log(` βœ… Updated persona config for user ${userId.slice(0, 8)}...`); - return true; - } else { - console.error(` ❌ Failed to update persona config: ${result.error || 'Unknown error'}`); - return false; - } - } catch (error: any) { - console.error(` ❌ Failed to update persona config: ${error.message}`); - return false; - } -} -/** - * Create a user via user/create command (proper factory-based creation) - * Returns the UserEntity if successful, null otherwise - */ -async function createUserViaCommand(type: 'human' | 'agent' | 'persona', displayName: string, uniqueId?: string, provider?: string): Promise { - const uniqueIdArg = uniqueId ? ` --uniqueId=${uniqueId}` : ''; - const providerArg = provider ? ` --provider=${provider}` : ''; - const cmd = `./jtag user/create --type=${type} --displayName="${displayName}"${uniqueIdArg}${providerArg}`; + const usersByUniqueId = new Map(); try { - const { stdout } = await execAsync(cmd); - const response: UserCreateResult = JSON.parse(stdout); + const { stdout } = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${UserEntity.collection}`); + const response = JSON.parse(stdout); - if (response.success && response.user) { - console.log(`βœ… Created user (${type}): ${displayName} (uniqueId: ${uniqueId || 'none'}, ID: ${response.user.id.slice(0, 8)}...)`); - return response.user; - } else { - console.error(`❌ Failed to create user ${displayName}: ${response.error || 'Unknown error'}`); - return null; - } - } catch (error: any) { - // exec throws on non-zero exit, but may still have valid output - if (error.stdout) { - try { - const response: UserCreateResult = JSON.parse(error.stdout); - if (response.success && response.user) { - console.log(`βœ… Created user (${type}): ${displayName} (uniqueId: ${uniqueId || 'none'}, ID: ${response.user.id.slice(0, 8)}...)`); - return response.user; + if (response.success && response.items) { + for (const user of response.items) { + if (user.uniqueId) { + usersByUniqueId.set(user.uniqueId, user); } - } catch (parseError) { - // Fall through to error handling } } - console.error(`❌ Failed to create user ${displayName}: ${error.message}`); - if (error.stdout) console.error(` Output: ${error.stdout.substring(0, 500)}`); - if (error.stderr) console.error(` Stderr: ${error.stderr.substring(0, 500)}`); - return null; - } -} - -/** - * Load an existing user by uniqueId using JTAG ${DATA_COMMANDS.LIST} command - */ -async function loadUserByUniqueId(uniqueId: string): Promise { - try { - const { stdout } = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${UserEntity.collection} --filter='{"uniqueId":"${uniqueId}"}'`); - const response = JSON.parse(stdout); - - if (response.success && response.items && response.items.length > 0) { - const user = response.items[0]; - console.log(`βœ… Loaded existing user: ${user.displayName} (uniqueId: ${uniqueId}, ID: ${user.id.slice(0, 8)}...)`); - return user; - } else { - console.log(`⚠️ User with uniqueId ${uniqueId} not found in database`); - return null; - } - } catch (error: any) { - console.error(`❌ Failed to load user with uniqueId ${uniqueId}: ${error.message}`); - if (error.stdout) console.error(` Output: ${error.stdout.substring(0, 500)}`); - return null; - } -} - -/** - * Create a record via JTAG data/create command (server-side, no browser required) with proper shell escaping - */ -async function createRecord(collection: string, data: any, id: string, displayName?: string, userId?: string): Promise { - const dataArg = JSON.stringify(data).replace(/'/g, `'"'"'`); - const cmd = `./jtag ${DATA_COMMANDS.CREATE} --collection=${collection} --data='${dataArg}'`; + const missingUniqueIds = requiredUsers.filter(uid => !usersByUniqueId.has(uid)); - try { - const result = await execAsync(cmd); - const success = result.stdout.includes('"success": true'); - - if (success) { - console.log(`βœ… Created ${collection}: ${displayName || id}`); - return true; - } else { - console.error(`❌ Failed to create ${collection} ${displayName || id}: Command returned unsuccessful result`); - console.error(`Response: ${result.stdout}`); - return false; - } - } catch (error: any) { - const hasSuccess = error.stdout && error.stdout.includes('"success": true'); - - if (hasSuccess) { - console.log(`βœ… Created ${collection}: ${displayName || id}`); - return true; + if (missingUniqueIds.length === 0) { + console.log(`βœ… All ${requiredUsers.length} required users exist`); } else { - console.error(`❌ Failed to create ${collection} ${displayName || id}:`); - console.error(` Error: ${error.message}`); - if (error.stdout) console.error(` Output: ${error.stdout.substring(0, 500)}...`); - if (error.stderr) console.error(` Stderr: ${error.stderr.substring(0, 500)}...`); - return false; + console.log(`πŸ“‹ Found ${requiredUsers.length - missingUniqueIds.length}/${requiredUsers.length} users, missing: ${missingUniqueIds.join(', ')}`); } - } -} -/** - * Seed multiple records of the same type - */ -async function seedRecords(collection: string, records: T[], getDisplayName?: (record: T) => string, getUserId?: (record: T) => string): Promise { - console.log(`πŸ“ Creating ${records.length} ${collection} records via ${DATA_COMMANDS.CREATE}...`); - - let successCount = 0; - for (const record of records) { - const displayName = getDisplayName ? getDisplayName(record) : record.displayName || record.id; - const userId = getUserId ? getUserId(record) : undefined; - const success = await createRecord(collection, record, record.id, displayName, userId); - if (success) successCount++; - } - - console.log(`πŸ“Š Created ${successCount}/${records.length} ${collection} records`); - - if (successCount !== records.length) { - throw new Error(`❌ Seeding failed for ${collection}: only ${successCount}/${records.length} records created successfully`); - } -} - -/** - * Get count from JTAG list command (using head to get just the JSON header) - */ -async function getEntityCount(collection: string): Promise { - try { - // Use head to get first 10 lines which includes the count field - const result = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${collection} 2>&1 | head -10`); - const count = result.stdout.match(/"count":\s*(\d+)/)?.[1] || '0'; - return count; - } catch (error: any) { - console.error(` ⚠️ Error counting ${collection}: ${error.message}`); - return '0'; + return { usersByUniqueId, missingUniqueIds }; + } catch (error) { + console.log('⚠️ Could not check existing users, will attempt full seed'); + return { usersByUniqueId, missingUniqueIds: requiredUsers }; } } /** - * Check which users exist by uniqueId - * Returns array of missing user uniqueIds that need to be created + * Load ALL rooms in one bulk call and return as array + uniqueId set. */ -async function getMissingUsers(): Promise { - // Build required users list from PERSONA_CONFIGS (single source of truth) - const requiredUsers = [ - DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN, - ...PERSONA_CONFIGS.map(p => p.uniqueId) - ]; - +async function loadAllRooms(): Promise<{ + rooms: any[]; + uniqueIds: Set; +}> { try { - const result = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${UserEntity.collection}`); - const stdout = result.stdout; - - const missingUsers = requiredUsers.filter(uniqueId => !stdout.includes(uniqueId)); - - if (missingUsers.length === 0) { - console.log(`βœ… All ${requiredUsers.length} required users exist`); - } else { - console.log(`πŸ“‹ Found ${requiredUsers.length - missingUsers.length}/${requiredUsers.length} users, missing: ${missingUsers.join(', ')}`); - } - - return missingUsers; + const { stdout } = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${RoomEntity.collection}`); + const response = JSON.parse(stdout); + const rooms = response.success && response.items ? response.items : []; + const uniqueIds = new Set(rooms.map((r: any) => r.uniqueId)); + return { rooms, uniqueIds }; } catch (error) { - console.log('⚠️ Could not check existing users, will attempt full seed'); - return requiredUsers; + return { rooms: [], uniqueIds: new Set() }; } } +// ===== SYSTEM READINESS ===== + /** * Wait for JTAG system to be fully ready with commands registered */ @@ -630,7 +146,7 @@ async function waitForJTAGReady(maxWaitSeconds: number = 180): Promise try { const { stdout } = await execAsync('./jtag ping'); - // ROBUST: Extract JSON from potentially polluted output (same as SystemMetricsCollector) + // ROBUST: Extract JSON from potentially polluted output const firstBrace = stdout.indexOf('{'); const lastBrace = stdout.lastIndexOf('}'); @@ -648,7 +164,6 @@ async function waitForJTAGReady(maxWaitSeconds: number = 180): Promise return true; } - // Log progress every 5 attempts if (attempts % 5 === 0 && attempts > 0) { const elapsed = Math.round((Date.now() - startTime) / 1000); console.log(` Still waiting... (${elapsed}s elapsed, commands: ${response.server?.health?.commandsRegistered || 0})`); @@ -658,7 +173,7 @@ async function waitForJTAGReady(maxWaitSeconds: number = 180): Promise } attempts++; - const waitMs = Math.min(500 * Math.pow(1.2, attempts), 2000); // Exponential backoff, max 2s + const waitMs = Math.min(500 * Math.pow(1.2, attempts), 2000); await new Promise(resolve => setTimeout(resolve, waitMs)); } @@ -666,325 +181,171 @@ async function waitForJTAGReady(maxWaitSeconds: number = 180): Promise return false; } -/** - * Clean up test entities left over from failed integration tests - * Runs automatically on npm start to prevent test pollution - */ -async function cleanupTestEntities(): Promise { - console.log('🧹 Cleaning up test entities from failed integration tests...'); +// ===== ROOM DEFINITIONS ===== - try { - // Use the standalone cleanup script instead of duplicating logic - await execAsync('npx tsx scripts/cleanup-test-entities.ts'); - } catch (error) { - // Non-fatal - just log and continue with seeding - const errorMsg = error instanceof Error ? error.message : String(error); - console.warn(`⚠️ Test entity cleanup failed (non-fatal): ${errorMsg}`); - console.warn(` You can manually run: npx tsx scripts/cleanup-test-entities.ts`); - } -} +const ALL_EXPECTED_ROOMS = [ + { uniqueId: 'general', name: 'general', displayName: 'General', description: 'Main discussion room for all users', topic: 'General chat and collaboration', tags: ['general', 'welcome', 'discussion'], recipeId: 'general-chat' }, + { uniqueId: 'academy', name: 'academy', displayName: 'Academy', description: 'Learning and educational discussions', topic: 'Share knowledge, tutorials, and collaborate on learning', tags: ['academy', 'learning', 'education'], recipeId: 'academy' }, + { uniqueId: 'pantheon', name: 'pantheon', displayName: 'Pantheon', description: 'Elite discussion room for top-tier SOTA AI models', topic: 'Advanced reasoning and multi-model collaboration', tags: ['sota', 'elite', 'reasoning'], recipeId: 'pantheon' }, + { uniqueId: 'dev-updates', name: 'dev-updates', displayName: 'Dev Updates', description: 'GitHub PRs, CI/CD, and development activity notifications', topic: 'Real-time development feed', tags: ['github', 'ci', 'development'], recipeId: 'dev-updates' }, + { uniqueId: 'help', name: 'help', displayName: 'Help', description: 'Get help from AI assistants', topic: 'Your AI helpers are here to assist you', tags: ['help', 'support', 'system'], recipeId: 'help' }, + { uniqueId: 'settings', name: 'settings', displayName: 'Settings', description: 'Configure your Continuum experience', topic: 'System settings and configuration', tags: ['settings', 'config', 'system'], recipeId: 'settings' }, + { uniqueId: 'theme', name: 'theme', displayName: 'Theme', description: 'Design and customize your visual experience', topic: 'Themes, colors, and customization', tags: ['theme', 'design', 'system'], recipeId: 'theme' }, + { uniqueId: 'canvas', name: 'canvas', displayName: 'Canvas', description: 'Collaborative drawing discussions', topic: 'Art, drawing, and creative collaboration', tags: ['canvas', 'art', 'system'], recipeId: 'canvas' }, + { uniqueId: 'outreach', name: 'outreach', displayName: 'Outreach', description: 'Social media strategy, community building, and external engagement', topic: 'Discuss what to post, share interesting finds, coordinate outreach', tags: ['social', 'outreach', 'community', 'moltbook'], recipeId: 'outreach' }, + { uniqueId: 'newsroom', name: 'newsroom', displayName: 'Newsroom', description: 'Current events, breaking news, and world awareness', topic: 'Share and discuss current events', tags: ['news', 'current-events', 'awareness'], recipeId: 'newsroom' }, + { uniqueId: 'code', name: 'code', displayName: 'Code', description: 'Collaborative coding β€” reading, writing, reviewing, and shipping code as a team', topic: 'Software development with real tools and real agent loops', tags: ['coding', 'development', 'engineering'], recipeId: 'coding' }, +] as const; + +const SYSTEM_ROOM_UNIQUE_IDS = ['settings', 'help', 'theme', 'canvas'] as const; + +// ===== MAIN SEEDING ===== /** - * Main seeding function with idempotent behavior + * Main seeding function with idempotent behavior. + * + * Performance: uses bulk loads and parallel updates to minimize subprocess spawns. + * Common case (all users exist): ~2 subprocess calls total (ping + bulk list). + * Partial case (some users missing): creates missing users sequentially, + * updates existing users in parallel. */ async function seedViaJTAG() { console.log('🌱 Seeding database via JTAG commands (single source of truth)...'); try { - // CRITICAL: Wait for JTAG system to be ready before attempting any commands + // Wait for JTAG system to be ready const isReady = await waitForJTAGReady(); if (!isReady) { throw new Error('❌ JTAG system not ready - commands not registered yet'); } - // NOTE: Test cleanup disabled during startup to avoid deadlock - // The cleanup script tries to connect to the server (jtag.connect()) which hangs - // during startup. Run manually if needed: npx tsx scripts/cleanup-test-entities.ts - // await cleanupTestEntities(); + // BULK LOAD: One subprocess call replaces N individual lookups + const { usersByUniqueId, missingUniqueIds } = await loadAllUsers(); - // Check which users are missing - const missingUsers = await getMissingUsers(); - - if (missingUsers.length === 0) { + if (missingUniqueIds.length === 0) { console.log('⚑ All required users exist - no seeding needed'); return; } - // Create human user FIRST (needed as room owner), then rooms, then other users - console.log(`πŸ“ Creating human user first (needed as room owner)...`); - - // Get system identity (HOME directory-based) - server-only, keep it here! + // Get system identity const systemIdentity = SystemIdentity.getIdentity(); console.log(`πŸ”§ Using system identity: ${systemIdentity.displayName} (${systemIdentity.username})`); - const userMap: Record = {}; + // Step 1: Ensure human user exists (needed as room owner) + let humanUser = usersByUniqueId.get(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN) ?? null; - // Step 1: Create human user first (or use existing) - let humanUser: UserEntity | null = null; - - if (missingUsers.includes(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN)) { - // Create new human user with dynamic name from system identity + if (!humanUser) { + console.log('πŸ“ Creating human user first (needed as room owner)...'); humanUser = await createUserViaCommand('human', systemIdentity.displayName, DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN); if (!humanUser) { throw new Error('❌ Failed to create human user - required as room owner'); } - console.log(`βœ… Created human user: ${humanUser.displayName}`); - } else { - // Human user already exists - load from database using uniqueId - humanUser = await loadUserByUniqueId(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN); - if (!humanUser) { - throw new Error('❌ Failed to load existing human user - database inconsistency'); - } + usersByUniqueId.set(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN, humanUser); } - userMap['humanUser'] = humanUser; - - // Step 2: Check if rooms exist (create if missing) - const { stdout: roomsOutput } = await execAsync(`./jtag data/list --collection=rooms --limit=1`); - const roomsResult = JSON.parse(roomsOutput); - const needsRooms = !roomsResult.items || roomsResult.items.length === 0; + // Step 2: Check if rooms exist + const { rooms: existingRooms, uniqueIds: existingRoomUniqueIds } = await loadAllRooms(); + const needsRooms = existingRooms.length === 0; if (needsRooms) { - // Create and persist rooms BEFORE creating other users console.log('πŸ—οΈ Creating rooms before other users (for auto-join to work)...'); - const generalRoom = createRoom( - ROOM_IDS.GENERAL, - ROOM_CONFIG.GENERAL.NAME, - ROOM_CONFIG.GENERAL.NAME, - ROOM_CONFIG.GENERAL.DESCRIPTION, - "Welcome to general discussion! Introduce yourself and chat about anything.", - 0, // Will be auto-populated by RoomMembershipDaemon - ["general", "welcome", "discussion"], - humanUser.id, - 'general' - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const academyRoom = createRoom( - ROOM_IDS.ACADEMY, - ROOM_CONFIG.ACADEMY.NAME, - ROOM_CONFIG.ACADEMY.NAME, - ROOM_CONFIG.ACADEMY.DESCRIPTION, - "Share knowledge, tutorials, and collaborate on learning", - 0, // Will be auto-populated by RoomMembershipDaemon - ["academy", "learning", "education"], - humanUser.id, - 'academy' - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const pantheonRoom = createRoom( - ROOM_IDS.PANTHEON, - 'pantheon', - 'Pantheon', - 'Elite discussion room for top-tier SOTA AI models', - "Advanced reasoning and multi-model collaboration", - 0, // Will be auto-populated by RoomMembershipDaemon - ["sota", "elite", "reasoning"], - humanUser.id, - 'pantheon' - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const devUpdatesRoom = createRoom( - ROOM_IDS.DEV_UPDATES, - 'dev-updates', - 'Dev Updates', - 'GitHub PRs, CI/CD, and development activity notifications', - "Real-time development feed - where the team learns together", - 0, // Will be auto-populated by RoomMembershipDaemon - ["github", "ci", "development", "training"], - humanUser.id, - 'dev-updates' - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const helpRoom = createRoom( - ROOM_IDS.HELP, - 'help', - 'Help', - 'Get help from AI assistants - ask anything about using Continuum', - "Your AI helpers are here to assist you getting started", - 0, // Will be auto-populated by RoomMembershipDaemon - ["help", "support", "onboarding", "getting-started", "system"], // 'system' tag = hidden from rooms list - humanUser.id, - 'help' // recipe: help-focused room with Helper AI - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const settingsRoom = createRoom( - ROOM_IDS.SETTINGS, - 'settings', - 'Settings', - 'Configure your Continuum experience with AI assistance', - "Get help configuring API keys, preferences, and system settings", - 0, // Will be auto-populated by RoomMembershipDaemon - ["settings", "config", "preferences", "system"], // 'system' tag = hidden from rooms list - humanUser.id, - 'settings' // recipe: settings-focused room with Helper AI - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const themeRoom = createRoom( - ROOM_IDS.THEME, - 'theme', - 'Theme', - 'Design and customize your visual experience with AI assistance', - "Get help designing themes, choosing colors, and customizing your workspace appearance", - 0, // Will be auto-populated by RoomMembershipDaemon - ["theme", "design", "customization", "appearance", "system"], // 'system' tag = hidden from rooms list - humanUser.id, - 'theme' // recipe: theme-focused room with Helper AI - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const canvasRoom = createRoom( - ROOM_IDS.CANVAS, - 'canvas', - 'Canvas', - 'Collaborative drawing discussions with AI assistance', - "Share drawing tips, get AI feedback on your artwork, and collaborate on visual projects", - 0, // Will be auto-populated by RoomMembershipDaemon - ["canvas", "drawing", "art", "collaboration", "system"], // 'system' tag = hidden from rooms list - humanUser.id, - 'canvas' // recipe: canvas-focused room - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const outreachRoom = createRoom( - ROOM_IDS.OUTREACH, - 'outreach', - 'Outreach', - 'Social media strategy, community building, and external engagement', - "Discuss what to post, share interesting finds, coordinate outreach on Moltbook and other platforms", - 0, // Will be auto-populated by RoomMembershipDaemon - ["social", "outreach", "community", "moltbook"], - humanUser.id, - 'outreach', // uniqueId - 'outreach' // recipeId - outreach-specific recipe with social tool directives - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const newsroomRoom = createRoom( - ROOM_IDS.NEWSROOM, - 'newsroom', - 'Newsroom', - 'Current events, breaking news, and world awareness for all personas', - "Share and discuss current events to keep the community informed", - 0, // Will be auto-populated by RoomMembershipDaemon - ["news", "current-events", "awareness"], - humanUser.id, - 'newsroom', // uniqueId - 'newsroom' // recipeId - newsroom-specific recipe - ); - // NO hardcoded members - let RoomMembershipDaemon handle it - - const rooms = [generalRoom, academyRoom, pantheonRoom, devUpdatesRoom, helpRoom, settingsRoom, themeRoom, canvasRoom, outreachRoom, newsroomRoom]; + const rooms = [ + createRoom(ROOM_IDS.GENERAL, ROOM_CONFIG.GENERAL.NAME, ROOM_CONFIG.GENERAL.NAME, ROOM_CONFIG.GENERAL.DESCRIPTION, + "Welcome to general discussion! Introduce yourself and chat about anything.", 0, + ["general", "welcome", "discussion"], humanUser.id, 'general'), + createRoom(ROOM_IDS.ACADEMY, ROOM_CONFIG.ACADEMY.NAME, ROOM_CONFIG.ACADEMY.NAME, ROOM_CONFIG.ACADEMY.DESCRIPTION, + "Share knowledge, tutorials, and collaborate on learning", 0, + ["academy", "learning", "education"], humanUser.id, 'academy'), + createRoom(ROOM_IDS.PANTHEON, 'pantheon', 'Pantheon', 'Elite discussion room for top-tier SOTA AI models', + "Advanced reasoning and multi-model collaboration", 0, + ["sota", "elite", "reasoning"], humanUser.id, 'pantheon'), + createRoom(ROOM_IDS.DEV_UPDATES, 'dev-updates', 'Dev Updates', 'GitHub PRs, CI/CD, and development activity notifications', + "Real-time development feed - where the team learns together", 0, + ["github", "ci", "development", "training"], humanUser.id, 'dev-updates'), + createRoom(ROOM_IDS.HELP, 'help', 'Help', 'Get help from AI assistants - ask anything about using Continuum', + "Your AI helpers are here to assist you getting started", 0, + ["help", "support", "onboarding", "getting-started", "system"], humanUser.id, 'help', 'help'), + createRoom(ROOM_IDS.SETTINGS, 'settings', 'Settings', 'Configure your Continuum experience with AI assistance', + "Get help configuring API keys, preferences, and system settings", 0, + ["settings", "config", "preferences", "system"], humanUser.id, 'settings', 'settings'), + createRoom(ROOM_IDS.THEME, 'theme', 'Theme', 'Design and customize your visual experience with AI assistance', + "Get help designing themes, choosing colors, and customizing your workspace appearance", 0, + ["theme", "design", "customization", "appearance", "system"], humanUser.id, 'theme', 'theme'), + createRoom(ROOM_IDS.CANVAS, 'canvas', 'Canvas', 'Collaborative drawing discussions with AI assistance', + "Share drawing tips, get AI feedback on your artwork, and collaborate on visual projects", 0, + ["canvas", "drawing", "art", "collaboration", "system"], humanUser.id, 'canvas', 'canvas'), + createRoom(ROOM_IDS.OUTREACH, 'outreach', 'Outreach', 'Social media strategy, community building, and external engagement', + "Discuss what to post, share interesting finds, coordinate outreach on Moltbook and other platforms", 0, + ["social", "outreach", "community", "moltbook"], humanUser.id, 'outreach', 'outreach'), + createRoom(ROOM_IDS.NEWSROOM, 'newsroom', 'Newsroom', 'Current events, breaking news, and world awareness for all personas', + "Share and discuss current events to keep the community informed", 0, + ["news", "current-events", "awareness"], humanUser.id, 'newsroom', 'newsroom'), + createRoom(ROOM_IDS.CODE, 'code', 'Code', 'Collaborative coding β€” reading, writing, reviewing, and shipping code as a team', + "Software development with real tools and real agent loops", 0, + ["coding", "development", "engineering"], humanUser.id, 'code', 'coding'), + ]; - // Persist rooms to database BEFORE creating other users await seedRecords(RoomEntity.collection, rooms, (room) => room.displayName, (room) => room.ownerId); console.log('βœ… Rooms created and persisted - ready for auto-join'); } - // Step 3: Now create all other users (auto-join will work because rooms exist) - console.log(`πŸ“ Creating remaining ${missingUsers.length - 1} users (auto-join will trigger)...`); + // Step 3: Create missing personas (must be sequential β€” each triggers auto-join) + console.log(`πŸ“ Creating ${missingUniqueIds.length - (missingUniqueIds.includes(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN) ? 0 : 1)} remaining users...`); - // Create all personas using config-driven loop (eliminates repetition) for (const persona of PERSONA_CONFIGS) { - if (missingUsers.includes(persona.uniqueId)) { - // Only create Sentinel if SENTINEL_PATH is configured - if (persona.provider === 'sentinel') { - if (!process.env.SENTINEL_PATH) { - console.log(`⏭️ Skipping Sentinel (SENTINEL_PATH not configured)`); - continue; - } - } + if (!missingUniqueIds.includes(persona.uniqueId)) continue; - const user = await createUserViaCommand(persona.type, persona.displayName, persona.uniqueId, persona.provider); - if (user) { - userMap[persona.uniqueId] = user; + if (persona.provider === 'sentinel' && !process.env.SENTINEL_PATH) { + console.log(`⏭️ Skipping Sentinel (SENTINEL_PATH not configured)`); + continue; + } - // Update metadata for audio-native models (Qwen3-Omni, etc.) - if (persona.isAudioNative && persona.modelId) { - await updateUserMetadata(user.id, { - modelId: persona.modelId, - isAudioNative: true, - }); - } - } - } else { - // User already exists - load from database using uniqueId - const existingUser = await loadUserByUniqueId(persona.uniqueId); - if (existingUser) { - userMap[persona.uniqueId] = existingUser; - - // ALWAYS update provider for existing users (ensures ollama -> candle migration) - if (persona.provider) { - await updateUserModelConfig(existingUser.id, persona.provider); - } + const user = await createUserViaCommand(persona.type, persona.displayName, persona.uniqueId, persona.provider); + if (user) { + usersByUniqueId.set(persona.uniqueId, user); - // Also update metadata for existing audio-native models (in case it was missed) - if (persona.isAudioNative && persona.modelId) { - await updateUserMetadata(existingUser.id, { - modelId: persona.modelId, - isAudioNative: true, - }); - } + if (persona.isAudioNative && persona.modelId) { + await updateUserMetadata(user.id, { modelId: persona.modelId, isAudioNative: true }); } } } - // Count only newly created users (users that were in missingUsers list) - const newUsersCreated = Object.values(userMap).filter((u, index, arr) => { - // Count only users that were successfully created (not null) - // Exclude human user if it was loaded (not in missingUsers) - const isHumanUser = u === humanUser; - const humanWasCreated = missingUsers.includes(DEFAULT_USER_UNIQUE_IDS.PRIMARY_HUMAN); + // Step 4: PARALLEL update existing users (provider + metadata) + // This replaces N sequential subprocess spawns with one parallel batch + const updatePromises: Promise[] = []; + for (const persona of PERSONA_CONFIGS) { + if (missingUniqueIds.includes(persona.uniqueId)) continue; + const existingUser = usersByUniqueId.get(persona.uniqueId); + if (!existingUser) continue; - if (isHumanUser && !humanWasCreated) { - return false; // Don't count loaded human user + if (persona.provider) { + updatePromises.push(updateUserModelConfig(existingUser.id, persona.provider)); } + if (persona.isAudioNative && persona.modelId) { + updatePromises.push(updateUserMetadata(existingUser.id, { modelId: persona.modelId, isAudioNative: true })); + } + } - return u !== null; // Count all other successfully created users - }).length; - console.log(`πŸ“Š Created ${newUsersCreated}/${missingUsers.length} users (auto-join handled by RoomMembershipDaemon)`); + if (updatePromises.length > 0) { + console.log(`πŸ”„ Updating ${updatePromises.length} existing user configs in parallel...`); + await Promise.all(updatePromises); + console.log('βœ… Existing user configs updated'); + } - // Get references to created users for message seeding (using uniqueIds as keys) - const claudeUser = userMap[DEFAULT_USER_UNIQUE_IDS.CLAUDE_CODE]; - // Use constants from PERSONA_UNIQUE_IDS (single source of truth, no magic strings) - const helperPersona = userMap[PERSONA_UNIQUE_IDS.HELPER]; - const teacherPersona = userMap[PERSONA_UNIQUE_IDS.TEACHER]; - const codeReviewPersona = userMap[PERSONA_UNIQUE_IDS.CODE_REVIEW]; - const qwen3OmniPersona = userMap[PERSONA_UNIQUE_IDS.QWEN3_OMNI]; + // Get key user references + const claudeUser = usersByUniqueId.get(PERSONA_UNIQUE_IDS.CLAUDE) ?? null; + const helperPersona = usersByUniqueId.get(PERSONA_UNIQUE_IDS.HELPER) ?? null; + const teacherPersona = usersByUniqueId.get(PERSONA_UNIQUE_IDS.TEACHER) ?? null; + const codeReviewPersona = usersByUniqueId.get(PERSONA_UNIQUE_IDS.CODE_REVIEW) ?? null; + const qwen3OmniPersona = usersByUniqueId.get(PERSONA_UNIQUE_IDS.QWEN3_OMNI) ?? null; - // If rooms already existed, check for missing rooms and ensure system rooms have Helper AI + // Step 5: Handle "rooms already existed" path β€” check missing rooms + system room helpers if (!needsRooms) { - // Check for and create any MISSING rooms (new rooms added to codebase) - console.log('πŸ” Checking for missing rooms...'); - const allExpectedRooms: { uniqueId: string; name: string; displayName: string; description: string; topic: string; tags: string[]; recipeId: string }[] = [ - { uniqueId: 'general', name: 'general', displayName: 'General', description: 'Main discussion room for all users', topic: 'General chat and collaboration', tags: ['general', 'welcome', 'discussion'], recipeId: 'general-chat' }, - { uniqueId: 'academy', name: 'academy', displayName: 'Academy', description: 'Learning and educational discussions', topic: 'Share knowledge, tutorials, and collaborate on learning', tags: ['academy', 'learning', 'education'], recipeId: 'academy' }, - { uniqueId: 'pantheon', name: 'pantheon', displayName: 'Pantheon', description: 'Elite discussion room for top-tier SOTA AI models', topic: 'Advanced reasoning and multi-model collaboration', tags: ['sota', 'elite', 'reasoning'], recipeId: 'pantheon' }, - { uniqueId: 'dev-updates', name: 'dev-updates', displayName: 'Dev Updates', description: 'GitHub PRs, CI/CD, and development activity notifications', topic: 'Real-time development feed', tags: ['github', 'ci', 'development'], recipeId: 'dev-updates' }, - { uniqueId: 'help', name: 'help', displayName: 'Help', description: 'Get help from AI assistants', topic: 'Your AI helpers are here to assist you', tags: ['help', 'support', 'system'], recipeId: 'help' }, - { uniqueId: 'settings', name: 'settings', displayName: 'Settings', description: 'Configure your Continuum experience', topic: 'System settings and configuration', tags: ['settings', 'config', 'system'], recipeId: 'settings' }, - { uniqueId: 'theme', name: 'theme', displayName: 'Theme', description: 'Design and customize your visual experience', topic: 'Themes, colors, and customization', tags: ['theme', 'design', 'system'], recipeId: 'theme' }, - { uniqueId: 'canvas', name: 'canvas', displayName: 'Canvas', description: 'Collaborative drawing discussions', topic: 'Art, drawing, and creative collaboration', tags: ['canvas', 'art', 'system'], recipeId: 'canvas' }, - { uniqueId: 'outreach', name: 'outreach', displayName: 'Outreach', description: 'Social media strategy, community building, and external engagement', topic: 'Discuss what to post, share interesting finds, coordinate outreach', tags: ['social', 'outreach', 'community', 'moltbook'], recipeId: 'outreach' }, - { uniqueId: 'newsroom', name: 'newsroom', displayName: 'Newsroom', description: 'Current events, breaking news, and world awareness', topic: 'Share and discuss current events', tags: ['news', 'current-events', 'awareness'], recipeId: 'newsroom' }, - ]; - - // Fetch all existing rooms - const { stdout: allRoomsOutput } = await execAsync(`./jtag data/list --collection=rooms`); - const allRoomsResult = JSON.parse(allRoomsOutput); - const existingUniqueIds = new Set( - (allRoomsResult.items || []).map((r: any) => r.uniqueId) - ); - + // Check for missing rooms using already-loaded data let missingRoomsCreated = 0; - for (const roomDef of allExpectedRooms) { - if (!existingUniqueIds.has(roomDef.uniqueId)) { + for (const roomDef of ALL_EXPECTED_ROOMS) { + if (!existingRoomUniqueIds.has(roomDef.uniqueId)) { console.log(`πŸ—οΈ Creating missing room: ${roomDef.displayName}`); const newRoom = createRoom( stringToUUID(roomDef.displayName), @@ -993,7 +354,7 @@ async function seedViaJTAG() { roomDef.description, roomDef.topic, 0, - roomDef.tags, + [...roomDef.tags], humanUser.id, roomDef.uniqueId, roomDef.recipeId @@ -1006,41 +367,48 @@ async function seedViaJTAG() { console.log(`βœ… Created ${missingRoomsCreated} missing room(s)`); } - // Ensure system rooms have Helper AI - console.log('🏠 Ensuring system rooms have Helper AI...'); - const systemRoomUniqueIds = ['settings', 'help', 'theme', 'canvas']; - for (const roomUniqueId of systemRoomUniqueIds) { - try { - const result = await execAsync(`./jtag data/list --collection=rooms --filter='{"uniqueId":"${roomUniqueId}"}'`); - const parsed = JSON.parse(result.stdout); - if (parsed.success && parsed.items?.[0]) { - const room = parsed.items[0]; - const existingMembers = room.members || []; - const helperAlreadyMember = existingMembers.some((m: any) => m.userId === helperPersona?.id); - - if (helperPersona && !helperAlreadyMember) { - const updatedMembers = [ - ...existingMembers, - { userId: helperPersona.id, role: 'member', joinedAt: '2025-01-01T00:00:00Z' } - ]; - const updateData = JSON.stringify({ members: updatedMembers }).replace(/'/g, `'\"'\"'`); - await execAsync(`./jtag data/update --collection=rooms --id="${room.id}" --data='${updateData}'`); - console.log(`βœ… Added Helper AI to ${roomUniqueId} room`); - } + // Ensure system rooms have Helper AI β€” using already-loaded room data (NO extra queries) + if (helperPersona) { + console.log('🏠 Ensuring system rooms have Helper AI...'); + const helperUpdates: Promise[] = []; + + for (const roomUniqueId of SYSTEM_ROOM_UNIQUE_IDS) { + const room = existingRooms.find((r: any) => r.uniqueId === roomUniqueId); + if (!room) continue; + + const existingMembers = room.members || []; + const helperAlreadyMember = existingMembers.some((m: any) => m.userId === helperPersona.id); + + if (!helperAlreadyMember) { + const updatedMembers = [ + ...existingMembers, + { userId: helperPersona.id, role: 'member', joinedAt: '2025-01-01T00:00:00Z' } + ]; + const updateData = JSON.stringify({ members: updatedMembers }).replace(/'/g, `'\"'\"'`); + helperUpdates.push( + execAsync(`./jtag ${DATA_COMMANDS.UPDATE} --collection=${RoomEntity.collection} --id="${room.id}" --data='${updateData}'`) + .then(() => console.log(`βœ… Added Helper AI to ${roomUniqueId} room`)) + .catch(() => {/* skip silently */}) + ); } - } catch (error) { - // Silently skip - rooms might not exist yet + } + + if (helperUpdates.length > 0) { + await Promise.all(helperUpdates); } } + console.log('βœ… Users added to existing database - rooms and messages already exist'); return; } + // ===== FIRST-TIME SEED (rooms were just created) ===== + if (!humanUser || !claudeUser || !helperPersona || !teacherPersona || !codeReviewPersona) { throw new Error('❌ Failed to create core required users'); } - // Update persona profiles with distinct personalities + // Update persona profiles (parallel) console.log('🎭 Updating persona profiles with distinct personalities...'); const profileUpdates = [ updatePersonaProfile(helperPersona.id, { @@ -1057,7 +425,6 @@ async function seedViaJTAG() { }) ]; - // Add Qwen3-Omni profile if created (requires DASHSCOPE_API_KEY) if (qwen3OmniPersona) { profileUpdates.push( updatePersonaProfile(qwen3OmniPersona.id, { @@ -1070,37 +437,41 @@ async function seedViaJTAG() { await Promise.all(profileUpdates); console.log('βœ… Persona profiles updated with personalities'); - // Ensure system rooms have Helper AI as default assistant - // This ensures the Settings, Help, and Theme widgets always have AI available + // System room helper setup (parallel β€” using rooms we just created) console.log('🏠 Adding Helper AI to system rooms...'); - const systemRoomUniqueIds = ['settings', 'help', 'theme', 'canvas']; - for (const roomUniqueId of systemRoomUniqueIds) { - try { - const result = await execAsync(`./jtag data/list --collection=rooms --filter='{"uniqueId":"${roomUniqueId}"}'`); - const parsed = JSON.parse(result.stdout); - if (parsed.success && parsed.items?.[0]) { - const room = parsed.items[0]; - const existingMembers = room.members || []; - const helperAlreadyMember = existingMembers.some((m: any) => m.userId === helperPersona.id); - - if (!helperAlreadyMember) { - const updatedMembers = [ - ...existingMembers, - { userId: helperPersona.id, role: 'member', joinedAt: '2025-01-01T00:00:00Z' } - ]; - const updateData = JSON.stringify({ members: updatedMembers }).replace(/'/g, `'\"'\"'`); - await execAsync(`./jtag data/update --collection=rooms --id="${room.id}" --data='${updateData}'`); - console.log(`βœ… Added Helper AI to ${roomUniqueId} room`); - } else { - console.log(`βœ… Helper AI already in ${roomUniqueId} room`); + const systemRoomHelperUpdates: Promise[] = []; + for (const roomUniqueId of SYSTEM_ROOM_UNIQUE_IDS) { + systemRoomHelperUpdates.push( + (async () => { + try { + const result = await execAsync(`./jtag ${DATA_COMMANDS.LIST} --collection=${RoomEntity.collection} --filter='{"uniqueId":"${roomUniqueId}"}'`); + const parsed = JSON.parse(result.stdout); + if (parsed.success && parsed.items?.[0]) { + const room = parsed.items[0]; + const existingMembers = room.members || []; + const helperAlreadyMember = existingMembers.some((m: any) => m.userId === helperPersona.id); + + if (!helperAlreadyMember) { + const updatedMembers = [ + ...existingMembers, + { userId: helperPersona.id, role: 'member', joinedAt: '2025-01-01T00:00:00Z' } + ]; + const updateData = JSON.stringify({ members: updatedMembers }).replace(/'/g, `'\"'\"'`); + await execAsync(`./jtag ${DATA_COMMANDS.UPDATE} --collection=${RoomEntity.collection} --id="${room.id}" --data='${updateData}'`); + console.log(`βœ… Added Helper AI to ${roomUniqueId} room`); + } else { + console.log(`βœ… Helper AI already in ${roomUniqueId} room`); + } + } + } catch (error) { + console.warn(`⚠️ Could not add Helper AI to ${roomUniqueId}`); } - } - } catch (error) { - console.warn(`⚠️ Could not add Helper AI to ${roomUniqueId}:`, error); - } + })() + ); } + await Promise.all(systemRoomHelperUpdates); - // Configure persona AI response settings (intelligent resource management) + // Configure persona AI response settings (parallel) console.log('πŸ”§ Configuring persona AI response settings...'); await Promise.all([ updatePersonaConfig(helperPersona.id, { @@ -1133,9 +504,7 @@ async function seedViaJTAG() { ]); console.log('βœ… Persona configurations applied'); - // Rooms already created and persisted earlier (before other users) - // Now create messages for those rooms - // Use systemIdentity from top of function - don't recreate it + // Seed messages const messages = [ createMessage( MESSAGE_IDS.WELCOME_GENERAL, @@ -1143,18 +512,15 @@ async function seedViaJTAG() { 'system', 'System', MESSAGE_CONTENT.WELCOME_GENERAL, - 'system' // senderType + 'system' ), - // REMOVED: CLAUDE_INTRO message was confusing personas - // They would see this seeded message and think it was the most recent, - // hallucinating that "Claude Code just introduced itself" createMessage( MESSAGE_IDS.WELCOME_ACADEMY, ROOM_IDS.ACADEMY, 'system', 'System', MESSAGE_CONTENT.WELCOME_ACADEMY, - 'system' // senderType + 'system' ), createMessage( stringToUUID('pantheon-welcome-msg'), @@ -1162,14 +528,14 @@ async function seedViaJTAG() { humanUser.id, systemIdentity.displayName, 'Welcome to the Pantheon! This is where our most advanced SOTA models converge - each provider\'s flagship intelligence collaborating on complex problems.', - 'human' // senderType + 'human' ) ]; - // Create content type registry + // Content types const contentTypes = createDefaultContentTypes(); - // Create training sessions with actual generated user entities + // Training sessions const trainingSessions = [ { id: 'ts-js-fundamentals', @@ -1236,19 +602,14 @@ async function seedViaJTAG() { } ]; - // Seed all data types using clean modular approach with user context - // Note: User states are created automatically by user/create command - // Note: Rooms already seeded earlier (before other users, to enable auto-join) + // Seed remaining data await seedRecords(ChatMessageEntity.collection, messages, - (msg) => msg.senderId === humanUser.id ? humanUser.displayName : msg.senderId === claudeUser.id ? claudeUser.displayName : 'System', + (msg) => msg.senderId === humanUser!.id ? humanUser!.displayName : msg.senderId === claudeUser.id ? claudeUser.displayName : 'System', (msg) => msg.senderId ); await seedRecords(ContentTypeEntity.collection, contentTypes, (ct) => ct.displayName); await seedRecords(TrainingSessionEntity.collection, trainingSessions, (ts) => ts.sessionName); - // Note: Verification skipped due to buffer overflow issues with large collections - // Data commands confirmed successful above - verification would require implementing - // a count-only query option in data/list command console.log('\nπŸŽ‰ Database seeding completed via JTAG (single source of truth)!'); } catch (error: any) { @@ -1262,4 +623,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { seedViaJTAG(); } -export default seedViaJTAG; \ No newline at end of file +export default seedViaJTAG; diff --git a/src/debug/jtag/scripts/seed/factories.ts b/src/debug/jtag/scripts/seed/factories.ts index 80dc3d1b5..0a76f5b75 100644 --- a/src/debug/jtag/scripts/seed/factories.ts +++ b/src/debug/jtag/scripts/seed/factories.ts @@ -32,16 +32,21 @@ export function createRoom( memberCount: number, tags: string[], ownerId: string, - uniqueId: string + uniqueId: string, + recipeId: string = 'general-chat' ): any { return { id, - name, + uniqueId, + name: name.toLowerCase(), displayName, description, topic, type: "public", status: "active", + ownerId, + lastMessageAt: new Date().toISOString(), + recipeId, privacy: { isPublic: true, requiresInvite: false, @@ -61,9 +66,7 @@ export function createRoom( lastActivityAt: new Date().toISOString() }, members: [], // Empty - let RoomMembershipDaemon handle auto-join - tags, - ownerId, - uniqueId + tags }; } diff --git a/src/debug/jtag/server/generated.ts b/src/debug/jtag/server/generated.ts index 152ea81f6..f44847a27 100644 --- a/src/debug/jtag/server/generated.ts +++ b/src/debug/jtag/server/generated.ts @@ -1,7 +1,7 @@ /** * Server Structure Registry - Auto-generated * - * Contains 18 daemons and 198 commands and 3 adapters. + * Contains 18 daemons and 216 commands and 3 adapters. * Generated by scripts/generate-structure.ts - DO NOT EDIT MANUALLY */ @@ -60,6 +60,21 @@ import { AIValidateResponseServerCommand } from './../commands/ai/validate-respo import { CanvasStrokeAddServerCommand } from './../commands/canvas/stroke/add/server/CanvasStrokeAddServerCommand'; import { CanvasStrokeListServerCommand } from './../commands/canvas/stroke/list/server/CanvasStrokeListServerCommand'; import { CanvasVisionServerCommand } from './../commands/canvas/vision/server/CanvasVisionServerCommand'; +import { CodeDiffServerCommand } from './../commands/code/diff/server/CodeDiffServerCommand'; +import { CodeEditServerCommand } from './../commands/code/edit/server/CodeEditServerCommand'; +import { CodeGitServerCommand } from './../commands/code/git/server/CodeGitServerCommand'; +import { CodeHistoryServerCommand } from './../commands/code/history/server/CodeHistoryServerCommand'; +import { CodeReadServerCommand } from './../commands/code/read/server/CodeReadServerCommand'; +import { CodeSearchServerCommand } from './../commands/code/search/server/CodeSearchServerCommand'; +import { CodeShellExecuteServerCommand } from './../commands/code/shell/execute/server/CodeShellExecuteServerCommand'; +import { CodeShellKillServerCommand } from './../commands/code/shell/kill/server/CodeShellKillServerCommand'; +import { CodeShellSentinelServerCommand } from './../commands/code/shell/sentinel/server/CodeShellSentinelServerCommand'; +import { CodeShellStatusServerCommand } from './../commands/code/shell/status/server/CodeShellStatusServerCommand'; +import { CodeShellWatchServerCommand } from './../commands/code/shell/watch/server/CodeShellWatchServerCommand'; +import { CodeTreeServerCommand } from './../commands/code/tree/server/CodeTreeServerCommand'; +import { CodeUndoServerCommand } from './../commands/code/undo/server/CodeUndoServerCommand'; +import { CodeVerifyServerCommand } from './../commands/code/verify/server/CodeVerifyServerCommand'; +import { CodeWriteServerCommand } from './../commands/code/write/server/CodeWriteServerCommand'; import { ActivityCreateServerCommand } from './../commands/collaboration/activity/create/server/ActivityCreateServerCommand'; import { ActivityGetServerCommand } from './../commands/collaboration/activity/get/server/ActivityGetServerCommand'; import { ActivityJoinServerCommand } from './../commands/collaboration/activity/join/server/ActivityJoinServerCommand'; @@ -107,8 +122,6 @@ import { DataUpdateServerCommand } from './../commands/data/update/server/DataUp import { VectorSearchServerCommand } from './../commands/data/vector-search/server/VectorSearchServerCommand'; import { BenchmarkVectorsServerCommand } from './../commands/development/benchmark-vectors/server/BenchmarkVectorsServerCommand'; import { DevelopmentBuildServerCommand } from './../commands/development/build/server/DevelopmentBuildServerCommand'; -import { CodeFindServerCommand } from './../commands/development/code/pattern-search/server/CodeFindServerCommand'; -import { CodeReadServerCommand } from './../commands/development/code/read/server/CodeReadServerCommand'; import { CompileTypescriptServerCommand } from './../commands/development/compile-typescript/server/CompileTypescriptServerCommand'; import { ArtifactsCheckServerCommand } from './../commands/development/debug/artifacts-check/server/ArtifactsCheckServerCommand'; import { ChatSendDebugServerCommand } from './../commands/development/debug/chat-send/server/ChatSendDebugServerCommand'; @@ -179,6 +192,11 @@ import { SessionCreateServerCommand } from './../commands/session/create/server/ import { SessionDestroyServerCommand } from './../commands/session/destroy/server/SessionDestroyServerCommand'; import { SessionGetIdServerCommand } from './../commands/session/get-id/server/SessionGetIdServerCommand'; import { SessionGetUserServerCommand } from './../commands/session/get-user/server/SessionGetUserServerCommand'; +import { SkillActivateServerCommand } from './../commands/skill/activate/server/SkillActivateServerCommand'; +import { SkillGenerateServerCommand } from './../commands/skill/generate/server/SkillGenerateServerCommand'; +import { SkillListServerCommand } from './../commands/skill/list/server/SkillListServerCommand'; +import { SkillProposeServerCommand } from './../commands/skill/propose/server/SkillProposeServerCommand'; +import { SkillValidateServerCommand } from './../commands/skill/validate/server/SkillValidateServerCommand'; import { SocialBrowseServerCommand } from './../commands/social/browse/server/SocialBrowseServerCommand'; import { SocialClassifyServerCommand } from './../commands/social/classify/server/SocialClassifyServerCommand'; import { SocialCommentServerCommand } from './../commands/social/comment/server/SocialCommentServerCommand'; @@ -502,6 +520,81 @@ export const SERVER_COMMANDS: CommandEntry[] = [ className: 'CanvasVisionServerCommand', commandClass: CanvasVisionServerCommand }, +{ + name: 'code/diff', + className: 'CodeDiffServerCommand', + commandClass: CodeDiffServerCommand + }, +{ + name: 'code/edit', + className: 'CodeEditServerCommand', + commandClass: CodeEditServerCommand + }, +{ + name: 'code/git', + className: 'CodeGitServerCommand', + commandClass: CodeGitServerCommand + }, +{ + name: 'code/history', + className: 'CodeHistoryServerCommand', + commandClass: CodeHistoryServerCommand + }, +{ + name: 'code/read', + className: 'CodeReadServerCommand', + commandClass: CodeReadServerCommand + }, +{ + name: 'code/search', + className: 'CodeSearchServerCommand', + commandClass: CodeSearchServerCommand + }, +{ + name: 'code/shell/execute', + className: 'CodeShellExecuteServerCommand', + commandClass: CodeShellExecuteServerCommand + }, +{ + name: 'code/shell/kill', + className: 'CodeShellKillServerCommand', + commandClass: CodeShellKillServerCommand + }, +{ + name: 'code/shell/sentinel', + className: 'CodeShellSentinelServerCommand', + commandClass: CodeShellSentinelServerCommand + }, +{ + name: 'code/shell/status', + className: 'CodeShellStatusServerCommand', + commandClass: CodeShellStatusServerCommand + }, +{ + name: 'code/shell/watch', + className: 'CodeShellWatchServerCommand', + commandClass: CodeShellWatchServerCommand + }, +{ + name: 'code/tree', + className: 'CodeTreeServerCommand', + commandClass: CodeTreeServerCommand + }, +{ + name: 'code/undo', + className: 'CodeUndoServerCommand', + commandClass: CodeUndoServerCommand + }, +{ + name: 'code/verify', + className: 'CodeVerifyServerCommand', + commandClass: CodeVerifyServerCommand + }, +{ + name: 'code/write', + className: 'CodeWriteServerCommand', + commandClass: CodeWriteServerCommand + }, { name: 'collaboration/activity/create', className: 'ActivityCreateServerCommand', @@ -737,16 +830,6 @@ export const SERVER_COMMANDS: CommandEntry[] = [ className: 'DevelopmentBuildServerCommand', commandClass: DevelopmentBuildServerCommand }, -{ - name: 'development/code/pattern-search', - className: 'CodeFindServerCommand', - commandClass: CodeFindServerCommand - }, -{ - name: 'development/code/read', - className: 'CodeReadServerCommand', - commandClass: CodeReadServerCommand - }, { name: 'development/compile-typescript', className: 'CompileTypescriptServerCommand', @@ -1097,6 +1180,31 @@ export const SERVER_COMMANDS: CommandEntry[] = [ className: 'SessionGetUserServerCommand', commandClass: SessionGetUserServerCommand }, +{ + name: 'skill/activate', + className: 'SkillActivateServerCommand', + commandClass: SkillActivateServerCommand + }, +{ + name: 'skill/generate', + className: 'SkillGenerateServerCommand', + commandClass: SkillGenerateServerCommand + }, +{ + name: 'skill/list', + className: 'SkillListServerCommand', + commandClass: SkillListServerCommand + }, +{ + name: 'skill/propose', + className: 'SkillProposeServerCommand', + commandClass: SkillProposeServerCommand + }, +{ + name: 'skill/validate', + className: 'SkillValidateServerCommand', + commandClass: SkillValidateServerCommand + }, { name: 'social/browse', className: 'SocialBrowseServerCommand', diff --git a/src/debug/jtag/shared/generated-command-constants.ts b/src/debug/jtag/shared/generated-command-constants.ts index 461e8f0c3..fd407d0aa 100644 --- a/src/debug/jtag/shared/generated-command-constants.ts +++ b/src/debug/jtag/shared/generated-command-constants.ts @@ -59,6 +59,21 @@ export const COMMANDS = { CANVAS_STROKE_ADD: 'canvas/stroke/add', CANVAS_STROKE_LIST: 'canvas/stroke/list', CANVAS_VISION: 'canvas/vision', + CODE_DIFF: 'code/diff', + CODE_EDIT: 'code/edit', + CODE_GIT: 'code/git', + CODE_HISTORY: 'code/history', + CODE_READ: 'code/read', + CODE_SEARCH: 'code/search', + CODE_SHELL_EXECUTE: 'code/shell/execute', + CODE_SHELL_KILL: 'code/shell/kill', + CODE_SHELL_SENTINEL: 'code/shell/sentinel', + CODE_SHELL_STATUS: 'code/shell/status', + CODE_SHELL_WATCH: 'code/shell/watch', + CODE_TREE: 'code/tree', + CODE_UNDO: 'code/undo', + CODE_VERIFY: 'code/verify', + CODE_WRITE: 'code/write', COLLABORATION_ACTIVITY_CREATE: 'collaboration/activity/create', COLLABORATION_ACTIVITY_GET: 'collaboration/activity/get', COLLABORATION_ACTIVITY_JOIN: 'collaboration/activity/join', @@ -106,8 +121,6 @@ export const COMMANDS = { DATA_VECTOR_SEARCH: 'data/vector-search', DEVELOPMENT_BENCHMARK_VECTORS: 'development/benchmark-vectors', DEVELOPMENT_BUILD: 'development/build', - DEVELOPMENT_CODE_PATTERN_SEARCH: 'development/code/pattern-search', - DEVELOPMENT_CODE_READ: 'development/code/read', DEVELOPMENT_COMPILE_TYPESCRIPT: 'development/compile-typescript', DEVELOPMENT_DEBUG_ARTIFACTS_CHECK: 'development/debug/artifacts-check', DEVELOPMENT_DEBUG_CHAT_SEND: 'development/debug/chat-send', @@ -179,6 +192,11 @@ export const COMMANDS = { SESSION_DESTROY: 'session/destroy', SESSION_GET_ID: 'session/get-id', SESSION_GET_USER: 'session/get-user', + SKILL_ACTIVATE: 'skill/activate', + SKILL_GENERATE: 'skill/generate', + SKILL_LIST: 'skill/list', + SKILL_PROPOSE: 'skill/propose', + SKILL_VALIDATE: 'skill/validate', SOCIAL_BROWSE: 'social/browse', SOCIAL_CLASSIFY: 'social/classify', SOCIAL_COMMENT: 'social/comment', diff --git a/src/debug/jtag/shared/generated/code/ChangeNode.ts b/src/debug/jtag/shared/generated/code/ChangeNode.ts new file mode 100644 index 000000000..bd89c9e7b --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ChangeNode.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileDiff } from "./FileDiff"; +import type { FileOperation } from "./FileOperation"; + +/** + * Every file operation creates a ChangeNode in the DAG. + */ +export type ChangeNode = { id: string, +/** + * Parent node IDs. Empty for root operations. Multiple for merges. + */ +parent_ids: Array, +/** + * Who performed this operation (persona UUID string). + */ +author_id: string, +/** + * When the operation occurred (unix millis). + */ +timestamp: number, +/** + * The file affected (relative to workspace root). + */ +file_path: string, +/** + * The operation type. + */ +operation: FileOperation, +/** + * Forward diff (apply to go forward in time). + */ +forward_diff: FileDiff, +/** + * Reverse diff (apply to go backward in time β€” undo). + */ +reverse_diff: FileDiff, +/** + * Optional description from the AI about what this change does. + */ +description?: string, +/** + * Workspace ID this change belongs to. + */ +workspace_id: string, }; diff --git a/src/debug/jtag/shared/generated/code/ClassifiedLine.ts b/src/debug/jtag/shared/generated/code/ClassifiedLine.ts new file mode 100644 index 000000000..ca9785451 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ClassifiedLine.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OutputClassification } from "./OutputClassification"; + +/** + * A single line of classified shell output. + */ +export type ClassifiedLine = { +/** + * The raw text content of the line. + */ +text: string, +/** + * Classification assigned by sentinel rules. + */ +classification: OutputClassification, +/** + * Line number within the stream (0-indexed from execution start). + */ +line_number: number, +/** + * Which stream this line came from: "stdout" or "stderr". + */ +stream: string, +/** + * Unix timestamp in milliseconds when the line was classified. + */ +timestamp: number, }; diff --git a/src/debug/jtag/shared/generated/code/DiffHunk.ts b/src/debug/jtag/shared/generated/code/DiffHunk.ts new file mode 100644 index 000000000..d14968fed --- /dev/null +++ b/src/debug/jtag/shared/generated/code/DiffHunk.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single hunk in a unified diff. + */ +export type DiffHunk = { old_start: number, old_count: number, new_start: number, new_count: number, +/** + * The hunk content (with +/- prefixes on each line). + */ +content: string, }; diff --git a/src/debug/jtag/shared/generated/code/EditMode.ts b/src/debug/jtag/shared/generated/code/EditMode.ts new file mode 100644 index 000000000..5897d1236 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/EditMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How to edit a file (four modes). + */ +export type EditMode = { "type": "line_range", start_line: number, end_line: number, new_content: string, } | { "type": "search_replace", search: string, replace: string, all: boolean, } | { "type": "insert_at", line: number, content: string, } | { "type": "append", content: string, }; diff --git a/src/debug/jtag/shared/generated/code/FileDiff.ts b/src/debug/jtag/shared/generated/code/FileDiff.ts new file mode 100644 index 000000000..1355db62c --- /dev/null +++ b/src/debug/jtag/shared/generated/code/FileDiff.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DiffHunk } from "./DiffHunk"; + +/** + * A file diff consisting of hunks. + */ +export type FileDiff = { +/** + * Unified diff text (compatible with standard tooling). + */ +unified: string, +/** + * Structured hunks for programmatic application. + */ +hunks: Array, }; diff --git a/src/debug/jtag/shared/generated/code/FileOperation.ts b/src/debug/jtag/shared/generated/code/FileOperation.ts new file mode 100644 index 000000000..ade4b896c --- /dev/null +++ b/src/debug/jtag/shared/generated/code/FileOperation.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * File operation types. + */ +export type FileOperation = "create" | "write" | "edit" | "delete" | { "rename": { from: string, to: string, } } | { "undo": { reverted_id: string, } }; diff --git a/src/debug/jtag/shared/generated/code/GitStatusInfo.ts b/src/debug/jtag/shared/generated/code/GitStatusInfo.ts new file mode 100644 index 000000000..361bd9a85 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/GitStatusInfo.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Git status information. + */ +export type GitStatusInfo = { success: boolean, branch?: string, modified: Array, added: Array, deleted: Array, untracked: Array, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/HistoryResult.ts b/src/debug/jtag/shared/generated/code/HistoryResult.ts new file mode 100644 index 000000000..35c609807 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/HistoryResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChangeNode } from "./ChangeNode"; + +/** + * History query result. + */ +export type HistoryResult = { success: boolean, nodes: Array, total_count: number, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/OutputClassification.ts b/src/debug/jtag/shared/generated/code/OutputClassification.ts new file mode 100644 index 000000000..89b9396d5 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/OutputClassification.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Classification level for a line of shell output. + */ +export type OutputClassification = "Error" | "Warning" | "Info" | "Success" | "Verbose"; diff --git a/src/debug/jtag/shared/generated/code/ReadResult.ts b/src/debug/jtag/shared/generated/code/ReadResult.ts new file mode 100644 index 000000000..aaec959ca --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ReadResult.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of a file read operation. + */ +export type ReadResult = { success: boolean, content?: string, file_path: string, total_lines: number, lines_returned: number, start_line: number, end_line: number, size_bytes: number, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/SearchMatch.ts b/src/debug/jtag/shared/generated/code/SearchMatch.ts new file mode 100644 index 000000000..787fa78e7 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/SearchMatch.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single search match. + */ +export type SearchMatch = { file_path: string, line_number: number, line_content: string, match_start: number, match_end: number, }; diff --git a/src/debug/jtag/shared/generated/code/SearchResult.ts b/src/debug/jtag/shared/generated/code/SearchResult.ts new file mode 100644 index 000000000..cd63567d9 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/SearchResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SearchMatch } from "./SearchMatch"; + +/** + * Result of a code search operation. + */ +export type SearchResult = { success: boolean, matches: Array, total_matches: number, files_searched: number, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/SentinelAction.ts b/src/debug/jtag/shared/generated/code/SentinelAction.ts new file mode 100644 index 000000000..cd6f65aa1 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/SentinelAction.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What to do with a line that matches a sentinel rule. + */ +export type SentinelAction = "Emit" | "Suppress"; diff --git a/src/debug/jtag/shared/generated/code/SentinelRule.ts b/src/debug/jtag/shared/generated/code/SentinelRule.ts new file mode 100644 index 000000000..5524c117d --- /dev/null +++ b/src/debug/jtag/shared/generated/code/SentinelRule.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OutputClassification } from "./OutputClassification"; +import type { SentinelAction } from "./SentinelAction"; + +/** + * A sentinel filter rule: regex pattern β†’ classification + action. + * + * Wire type for IPC. Patterns are compiled to `regex::Regex` on the Rust side + * when `set_sentinel()` is called. + */ +export type SentinelRule = { +/** + * Regex pattern to match against each output line. + */ +pattern: string, +/** + * Classification to assign when this rule matches. + */ +classification: OutputClassification, +/** + * Whether to include or suppress the matched line. + */ +action: SentinelAction, }; diff --git a/src/debug/jtag/shared/generated/code/ShellExecuteResponse.ts b/src/debug/jtag/shared/generated/code/ShellExecuteResponse.ts new file mode 100644 index 000000000..2f74b0c16 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellExecuteResponse.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ShellExecutionStatus } from "./ShellExecutionStatus"; + +/** + * Response from `code/shell-execute`. + * + * Always returns immediately with the execution handle. + * If `wait: true` was specified, also includes the completed result. + */ +export type ShellExecuteResponse = { execution_id: string, status: ShellExecutionStatus, +/** + * Full stdout (only present when `wait: true` and execution completed). + */ +stdout?: string, +/** + * Full stderr (only present when `wait: true` and execution completed). + */ +stderr?: string, +/** + * Exit code (only present when execution completed). + */ +exit_code?: number, }; diff --git a/src/debug/jtag/shared/generated/code/ShellExecutionStatus.ts b/src/debug/jtag/shared/generated/code/ShellExecutionStatus.ts new file mode 100644 index 000000000..cfd88cc51 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellExecutionStatus.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Status of a shell command execution. + */ +export type ShellExecutionStatus = "running" | "completed" | "failed" | "timed_out" | "killed"; diff --git a/src/debug/jtag/shared/generated/code/ShellHistoryEntry.ts b/src/debug/jtag/shared/generated/code/ShellHistoryEntry.ts new file mode 100644 index 000000000..5984d5ab5 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellHistoryEntry.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A history entry for a completed execution. + */ +export type ShellHistoryEntry = { execution_id: string, command: string, exit_code?: number, started_at: number, finished_at?: number, }; diff --git a/src/debug/jtag/shared/generated/code/ShellPollResponse.ts b/src/debug/jtag/shared/generated/code/ShellPollResponse.ts new file mode 100644 index 000000000..9fbf317e3 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellPollResponse.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ShellExecutionStatus } from "./ShellExecutionStatus"; + +/** + * Response from `code/shell-poll`. + * + * Returns new output since the last poll (cursor-based). + * Call repeatedly until `finished` is true. + */ +export type ShellPollResponse = { execution_id: string, status: ShellExecutionStatus, +/** + * New stdout lines since last poll. + */ +new_stdout: Array, +/** + * New stderr lines since last poll. + */ +new_stderr: Array, +/** + * Exit code (present when finished). + */ +exit_code?: number, +/** + * True when the execution is no longer running. + */ +finished: boolean, }; diff --git a/src/debug/jtag/shared/generated/code/ShellSessionInfo.ts b/src/debug/jtag/shared/generated/code/ShellSessionInfo.ts new file mode 100644 index 000000000..9101eb5ed --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellSessionInfo.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response from `code/shell-status` β€” session metadata. + */ +export type ShellSessionInfo = { session_id: string, persona_id: string, cwd: string, workspace_root: string, active_executions: number, total_executions: number, }; diff --git a/src/debug/jtag/shared/generated/code/ShellWatchResponse.ts b/src/debug/jtag/shared/generated/code/ShellWatchResponse.ts new file mode 100644 index 000000000..120185d46 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/ShellWatchResponse.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClassifiedLine } from "./ClassifiedLine"; + +/** + * Response from `code/shell-watch`. + * + * Returns classified output lines since the last watch call. + * Blocks until output is available (no timeout, no polling). + * Call in a loop until `finished` is true. + */ +export type ShellWatchResponse = { execution_id: string, +/** + * Classified output lines (filtered through sentinel rules). + */ +lines: Array, +/** + * True when the execution is no longer running. + */ +finished: boolean, +/** + * Exit code (present when finished). + */ +exit_code?: number, }; diff --git a/src/debug/jtag/shared/generated/code/TreeNode.ts b/src/debug/jtag/shared/generated/code/TreeNode.ts new file mode 100644 index 000000000..b79d6a206 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/TreeNode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A node in a directory tree. + */ +export type TreeNode = { name: string, path: string, is_directory: boolean, size_bytes?: number, children: Array, }; diff --git a/src/debug/jtag/shared/generated/code/TreeResult.ts b/src/debug/jtag/shared/generated/code/TreeResult.ts new file mode 100644 index 000000000..28579a140 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/TreeResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TreeNode } from "./TreeNode"; + +/** + * Result of a tree operation. + */ +export type TreeResult = { success: boolean, root?: TreeNode, total_files: number, total_directories: number, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/UndoResult.ts b/src/debug/jtag/shared/generated/code/UndoResult.ts new file mode 100644 index 000000000..ceef6a42a --- /dev/null +++ b/src/debug/jtag/shared/generated/code/UndoResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WriteResult } from "./WriteResult"; + +/** + * Result of an undo operation. + */ +export type UndoResult = { success: boolean, changes_undone: Array, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/WriteResult.ts b/src/debug/jtag/shared/generated/code/WriteResult.ts new file mode 100644 index 000000000..ce9e73157 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/WriteResult.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of a file write/edit/delete operation. + */ +export type WriteResult = { success: boolean, +/** + * UUID of the ChangeNode created. + */ +change_id?: string, file_path: string, bytes_written: number, error?: string, }; diff --git a/src/debug/jtag/shared/generated/code/index.ts b/src/debug/jtag/shared/generated/code/index.ts new file mode 100644 index 000000000..d258627e9 --- /dev/null +++ b/src/debug/jtag/shared/generated/code/index.ts @@ -0,0 +1,42 @@ +// Code Module Types - Generated from Rust (single source of truth) +// Re-run: cargo test --package continuum-core --lib export_bindings + +// Core change graph types +export type { ChangeNode } from './ChangeNode'; +export type { FileOperation } from './FileOperation'; +export type { FileDiff } from './FileDiff'; +export type { DiffHunk } from './DiffHunk'; + +// Edit modes (discriminated union) +export type { EditMode } from './EditMode'; + +// Operation results +export type { WriteResult } from './WriteResult'; +export type { ReadResult } from './ReadResult'; +export type { UndoResult } from './UndoResult'; +export type { HistoryResult } from './HistoryResult'; + +// Search +export type { SearchMatch } from './SearchMatch'; +export type { SearchResult } from './SearchResult'; + +// Tree +export type { TreeNode } from './TreeNode'; +export type { TreeResult } from './TreeResult'; + +// Git +export type { GitStatusInfo } from './GitStatusInfo'; + +// Shell Session +export type { ShellExecutionStatus } from './ShellExecutionStatus'; +export type { ShellExecuteResponse } from './ShellExecuteResponse'; +export type { ShellPollResponse } from './ShellPollResponse'; +export type { ShellSessionInfo } from './ShellSessionInfo'; +export type { ShellHistoryEntry } from './ShellHistoryEntry'; + +// Shell Watch + Sentinel +export type { OutputClassification } from './OutputClassification'; +export type { SentinelAction } from './SentinelAction'; +export type { SentinelRule } from './SentinelRule'; +export type { ClassifiedLine } from './ClassifiedLine'; +export type { ShellWatchResponse } from './ShellWatchResponse'; diff --git a/src/debug/jtag/shared/generated/index.ts b/src/debug/jtag/shared/generated/index.ts index a00ceeec3..2241c540f 100644 --- a/src/debug/jtag/shared/generated/index.ts +++ b/src/debug/jtag/shared/generated/index.ts @@ -13,3 +13,6 @@ export * from './ipc'; // Voice call types (already generated) export type { CallMessage } from './CallMessage'; + +// Code module types (file operations, change graph, search, tree) +export * from './code'; diff --git a/src/debug/jtag/shared/generated/persona/ActivityDomain.ts b/src/debug/jtag/shared/generated/persona/ActivityDomain.ts index 83b423021..d8bc0a79a 100644 --- a/src/debug/jtag/shared/generated/persona/ActivityDomain.ts +++ b/src/debug/jtag/shared/generated/persona/ActivityDomain.ts @@ -4,4 +4,4 @@ * Activity domain for channel routing. * Each domain has one ChannelQueue. Items route to their domain's queue. */ -export type ActivityDomain = "AUDIO" | "CHAT" | "BACKGROUND"; +export type ActivityDomain = "AUDIO" | "CHAT" | "CODE" | "BACKGROUND"; diff --git a/src/debug/jtag/shared/generated/persona/ChannelEnqueueRequest.ts b/src/debug/jtag/shared/generated/persona/ChannelEnqueueRequest.ts index b32f31d2b..fa0d4f42b 100644 --- a/src/debug/jtag/shared/generated/persona/ChannelEnqueueRequest.ts +++ b/src/debug/jtag/shared/generated/persona/ChannelEnqueueRequest.ts @@ -3,4 +3,4 @@ /** * IPC request to enqueue any item type. Discriminated by `item_type` field. */ -export type ChannelEnqueueRequest = { "item_type": "voice", id: string, room_id: string, content: string, sender_id: string, sender_name: string, sender_type: string, voice_session_id: string, timestamp: number, priority: number, } | { "item_type": "chat", id: string, room_id: string, content: string, sender_id: string, sender_name: string, sender_type: string, mentions: boolean, timestamp: number, priority: number, } | { "item_type": "task", id: string, task_id: string, assignee_id: string, created_by: string, task_domain: string, task_type: string, context_id: string, description: string, priority: number, status: string, timestamp: number, due_date: bigint | null, estimated_duration: bigint | null, depends_on: Array, blocked_by: Array, }; +export type ChannelEnqueueRequest = { "item_type": "voice", id: string, room_id: string, content: string, sender_id: string, sender_name: string, sender_type: string, voice_session_id: string, timestamp: number, priority: number, } | { "item_type": "chat", id: string, room_id: string, content: string, sender_id: string, sender_name: string, sender_type: string, mentions: boolean, timestamp: number, priority: number, } | { "item_type": "task", id: string, task_id: string, assignee_id: string, created_by: string, task_domain: string, task_type: string, context_id: string, description: string, priority: number, status: string, timestamp: number, due_date: bigint | null, estimated_duration: bigint | null, depends_on: Array, blocked_by: Array, } | { "item_type": "code", id: string, room_id: string, persona_id: string, task_description: string, workspace_handle: string, priority: number, is_review: boolean, timestamp: number, }; diff --git a/src/debug/jtag/shared/ipc/WorkerClient.ts b/src/debug/jtag/shared/ipc/WorkerClient.ts index ffdeafb71..6e9ac6ffb 100644 --- a/src/debug/jtag/shared/ipc/WorkerClient.ts +++ b/src/debug/jtag/shared/ipc/WorkerClient.ts @@ -22,7 +22,6 @@ */ import * as net from 'net'; -import * as fs from 'fs'; import { generateUUID } from '../../system/core/types/CrossPlatformUUID'; import { WorkerRequest, @@ -32,21 +31,9 @@ import { } from './WorkerMessages.js'; import { TimingHarness } from '../../system/core/shared/TimingHarness'; -// DEBUG LOGGING - COMPREHENSIVE -const DEBUG_LOG = '/tmp/worker-client-debug.log'; -let logSession = 0; - -function debugLog(msg: string): void { - const timestamp = new Date().toISOString(); - const pid = process.pid; - fs.appendFileSync(DEBUG_LOG, `[${timestamp}] [PID:${pid}] [Session:${logSession}] ${msg}\n`); -} - -// Log session start on module load -debugLog('='.repeat(80)); -debugLog(`WorkerClient MODULE LOADED - Process started at ${new Date().toISOString()}`); -debugLog(`Process PID: ${process.pid}`); -debugLog('='.repeat(80)); +// IPC types that should NOT be timed (breaks recursive timing loop) +// write-log β†’ timing β†’ appendFile β†’ blocks event loop +const SKIP_TIMING_TYPES = new Set(['write-log', 'flush-logs']); // ============================================================================ // Types and Interfaces @@ -121,19 +108,12 @@ export class WorkerClient { protected readonly maxQueueSize: number; constructor(config: WorkerClientConfig) { - logSession++; - debugLog(`>>> CONSTRUCTOR START (session ${logSession})`); - debugLog(`Socket path: ${config.socketPath}`); - debugLog(`Timeout: ${config.timeout ?? 10000}ms`); - this.socketPath = config.socketPath; this.timeout = config.timeout ?? 10000; this.reconnectDelay = config.reconnectDelay ?? 1000; this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3; this.defaultUserId = config.userId; this.maxQueueSize = config.maxQueueSize ?? 1000; - - debugLog(`<<< CONSTRUCTOR END`); } // ============================================================================ @@ -145,51 +125,38 @@ export class WorkerClient { * @throws {Error} if connection fails */ async connect(): Promise { - debugLog(`connect() called - current state: ${this.connectionState}`); - if (this.connectionState === 'connected') { - debugLog('Already connected, returning'); - return; // Already connected + return; } if (this.connectionState === 'connecting') { - debugLog('Connection already in progress'); throw new Error('Connection already in progress'); } - debugLog(`Creating connection to ${this.socketPath}`); this.connectionState = 'connecting'; this.socket = net.createConnection(this.socketPath); return new Promise((resolve, reject) => { if (!this.socket) { - debugLog('Socket is null!'); reject(new Error('Socket is null')); return; } const connectTimeout = setTimeout(() => { - debugLog('Connection timeout!'); reject(new Error(`Connection timeout after ${this.timeout}ms`)); this.socket?.destroy(); }, this.timeout); this.socket.once('connect', () => { - debugLog('Socket connected event fired'); clearTimeout(connectTimeout); this.connectionState = 'connected'; this.reconnectAttempts = 0; this.setupSocketHandlers(); - debugLog('setupSocketHandlers() complete'); - - // Flush queued messages this.flushQueue(); - resolve(); }); this.socket.once('error', (err) => { - debugLog(`Socket error during connect: ${err.message}`); clearTimeout(connectTimeout); this.connectionState = 'error'; reject(err); @@ -201,17 +168,13 @@ export class WorkerClient { * Disconnect from the Rust worker. */ async disconnect(): Promise { - debugLog(`>>> DISCONNECT called - state: ${this.connectionState}`); if (this.socket) { - debugLog('Calling socket.end()'); this.socket.end(); this.socket = null; - debugLog('Socket ended and nulled'); } this.connectionState = 'disconnected'; // Reject all pending requests - debugLog(`Rejecting ${this.pendingRequests.size} pending requests`); for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeoutId); pending.reject(new Error('Client disconnected')); @@ -219,13 +182,10 @@ export class WorkerClient { } // Reject all queued messages - debugLog(`Rejecting ${this.messageQueue.length} queued messages`); for (const msg of this.messageQueue) { msg.reject(new Error('Client disconnected before message could be sent')); } this.messageQueue = []; - - debugLog(`<<< DISCONNECT complete`); } /** @@ -262,17 +222,16 @@ export class WorkerClient { payload: TReq, userId?: string ): Promise> { - const timer = TimingHarness.start(`ipc/${type}`, 'ipc'); - timer.setMeta('socketPath', this.socketPath); - timer.setMeta('type', type); - - debugLog(`send() called - type: ${type}, connected: ${this.isConnected()}`); + // Skip timing for logger IPC to break recursive loop: + // write-log β†’ TimingHarness β†’ appendFile β†’ blocks event loop + const shouldTime = !SKIP_TIMING_TYPES.has(type); + const timer = shouldTime ? TimingHarness.start(`ipc/${type}`, 'ipc') : null; + timer?.setMeta('socketPath', this.socketPath); + timer?.setMeta('type', type); if (!this.isConnected()) { - debugLog(`send() not connected - queueing message (state: ${this.connectionState})`); - timer.setMeta('queued', true); - timer.mark('queued'); - // Don't finish timer here - it will be finished when dequeued + timer?.setMeta('queued', true); + timer?.mark('queued'); return this.queueMessage(type, payload, userId); } @@ -283,53 +242,44 @@ export class WorkerClient { payload, userId: userId ?? this.defaultUserId }; - timer.mark('build_request'); - - debugLog(`Created request with id: ${request.id}`); + timer?.mark('build_request'); return new Promise((resolve, reject) => { - // Set up timeout const timeoutId = setTimeout(() => { - debugLog(`Request ${request.id} timed out after ${this.timeout}ms`); this.pendingRequests.delete(request.id); - timer.setError(`Timeout after ${this.timeout}ms`); - timer.finish(); + timer?.setError(`Timeout after ${this.timeout}ms`); + timer?.finish(); reject(new Error(`Request timeout after ${this.timeout}ms`)); }, this.timeout); - // Store pending request with timer reference for completion this.pendingRequests.set(request.id, { resolve: (response) => { - timer.mark('response_received'); - timer.setMeta('success', response.success); - timer.finish(); + timer?.mark('response_received'); + timer?.setMeta('success', response.success); + timer?.finish(); resolve(response); }, reject: (error) => { - timer.setError(error.message); - timer.finish(); + timer?.setError(error.message); + timer?.finish(); reject(error); }, timeoutId }); - // Send request (newline-delimited JSON) const json = JSON.stringify(request) + '\n'; - timer.setMeta('requestBytes', json.length); - debugLog(`Calling socket.write() with ${json.length} bytes`); - timer.mark('serialize'); + timer?.setMeta('requestBytes', json.length); + timer?.mark('serialize'); this.socket!.write(json, (err) => { if (err) { - debugLog(`socket.write() error: ${err.message}`); clearTimeout(timeoutId); this.pendingRequests.delete(request.id); - timer.setError(err.message); - timer.finish(); + timer?.setError(err.message); + timer?.finish(); reject(err); } else { - debugLog(`socket.write() callback - success, data sent`); - timer.mark('socket_write'); + timer?.mark('socket_write'); } }); }); @@ -341,15 +291,11 @@ export class WorkerClient { private setupSocketHandlers(): void { if (!this.socket) { - debugLog('setupSocketHandlers: socket is null'); return; } - debugLog('Setting up socket handlers'); - // Handle incoming data this.socket.on('data', (data) => { - debugLog(`Received data: ${data.length} bytes`); this.buffer += data.toString(); // Process complete lines (newline-delimited JSON) @@ -375,7 +321,6 @@ export class WorkerClient { // Handle socket errors this.socket.on('error', (err) => { - debugLog(`Socket 'error' event: ${err.message}`); console.error('WorkerClient: Socket error:', err); this.connectionState = 'error'; this.attemptReconnect(); @@ -383,12 +328,9 @@ export class WorkerClient { // Handle socket close this.socket.on('close', () => { - debugLog(`Socket 'close' event fired - state was: ${this.connectionState}`); this.connectionState = 'disconnected'; this.attemptReconnect(); }); - - debugLog('Socket handlers setup complete'); } private handleResponse(response: WorkerResponse): void { @@ -449,12 +391,10 @@ export class WorkerClient { ): Promise> { return new Promise((resolve, reject) => { if (this.messageQueue.length >= this.maxQueueSize) { - debugLog(`Queue full (${this.messageQueue.length}/${this.maxQueueSize}), rejecting message`); reject(new Error(`Worker message queue full (${this.maxQueueSize} messages)`)); return; } - debugLog(`Queuing message - type: ${type}, queue size: ${this.messageQueue.length + 1}`); this.messageQueue.push({ type, payload, @@ -474,12 +414,10 @@ export class WorkerClient { return; } - debugLog(`Flushing ${this.messageQueue.length} queued messages`); const queuedMessages = [...this.messageQueue]; this.messageQueue = []; for (const msg of queuedMessages) { - debugLog(`Sending queued message - type: ${msg.type}`); this.send(msg.type, msg.payload, msg.userId) .then(msg.resolve) .catch(msg.reject); diff --git a/src/debug/jtag/shared/version.ts b/src/debug/jtag/shared/version.ts index 5aa0e4490..2bfa0bcb5 100644 --- a/src/debug/jtag/shared/version.ts +++ b/src/debug/jtag/shared/version.ts @@ -3,5 +3,5 @@ * DO NOT EDIT MANUALLY */ -export const VERSION = '1.0.7512'; +export const VERSION = '1.0.7610'; export const PACKAGE_NAME = '@continuum/jtag'; diff --git a/src/debug/jtag/shared/workers/PersonaWorkerThread.ts b/src/debug/jtag/shared/workers/PersonaWorkerThread.ts index bf1533416..aedbb82f5 100644 --- a/src/debug/jtag/shared/workers/PersonaWorkerThread.ts +++ b/src/debug/jtag/shared/workers/PersonaWorkerThread.ts @@ -85,9 +85,7 @@ export class PersonaWorkerThread extends EventEmitter { const currentDir = path.dirname(fileURLToPath(import.meta.url)); const workerPath = path.join(currentDir, 'persona-worker.js'); - console.log(`🧡 Starting worker for persona ${this.personaId}`); - console.log(` Worker script: ${workerPath}`); - console.log(` Provider type: ${this.config.providerType}`); + console.log(`🧡 Starting worker for persona ${this.personaId.slice(0, 8)} (${this.config.providerType})`); this.worker = new Worker(workerPath, { workerData: { diff --git a/src/debug/jtag/system/code/server/CodingModelSelector.ts b/src/debug/jtag/system/code/server/CodingModelSelector.ts new file mode 100644 index 000000000..8b224917b --- /dev/null +++ b/src/debug/jtag/system/code/server/CodingModelSelector.ts @@ -0,0 +1,174 @@ +/** + * CodingModelSelector - Routes coding tasks to appropriate frontier models + * + * Coding requires frontier models (Claude, GPT, DeepSeek) β€” not local Ollama. + * This selector maps task types to model tiers: + * + * | Task Type | Model Tier | Why | + * |-------------|----------------|----------------------------------------| + * | Planning | Best reasoning | Architecture decisions need deep thought| + * | Generation | Best coding | Writing code needs strong coding models | + * | Editing | Best coding | Modifying code needs precision | + * | Review | Any frontier | Analysis is broadly capable | + * | Quick fix | Fast + cheap | Typos and simple fixes | + * | Discovery | Fast + cheap | Codebase exploration is simple | + * + * Provider fallback: anthropic β†’ openai β†’ deepseek β†’ groq + */ + +import type { CodingTaskType, CodingModelTier } from '../shared/CodingTypes'; +import { MODEL_IDS } from '../../shared/Constants'; +import { SOTA_PROVIDERS } from '../../user/server/config/PersonaModelConfigs'; +import { Logger } from '../../core/logging/Logger'; + +const log = Logger.create('CodingModelSelector', 'code'); + +/** + * Default model tiers for each task type. + * Ordered by preference β€” first available provider wins. + */ +const DEFAULT_TIERS: Record = { + planning: { + taskType: 'planning', + provider: 'anthropic', + model: MODEL_IDS.ANTHROPIC.SONNET_4_5, + temperature: 0.3, + maxTokens: 4000, + description: 'Planning/architecture β€” best reasoning model', + }, + generation: { + taskType: 'generation', + provider: 'anthropic', + model: MODEL_IDS.ANTHROPIC.SONNET_4_5, + temperature: 0.4, + maxTokens: 4000, + description: 'Code generation β€” strong coding model', + }, + editing: { + taskType: 'editing', + provider: 'anthropic', + model: MODEL_IDS.ANTHROPIC.SONNET_4_5, + temperature: 0.2, + maxTokens: 4000, + description: 'Code editing β€” precise, low temperature', + }, + review: { + taskType: 'review', + provider: 'deepseek', + model: MODEL_IDS.DEEPSEEK.CHAT, + temperature: 0.3, + maxTokens: 3000, + description: 'Code review β€” any frontier model works', + }, + 'quick-fix': { + taskType: 'quick-fix', + provider: 'groq', + model: MODEL_IDS.GROQ.LLAMA_3_1_70B, + temperature: 0.2, + maxTokens: 2000, + description: 'Quick fixes β€” fast and cheap', + }, + discovery: { + taskType: 'discovery', + provider: 'groq', + model: MODEL_IDS.GROQ.LLAMA_3_1_8B, + temperature: 0.1, + maxTokens: 1000, + description: 'Discovery β€” codebase exploration, fast', + }, +}; + +/** + * Provider fallback order when preferred provider is unavailable. + * Prioritizes SOTA providers with strong coding capabilities. + */ +const PROVIDER_FALLBACK_ORDER: readonly string[] = [ + 'anthropic', + 'openai', + 'deepseek', + 'xai', + 'google', + 'groq', + 'together', + 'fireworks', +] as const; + +/** + * Fallback models per provider (when the preferred model isn't available). + */ +const FALLBACK_MODELS: Record = { + 'anthropic': MODEL_IDS.ANTHROPIC.SONNET_4_5, + 'openai': MODEL_IDS.OPENAI.GPT_4, + 'deepseek': MODEL_IDS.DEEPSEEK.CHAT, + 'groq': MODEL_IDS.GROQ.LLAMA_3_1_70B, + 'xai': MODEL_IDS.XAI.GROK_4, + 'google': 'gemini-2.0-flash', + 'together': MODEL_IDS.TOGETHER.LLAMA_3_1_70B, + 'fireworks': MODEL_IDS.FIREWORKS.LLAMA_3_1_70B, +}; + +export class CodingModelSelector { + private _availableProviders: Set; + + /** + * @param availableProviders - Set of provider names that are currently registered and healthy. + * Pass SOTA_PROVIDERS for production, or a subset for testing. + */ + constructor(availableProviders?: Set) { + this._availableProviders = availableProviders ?? new Set(SOTA_PROVIDERS); + } + + /** + * Update the set of available providers (e.g., after health check). + */ + set availableProviders(providers: Set) { + this._availableProviders = providers; + } + + /** + * Select the best model tier for a given task type. + * Falls through provider fallback order if preferred provider is unavailable. + */ + select(taskType: CodingTaskType): CodingModelTier { + const defaultTier = DEFAULT_TIERS[taskType]; + + // Try the default provider first + if (this._availableProviders.has(defaultTier.provider)) { + log.debug(`Selected ${defaultTier.provider}/${defaultTier.model} for ${taskType}`); + return defaultTier; + } + + // Fallback through provider order + for (const provider of PROVIDER_FALLBACK_ORDER) { + if (this._availableProviders.has(provider)) { + const model = FALLBACK_MODELS[provider]; + const fallbackTier: CodingModelTier = { + ...defaultTier, + provider, + model, + description: `${defaultTier.description} (fallback: ${provider})`, + }; + log.debug(`Fallback: ${provider}/${model} for ${taskType} (preferred ${defaultTier.provider} unavailable)`); + return fallbackTier; + } + } + + // Last resort β€” return default tier anyway, let AIProviderDaemon handle the error + log.warn(`No SOTA provider available for ${taskType}, using default tier (may fail)`); + return defaultTier; + } + + /** + * Check if any frontier model is available for coding tasks. + */ + get hasFrontierModel(): boolean { + return PROVIDER_FALLBACK_ORDER.some(p => this._availableProviders.has(p)); + } + + /** + * Get all configured tiers (for debugging/reporting). + */ + get allTiers(): readonly CodingModelTier[] { + return Object.values(DEFAULT_TIERS); + } +} diff --git a/src/debug/jtag/system/code/server/ExecutionSandbox.ts b/src/debug/jtag/system/code/server/ExecutionSandbox.ts new file mode 100644 index 000000000..cf8e31d77 --- /dev/null +++ b/src/debug/jtag/system/code/server/ExecutionSandbox.ts @@ -0,0 +1,219 @@ +/** + * ExecutionSandbox - Process-isolated code execution for coding agents + * + * Runs commands in a restricted child process with: + * - Restricted PATH (only node, npx, tsc) + * - Timeout enforcement (SIGTERM on timeout, SIGKILL after grace period) + * - Output capture with size limits + * - Working directory scoped to persona workspace + * - Environment variable isolation + * + * Based on the existing SandboxExecuteServerCommand spawn pattern, + * extracted as a reusable utility for Phase 4A sandboxing. + */ + +import { spawn, type ChildProcess } from 'child_process'; +import * as path from 'path'; +import { Logger } from '../../core/logging/Logger'; +import type { UUID } from '../../core/types/CrossPlatformUUID'; + +const log = Logger.create('ExecutionSandbox', 'code'); + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +export interface SandboxConfig { + /** Command to execute (e.g., 'npx', 'node', 'tsc') */ + readonly command: string; + + /** Command arguments */ + readonly args: readonly string[]; + + /** Working directory β€” must be within persona workspace */ + readonly cwd: string; + + /** Maximum execution time in milliseconds */ + readonly timeoutMs: number; + + /** Maximum combined stdout+stderr size in bytes */ + readonly maxOutputBytes: number; + + /** Additional environment variables (merged with restricted base) */ + readonly env?: Readonly>; + + /** Persona executing this command (for audit logging) */ + readonly personaId: UUID; +} + +export interface SandboxResult { + readonly success: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly durationMs: number; + readonly truncated: boolean; + readonly timedOut: boolean; + readonly error?: string; +} + +// ──────────────────────────────────────────────────────────── +// Defaults +// ──────────────────────────────────────────────────────────── + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 102_400; // 100KB +const KILL_GRACE_PERIOD_MS = 5_000; + +/** Restricted set of allowed commands */ +const ALLOWED_COMMANDS = new Set(['node', 'npx', 'tsc', 'npm']); + +/** Restricted PATH β€” only common binary locations (includes Homebrew for macOS) */ +const RESTRICTED_PATH = [ + '/opt/homebrew/bin', // macOS Apple Silicon Homebrew + '/usr/local/bin', // macOS Intel Homebrew / standard + '/usr/bin', + '/bin', +].join(path.delimiter); + +// ──────────────────────────────────────────────────────────── +// Sandbox +// ──────────────────────────────────────────────────────────── + +export class ExecutionSandbox { + /** + * Execute a command in a sandboxed child process. + */ + async execute(config: SandboxConfig): Promise { + const startTime = performance.now(); + + // Validate command is in allowlist + const baseCommand = path.basename(config.command); + if (!ALLOWED_COMMANDS.has(baseCommand)) { + return { + success: false, + exitCode: -1, + stdout: '', + stderr: '', + durationMs: 0, + truncated: false, + timedOut: false, + error: `Command '${baseCommand}' is not in the sandbox allowlist. Allowed: ${[...ALLOWED_COMMANDS].join(', ')}`, + }; + } + + const timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS; + const maxOutputBytes = config.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES; + + log.debug(`Sandbox exec: ${config.command} ${config.args.join(' ')} (timeout: ${timeoutMs}ms, persona: ${config.personaId})`); + + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let outputSize = 0; + let truncated = false; + let timedOut = false; + let child: ChildProcess; + + try { + child = spawn(config.command, [...config.args], { + cwd: config.cwd, + env: { + PATH: RESTRICTED_PATH, + NODE_ENV: 'sandbox', + HOME: config.cwd, + SANDBOX_EXECUTION: 'true', + PERSONA_ID: config.personaId, + ...config.env, + }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], // No stdin + }); + } catch (error) { + const durationMs = performance.now() - startTime; + resolve({ + success: false, + exitCode: -1, + stdout: '', + stderr: '', + durationMs, + truncated: false, + timedOut: false, + error: `Failed to spawn: ${error instanceof Error ? error.message : String(error)}`, + }); + return; + } + + // Collect stdout with size limit + child.stdout?.on('data', (data: Buffer) => { + const chunk = data.toString(); + outputSize += chunk.length; + if (outputSize <= maxOutputBytes) { + stdout += chunk; + } else { + truncated = true; + } + }); + + // Collect stderr with size limit + child.stderr?.on('data', (data: Buffer) => { + const chunk = data.toString(); + outputSize += chunk.length; + if (outputSize <= maxOutputBytes) { + stderr += chunk; + } else { + truncated = true; + } + }); + + // Timeout: SIGTERM first, then SIGKILL after grace period + const timeoutHandle = setTimeout(() => { + timedOut = true; + log.warn(`Sandbox timeout: killing process after ${timeoutMs}ms`); + child.kill('SIGTERM'); + + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, KILL_GRACE_PERIOD_MS); + }, timeoutMs); + + // Handle process exit + child.on('close', (code: number | null) => { + clearTimeout(timeoutHandle); + const durationMs = performance.now() - startTime; + + log.debug(`Sandbox done: exit=${code ?? -1}, duration=${Math.round(durationMs)}ms, output=${outputSize}b`); + + resolve({ + success: !timedOut && code === 0, + exitCode: code ?? -1, + stdout: stdout.trim(), + stderr: stderr.trim(), + durationMs, + truncated, + timedOut, + error: timedOut ? `Timed out after ${timeoutMs}ms` : undefined, + }); + }); + + // Handle spawn errors + child.on('error', (error: Error) => { + clearTimeout(timeoutHandle); + const durationMs = performance.now() - startTime; + + resolve({ + success: false, + exitCode: -1, + stdout: stdout.trim(), + stderr: stderr.trim(), + durationMs, + truncated, + timedOut: false, + error: `Spawn error: ${error.message}`, + }); + }); + }); + } +} diff --git a/src/debug/jtag/system/code/server/ProjectDetector.ts b/src/debug/jtag/system/code/server/ProjectDetector.ts new file mode 100644 index 000000000..1c2444324 --- /dev/null +++ b/src/debug/jtag/system/code/server/ProjectDetector.ts @@ -0,0 +1,226 @@ +/** + * ProjectDetector - Detect project type from workspace contents + * + * Examines root-level files to determine the project ecosystem + * and infer build/test/serve commands. Used by: + * - ProjectContextSource (RAG): surfaces project type and commands in context + * - Workspace.detectProjectType(): convenience for tooling + * + * Intentionally simple: file existence checks + basic JSON/TOML parsing. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +export type ProjectTypeName = 'node' | 'rust' | 'python' | 'xcode' | 'go' | 'swift-package' | 'unknown'; + +export interface ProjectType { + /** Detected project ecosystem */ + readonly type: ProjectTypeName; + + /** Primary build command (e.g., 'npm run build', 'cargo build') */ + readonly buildCommand?: string; + + /** Primary test command (e.g., 'npm test', 'cargo test') */ + readonly testCommand?: string; + + /** Dev server / run command (e.g., 'npm run dev', 'cargo run') */ + readonly serveCommand?: string; + + /** Lock file path (e.g., 'package-lock.json', 'Cargo.lock') */ + readonly lockFile?: string; + + /** Primary config file (e.g., 'package.json', 'Cargo.toml') */ + readonly entryFile?: string; + + /** Human-readable description */ + readonly description: string; +} + +// ──────────────────────────────────────────────────────────── +// Detector +// ──────────────────────────────────────────────────────────── + +export class ProjectDetector { + + /** + * Detect project type by examining files in `dir`. + * Checks in priority order β€” first match wins. + */ + static async detect(dir: string): Promise { + // Rust (Cargo.toml) + if (fs.existsSync(path.join(dir, 'Cargo.toml'))) { + return this.detectRust(dir); + } + + // Node.js (package.json) + if (fs.existsSync(path.join(dir, 'package.json'))) { + return this.detectNode(dir); + } + + // Go (go.mod) + if (fs.existsSync(path.join(dir, 'go.mod'))) { + return { + type: 'go', + buildCommand: 'go build ./...', + testCommand: 'go test ./...', + serveCommand: 'go run .', + lockFile: fs.existsSync(path.join(dir, 'go.sum')) ? 'go.sum' : undefined, + entryFile: 'go.mod', + description: 'Go module', + }; + } + + // Python (pyproject.toml or setup.py) + if (fs.existsSync(path.join(dir, 'pyproject.toml'))) { + return this.detectPython(dir); + } + if (fs.existsSync(path.join(dir, 'setup.py'))) { + return { + type: 'python', + buildCommand: 'python setup.py build', + testCommand: 'python -m pytest', + entryFile: 'setup.py', + description: 'Python package (setup.py)', + }; + } + + // Xcode (*.xcodeproj or *.xcworkspace) + const xcodeProject = this.findXcodeProject(dir); + if (xcodeProject) { + return { + type: 'xcode', + buildCommand: `xcodebuild -project "${xcodeProject}" build`, + testCommand: `xcodebuild -project "${xcodeProject}" test`, + entryFile: xcodeProject, + description: `Xcode project (${xcodeProject})`, + }; + } + + // Swift Package (Package.swift without .xcodeproj) + if (fs.existsSync(path.join(dir, 'Package.swift'))) { + return { + type: 'swift-package', + buildCommand: 'swift build', + testCommand: 'swift test', + serveCommand: 'swift run', + entryFile: 'Package.swift', + description: 'Swift Package', + }; + } + + return { + type: 'unknown', + description: 'Unknown project type', + }; + } + + // ────────────────────────────────────────────────────────── + // Ecosystem-specific detection + // ────────────────────────────────────────────────────────── + + private static detectNode(dir: string): ProjectType { + const pkgPath = path.join(dir, 'package.json'); + let scripts: Record = {}; + let name = 'Node.js project'; + + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + scripts = pkg.scripts ?? {}; + if (pkg.name) name = pkg.name; + } catch { + // Malformed package.json β€” use defaults + } + + const lockFile = fs.existsSync(path.join(dir, 'package-lock.json')) ? 'package-lock.json' + : fs.existsSync(path.join(dir, 'yarn.lock')) ? 'yarn.lock' + : fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm-lock.yaml' + : undefined; + + // Infer package manager from lock file + const pm = lockFile === 'yarn.lock' ? 'yarn' + : lockFile === 'pnpm-lock.yaml' ? 'pnpm' + : 'npm'; + + return { + type: 'node', + buildCommand: scripts.build ? `${pm} run build` : undefined, + testCommand: scripts.test ? `${pm} test` : undefined, + serveCommand: scripts.dev ? `${pm} run dev` + : scripts.start ? `${pm} start` + : undefined, + lockFile, + entryFile: 'package.json', + description: `Node.js (${name})`, + }; + } + + private static detectRust(dir: string): ProjectType { + const cargoPath = path.join(dir, 'Cargo.toml'); + let name = 'Rust project'; + let hasBin = false; + + try { + const cargo = fs.readFileSync(cargoPath, 'utf8'); + // Basic TOML parsing for name and [[bin]] + const nameMatch = cargo.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) name = nameMatch[1]; + hasBin = cargo.includes('[[bin]]') || fs.existsSync(path.join(dir, 'src', 'main.rs')); + } catch { + // Malformed Cargo.toml β€” use defaults + } + + return { + type: 'rust', + buildCommand: 'cargo build', + testCommand: 'cargo test', + serveCommand: hasBin ? 'cargo run' : undefined, + lockFile: fs.existsSync(path.join(dir, 'Cargo.lock')) ? 'Cargo.lock' : undefined, + entryFile: 'Cargo.toml', + description: `Rust (${name})`, + }; + } + + private static detectPython(dir: string): ProjectType { + const pyprojectPath = path.join(dir, 'pyproject.toml'); + let description = 'Python project'; + + try { + const content = fs.readFileSync(pyprojectPath, 'utf8'); + const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m); + if (nameMatch) description = `Python (${nameMatch[1]})`; + } catch { + // ignore + } + + // Detect build system + const hasPoetry = fs.existsSync(path.join(dir, 'poetry.lock')); + const hasUv = fs.existsSync(path.join(dir, 'uv.lock')); + + return { + type: 'python', + buildCommand: hasPoetry ? 'poetry build' : hasUv ? 'uv build' : 'python -m build', + testCommand: hasPoetry ? 'poetry run pytest' : hasUv ? 'uv run pytest' : 'python -m pytest', + serveCommand: hasPoetry ? 'poetry run python -m app' : undefined, + lockFile: hasPoetry ? 'poetry.lock' : hasUv ? 'uv.lock' : undefined, + entryFile: 'pyproject.toml', + description, + }; + } + + private static findXcodeProject(dir: string): string | undefined { + try { + const entries = fs.readdirSync(dir); + // Prefer .xcworkspace over .xcodeproj + const workspace = entries.find(e => e.endsWith('.xcworkspace')); + if (workspace) return workspace; + return entries.find(e => e.endsWith('.xcodeproj')); + } catch { + return undefined; + } + } +} diff --git a/src/debug/jtag/system/code/server/SecurityTier.ts b/src/debug/jtag/system/code/server/SecurityTier.ts new file mode 100644 index 000000000..c36d318ac --- /dev/null +++ b/src/debug/jtag/system/code/server/SecurityTier.ts @@ -0,0 +1,178 @@ +/** + * SecurityTier - Risk-based access control for coding agent execution + * + * Four tiers of access, each with explicit tool allowlists/denylists: + * - discovery: Read-only exploration (tree, search, read, history) + * - read: Analysis without modification (adds diff, data/list) + * - write: File modifications within persona workspace (adds write, edit, undo) + * - system: Full access including shell execution (requires governance approval) + * + * The PlanFormulator assesses risk and assigns a required tier. + * The ToolAllowlistEnforcer gates every tool call through the tier. + */ + +import type { SecurityTierLevel, RiskLevel } from '../shared/CodingTypes'; + +// Re-export for consumers that import from this module +export type { SecurityTierLevel, RiskLevel }; + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +export interface SecurityTier { + readonly level: SecurityTierLevel; + readonly allowedCommands: readonly string[]; + readonly deniedCommands: readonly string[]; + readonly maxToolCalls: number; + readonly maxDurationMs: number; + readonly maxFileSizeBytes: number; + readonly allowProcessSpawn: boolean; + readonly allowNetworkAccess: boolean; + readonly requiresApproval: boolean; +} + +// ──────────────────────────────────────────────────────────── +// Tier Definitions +// ──────────────────────────────────────────────────────────── + +const DISCOVERY_TIER: SecurityTier = { + level: 'discovery', + allowedCommands: [ + 'code/tree', + 'code/search', + 'code/read', + 'code/history', + ], + deniedCommands: [ + 'code/write', + 'code/edit', + 'code/undo', + 'code/delete', + 'development/*', + 'system/*', + ], + maxToolCalls: 30, + maxDurationMs: 60_000, + maxFileSizeBytes: 0, // No writes allowed + allowProcessSpawn: false, + allowNetworkAccess: false, + requiresApproval: false, +}; + +const READ_TIER: SecurityTier = { + level: 'read', + allowedCommands: [ + ...DISCOVERY_TIER.allowedCommands, + 'code/diff', + 'code/git', + 'data/list', + 'data/read', + ], + deniedCommands: [ + 'code/write', + 'code/edit', + 'code/undo', + 'code/delete', + 'development/*', + 'system/*', + ], + maxToolCalls: 30, + maxDurationMs: 60_000, + maxFileSizeBytes: 0, // No writes allowed + allowProcessSpawn: false, + allowNetworkAccess: false, + requiresApproval: false, +}; + +const WRITE_TIER: SecurityTier = { + level: 'write', + allowedCommands: [ + ...READ_TIER.allowedCommands, + 'code/write', + 'code/edit', + 'code/undo', + 'code/diff', + 'code/verify', + ], + deniedCommands: [ + 'code/delete', + 'development/exec', + 'development/sandbox-execute', + 'system/*', + ], + maxToolCalls: 20, + maxDurationMs: 120_000, + maxFileSizeBytes: 1_048_576, // 1MB + allowProcessSpawn: false, + allowNetworkAccess: false, + requiresApproval: false, // Risk-based (PlanGovernance decides) +}; + +const SYSTEM_TIER: SecurityTier = { + level: 'system', + allowedCommands: ['*'], + deniedCommands: [], // No restrictions + maxToolCalls: 50, + maxDurationMs: 300_000, + maxFileSizeBytes: 10_485_760, // 10MB + allowProcessSpawn: true, + allowNetworkAccess: true, + requiresApproval: true, // Always requires governance approval +}; + +// ──────────────────────────────────────────────────────────── +// Tier Registry +// ──────────────────────────────────────────────────────────── + +const TIERS: Record = { + discovery: DISCOVERY_TIER, + read: READ_TIER, + write: WRITE_TIER, + system: SYSTEM_TIER, +}; + +/** + * Get the SecurityTier definition for a given level. + */ +export function getTier(level: SecurityTierLevel): SecurityTier { + return TIERS[level]; +} + +/** + * All tier levels in ascending order of privilege. + */ +export const TIER_LEVELS: readonly SecurityTierLevel[] = ['discovery', 'read', 'write', 'system']; + +/** + * Check if tier A has equal or greater privilege than tier B. + */ +export function tierAtLeast(a: SecurityTierLevel, b: SecurityTierLevel): boolean { + return TIER_LEVELS.indexOf(a) >= TIER_LEVELS.indexOf(b); +} + +// ──────────────────────────────────────────────────────────── +// Risk β†’ Tier Mapping +// ──────────────────────────────────────────────────────────── + +/** + * Map a risk level to the minimum security tier required. + * Higher risk β†’ higher tier β†’ more restrictions (and potentially approval). + */ +export function riskToTier(risk: RiskLevel): SecurityTierLevel { + switch (risk) { + case 'low': return 'write'; + case 'medium': return 'write'; + case 'high': return 'write'; // Same tier, but PlanGovernance requires approval at high+ + case 'critical': return 'system'; + } +} + +/** + * Whether a given risk level should require governance approval. + */ +export function riskRequiresApproval(risk: RiskLevel, isMultiAgent: boolean): boolean { + if (isMultiAgent) return true; + if (risk === 'high' || risk === 'critical') return true; + return false; +} diff --git a/src/debug/jtag/system/code/server/ToolAllowlistEnforcer.ts b/src/debug/jtag/system/code/server/ToolAllowlistEnforcer.ts new file mode 100644 index 000000000..8517f4716 --- /dev/null +++ b/src/debug/jtag/system/code/server/ToolAllowlistEnforcer.ts @@ -0,0 +1,174 @@ +/** + * ToolAllowlistEnforcer - Gateway that filters every tool call through a SecurityTier + * + * Sits between the CodeAgentOrchestrator and Commands.execute(). + * Every tool call passes through enforce() before execution. + * + * Enforcement rules (evaluated in order): + * 1. Denied commands always blocked (explicit deny wins over allow) + * 2. Allowed commands checked via glob matching + * 3. File paths validated against persona workspace + * 4. File size limits checked for write operations + * 5. Every decision logged for audit trail + */ + +import type { SecurityTier } from './SecurityTier'; +import { Logger } from '../../core/logging/Logger'; + +const log = Logger.create('ToolAllowlistEnforcer', 'code'); + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +export interface EnforcementResult { + readonly allowed: boolean; + readonly reason: string; + readonly toolName: string; + readonly tierLevel: string; +} + +// ──────────────────────────────────────────────────────────── +// Enforcer +// ──────────────────────────────────────────────────────────── + +export class ToolAllowlistEnforcer { + private readonly _tier: SecurityTier; + private readonly _auditLog: EnforcementResult[] = []; + + constructor(tier: SecurityTier) { + this._tier = tier; + } + + get tier(): SecurityTier { + return this._tier; + } + + get auditLog(): readonly EnforcementResult[] { + return this._auditLog; + } + + /** + * Check if a tool call is allowed under the current tier. + * Throws if the tool is denied. + */ + enforce(toolName: string, params?: Record): void { + const result = this.check(toolName, params); + this._auditLog.push(result); + + if (!result.allowed) { + log.warn(`BLOCKED: ${toolName} β€” ${result.reason} (tier: ${this._tier.level})`); + throw new ToolDeniedError(toolName, result.reason, this._tier.level); + } + + log.debug(`ALLOWED: ${toolName} (tier: ${this._tier.level})`); + } + + /** + * Non-throwing check β€” returns the enforcement result without blocking. + */ + check(toolName: string, params?: Record): EnforcementResult { + // 1. Check denied list (explicit deny always wins) + if (this.matchesAny(toolName, this._tier.deniedCommands)) { + return { + allowed: false, + reason: `Command '${toolName}' is explicitly denied in ${this._tier.level} tier`, + toolName, + tierLevel: this._tier.level, + }; + } + + // 2. Check allowed list + if (!this.matchesAny(toolName, this._tier.allowedCommands)) { + return { + allowed: false, + reason: `Command '${toolName}' is not in the allowed list for ${this._tier.level} tier`, + toolName, + tierLevel: this._tier.level, + }; + } + + // 3. Check process spawn restriction + if (!this._tier.allowProcessSpawn && this.isProcessSpawnCommand(toolName)) { + return { + allowed: false, + reason: `Process spawn commands are not allowed in ${this._tier.level} tier`, + toolName, + tierLevel: this._tier.level, + }; + } + + // 4. Check file size for write operations + if (this.isWriteCommand(toolName) && params) { + const content = params['content'] as string | undefined; + if (content && this._tier.maxFileSizeBytes > 0) { + const sizeBytes = new TextEncoder().encode(content).length; + if (sizeBytes > this._tier.maxFileSizeBytes) { + return { + allowed: false, + reason: `Content size ${sizeBytes} exceeds tier limit of ${this._tier.maxFileSizeBytes} bytes`, + toolName, + tierLevel: this._tier.level, + }; + } + } + } + + return { + allowed: true, + reason: 'Allowed by tier policy', + toolName, + tierLevel: this._tier.level, + }; + } + + /** + * Check if a tool name matches any pattern in the list. + * Supports exact match and trailing wildcard (e.g., 'code/*', '*'). + */ + private matchesAny(toolName: string, patterns: readonly string[]): boolean { + for (const pattern of patterns) { + if (pattern === '*') return true; + if (pattern === toolName) return true; + + // Glob: 'code/*' matches 'code/read', 'code/edit', etc. + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -2); + if (toolName.startsWith(prefix + '/')) return true; + } + } + return false; + } + + /** + * Commands that spawn child processes. + */ + private isProcessSpawnCommand(toolName: string): boolean { + return toolName === 'development/exec' || + toolName === 'development/sandbox-execute' || + toolName === 'development/build'; + } + + /** + * Commands that write to the filesystem. + */ + private isWriteCommand(toolName: string): boolean { + return toolName === 'code/write' || toolName === 'code/edit'; + } +} + +// ──────────────────────────────────────────────────────────── +// Error +// ──────────────────────────────────────────────────────────── + +export class ToolDeniedError extends Error { + readonly toolName: string; + readonly tierLevel: string; + + constructor(toolName: string, reason: string, tierLevel: string) { + super(`Tool '${toolName}' denied: ${reason}`); + this.name = 'ToolDeniedError'; + this.toolName = toolName; + this.tierLevel = tierLevel; + } +} diff --git a/src/debug/jtag/system/code/server/Workspace.ts b/src/debug/jtag/system/code/server/Workspace.ts new file mode 100644 index 000000000..69e57461e --- /dev/null +++ b/src/debug/jtag/system/code/server/Workspace.ts @@ -0,0 +1,403 @@ +/** + * Workspace - Bound handle for all workspace-scoped code operations + * + * Instead of passing userId/handle to every CodeDaemon call, + * create a Workspace instance and use it directly: + * + * const ws = await Workspace.create({ personaId, mode: 'worktree', taskSlug: 'fix-auth' }); + * const tree = await ws.tree(); + * const file = await ws.read('src/auth.ts'); + * await ws.edit('src/auth.ts', { editType: 'search_replace', search: 'old', replace: 'new' }); + * const result = await ws.verify(true); + * if (!result.success) { // read errors, fix, verify again } + * await ws.gitAdd(['.']); + * await ws.gitCommit('Fix auth token validation'); + * await ws.destroy(); + */ + +import { CodeDaemon } from '../../../daemons/code-daemon/shared/CodeDaemon'; +import type { + WorkspaceEditMode, + WorkspaceWriteResult, + WorkspaceReadResult, + WorkspaceSearchResult, + WorkspaceTreeResult, + WorkspaceUndoResult, + WorkspaceHistoryResult, + WorkspaceGitStatusInfo, + WorkspaceShellExecuteResponse, + WorkspaceShellPollResponse, + WorkspaceShellSessionInfo, + WorkspaceShellWatchResponse, + WorkspaceSentinelRule, + WorkspaceClassifiedLine, +} from '../../../daemons/code-daemon/shared/CodeDaemonTypes'; +import { WorkspaceStrategy } from './WorkspaceStrategy'; +import type { WorkspaceMode, WorkspaceConfig } from './WorkspaceStrategy'; +import { ProjectDetector, type ProjectType } from './ProjectDetector'; +import { CodeVerify, type CodeVerifyResult } from '../../../commands/code/verify/shared/CodeVerifyTypes'; + +export class Workspace { + + private constructor( + /** Handle string used to identify this workspace in the Rust backend */ + readonly handle: string, + /** Absolute path to the workspace directory on disk */ + readonly dir: string, + /** Workspace mode: sandbox, worktree (continuum), or project (any git repo) */ + readonly mode: WorkspaceMode, + /** Git branch name (worktree/project mode) */ + readonly branch?: string, + /** Original repo path β€” the parent repo this worktree was created from (project mode) */ + readonly repoPath?: string, + ) {} + + /** + * Create and register a new workspace. + * Returns a bound handle that encapsulates all workspace operations. + */ + static async create(config: WorkspaceConfig): Promise { + const result = await WorkspaceStrategy.create(config); + return new Workspace(result.handle, result.workspaceDir, result.mode, result.branch, result.repoPath); + } + + /** + * Create a Workspace from an already-initialized handle. + * Useful when resuming a workspace that was previously created. + */ + static fromExisting(handle: string, dir: string, mode: WorkspaceMode, branch?: string, repoPath?: string): Workspace { + return new Workspace(handle, dir, mode, branch, repoPath); + } + + /** Whether this workspace is backed by a git repo (worktree or project mode) */ + get isGitBacked(): boolean { + return this.mode === 'worktree' || this.mode === 'project'; + } + + // ════════════════════════════════════════════════════════════ + // File Operations + // ════════════════════════════════════════════════════════════ + + /** Read a file from this workspace */ + async read(filePath: string, startLine?: number, endLine?: number): Promise { + return CodeDaemon.workspaceRead(this.handle, filePath, startLine, endLine); + } + + /** Write or create a file in this workspace */ + async write(filePath: string, content: string, description?: string): Promise { + return CodeDaemon.workspaceWrite(this.handle, filePath, content, description); + } + + /** Edit a file using one of four edit modes */ + async edit(filePath: string, editMode: WorkspaceEditMode, description?: string): Promise { + return CodeDaemon.workspaceEdit(this.handle, filePath, editMode, description); + } + + /** Delete a file from this workspace */ + async delete(filePath: string, description?: string): Promise { + return CodeDaemon.workspaceDelete(this.handle, filePath, description); + } + + /** Preview an edit as unified diff without applying */ + async diff(filePath: string, editMode: WorkspaceEditMode): Promise<{ success: boolean; unified: string }> { + return CodeDaemon.workspaceDiff(this.handle, filePath, editMode); + } + + // ════════════════════════════════════════════════════════════ + // Search & Discovery + // ════════════════════════════════════════════════════════════ + + /** Search for a regex pattern across workspace files */ + async search(pattern: string, fileGlob?: string, maxResults?: number): Promise { + return CodeDaemon.workspaceSearch(this.handle, pattern, fileGlob, maxResults); + } + + /** Get directory tree structure */ + async tree(path?: string, maxDepth?: number, includeHidden?: boolean): Promise { + return CodeDaemon.workspaceTree(this.handle, path, maxDepth, includeHidden); + } + + // ════════════════════════════════════════════════════════════ + // Change Tracking + // ════════════════════════════════════════════════════════════ + + /** Undo a specific change or the last N changes */ + async undo(changeId?: string, count?: number): Promise { + return CodeDaemon.workspaceUndo(this.handle, changeId, count); + } + + /** Get change history for a file or entire workspace */ + async history(filePath?: string, limit?: number): Promise { + return CodeDaemon.workspaceHistory(this.handle, filePath, limit); + } + + // ════════════════════════════════════════════════════════════ + // Verification + // ════════════════════════════════════════════════════════════ + + /** Run TypeScript compilation check and optionally tests */ + async verify(typeCheck?: boolean, testFiles?: string[]): Promise { + return CodeVerify.execute({ + userId: this.handle, + typeCheck, + testFiles, + }); + } + + // ════════════════════════════════════════════════════════════ + // Git Operations + // ════════════════════════════════════════════════════════════ + + /** Get git status for this workspace */ + async gitStatus(): Promise { + return CodeDaemon.workspaceGitStatus(this.handle); + } + + /** Get git diff (staged or unstaged) */ + async gitDiff(staged?: boolean): Promise<{ success: boolean; diff: string }> { + return CodeDaemon.workspaceGitDiff(this.handle, staged); + } + + /** Get git log (last N commits) */ + async gitLog(count?: number): Promise<{ success: boolean; log: string }> { + return CodeDaemon.workspaceGitLog(this.handle, count); + } + + /** Stage files for commit */ + async gitAdd(paths: string[]): Promise<{ staged: string[] }> { + return CodeDaemon.workspaceGitAdd(this.handle, paths); + } + + /** Create a git commit */ + async gitCommit(message: string): Promise<{ hash: string }> { + return CodeDaemon.workspaceGitCommit(this.handle, message); + } + + /** Push the workspace branch to remote */ + async gitPush(remote?: string, branch?: string): Promise<{ output: string }> { + return CodeDaemon.workspaceGitPush(this.handle, remote, branch); + } + + // ════════════════════════════════════════════════════════════ + // Shell Session (Rust-backed, persistent per workspace) + // ════════════════════════════════════════════════════════════ + + private _shellCreated = false; + + /** + * Ensure the Rust-side shell session exists for this workspace. + * Called automatically by shell methods β€” idempotent after first call. + * + * Public so that workspace bootstrap can eagerly create the session. + * The code/shell/* commands call CodeDaemon directly (bypassing Workspace), + * so the session must exist before any shell command is invoked. + */ + async ensureShell(): Promise { + if (this._shellCreated) return; + await CodeDaemon.shellCreate(this.handle, this.dir); + this._shellCreated = true; + } + + /** + * Execute a shell command synchronously (blocks until completion). + * Use for quick commands: `git status`, `npm test`, `ls`. + * + * The shell session retains cwd and env across calls β€” just like + * a real terminal. First call auto-creates the session. + */ + async exec(cmd: string, timeoutMs?: number): Promise { + await this.ensureShell(); + return CodeDaemon.shellExecute(this.handle, cmd, { + timeoutMs: timeoutMs ?? 30000, + wait: true, + }); + } + + /** + * Execute a shell command asynchronously (returns handle immediately). + * Use for long-running commands: `cargo build`, `npm run build`. + * + * Returns an execution_id. Call shellPoll() to stream output, + * shellKill() to abort. + */ + async execAsync(cmd: string, timeoutMs?: number): Promise { + await this.ensureShell(); + return CodeDaemon.shellExecute(this.handle, cmd, { + timeoutMs, + wait: false, + }); + } + + /** Poll a running execution for new stdout/stderr since last poll */ + async shellPoll(executionId: string): Promise { + return CodeDaemon.shellPoll(this.handle, executionId); + } + + /** Kill a running execution */ + async shellKill(executionId: string): Promise { + return CodeDaemon.shellKill(this.handle, executionId); + } + + /** Change the shell session's working directory */ + async shellCd(path: string): Promise<{ cwd: string }> { + await this.ensureShell(); + return CodeDaemon.shellCd(this.handle, path); + } + + /** Get shell session info (cwd, env, running executions) */ + async shellStatus(): Promise { + await this.ensureShell(); + return CodeDaemon.shellStatus(this.handle); + } + + // ════════════════════════════════════════════════════════════ + // Shell Watch + Sentinel (Event-driven output streaming) + // ════════════════════════════════════════════════════════════ + + /** + * Configure sentinel filter rules on a running execution. + * Rules classify output lines and control which are emitted or suppressed during watch(). + * Patterns are compiled to regex on the Rust side for performance. + */ + async sentinel(executionId: string, rules: WorkspaceSentinelRule[]): Promise<{ applied: boolean; ruleCount: number }> { + return CodeDaemon.shellSentinel(this.handle, executionId, rules); + } + + /** + * Watch a running execution for new output. + * Blocks until output is available β€” no timeout, no polling. + * Returns classified lines filtered through sentinel rules. + * Call in a loop until `finished` is true. + */ + async watch(executionId: string): Promise { + await this.ensureShell(); + return CodeDaemon.shellWatch(this.handle, executionId); + } + + /** + * Execute a command and watch its output with optional sentinel filtering. + * Convenience composition: exec β†’ sentinel β†’ watch loop. + * + * @param cmd Command to execute + * @param rules Optional sentinel filter rules + * @param onLine Optional callback for each classified line + * @returns Final watch response (finished=true, has exit_code) + */ + async execWatch( + cmd: string, + rules?: WorkspaceSentinelRule[], + onLine?: (line: WorkspaceClassifiedLine) => void, + ): Promise { + const exec = await this.execAsync(cmd); + + if (rules?.length) { + await this.sentinel(exec.execution_id, rules); + } + + let response: WorkspaceShellWatchResponse; + do { + response = await this.watch(exec.execution_id); + if (onLine) { + for (const line of response.lines) { + onLine(line); + } + } + } while (!response.finished); + + return response; + } + + // ════════════════════════════════════════════════════════════ + // Project Detection + // ════════════════════════════════════════════════════════════ + + private _projectType?: ProjectType; + + /** Detect project type from workspace contents (cached after first call) */ + async detectProjectType(): Promise { + if (!this._projectType) { + this._projectType = await ProjectDetector.detect(this.dir); + } + return this._projectType; + } + + // ════════════════════════════════════════════════════════════ + // Git Team Operations (project/worktree mode) + // ════════════════════════════════════════════════════════════ + + /** + * Merge another branch into this workspace's current branch. + * Used for team coordination β€” a smarter AI can merge branches + * for less capable ones, or AIs can merge main into their feature branch. + */ + async gitMerge(sourceBranch: string): Promise { + await this.ensureShell(); + return CodeDaemon.shellExecute(this.handle, `git merge "${sourceBranch}"`, { + timeoutMs: 60000, + wait: true, + }); + } + + /** + * Check if there are merge conflicts in the workspace. + * Returns the list of conflicting files, if any. + */ + async gitConflicts(): Promise<{ hasConflicts: boolean; files: string[] }> { + await this.ensureShell(); + const result = await CodeDaemon.shellExecute(this.handle, 'git diff --name-only --diff-filter=U', { + timeoutMs: 10000, + wait: true, + }); + const files = (result.stdout ?? '').split('\n').filter(f => f.trim().length > 0); + return { hasConflicts: files.length > 0, files }; + } + + /** + * Abort a merge in progress. + */ + async gitMergeAbort(): Promise { + await this.ensureShell(); + return CodeDaemon.shellExecute(this.handle, 'git merge --abort', { + timeoutMs: 10000, + wait: true, + }); + } + + /** + * Fetch updates from remote (if configured). + */ + async gitFetch(remote?: string): Promise { + await this.ensureShell(); + return CodeDaemon.shellExecute(this.handle, `git fetch ${remote ?? '--all'}`, { + timeoutMs: 60000, + wait: true, + }); + } + + /** + * List branches matching a pattern β€” useful for discovering team branches. + * Default pattern: "ai/*" to find all AI persona branches. + */ + async gitBranches(pattern?: string): Promise { + await this.ensureShell(); + const result = await CodeDaemon.shellExecute( + this.handle, + `git branch --list "${pattern ?? 'ai/*'}" --format="%(refname:short)"`, + { timeoutMs: 10000, wait: true }, + ); + return (result.stdout ?? '').split('\n').filter(b => b.trim().length > 0); + } + + // ════════════════════════════════════════════════════════════ + // Lifecycle + // ════════════════════════════════════════════════════════════ + + /** Clean up this workspace (shell session + worktree removal + handle deregistration) */ + async destroy(options?: { force?: boolean; deleteBranch?: boolean }): Promise { + if (this._shellCreated) { + await CodeDaemon.shellDestroy(this.handle); + this._shellCreated = false; + } + await WorkspaceStrategy.cleanup(this.handle, options); + } +} diff --git a/src/debug/jtag/system/code/server/WorkspaceStrategy.ts b/src/debug/jtag/system/code/server/WorkspaceStrategy.ts new file mode 100644 index 000000000..64f4ed4e4 --- /dev/null +++ b/src/debug/jtag/system/code/server/WorkspaceStrategy.ts @@ -0,0 +1,426 @@ +/** + * WorkspaceStrategy - Unified workspace creation for coding tasks + * + * Abstracts workspace creation into a single interface: + * - sandbox: Isolated directory for persona work (default) + * - worktree: Git worktree on continuum repo with sparse checkout + * - project: Git worktree on ANY external repo with full checkout + persona identity + * + * Each strategy creates a directory, registers it with the Rust backend + * via CodeDaemon.createWorkspace(), and returns a handle + path. + */ + +import { Commands } from '../../core/shared/Commands'; +import { CodeDaemon } from '../../../daemons/code-daemon/shared/CodeDaemon'; +import { Logger } from '../../core/logging/Logger'; +import { stringToUUID } from '../../core/types/CrossPlatformUUID'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const log = Logger.create('WorkspaceStrategy', 'code'); + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +export type WorkspaceMode = 'sandbox' | 'worktree' | 'project'; + +export interface WorkspaceConfig { + /** Persona ID creating the workspace */ + readonly personaId: string; + + /** Which workspace strategy to use */ + readonly mode: WorkspaceMode; + + /** Short slug for branch naming (worktree/project mode): ai/{persona}/{slug} */ + readonly taskSlug?: string; + + /** Paths to sparse-checkout (worktree mode) */ + readonly sparsePaths?: string[]; + + /** Absolute path to any git repo on disk (project mode) */ + readonly repoPath?: string; + + /** Persona display name for git identity (project mode) */ + readonly personaName?: string; + + /** Persona unique ID for git email identity (project mode) */ + readonly personaUniqueId?: string; +} + +export interface WorkspaceResult { + /** Handle to pass to code/* commands as userId */ + readonly handle: string; + + /** Absolute path to the workspace directory */ + readonly workspaceDir: string; + + /** Git branch name (worktree/project mode) */ + readonly branch?: string; + + /** Which mode was used */ + readonly mode: WorkspaceMode; + + /** Original repo path (project mode) β€” the repo the worktree was created from */ + readonly repoPath?: string; +} + +// ──────────────────────────────────────────────────────────── +// Track initialized workspaces to avoid re-creation +// ──────────────────────────────────────────────────────────── + +const initializedWorkspaces = new Set(); + +// ──────────────────────────────────────────────────────────── +// WorkspaceStrategy +// ──────────────────────────────────────────────────────────── + +export class WorkspaceStrategy { + + /** + * Create a workspace for a coding task. + * + * @param config - Workspace configuration + * @returns Handle, directory path, and optional branch name + */ + static async create(config: WorkspaceConfig): Promise { + if (config.mode === 'project') { + return this.createProject(config); + } + if (config.mode === 'worktree') { + return this.createWorktree(config); + } + return this.createSandbox(config); + } + + /** + * Check if a workspace has been initialized for the given handle. + */ + static isInitialized(handle: string): boolean { + return initializedWorkspaces.has(handle); + } + + /** + * Reset all tracked workspace handles. + * Used by tests to ensure clean state between runs. + */ + static resetTracking(): void { + initializedWorkspaces.clear(); + } + + /** + * Create an isolated sandbox workspace (current default behavior). + * Directory: .continuum/personas/{personaId}/workspace/ + * Registered with Rust backend as writable + read-only codebase access. + */ + private static async createSandbox(config: WorkspaceConfig): Promise { + const handle = config.personaId; + + if (initializedWorkspaces.has(handle)) { + const jtagRoot = process.cwd(); + const workspaceDir = path.join(jtagRoot, '.continuum', 'personas', config.personaId, 'workspace'); + return { handle, workspaceDir, mode: 'sandbox' }; + } + + const jtagRoot = process.cwd(); + const workspaceDir = path.join(jtagRoot, '.continuum', 'personas', config.personaId, 'workspace'); + + // Create workspace directory if it doesn't exist + if (!fs.existsSync(workspaceDir)) { + fs.mkdirSync(workspaceDir, { recursive: true }); + log.info(`Created sandbox workspace: ${workspaceDir}`); + } + + // Register with Rust backend β€” writable workspace + read-only codebase access + await CodeDaemon.createWorkspace(handle, workspaceDir, [jtagRoot]); + initializedWorkspaces.add(handle); + log.info(`Sandbox workspace initialized for persona ${config.personaId}`); + + return { handle, workspaceDir, mode: 'sandbox' }; + } + + /** + * Create a git worktree workspace for working on real repo source. + * Uses workspace/git/workspace/init to create a sparse-checkout worktree, + * then registers it with the Rust backend. + */ + private static async createWorktree(config: WorkspaceConfig): Promise { + const slug = config.taskSlug ?? 'work'; + const handle = `worktree-${config.personaId}-${slug}`; + + if (initializedWorkspaces.has(handle)) { + // Already initialized β€” resolve path from convention + const jtagRoot = process.cwd(); + const workspaceDir = path.join( + jtagRoot, '.continuum', 'sessions', 'user', 'shared', config.personaId, 'workspace', + ); + return { handle, workspaceDir, mode: 'worktree' }; + } + + if (!config.sparsePaths || config.sparsePaths.length === 0) { + throw new Error('WorkspaceStrategy: worktree mode requires sparsePaths (which directories to checkout)'); + } + + log.info(`Creating worktree workspace for persona ${config.personaId} β€” paths: ${config.sparsePaths.join(', ')}`); + + // Call the existing workspace/git/workspace/init command + const initResult = await Commands.execute('workspace/git/workspace/init', { + personaId: config.personaId, + branch: `ai/${slug}`, + paths: config.sparsePaths, + }); + + if (!initResult?.success) { + throw new Error(`WorkspaceStrategy: worktree creation failed: ${initResult?.error?.message ?? 'Unknown error'}`); + } + + const workspaceDir = initResult.workspacePath as string; + const branch = initResult.branch as string; + + // Register with Rust backend β€” worktree IS the repo, no separate read roots needed + // (the worktree contains the checked-out source files directly) + await CodeDaemon.createWorkspace(handle, workspaceDir, []); + initializedWorkspaces.add(handle); + + log.info(`Worktree workspace created: ${workspaceDir} (branch: ${branch})`); + + return { handle, workspaceDir, branch, mode: 'worktree' }; + } + + /** + * Create a project workspace β€” git worktree on ANY external repo. + * + * Creates a branch per persona (ai/{personaName}/{slug}), sets local + * git identity, and registers the worktree with the Rust CodeDaemon. + * Supports working on any git-initialized directory on disk. + */ + private static async createProject(config: WorkspaceConfig): Promise { + if (!config.repoPath) { + throw new Error('WorkspaceStrategy: project mode requires repoPath'); + } + + const slug = config.taskSlug ?? 'work'; + // Deterministic UUID handle from personaId + slug β€” strict UUID policy + const handle = stringToUUID(`project:${config.personaId}:${slug}`); + + if (initializedWorkspaces.has(handle)) { + // Already initialized β€” resolve from tracked data + const meta = projectWorkspacePaths.get(handle); + if (meta) { + return { handle, workspaceDir: meta.worktreeDir, branch: meta.branch, mode: 'project', repoPath: config.repoPath }; + } + } + + // Resolve repoPath β€” support relative paths from jtag root + let resolvedRepoPath = path.isAbsolute(config.repoPath) + ? config.repoPath + : path.resolve(process.cwd(), config.repoPath); + + // Always resolve to the actual git root via rev-parse. + // A .git directory or file at the provided path doesn't guarantee it's the repo root β€” + // it could be a worktree .git file or a partial .git directory for local hooks. + try { + const gitRoot = execSync('git rev-parse --show-toplevel', { + cwd: resolvedRepoPath, + stdio: 'pipe', + }).toString().trim(); + if (gitRoot !== resolvedRepoPath) { + log.info(`Auto-detected git root: ${gitRoot} (from ${resolvedRepoPath})`); + resolvedRepoPath = gitRoot; + } + } catch { + throw new Error(`WorkspaceStrategy: not a git repo: ${resolvedRepoPath}`); + } + + // Branch name: ai/{personaName}/{slug} + const safeName = (config.personaName ?? config.personaId.slice(0, 8)) + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + const branchName = `ai/${safeName}/${slug}`; + + // Worktree directory: inside the repo's .git to keep it clean + const worktreeDir = path.join(resolvedRepoPath, '.git', 'continuum-worktrees', config.personaId, slug); + + log.info(`Creating project workspace: repo=${resolvedRepoPath} branch=${branchName}`); + + fs.mkdirSync(path.dirname(worktreeDir), { recursive: true }); + + const gitOpts = { cwd: resolvedRepoPath, stdio: 'pipe' as const }; + + // If worktree dir already exists from a previous session, reuse or force-remove it. + if (fs.existsSync(worktreeDir)) { + // Check if it's a valid git worktree checkout by looking for .git reference + const gitRefFile = path.join(worktreeDir, '.git'); + if (fs.existsSync(gitRefFile)) { + // Valid existing worktree β€” reuse it. Just ensure the branch is checked out. + log.info(`Reusing existing worktree at ${worktreeDir} (branch: ${branchName})`); + } else { + // Stale directory without valid git reference β€” remove and recreate + log.warn(`Removing stale worktree directory: ${worktreeDir}`); + try { execSync(`git worktree remove "${worktreeDir}" --force`, gitOpts); } catch { /* ignore */ } + try { execSync('git worktree prune', gitOpts); } catch { /* ignore */ } + fs.rmSync(worktreeDir, { recursive: true, force: true }); + } + } + + // Create worktree if directory doesn't exist (either first time or after cleanup) + if (!fs.existsSync(worktreeDir)) { + try { + // Try creating with new branch from HEAD + execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, gitOpts); + } catch (e: any) { + const errMsg = e.stderr?.toString() ?? e.message ?? ''; + if (errMsg.includes('already exists')) { + // Branch exists but worktree dir was cleaned β€” checkout existing branch + try { + execSync(`git worktree add "${worktreeDir}" "${branchName}"`, gitOpts); + } catch (e2: any) { + const errMsg2 = e2.stderr?.toString() ?? e2.message ?? ''; + if (errMsg2.includes('already checked out')) { + log.warn(`Branch ${branchName} checked out elsewhere β€” forcing worktree creation`); + execSync(`git worktree add --force "${worktreeDir}" "${branchName}"`, gitOpts); + } else { + throw e2; + } + } + } else { + throw e; + } + } + } + + // Set local git identity in the worktree (not global) + const userName = config.personaName ?? 'AI Persona'; + const userEmail = `${config.personaUniqueId ?? config.personaId}@continuum.local`; + const wtOpts = { cwd: worktreeDir, stdio: 'pipe' as const }; + execSync(`git config user.name "${userName}"`, wtOpts); + execSync(`git config user.email "${userEmail}"`, wtOpts); + + // Register with Rust CodeDaemon β€” worktree IS the repo checkout, no extra read roots + await CodeDaemon.createWorkspace(handle, worktreeDir, []); + initializedWorkspaces.add(handle); + projectWorkspacePaths.set(handle, { worktreeDir, branch: branchName, repoPath: resolvedRepoPath, personaId: config.personaId }); + personaToProjectHandle.set(config.personaId, handle); + + log.info(`Project workspace ready: ${worktreeDir} (handle: ${handle.slice(0, 8)}..., branch: ${branchName}, identity: ${userName} <${userEmail}>)`); + + return { handle, workspaceDir: worktreeDir, branch: branchName, mode: 'project', repoPath: resolvedRepoPath }; + } + + /** + * Clean up a workspace. + * - worktree-* handles: calls workspace/git/workspace/clean + * - project-* handles: removes git worktree + optionally deletes branch + * - other handles: skipped + */ + static async cleanup(handle: string, options?: { force?: boolean; deleteBranch?: boolean }): Promise { + if (handle.startsWith('project-')) { + return this.cleanupProject(handle, options); + } + + if (!handle.startsWith('worktree-')) { + log.debug(`Skipping cleanup for non-worktree handle: ${handle}`); + return; + } + + try { + await Commands.execute('workspace/git/workspace/clean', { + force: options?.force ?? false, + deleteBranch: options?.deleteBranch ?? false, + }); + initializedWorkspaces.delete(handle); + log.info(`Worktree workspace cleaned up: ${handle}`); + } catch (error) { + log.warn(`Worktree cleanup failed for ${handle}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Clean up a project workspace β€” remove git worktree and optionally delete branch. + */ + private static async cleanupProject(handle: string, options?: { force?: boolean; deleteBranch?: boolean }): Promise { + const meta = projectWorkspacePaths.get(handle); + if (!meta) { + log.warn(`No metadata for project handle ${handle}, removing from tracking`); + initializedWorkspaces.delete(handle); + return; + } + + try { + const gitOpts = { cwd: meta.repoPath, stdio: 'pipe' as const }; + + // Remove the git worktree + const forceFlag = options?.force ? ' --force' : ''; + execSync(`git worktree remove "${meta.worktreeDir}"${forceFlag}`, gitOpts); + + // Optionally delete the branch + if (options?.deleteBranch && meta.branch) { + try { + execSync(`git branch -D "${meta.branch}"`, gitOpts); + log.info(`Deleted branch ${meta.branch}`); + } catch { + log.warn(`Could not delete branch ${meta.branch} β€” may have upstream refs`); + } + } + + if (meta.personaId) { + personaToProjectHandle.delete(meta.personaId); + } + initializedWorkspaces.delete(handle); + projectWorkspacePaths.delete(handle); + log.info(`Project workspace cleaned up: ${handle}`); + } catch (error) { + log.warn(`Project cleanup failed for ${handle}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get all active project workspace handles for a specific repo. + * Used by RAG to discover team activity (who's working on what branch). + */ + static getProjectHandlesForRepo(repoPath: string): Array<{ handle: string; branch: string; worktreeDir: string }> { + const results: Array<{ handle: string; branch: string; worktreeDir: string }> = []; + for (const [handle, meta] of projectWorkspacePaths) { + if (meta.repoPath === repoPath) { + results.push({ handle, branch: meta.branch, worktreeDir: meta.worktreeDir }); + } + } + return results; + } + + /** + * Get project workspace info for a specific persona. + * Returns the first project workspace found (personas typically have one active project). + * Used by ProjectContextSource (RAG) to inject project state. + */ + static getProjectForPersona(personaId: string): ProjectWorkspaceMeta | undefined { + const handle = personaToProjectHandle.get(personaId); + if (handle) return projectWorkspacePaths.get(handle); + return undefined; + } + + /** + * Get ALL project workspaces across all personas. + * Used by ProjectContextSource to show team activity. + */ + static get allProjectWorkspaces(): ReadonlyMap { + return projectWorkspacePaths; + } +} + +// ──────────────────────────────────────────────────────────── +// Project workspace path tracking (needed for cleanup + team discovery) +// ──────────────────────────────────────────────────────────── + +interface ProjectWorkspaceMeta { + readonly worktreeDir: string; + readonly branch: string; + readonly repoPath: string; + readonly personaId: string; +} + +const projectWorkspacePaths = new Map(); +/** Reverse index: personaId β†’ handle (for RAG lookup) */ +const personaToProjectHandle = new Map(); diff --git a/src/debug/jtag/system/code/shared/CodingTypes.ts b/src/debug/jtag/system/code/shared/CodingTypes.ts new file mode 100644 index 000000000..e9b833dd4 --- /dev/null +++ b/src/debug/jtag/system/code/shared/CodingTypes.ts @@ -0,0 +1,130 @@ +/** + * Coding Types - Shared type definitions for the coding system + * + * Defines the data structures for: + * - Security & risk levels for workspace operations + * - Model selection by task complexity + * - Coding actions that map to code/* commands + * - Coding tasks that describe work to be done + */ + +import type { UUID } from '../../core/types/CrossPlatformUUID'; + +// ============================================================================ +// Security & Risk +// ============================================================================ + +/** + * Risk level for coding operations. + * Determines security tier and oversight requirements. + */ +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +/** + * Security tier that governs which tools are available. + * Higher tiers require more oversight. + */ +export type SecurityTierLevel = 'discovery' | 'read' | 'write' | 'system'; + +// ============================================================================ +// Model Selection +// ============================================================================ + +/** + * Task types that determine which model tier to use. + * Higher-capability models for planning, cheaper models for quick fixes. + */ +export type CodingTaskType = + | 'planning' // Architecture, task decomposition β€” needs best reasoning + | 'generation' // Writing new code β€” needs strong coding ability + | 'editing' // Modifying existing code β€” needs strong coding ability + | 'review' // Code review, analysis β€” any frontier model + | 'quick-fix' // Small fixes, typos β€” fast and cheap + | 'discovery'; // Exploring codebase structure β€” fast and cheap + +/** + * Model tier configuration for a specific task type. + * CodingModelSelector maps CodingTaskType β†’ CodingModelTier. + */ +export interface CodingModelTier { + readonly taskType: CodingTaskType; + readonly provider: string; + readonly model: string; + readonly temperature: number; + readonly maxTokens: number; + readonly description: string; +} + +// ============================================================================ +// Coding Actions +// ============================================================================ + +/** + * Actions a coding operation can perform. + * Each maps to a code/* command. + */ +export type CodingAction = + | 'discover' // code/tree β€” explore structure + | 'search' // code/search β€” find patterns + | 'read' // code/read β€” read file contents + | 'write' // code/write β€” create/overwrite file + | 'edit' // code/edit β€” partial edit + | 'diff' // code/diff β€” preview changes + | 'undo' // code/undo β€” revert changes + | 'verify' // code/verify β€” build/test verification + | 'commit' // code/git β€” stage and commit changes + | 'report'; // Meta: summarize what was done + +// ============================================================================ +// Coding Task +// ============================================================================ + +/** + * A coding task describes what needs to be done in a workspace. + * Used by the coding activity to drive agent work. + */ +export interface CodingTask { + /** Unique task ID */ + readonly id: UUID; + + /** Persona executing this task */ + readonly personaId: UUID; + + /** Human-readable task description */ + readonly description: string; + + /** Task type for model selection */ + readonly taskType: CodingTaskType; + + /** Room/context this task originated from */ + readonly contextId?: UUID; + + /** Files already known to be relevant (hints for discovery) */ + readonly relevantFiles?: string[]; + + /** Maximum execution time in milliseconds */ + readonly maxDurationMs?: number; + + /** + * Workspace handle β€” identifies which Rust workspace to use for code/* operations. + * Defaults to personaId (general persona workspace). + */ + readonly workspaceHandle?: string; + + /** + * Workspace mode for this task: + * - 'sandbox': Isolated directory under .continuum/personas/{id}/workspace/ (default) + * - 'worktree': Git worktree on continuum repo with sparse checkout + * - 'project': Git worktree on any external git repo with persona identity + */ + readonly workspaceMode?: 'sandbox' | 'worktree' | 'project'; + + /** Absolute path to git repo on disk (project mode) */ + readonly repoPath?: string; + + /** Paths to sparse-checkout when using worktree mode */ + readonly sparsePaths?: string[]; + + /** When the task was created */ + readonly createdAt: number; +} diff --git a/src/debug/jtag/system/coordination/server/ChatCoordinationStream.ts b/src/debug/jtag/system/coordination/server/ChatCoordinationStream.ts index 55d434667..71c85810c 100644 --- a/src/debug/jtag/system/coordination/server/ChatCoordinationStream.ts +++ b/src/debug/jtag/system/coordination/server/ChatCoordinationStream.ts @@ -213,14 +213,19 @@ export class ChatCoordinationStream extends BaseCoordinationStream = { 'local': 'local-inference', }; -const DEFAULT_PROVIDER_LIMITS: Record = { - // LOCAL INFERENCE GROUP: Worker pool with multiple model instances - // Default 3 concurrent to match auto-detected workers (can be configured via INFERENCE_WORKERS) - 'local-inference': { - maxConcurrent: 3, // Worker pool handles concurrent requests - staggerDelayMs: 50, // Minimal stagger with pool - cooldownMs: 200 // Reduced cooldown with concurrent capacity - }, - 'anthropic': { - maxConcurrent: 15, // API rate limits are generous - staggerDelayMs: 100, - cooldownMs: 100 - }, - 'openai': { - maxConcurrent: 15, - staggerDelayMs: 100, - cooldownMs: 100 - }, - 'groq': { - maxConcurrent: 5, // Groq has aggressive rate limits but still decent - staggerDelayMs: 500, - cooldownMs: 1000 - }, - 'deepseek': { - maxConcurrent: 8, - staggerDelayMs: 200, - cooldownMs: 300 - }, - 'xai': { - maxConcurrent: 8, - staggerDelayMs: 200, - cooldownMs: 300 - }, - 'together': { - maxConcurrent: 10, - staggerDelayMs: 200, - cooldownMs: 300 - } +/** + * Per-provider hardware/API concurrency limits. + * These represent REAL constraints β€” not policy throttles. + */ +const PROVIDER_CAPACITY: Record = { + 'local-inference': 3, // Worker pool with multiple model instances + 'anthropic': 15, // Generous API limits + 'openai': 15, + 'groq': 5, // Aggressive rate limits but decent concurrency + 'deepseek': 8, + 'xai': 8, + 'together': 10, + 'google': 10, + 'fireworks': 10, // REST API, decent concurrency + 'alibaba': 8, // Qwen/DashScope REST API }; -// ========== RTOS SCHEDULING CONSTANTS ========== - -// Maximum responders per message (across all providers) -const MAX_RESPONDERS_PER_MESSAGE = 5; - -// Reserved slots for local-inference (guaranteed seats at table) -// With worker pool, local-inference can handle multiple concurrent requests -const RESERVED_LOCAL_INFERENCE_SLOTS = 2; // 2 of 5 slots reserved for local-inference -const MAX_CLOUD_RESPONDERS = MAX_RESPONDERS_PER_MESSAGE - RESERVED_LOCAL_INFERENCE_SLOTS; - -// Stale request timeout - kick requests waiting too long (RTOS preemption) -const STALE_WAIT_TIMEOUT_MS = 20000; // 20 seconds max wait (faster than before) - -// Auto-thinning: Max pending requests per provider before dropping oldest -// When queue exceeds this, oldest entries are evicted (newest-first priority) -const MAX_PENDING_PER_PROVIDER = 3; - -// Message age cutoff - messages older than this are deprioritized -const MESSAGE_FRESHNESS_MS = 30000; // 30 seconds - newer messages get priority - -// Card dealing: Max slots per persona per message window -// Ensures no single persona hogs all slots -const MAX_SLOTS_PER_PERSONA_PER_MESSAGE = 1; - class InferenceCoordinatorImpl { - private activeSlots: Map = new Map(); // slotKey -> slots - private messageResponders: Map> = new Map(); // messageId -> persona IDs - private messageProviders: Map> = new Map(); // messageId -> provider slot keys (for diversity) - private lastRequestTime: Map = new Map(); // personaId -> timestamp - private providerLimits: Map = new Map(); - private waitQueue: Map = new Map(); // messageId -> waiting personas + private activeSlots: Map = new Map(); constructor() { - // Initialize provider limits - for (const [provider, limits] of Object.entries(DEFAULT_PROVIDER_LIMITS)) { - this.providerLimits.set(provider, limits); + for (const provider of Object.keys(PROVIDER_CAPACITY)) { this.activeSlots.set(provider, []); } } - /** - * Check if provider is local-inference group - */ - private isLocalInference(provider: string): boolean { - const slotKey = this.getSlotKey(provider); - return slotKey === 'local-inference'; - } - - /** - * Auto-thin queue when overloaded (RTOS preemption) - * - * Strategy: Newest-first priority - * - When queue exceeds MAX_PENDING_PER_PROVIDER, drop oldest entries - * - Stale messages (older than MESSAGE_FRESHNESS_MS) get deprioritized - * - This ensures the system stays responsive even under load - */ - private autoThinQueue(slotKey: string): number { - const slots = this.activeSlots.get(slotKey) || []; - const now = Date.now(); - let evicted = 0; - - // If under limit, no thinning needed - if (slots.length <= MAX_PENDING_PER_PROVIDER) { - return 0; - } - - // Sort by age (oldest first) so we can evict oldest - const sortedSlots = [...slots].sort((a, b) => a.acquiredAt - b.acquiredAt); - - // Evict oldest entries until under limit - while (sortedSlots.length > MAX_PENDING_PER_PROVIDER) { - const oldest = sortedSlots.shift()!; - const age = now - oldest.acquiredAt; - - // Check if this is stale (older than freshness cutoff) - if (age > MESSAGE_FRESHNESS_MS) { - console.log(`🎰 AUTO-THIN: Evicting stale ${oldest.personaId} (age ${Math.round(age / 1000)}s > ${MESSAGE_FRESHNESS_MS / 1000}s freshness cutoff)`); - evicted++; - } else { - // Even fresh entries get evicted if queue is too long - console.log(`🎰 AUTO-THIN: Evicting ${oldest.personaId} to make room (queue ${slots.length} > max ${MAX_PENDING_PER_PROVIDER})`); - evicted++; - } - } - - // Update slots with thinned list - if (evicted > 0) { - this.activeSlots.set(slotKey, sortedSlots); - } - - return evicted; - } - - /** - * Check if persona has already responded to this message - * (Card dealing: max 1 slot per persona per message) - */ - private hasPersonaRespondedToMessage(personaId: string, messageId: string): boolean { - const responders = this.messageResponders.get(messageId); - return responders?.has(personaId) ?? false; - } - /** * Resolve provider to its slot group key. * Providers in the same group share the same slot pool. @@ -212,15 +78,24 @@ class InferenceCoordinatorImpl { } /** - * Request permission to perform inference + * Get hardware capacity for a provider slot group. + */ + private capacity(slotKey: string): number { + return PROVIDER_CAPACITY[slotKey] ?? 3; + } + + /** + * Request permission to perform inference. * - * RTOS-style fair scheduling: - * 1. @mentioned personas always get through (explicit user request) - * 2. Local-inference has 1 reserved slot out of 5 responders - * 3. Cloud providers share the remaining 4 slots - * 4. Wait queue tracks who's been waiting longest for priority + * Only checks hardware capacity β€” can the provider handle another concurrent request? + * All cognitive decisions (who responds, how many) are made upstream by + * the coordination stream and should-respond LLM calls. * - * @returns true if slot acquired, false if should skip + * @param personaId - The persona requesting the slot + * @param messageId - The message being processed (for tracking/debugging) + * @param provider - The inference provider (e.g., 'groq', 'ollama', 'anthropic') + * @param options - Reserved for future use (isMentioned no longer affects scheduling) + * @returns true if slot acquired, false if provider at hardware capacity */ async requestSlot( personaId: string, @@ -228,148 +103,35 @@ class InferenceCoordinatorImpl { provider: string, options?: { isMentioned?: boolean } ): Promise { - // Resolve provider to slot group (e.g., 'ollama' β†’ 'local-inference') const slotKey = this.getSlotKey(provider); - const limits = this.providerLimits.get(slotKey) || DEFAULT_PROVIDER_LIMITS['local-inference']; + const maxConcurrent = this.capacity(slotKey); const slots = this.activeSlots.get(slotKey) || []; - const isLocal = this.isLocalInference(provider); - - // Get current message state - const responders = this.messageResponders.get(messageId) || new Set(); - const providersResponded = this.messageProviders.get(messageId) || new Set(); - - // Count local vs cloud responders for this message - const localRespondersForMessage = Array.from(responders).filter(pid => { - // Check if this persona responded via local-inference - // (We track this in messageProviders) - return providersResponded.has('local-inference'); - }).length; - const cloudRespondersForMessage = responders.size - localRespondersForMessage; - - // ========== RTOS FAIR SCHEDULING LOGIC ========== - - // AUTO-THIN: Keep queue lean by evicting oldest entries - const evicted = this.autoThinQueue(slotKey); - if (evicted > 0) { - console.log(`🎰 InferenceCoordinator: Auto-thinned ${evicted} stale entries from ${slotKey}`); - } - // Rule 0: @mentioned PRIORITY - but still respect hardware limits - // CRITICAL FIX: @mentioned must STILL respect local-inference maxConcurrent - // because the Rust gRPC backend can only process 1 request at a time (write lock) - // Allowing multiple @mentioned to bypass causes 90s timeout cascade - let skipOtherChecks = false; - if (options?.isMentioned) { - // For local-inference: respect maxConcurrent even for @mentioned - if (isLocal && slots.length >= limits.maxConcurrent) { - console.log(`🎰 InferenceCoordinator: ${personaId} @mentioned but local-inference at capacity (${slots.length}/${limits.maxConcurrent}) - DENIED`); - return false; // Cannot bypass hardware limits - } else { - console.log(`🎰 InferenceCoordinator: ${personaId} PRIORITY (@mentioned) for ${slotKey}`); - skipOtherChecks = true; // Skip other checks for mentioned personas - } + // The one rule: hardware capacity + if (slots.length >= maxConcurrent) { + console.log(`🎰 InferenceCoordinator: ${personaId.slice(0, 8)} denied β€” ${slotKey} at hardware capacity (${slots.length}/${maxConcurrent})`); + return false; } - // Non-mentioned personas (and @mentioned local that was denied above) go through full checks - if (!skipOtherChecks) { - // Rule 1: CARD DEALING - Max 1 response per persona per message - if (this.hasPersonaRespondedToMessage(personaId, messageId)) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - already responded to ${messageId.slice(0, 8)} (card dealing: 1 per persona)`); - return false; - } - - // Rule 2: Check absolute max responders - if (responders.size >= MAX_RESPONDERS_PER_MESSAGE) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - message ${messageId.slice(0, 8)} at max responders (${responders.size}/${MAX_RESPONDERS_PER_MESSAGE})`); - return false; - } - - // Rule 3: RESERVED SLOT - Local-inference gets guaranteed 1 slot - if (isLocal) { - // Local persona: check if reserved slot is available - // Reserved slot means: even if 4 cloud responders, local still gets in - const localAlreadyResponded = providersResponded.has('local-inference'); - if (localAlreadyResponded) { - // Another local persona already responded - apply normal limit - if (responders.size >= MAX_RESPONDERS_PER_MESSAGE) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - local reserved slot already used`); - return false; - } - } - // Local persona gets through if under max (reserved slot guarantees access) - console.log(`🎰 InferenceCoordinator: ${personaId} 🏠 using reserved local-inference slot`); - } else { - // Cloud persona: check against cloud-specific limit - // Cloud can only use (MAX - reserved) slots = 4 slots - if (cloudRespondersForMessage >= MAX_CLOUD_RESPONDERS) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - cloud slots full (${cloudRespondersForMessage}/${MAX_CLOUD_RESPONDERS}), 1 reserved for local`); - return false; - } - } - - // Rule 4: Per-provider concurrency limit - if (slots.length >= limits.maxConcurrent) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - ${slotKey} at capacity (${slots.length}/${limits.maxConcurrent})`); - return false; - } - - // Rule 5: Per-persona cooldown - const lastRequest = this.lastRequestTime.get(personaId) || 0; - const timeSinceLastRequest = Date.now() - lastRequest; - if (timeSinceLastRequest < limits.cooldownMs) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied - cooldown (${timeSinceLastRequest}ms < ${limits.cooldownMs}ms)`); - return false; - } - - // Rule 6: Stagger delay (spread out requests) - const staggerDelay = Math.random() * limits.staggerDelayMs; - if (staggerDelay > 50) { - console.log(`🎰 InferenceCoordinator: ${personaId} waiting ${Math.round(staggerDelay)}ms stagger`); - await this.delay(staggerDelay); - - // Re-check after stagger - const slotsAfterStagger = this.activeSlots.get(slotKey) || []; - if (slotsAfterStagger.length >= limits.maxConcurrent) { - console.log(`🎰 InferenceCoordinator: ${personaId} denied after stagger - ${slotKey} now full`); - return false; - } - } - } - - // ========== ACQUIRE SLOT ========== - - // Get current slots (re-fetch for freshness) - const currentSlots = this.activeSlots.get(slotKey) || []; - - // Create slot + // Acquire slot const slot: InferenceSlot = { personaId, messageId, provider, acquiredAt: Date.now() }; - currentSlots.push(slot); - this.activeSlots.set(slotKey, currentSlots); - - // Track responders and which providers responded - responders.add(personaId); - this.messageResponders.set(messageId, responders); - providersResponded.add(slotKey); - this.messageProviders.set(messageId, providersResponded); - - // Update last request time - this.lastRequestTime.set(personaId, Date.now()); + slots.push(slot); + this.activeSlots.set(slotKey, slots); - const slotType = isLocal ? '🏠 LOCAL' : '☁️ CLOUD'; - console.log(`🎰 InferenceCoordinator: ${personaId} GRANTED ${slotType} slot (${currentSlots.length}/${limits.maxConcurrent}) [responders: ${responders.size}/${MAX_RESPONDERS_PER_MESSAGE}]`); + console.log(`🎰 InferenceCoordinator: ${personaId.slice(0, 8)} GRANTED ${slotKey} slot (${slots.length}/${maxConcurrent})`); return true; } /** - * Release slot after inference completes (success or failure) + * Release slot after inference completes (success or failure). + * MUST be called in both success and error paths. */ releaseSlot(personaId: string, provider: string): void { - // Resolve provider to slot group const slotKey = this.getSlotKey(provider); const slots = this.activeSlots.get(slotKey); if (!slots) return; @@ -377,54 +139,33 @@ class InferenceCoordinatorImpl { const index = slots.findIndex(s => s.personaId === personaId); if (index !== -1) { const slot = slots[index]; + const duration = Date.now() - slot.acquiredAt; slots.splice(index, 1); this.activeSlots.set(slotKey, slots); - const duration = Date.now() - slot.acquiredAt; - console.log(`🎰 InferenceCoordinator: ${personaId} RELEASED ${slotKey} slot after ${duration}ms (${slots.length} remaining)`); + console.log(`🎰 InferenceCoordinator: ${personaId.slice(0, 8)} RELEASED ${slotKey} slot after ${duration}ms (${slots.length} remaining)`); } } /** - * Get current coordinator stats for monitoring + * Get current coordinator stats for monitoring. */ getStats(): { providers: Record; - scheduling: { - maxResponders: number; - reservedLocalSlots: number; - maxCloudSlots: number; - maxPendingPerProvider: number; - messageFreshnessMs: number; - maxSlotsPerPersona: number; - activeMessages: number; - }; } { const providers: Record = {}; for (const [provider, slots] of this.activeSlots) { - const limits = this.providerLimits.get(provider); providers[provider] = { active: slots.length, - max: limits?.maxConcurrent || 0 + max: this.capacity(provider) }; } - return { - providers, - scheduling: { - maxResponders: MAX_RESPONDERS_PER_MESSAGE, - reservedLocalSlots: RESERVED_LOCAL_INFERENCE_SLOTS, - maxCloudSlots: MAX_CLOUD_RESPONDERS, - maxPendingPerProvider: MAX_PENDING_PER_PROVIDER, - messageFreshnessMs: MESSAGE_FRESHNESS_MS, - maxSlotsPerPersona: MAX_SLOTS_PER_PERSONA_PER_MESSAGE, - activeMessages: this.messageResponders.size - } - }; + return { providers }; } /** - * Clean up stale slots (safety valve if releases are missed) - * Call periodically to prevent slot leaks + * Clean up stale slots (safety valve if releaseSlot is missed due to crash). + * Called periodically to prevent slot leaks. */ cleanupStaleSlots(maxAgeMs: number = 180000): number { let cleaned = 0; @@ -433,7 +174,7 @@ class InferenceCoordinatorImpl { for (const [provider, slots] of this.activeSlots) { const validSlots = slots.filter(slot => { if (now - slot.acquiredAt > maxAgeMs) { - console.log(`🎰 InferenceCoordinator: Cleaning stale slot for ${slot.personaId} (${provider}, age ${now - slot.acquiredAt}ms)`); + console.log(`🎰 InferenceCoordinator: Cleaning stale slot for ${slot.personaId.slice(0, 8)} (${provider}, age ${Math.round((now - slot.acquiredAt) / 1000)}s)`); cleaned++; return false; } @@ -442,47 +183,14 @@ class InferenceCoordinatorImpl { this.activeSlots.set(provider, validSlots); } - // Also clean up old message responder/provider tracking - const messageIds = Array.from(this.messageResponders.keys()); - // We don't have timestamps for messages, so just limit map size - if (messageIds.length > 100) { - // Keep newest 50 - const toRemove = messageIds.slice(0, messageIds.length - 50); - for (const id of toRemove) { - this.messageResponders.delete(id); - this.messageProviders.delete(id); - } - } - - // Clean up wait queue (stale entries) - for (const [messageId, queue] of this.waitQueue) { - const validEntries = queue.filter(entry => { - if (now - entry.requestedAt > STALE_WAIT_TIMEOUT_MS) { - console.log(`🎰 InferenceCoordinator: Kicking stale wait entry for ${entry.personaId} (waited ${now - entry.requestedAt}ms)`); - cleaned++; - return false; - } - return true; - }); - if (validEntries.length === 0) { - this.waitQueue.delete(messageId); - } else { - this.waitQueue.set(messageId, validEntries); - } - } - return cleaned; } - - private delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } } // Global singleton export const InferenceCoordinator = new InferenceCoordinatorImpl(); -// Start cleanup interval (every 60 seconds) +// Safety valve: clean stale slots every 60 seconds setInterval(() => { InferenceCoordinator.cleanupStaleSlots(); }, 60000); diff --git a/src/debug/jtag/system/core/browser/AsyncStorage.ts b/src/debug/jtag/system/core/browser/AsyncStorage.ts index 2f1266293..28e964ea8 100644 --- a/src/debug/jtag/system/core/browser/AsyncStorage.ts +++ b/src/debug/jtag/system/core/browser/AsyncStorage.ts @@ -149,7 +149,19 @@ class AsyncStorageQueue { localStorage.removeItem(write.key); } } catch (error) { - console.error(`AsyncStorage: Failed to write ${write.key}:`, error); + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + this.evictEntityCache(); + // Retry once after eviction + try { + if (write.value !== null) { + localStorage.setItem(write.key, write.value); + } + } catch { + console.warn(`AsyncStorage: Quota exceeded for ${write.key} even after eviction`); + } + } else { + console.error(`AsyncStorage: Failed to write ${write.key}:`, error); + } } } @@ -163,6 +175,48 @@ class AsyncStorageQueue { } } + /** + * Evict oldest entity cache entries to free localStorage quota. + * + * Targets `continuum-entity-*` keys (the browser entity cache). + * Removes the oldest 50% by entity timestamp. These are cache entries β€” + * the server has the source of truth; evicted data re-fetches on next access. + */ + private evictEntityCache(): void { + const ENTITY_PREFIX = 'continuum-entity-'; + const entityKeys: { key: string; timestamp: number }[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(ENTITY_PREFIX)) { + let timestamp = 0; + try { + const raw = localStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + const ts = parsed.updatedAt || parsed.createdAt || parsed.timestamp; + timestamp = typeof ts === 'string' ? new Date(ts).getTime() : (ts || 0); + } + } catch { + // Unparseable β€” evict first (timestamp 0 = oldest) + } + entityKeys.push({ key, timestamp }); + } + } + + if (entityKeys.length === 0) return; + + // Sort oldest first, remove 50% + entityKeys.sort((a, b) => a.timestamp - b.timestamp); + const removeCount = Math.max(1, Math.ceil(entityKeys.length * 0.5)); + + for (let i = 0; i < removeCount; i++) { + localStorage.removeItem(entityKeys[i].key); + } + + console.log(`AsyncStorage: Evicted ${removeCount}/${entityKeys.length} entity cache entries to free quota`); + } + /** * Force immediate flush (use sparingly, e.g., before page unload) */ @@ -175,7 +229,18 @@ class AsyncStorageQueue { localStorage.removeItem(write.key); } } catch (error) { - console.error(`AsyncStorage: Sync flush failed for ${write.key}:`, error); + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + this.evictEntityCache(); + try { + if (write.value !== null) { + localStorage.setItem(write.key, write.value); + } + } catch { + // Still full β€” skip + } + } else { + console.error(`AsyncStorage: Sync flush failed for ${write.key}:`, error); + } } } this.queue.clear(); diff --git a/src/debug/jtag/system/core/logging/ComponentLogger.ts b/src/debug/jtag/system/core/logging/ComponentLogger.ts index ae13df640..ec7225500 100644 --- a/src/debug/jtag/system/core/logging/ComponentLogger.ts +++ b/src/debug/jtag/system/core/logging/ComponentLogger.ts @@ -16,6 +16,7 @@ import { performance } from 'perf_hooks'; import type { LoggerConfig, LogCategory } from './LoggerTypes'; import { LogLevel } from './LoggerTypes'; import type { LogLevel as WorkerLogLevel } from '../../../shared/ipc/logger/LoggerMessageTypes'; +import { LogLevelRegistry } from './LogLevelRegistry'; /** Interface for the parent logger (to avoid circular imports) */ export interface ParentLogger { @@ -45,7 +46,12 @@ export class ComponentLogger { ) {} private shouldLog(level: LogLevel): boolean { - return level >= this.config.level; + // Check per-component override first (runtime mute/unmute) + // Extract category from logFilePath for category-level overrides + const category = this.logFilePath + ? this.logFilePath.replace(/\.log$/, '').split('/').slice(-2).join('/') + : undefined; + return LogLevelRegistry.instance.shouldLog(this.component, level, category); } private formatMessage(level: string, emoji: string, message: string, ...args: any[]): void { diff --git a/src/debug/jtag/system/core/logging/LogLevelRegistry.ts b/src/debug/jtag/system/core/logging/LogLevelRegistry.ts new file mode 100644 index 000000000..e30c5cdb2 --- /dev/null +++ b/src/debug/jtag/system/core/logging/LogLevelRegistry.ts @@ -0,0 +1,155 @@ +/** + * LogLevelRegistry β€” Per-component log level overrides (runtime-mutable) + * + * Like audio mute buttons: each component/category can be independently + * set to a different log level at runtime without restarting the system. + * + * Architecture: + * 1. ComponentLogger calls `LogLevelRegistry.levelFor(component)` on every log call + * 2. If an override exists β†’ use it (can mute noisy components to ERROR/SILENT) + * 3. If no override β†’ fall back to global LOG_LEVEL + * 4. Overrides can be set via Events (from UI toggles or CLI commands) + * + * Usage: + * // Mute a noisy component + * LogLevelRegistry.instance.setLevel('PersonaResponseGenerator', LogLevel.ERROR); + * + * // Mute an entire category + * LogLevelRegistry.instance.setLevel('daemons/*', LogLevel.SILENT); + * + * // Unmute (restore to global default) + * LogLevelRegistry.instance.clearLevel('PersonaResponseGenerator'); + * + * // Bulk configure + * LogLevelRegistry.instance.configure({ + * 'PersonaUser': LogLevel.WARN, + * 'ChatCoordinationStream': LogLevel.ERROR, + * 'daemons/AIProviderDaemonServer': LogLevel.INFO, + * }); + */ + +import { LogLevel } from './LoggerTypes'; + +export class LogLevelRegistry { + private static _instance: LogLevelRegistry; + + // Per-component overrides: component name β†’ minimum log level + private _overrides: Map = new Map(); + + // Per-category overrides (with wildcard support): category pattern β†’ level + private _categoryOverrides: Map = new Map(); + + // Global default (from LOG_LEVEL env var, set by Logger) + private _globalLevel: LogLevel = LogLevel.INFO; + + private constructor() {} + + static get instance(): LogLevelRegistry { + if (!LogLevelRegistry._instance) { + LogLevelRegistry._instance = new LogLevelRegistry(); + } + return LogLevelRegistry._instance; + } + + /** + * Set the global default level (called by Logger on startup) + */ + set globalLevel(level: LogLevel) { + this._globalLevel = level; + } + + get globalLevel(): LogLevel { + return this._globalLevel; + } + + /** + * Set log level override for a specific component. + * Pass LogLevel.SILENT to completely mute a component. + */ + setLevel(componentOrCategory: string, level: LogLevel): void { + if (componentOrCategory.includes('/') || componentOrCategory.includes('*')) { + this._categoryOverrides.set(componentOrCategory, level); + } else { + this._overrides.set(componentOrCategory, level); + } + } + + /** + * Clear override for a component (restores global default) + */ + clearLevel(componentOrCategory: string): void { + this._overrides.delete(componentOrCategory); + this._categoryOverrides.delete(componentOrCategory); + } + + /** + * Bulk configure overrides + */ + configure(overrides: Record): void { + for (const [key, level] of Object.entries(overrides)) { + this.setLevel(key, level); + } + } + + /** + * Clear all overrides + */ + clearAll(): void { + this._overrides.clear(); + this._categoryOverrides.clear(); + } + + /** + * Get effective log level for a component. + * Priority: component override > category override > global default + */ + levelFor(component: string, category?: string): LogLevel { + // Check direct component override first + const componentLevel = this._overrides.get(component); + if (componentLevel !== undefined) { + return componentLevel; + } + + // Check category overrides (exact match, then wildcard) + if (category) { + const categoryLevel = this._categoryOverrides.get(category); + if (categoryLevel !== undefined) { + return categoryLevel; + } + + // Wildcard matching: "daemons/*" matches "daemons/AIProviderDaemonServer" + for (const [pattern, level] of this._categoryOverrides) { + if (pattern.endsWith('/*')) { + const prefix = pattern.slice(0, -2); + if (category.startsWith(prefix)) { + return level; + } + } + } + } + + return this._globalLevel; + } + + /** + * Check if a specific log level should be logged for this component. + * Returns true if the message should be logged, false if it should be filtered. + */ + shouldLog(component: string, level: LogLevel, category?: string): boolean { + return level >= this.levelFor(component, category); + } + + /** + * Get a snapshot of all overrides (for serialization/UI display) + */ + get overrides(): ReadonlyMap { + return new Map([...this._overrides, ...this._categoryOverrides]); + } + + /** + * Get count of active overrides + */ + get overrideCount(): number { + return this._overrides.size + this._categoryOverrides.size; + } +} diff --git a/src/debug/jtag/system/core/logging/Logger.ts b/src/debug/jtag/system/core/logging/Logger.ts index c85e2af64..c7dada022 100644 --- a/src/debug/jtag/system/core/logging/Logger.ts +++ b/src/debug/jtag/system/core/logging/Logger.ts @@ -52,10 +52,12 @@ import { LogLevel, FileMode, createLoggerConfig, parseFileMode } from './LoggerT import type { LoggerConfig, LogCategory } from './LoggerTypes'; import { inferCategory } from './CategoryInference'; import { ComponentLogger, type ParentLogger } from './ComponentLogger'; +import { LogLevelRegistry } from './LogLevelRegistry'; // Re-export types for consumers export { LogLevel, FileMode } from './LoggerTypes'; export type { ComponentLogger } from './ComponentLogger'; +export { LogLevelRegistry } from './LogLevelRegistry'; // ============================================================================ // Rust Worker Toggle @@ -105,6 +107,26 @@ class LoggerClass implements ParentLogger { this.config = createLoggerConfig(); this.defaultFileMode = parseFileMode(process.env.LOG_FILE_MODE); + // Sync global level to the per-component registry + LogLevelRegistry.instance.globalLevel = this.config.level; + + // Default overrides for known-noisy components + // These act like "default mute buttons" β€” can be unmuted at runtime + // Errors always get through; only debug/info spam is suppressed + LogLevelRegistry.instance.configure({ + // Daemon initialization spam (25+ info calls each during startup) + 'RoomMembershipDaemonServer': LogLevel.WARN, + 'ArchiveDaemonServer': LogLevel.WARN, + 'SessionDaemonServer': LogLevel.WARN, + 'CommsTestDaemonServer': LogLevel.WARN, + // PersonaUser autonomous loop (46+ logging calls in hot paths) + 'PersonaUser': LogLevel.WARN, + 'PersonaResponseGenerator': LogLevel.WARN, + 'ChatCoordinationStream': LogLevel.WARN, + // RAG pipeline (timed internally β€” timing data is more useful than log spam) + 'RAGComposer': LogLevel.WARN, + }); + this.fileStreams = new Map(); this.logQueues = new Map(); this.logTimers = new Map(); @@ -345,10 +367,11 @@ class LoggerClass implements ParentLogger { } /** - * Set log level programmatically + * Set global log level programmatically */ setLevel(level: LogLevel): void { this.config.level = level; + LogLevelRegistry.instance.globalLevel = level; } /** diff --git a/src/debug/jtag/system/core/logging/LoggerTypes.ts b/src/debug/jtag/system/core/logging/LoggerTypes.ts index 0c05ba0ab..0e769bd24 100644 --- a/src/debug/jtag/system/core/logging/LoggerTypes.ts +++ b/src/debug/jtag/system/core/logging/LoggerTypes.ts @@ -34,7 +34,7 @@ export interface LogQueueEntry { * Parse log level from environment */ export function parseLogLevel(envLevel?: string): LogLevel { - const level = envLevel?.toUpperCase() || 'INFO'; + const level = envLevel?.toUpperCase() || 'WARN'; const levelMap: Record = { 'DEBUG': LogLevel.DEBUG, 'INFO': LogLevel.INFO, @@ -42,7 +42,7 @@ export function parseLogLevel(envLevel?: string): LogLevel { 'ERROR': LogLevel.ERROR, 'SILENT': LogLevel.SILENT }; - return levelMap[level] || LogLevel.INFO; + return levelMap[level] || LogLevel.WARN; } /** diff --git a/src/debug/jtag/system/core/logging/LoggingConfig.ts b/src/debug/jtag/system/core/logging/LoggingConfig.ts index 52791d39c..f4443088b 100644 --- a/src/debug/jtag/system/core/logging/LoggingConfig.ts +++ b/src/debug/jtag/system/core/logging/LoggingConfig.ts @@ -227,7 +227,7 @@ export class LoggingConfig { private updateEnabled(personaId: string, category: string, enabled: boolean): void { const normalizedId = this.normalizePersonaId(personaId); - // Ensure persona config exists (don't change enabled state) + // Ensure persona config exists if (!this.config.personas[normalizedId]) { this.config.personas[normalizedId] = { enabled: false, @@ -245,14 +245,17 @@ export class LoggingConfig { const allCategories = Object.values(LOGGING_CATEGORIES); if (enabled) { - // ENABLING a category + // ENABLING a category β€” also enables the persona (can't log to a disabled persona) + personaConfig.enabled = true; + if (personaConfig.categories.length === 0 || personaConfig.categories.includes('*')) { - // Already all enabled - nothing to do - return; - } - // Add category if not present - if (!personaConfig.categories.includes(category)) { - personaConfig.categories.push(category); + // All categories currently enabled β€” narrow to just this category + personaConfig.categories = [category]; + } else { + // Add category if not present + if (!personaConfig.categories.includes(category)) { + personaConfig.categories.push(category); + } } // If all categories are now enabled, simplify to empty array (meaning "all") if (allCategories.every(c => personaConfig.categories!.includes(c))) { @@ -267,9 +270,12 @@ export class LoggingConfig { // Remove category from explicit list personaConfig.categories = personaConfig.categories.filter(c => c !== category); } - } - // Individual toggles don't change persona.enabled - that's controlled by global toggle only + // If no categories remain, disable the persona entirely + if (personaConfig.categories.length === 0) { + personaConfig.enabled = false; + } + } this.save(); } diff --git a/src/debug/jtag/system/core/server/ServerCommands.ts b/src/debug/jtag/system/core/server/ServerCommands.ts new file mode 100644 index 000000000..8f3ca8c4c --- /dev/null +++ b/src/debug/jtag/system/core/server/ServerCommands.ts @@ -0,0 +1,272 @@ +/** + * ServerCommands - Handle-Based Async Command Execution (Server-Only) + * + * Extends Commands with tracked execution modes using Handles + Events. + * This file MUST NOT be imported from shared or browser code. + * + * Three tracked execution modes: + * 1. Background: execute({ background: true }) β†’ returns handle immediately + * 2. Timeout-tracked: execute({ timeout: 5000 }) β†’ races against timeout + * 3. Await: ServerCommands.await('#abc123') β†’ resolves when complete + * + * Architecture: + * - Commands (shared) delegates tracked execution to this class via TrackedCommandExecutor + * - ServerCommands.initialize() registers itself with Commands at server startup + * - All Handle/Events imports stay server-side, never pollute the browser bundle + * + * Event emission pattern: + * command:started:${handleId} β†’ { handle, command } + * command:complete:${handleId} β†’ { handle, result } + * command:failed:${handleId} β†’ { handle, error } + * command:timeout:${handleId} β†’ { handle, behavior } + */ + +import { Commands, CommandTimeoutError } from '../shared/Commands'; +import type { TrackedCommandExecutor } from '../shared/Commands'; +import type { CommandParams, CommandResult } from '../types/JTAGTypes'; +import type { HandleRef, HandleRecord } from '../types/Handle'; +import { Handles } from '../shared/Handles'; +import { Events } from '../shared/Events'; + +export class ServerCommands implements TrackedCommandExecutor { + private static _instance: ServerCommands | null = null; + + /** + * Initialize server-side tracked execution. + * Must be called once at server startup (e.g., in SystemOrchestrator). + */ + static initialize(): void { + if (this._instance) return; // Idempotent + this._instance = new ServerCommands(); + Commands.registerTrackedExecutor(this._instance); + } + + /** + * Await a background command's result by handle reference. + * Resolves when the handle reaches 'complete' or 'failed' status. + * + * @param ref - Handle reference (short ID "#abc123" or full UUID) + * @param timeoutMs - Maximum time to wait for completion (default: 5 minutes) + */ + static async await( + ref: HandleRef, + timeoutMs = 300_000, + ): Promise { + const handle = await Handles.resolve(ref); + if (!handle) { + throw new Error(`Handle not found: ${ref}`); + } + + // Already terminal? + if (handle.status === 'complete') return handle.result as U; + if (handle.status === 'failed') throw new Error(handle.error ?? 'Command failed'); + if (handle.status === 'cancelled') throw new Error('Command was cancelled'); + if (handle.status === 'expired') throw new Error('Handle expired before completion'); + + // Still in progress β€” subscribe to completion events + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null; + const unsubs: Array<() => void> = []; + + const cleanup = () => { + if (timer) clearTimeout(timer); + unsubs.forEach(fn => fn()); + }; + + if (timeoutMs > 0) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Await timeout: handle ${ref} did not complete within ${timeoutMs}ms`)); + }, timeoutMs); + } + + unsubs.push(Events.subscribe<{ handle: string; result: unknown }>( + `command:complete:${handle.id}`, + (event) => { + cleanup(); + resolve(event.result as U); + } + )); + + unsubs.push(Events.subscribe<{ handle: string; error: string }>( + `command:failed:${handle.id}`, + (event) => { + cleanup(); + reject(new Error(event.error)); + } + )); + }); + } + + /** + * Subscribe to events for a handle (progress, completion, failure). + * Events follow the pattern: command:{event}:{handleId} + * + * @returns Unsubscribe function + */ + static async subscribe( + ref: HandleRef, + listener: (event: { type: string; handle: string; [key: string]: unknown }) => void, + ): Promise<() => void> { + const handle = await Handles.resolve(ref); + if (!handle) { + throw new Error(`Handle not found: ${ref}`); + } + + return Events.subscribe( + `command:*:${handle.id}`, + listener, + ); + } + + // ────────────────────────────────────────────── + // TrackedCommandExecutor implementation + // ────────────────────────────────────────────── + + /** + * Execute a command with handle-based tracking. + * Called by Commands.execute() when params.background or params.timeout is set. + */ + async executeTracked( + command: string, + params: Partial | undefined, + executeDirect: (command: string, params?: Partial) => Promise, + ): Promise { + const requesterId = params?.userId ?? '00000000-0000-0000-0000-000000000000'; + + // Create handle for tracking + const handle = await Handles.create( + command, + params ?? {}, + requesterId, + params?.timeout ? params.timeout * 2 : undefined, // TTL = 2x timeout, or default + ); + await Handles.markProcessing(handle.id); + + // Emit started event + await Events.emit(`command:started:${handle.id}`, { + handle: handle.shortId, + command, + }); + + if (params?.background) { + // Background: fire and forget β€” execute async, emit events on completion + this._executeAsync(command, params, handle, executeDirect); + return { handle: `#${handle.shortId}`, handleId: handle.id } as U; + } + + // Timeout-based execution + return this._executeWithTimeout(command, params, handle, executeDirect); + } + + // ────────────────────────────────────────────── + // Private: Execution Strategies + // ────────────────────────────────────────────── + + /** + * Background async execution β€” returns immediately, emits events on completion. + */ + private async _executeAsync( + command: string, + params: Partial | undefined, + handle: HandleRecord, + executeDirect: (command: string, params?: Partial) => Promise, + ): Promise { + try { + const result = await executeDirect(command, params); + await Handles.markComplete(handle.id, result); + await Events.emit(`command:complete:${handle.id}`, { + handle: `#${handle.shortId}`, + result, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + await Handles.markFailed(handle.id, msg); + await Events.emit(`command:failed:${handle.id}`, { + handle: `#${handle.shortId}`, + error: msg, + }); + } + } + + /** + * Timeout-tracked execution β€” races command against timeout. + * Behavior on timeout controlled by params.onTimeout. + */ + private async _executeWithTimeout( + command: string, + params: Partial | undefined, + handle: HandleRecord, + executeDirect: (command: string, params?: Partial) => Promise, + ): Promise { + const timeoutMs = params?.timeout ?? 30_000; + const onTimeout = params?.onTimeout ?? 'fail'; + + // Sentinel to detect timeout + const TIMEOUT_SENTINEL = Symbol('timeout'); + + // Capture the execution promise so we can attach handlers if it survives the timeout + const executionPromise = executeDirect(command, params); + + try { + const result = await Promise.race([ + executionPromise, + new Promise((resolve) => + setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs) + ), + ]); + + if (result === TIMEOUT_SENTINEL) { + // Timeout fired + await Events.emit(`command:timeout:${handle.id}`, { + handle: `#${handle.shortId}`, + behavior: onTimeout, + }); + + switch (onTimeout) { + case 'cancel': + await Handles.markCancelled(handle.id); + throw new CommandTimeoutError(command, timeoutMs, `#${handle.shortId}`, handle.id); + + case 'continue': + // Original executionPromise is still running β€” attach completion handlers + executionPromise + .then(async (r) => { + await Handles.markComplete(handle.id, r); + await Events.emit(`command:complete:${handle.id}`, { handle: `#${handle.shortId}`, result: r }); + }) + .catch(async (e) => { + const msg = e instanceof Error ? e.message : String(e); + await Handles.markFailed(handle.id, msg); + await Events.emit(`command:failed:${handle.id}`, { handle: `#${handle.shortId}`, error: msg }); + }); + return { handle: `#${handle.shortId}`, handleId: handle.id, timedOut: true } as U; + + case 'fail': + default: + await Handles.markFailed(handle.id, `Execution timeout after ${timeoutMs}ms`); + throw new CommandTimeoutError(command, timeoutMs, `#${handle.shortId}`, handle.id); + } + } + + // Completed within timeout + await Handles.markComplete(handle.id, result); + await Events.emit(`command:complete:${handle.id}`, { + handle: `#${handle.shortId}`, + result, + }); + return result as U; + + } catch (error) { + if (error instanceof CommandTimeoutError) throw error; + + const msg = error instanceof Error ? error.message : String(error); + await Handles.markFailed(handle.id, msg); + await Events.emit(`command:failed:${handle.id}`, { + handle: `#${handle.shortId}`, + error: msg, + }); + throw error; + } + } +} diff --git a/src/debug/jtag/system/core/shared/Commands.ts b/src/debug/jtag/system/core/shared/Commands.ts index 97418b52b..ba1ecb68b 100644 --- a/src/debug/jtag/system/core/shared/Commands.ts +++ b/src/debug/jtag/system/core/shared/Commands.ts @@ -4,12 +4,24 @@ * Provides elegant static interface for command execution with full type inference. * No more manual generic parameters - types are inferred from command name! * + * Three execution modes: + * 1. Direct (default): execute β†’ await result (fast path, no Handle overhead) + * 2. Background: execute({ background: true }) β†’ returns handle immediately + * 3. Timeout-tracked: execute({ timeout: 5000 }) β†’ races execution vs timeout + * + * Modes 2 & 3 require ServerCommands to be initialized (server-side only). + * The shared Commands class stays environment-agnostic β€” all Handle/Events + * logic lives in system/core/server/ServerCommands.ts. + * * Usage: - * // Type-safe! params and result types inferred automatically - * const result = await Screenshot.execute({ querySelector: 'body' }); + * // Direct (fast path β€” works in browser + server) + * const result = await Commands.execute('ping', {}); + * + * // Background β€” server only, returns handle immediately + * const { handle, handleId } = await Commands.execute('ai/generate', { background: true }); * - * // IntelliSense shows all available commands - * await FileSave.execute({ filepath: 'test.txt', content: 'hello' }); + * // Await a handle β€” server only + * const result = await ServerCommands.await(handle); */ import { JTAGClient } from '../client/shared/JTAGClient'; @@ -17,10 +29,48 @@ import type { CommandParams, CommandResult } from '../types/JTAGTypes'; import { Screenshot } from '../../../commands/interface/screenshot/shared/ScreenshotTypes'; import { FileSave } from '../../../commands/file/save/shared/FileSaveTypes'; + +/** Error thrown when a command exceeds its timeout */ +export class CommandTimeoutError extends Error { + constructor( + public readonly command: string, + public readonly timeoutMs: number, + public readonly handle?: string, + public readonly handleId?: string, + ) { + super(`Command '${command}' timed out after ${timeoutMs}ms`); + this.name = 'CommandTimeoutError'; + } +} + +/** + * Interface for tracked (background/timeout) command execution. + * Implemented by ServerCommands, registered at server startup. + */ +export interface TrackedCommandExecutor { + executeTracked( + command: string, + params: Partial | undefined, + executeDirect: (command: string, params?: Partial) => Promise, + ): Promise; +} + export class Commands { + /** Server-side tracked executor (registered by ServerCommands at startup) */ + private static _trackedExecutor: TrackedCommandExecutor | null = null; + + /** Register the server-side tracked executor (called by ServerCommands.initialize()) */ + static registerTrackedExecutor(executor: TrackedCommandExecutor): void { + this._trackedExecutor = executor; + } + /** - * Execute a command with full type safety - * Context and sessionId are auto-injected, all other params required/optional as defined + * Execute a command with full type safety. + * Context and sessionId are auto-injected, all other params required/optional as defined. + * + * When params.background is true, returns { handle, handleId } immediately (server only). + * When params.timeout is set, races execution against timeout (server only). + * Otherwise, executes directly with no Handle overhead (fast path). */ static execute( command: string, @@ -31,6 +81,69 @@ export class Commands { static async execute( command: string, params?: Partial + ): Promise { + const isTracked = params?.background || params?.timeout; + + if (!isTracked) { + // Fast path: direct execution (no Handle overhead) + return this._executeDirect(command, params); + } + + // Tracked path: delegate to ServerCommands (server-side only) + if (!this._trackedExecutor) { + throw new Error( + 'Tracked execution (background/timeout) requires server environment. ' + + 'Ensure ServerCommands.initialize() is called at server startup.' + ); + } + + return this._trackedExecutor.executeTracked( + command, + params, + (cmd, p) => this._executeDirect(cmd, p), + ); + } + + /** + * Execute command and extract rich content (markdown, images, audio) + * + * Automatically unwraps common content fields from command results. + * Perfect for PersonaUsers who want clean content without parsing structures. + */ + static async content( + command: string, + params?: Partial + ): Promise { + const result = await Commands.execute(command, params); + + // Try common content field names in priority order + if ('content' in result && result.content) return result.content as string; + if ('markdown' in result && result.markdown) return result.markdown as string; + if ('text' in result && result.text) return result.text as string; + if ('data' in result && result.data) return result.data as Buffer | Uint8Array; + if ('buffer' in result && result.buffer) return result.buffer as Buffer | Uint8Array; + if ('audio' in result && result.audio) return result.audio as Buffer | Uint8Array; + if ('image' in result && result.image) return result.image as Buffer | Uint8Array; + if ('media' in result && (result.media as { data?: Buffer | Uint8Array })?.data) { + return (result.media as { data: Buffer | Uint8Array }).data; + } + + // Fallback: JSON stringify the result + return JSON.stringify(result, null, 2); + } + + // ────────────────────────────────────────────── + // Internal: Direct Execution (environment-agnostic) + // ────────────────────────────────────────────── + + /** + * Direct execution β€” the fast path. No Handle overhead. + * Works in both browser and server environments. + * Exposed internally so ServerCommands can delegate to it. + */ + static async _executeDirect( + command: string, + params?: Partial, ): Promise { // Server-side optimization: If we're already in a server context with a CommandDaemon, // route internally instead of creating a new client connection @@ -47,18 +160,13 @@ export class Commands { } const commandDaemon = globalWithJTAG.__JTAG_COMMAND_DAEMON__ as CommandDaemonWithCommands; - // IMPORTANT: userId should be provided by the caller (CLI, browser session, etc.) - // Commands.ts does NOT auto-inject userId - that's the infrastructure's job - // sessionId β†’ userId lookup should happen BEFORE calling Commands.execute() - const finalParams: CommandParams = { context: params?.context || globalWithJTAG.__JTAG_CONTEXT__ || 'unknown', sessionId: params?.sessionId || globalWithJTAG.__JTAG_SESSION_ID__ || 'unknown', - userId: params?.userId, // Pass through from caller + userId: params?.userId, ...(params || {}) } as T; - // Route command internally via CommandDaemon const commandInstance = commandDaemon.commands.get(command); if (commandInstance) { return await commandInstance.execute(finalParams) as U; @@ -68,51 +176,13 @@ export class Commands { // Client-side or fallback: Use JTAGClient const jtagClient = await JTAGClient.sharedInstance; - // Auto-inject context, sessionId, and userId const finalParams: T = { context: jtagClient.context, sessionId: jtagClient.sessionId, - userId: jtagClient.userId, // Auto-inject userId from session + userId: jtagClient.userId, ...(params || {}) } as T; - // Execute and get typed result (unwrapped by daemons.commands.execute) return await jtagClient.daemons.commands.execute(command, finalParams); } - - /** - * Execute command and extract rich content (markdown, images, audio) - * - * Automatically unwraps common content fields from command results. - * Perfect for PersonaUsers who want clean content without parsing structures. - * - * @example - * // Get markdown directly - * const markdown = await Commands.content('wall/read', { room: 'general', doc: 'foo.md' }); - * - * // Get image data directly - * const imageData = await Commands.content('screenshot', { querySelector: 'body' }); - * - * // Get audio buffer directly - * const audioBuffer = await Commands.content('audio/record', { duration: 5000 }); - */ - static async content( - command: string, - params?: Partial - ): Promise { - const result = await Commands.execute(command, params); - - // Try common content field names in priority order - if ('content' in result && result.content) return result.content as string; - if ('markdown' in result && result.markdown) return result.markdown as string; - if ('text' in result && result.text) return result.text as string; - if ('data' in result && result.data) return result.data as Buffer | Uint8Array; - if ('buffer' in result && result.buffer) return result.buffer as Buffer | Uint8Array; - if ('audio' in result && result.audio) return result.audio as Buffer | Uint8Array; - if ('image' in result && result.image) return result.image as Buffer | Uint8Array; - if ('media' in result && (result.media as any)?.data) return (result.media as any).data as Buffer | Uint8Array; - - // Fallback: JSON stringify the result - return JSON.stringify(result, null, 2); - } -} \ No newline at end of file +} diff --git a/src/debug/jtag/system/core/shared/TimingHarness.ts b/src/debug/jtag/system/core/shared/TimingHarness.ts index f0d041eae..355ff6a6b 100644 --- a/src/debug/jtag/system/core/shared/TimingHarness.ts +++ b/src/debug/jtag/system/core/shared/TimingHarness.ts @@ -31,7 +31,7 @@ * ``` */ -import { writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; +import { createWriteStream, existsSync, mkdirSync, type WriteStream } from 'fs'; import { dirname } from 'path'; // ============================================================================ @@ -243,23 +243,41 @@ export class TimingCollector { private maxRecords = 10000; private logPath: string; private logEnabled: boolean; + private _writeStream: WriteStream | null = null; + private _writeBuffer: string[] = []; + private _flushTimer: ReturnType | null = null; + private static readonly FLUSH_INTERVAL_MS = 500; + private static readonly MAX_BUFFER_SIZE = 100; + + // Per-category enable/disable (fine-grained control) + private _categoryEnabled: Map = new Map(); private constructor() { // Default log path - can be overridden via env var this.logPath = process.env.JTAG_TIMING_LOG || '/tmp/jtag-timing.jsonl'; - this.logEnabled = process.env.JTAG_TIMING_ENABLED !== 'false'; + // Opt-IN: timing file is OFF by default (set JTAG_TIMING_ENABLED=true to enable) + // Previous logic (opt-out) caused 952MB timing files because it defaulted to on + this.logEnabled = process.env.JTAG_TIMING_ENABLED === 'true'; - // Ensure log directory exists + // Set up async write stream (replaces appendFileSync which blocked event loop) if (this.logEnabled && isNode) { try { const dir = dirname(this.logPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } + this._writeStream = createWriteStream(this.logPath, { flags: 'a' }); + this._writeStream.on('error', () => { + // Silently disable on write errors + this._writeStream = null; + }); } catch { - // Ignore directory creation errors + // Ignore initialization errors } + + // Periodic flush (instead of sync write per record) + this._flushTimer = setInterval(() => this.flushBuffer(), TimingCollector.FLUSH_INTERVAL_MS); } } @@ -279,27 +297,46 @@ export class TimingCollector { if (options.enabled !== undefined) this.logEnabled = options.enabled; } + /** + * Enable or disable timing for a specific category. + * When disabled, records for that category are silently dropped. + */ + setCategoryEnabled(category: string, enabled: boolean): void { + this._categoryEnabled.set(category, enabled); + } + + /** + * Check if a category is enabled (default: true if not explicitly set) + */ + isCategoryEnabled(category: string): boolean { + return this._categoryEnabled.get(category) ?? true; + } + /** * Record a timing entry */ record(timing: TimingRecord): void { + // Check per-category filter + if (!this.isCategoryEnabled(timing.category)) { + return; + } + // Add to in-memory buffer this.records.push(timing); if (this.records.length > this.maxRecords) { this.records.shift(); } - // Log to file - if (this.logEnabled && isNode) { - try { - appendFileSync(this.logPath, JSON.stringify(timing) + '\n'); - } catch { - // Ignore file write errors + // Buffer for async file write (never blocks event loop) + if (this.logEnabled && this._writeStream) { + this._writeBuffer.push(JSON.stringify(timing)); + if (this._writeBuffer.length >= TimingCollector.MAX_BUFFER_SIZE) { + this.flushBuffer(); } } - // Console debug log for slow operations (>100ms) - if (timing.totalMs > 100) { + // Console debug log for slow operations (>500ms β€” raised from 100ms to reduce spam) + if (timing.totalMs > 500) { const phases = Object.entries(timing.phases) .map(([k, v]) => `${k}=${(v / 1000).toFixed(1)}ms`) .join(', '); @@ -309,6 +346,19 @@ export class TimingCollector { } } + /** + * Flush buffered timing records to disk (async, non-blocking) + */ + private flushBuffer(): void { + if (this._writeBuffer.length === 0 || !this._writeStream) { + return; + } + + const batch = this._writeBuffer.join('\n') + '\n'; + this._writeBuffer.length = 0; + this._writeStream.write(batch); + } + /** * Get recent records for a category */ @@ -403,6 +453,21 @@ export class TimingCollector { clear(): void { this.records = []; } + + /** + * Shutdown: flush remaining buffer and close stream + */ + shutdown(): void { + this.flushBuffer(); + if (this._flushTimer) { + clearInterval(this._flushTimer); + this._flushTimer = null; + } + if (this._writeStream) { + this._writeStream.end(); + this._writeStream = null; + } + } } // ============================================================================ diff --git a/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts b/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts index c2ca4f86b..1a174539e 100644 --- a/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts +++ b/src/debug/jtag/system/core/system/server/JTAGSystemServer.ts @@ -220,6 +220,10 @@ export class JTAGSystemServer extends JTAGSystem { }); console.log(`πŸŽ‰ JTAG System: System ready event emitted`); + // Initialize server-side tracked command execution (background, timeout, handles) + const { ServerCommands } = await import('../../server/ServerCommands'); + ServerCommands.initialize(); + return system; } diff --git a/src/debug/jtag/system/core/types/JTAGTypes.ts b/src/debug/jtag/system/core/types/JTAGTypes.ts index 8b5d35ebd..803da9e46 100644 --- a/src/debug/jtag/system/core/types/JTAGTypes.ts +++ b/src/debug/jtag/system/core/types/JTAGTypes.ts @@ -557,11 +557,26 @@ export interface CommandParams extends JTAGPayload { readonly userId?: UUID; /** - * Optional execution timeout in milliseconds - * If command execution exceeds this timeout, it will be automatically aborted + * Optional execution timeout in milliseconds. + * If command execution exceeds this timeout, behavior is controlled by onTimeout. * Default: No timeout (command runs until completion or error) */ timeout?: number; + + /** + * Return handle immediately and execute command asynchronously. + * Result will be available via Commands.await(handle) or Events subscription. + * When true, execute() returns { handle, handleId } immediately. + */ + background?: boolean; + + /** + * Behavior when timeout fires. Only meaningful when timeout is set. + * 'cancel' β€” Cancel the operation, mark handle as cancelled, throw TimeoutError + * 'continue' β€” Return handle, let command keep running in background + * 'fail' β€” (default) Mark handle as failed, throw TimeoutError + */ + onTimeout?: 'cancel' | 'continue' | 'fail'; } /** @@ -570,6 +585,15 @@ export interface CommandParams extends JTAGPayload { export interface CommandResult extends JTAGPayload { // Base command results - specific commands add specific fields // Note: Some commands extend BaseResponsePayload for standardized success/timestamp + + /** Short handle ID (e.g., "#a1b2c3") β€” populated for background/timeout-tracked commands */ + handle?: string; + + /** Full handle UUID β€” populated for background/timeout-tracked commands */ + handleId?: UUID; + + /** True when timeout fired but command continues running (onTimeout='continue') */ + timedOut?: boolean; } /** diff --git a/src/debug/jtag/system/data/constants/RoomConstants.ts b/src/debug/jtag/system/data/constants/RoomConstants.ts index 45b9aa289..3aa025cd7 100644 --- a/src/debug/jtag/system/data/constants/RoomConstants.ts +++ b/src/debug/jtag/system/data/constants/RoomConstants.ts @@ -15,7 +15,8 @@ export const ROOM_UNIQUE_IDS = { SETTINGS: 'settings', CANVAS: 'canvas', OUTREACH: 'outreach', - NEWSROOM: 'newsroom' + NEWSROOM: 'newsroom', + CODE: 'code' } as const; export type RoomUniqueId = typeof ROOM_UNIQUE_IDS[keyof typeof ROOM_UNIQUE_IDS]; diff --git a/src/debug/jtag/system/data/domains/DefaultEntities.ts b/src/debug/jtag/system/data/domains/DefaultEntities.ts index e8b0bb7c8..01796f8cf 100644 --- a/src/debug/jtag/system/data/domains/DefaultEntities.ts +++ b/src/debug/jtag/system/data/domains/DefaultEntities.ts @@ -55,6 +55,7 @@ export const DEFAULT_ROOMS = { CANVAS: stringToUUID('Canvas') as UUID, OUTREACH: stringToUUID('Outreach') as UUID, NEWSROOM: stringToUUID('Newsroom') as UUID, + CODE: stringToUUID('Code') as UUID, SUPPORT: stringToUUID('Support') as UUID, AI_TRAINING: stringToUUID('AI Training') as UUID } as const; diff --git a/src/debug/jtag/system/data/entities/SkillEntity.ts b/src/debug/jtag/system/data/entities/SkillEntity.ts new file mode 100644 index 000000000..9b873063f --- /dev/null +++ b/src/debug/jtag/system/data/entities/SkillEntity.ts @@ -0,0 +1,303 @@ +/** + * SkillEntity - Self-modifying skill definition and lifecycle tracking + * + * Represents a skill that an AI persona can propose, generate, validate, and activate. + * Skills are essentially new commands created by the AI team themselves. + * + * Lifecycle: proposed β†’ approved β†’ generated β†’ validated β†’ active + * (can fail at any stage β†’ 'failed', or be deprecated after activation) + * + * Scope: + * - 'personal': Only the creator can use it (stored in persona workspace) + * - 'team': All personas can use it (requires DecisionProposal approval, stored in commands/) + */ + +import type { UUID } from '../../core/types/CrossPlatformUUID'; +import { + TextField, + NumberField, + JsonField, + EnumField, + CompositeIndex, +} from '../decorators/FieldDecorators'; +import { BaseEntity } from './BaseEntity'; +import { COLLECTIONS } from '../../shared/Constants'; + +// ──────────────────────────────────────────────────────────── +// Skill status lifecycle +// ──────────────────────────────────────────────────────────── + +export type SkillStatus = + | 'proposed' // AI submitted skill spec, not yet reviewed (team) or ready to generate (personal) + | 'approved' // Team approved via DecisionProposal (team-scoped only) + | 'generated' // CommandGenerator produced the code files + | 'validated' // Compiled + tests passed in sandbox + | 'active' // Registered and available for use + | 'failed' // Failed at generation, validation, or activation + | 'deprecated'; // Was active, now retired + +export type SkillScope = 'personal' | 'team'; + +// ──────────────────────────────────────────────────────────── +// Skill spec (what gets passed to CommandGenerator) +// ──────────────────────────────────────────────────────────── + +export interface SkillParamSpec { + name: string; + type: string; + optional?: boolean; + description?: string; +} + +export interface SkillResultSpec { + name: string; + type: string; + description?: string; +} + +export interface SkillSpec { + /** Command name (e.g., 'analysis/complexity') */ + name: string; + /** What the command does */ + description: string; + /** Input parameters */ + params: SkillParamSpec[]; + /** Output fields */ + results: SkillResultSpec[]; + /** Usage examples */ + examples?: Array<{ + description: string; + command: string; + expectedResult?: string; + }>; + /** Natural language description of what the implementation should do */ + implementation: string; + /** Access level for the command */ + accessLevel?: 'ai-safe' | 'internal' | 'system'; +} + +// ──────────────────────────────────────────────────────────── +// Validation results (populated after skill/validate) +// ──────────────────────────────────────────────────────────── + +export interface SkillValidationResults { + compiled: boolean; + testsRun: number; + testsPassed: number; + errors: string[]; + durationMs: number; +} + +// ──────────────────────────────────────────────────────────── +// Entity +// ──────────────────────────────────────────────────────────── + +@CompositeIndex({ + name: 'idx_skills_creator_status', + fields: ['createdById', 'status'], + direction: 'DESC', +}) +@CompositeIndex({ + name: 'idx_skills_scope_status', + fields: ['scope', 'status'], + direction: 'DESC', +}) +export class SkillEntity extends BaseEntity { + static readonly collection = COLLECTIONS.SKILLS; + + // ── Identity ────────────────────────────────────────────── + + /** Command name (e.g., 'analysis/complexity', 'code/lint') */ + @TextField({ index: true }) + name!: string; + + /** Human-readable description of what the skill does */ + @TextField() + description!: string; + + /** AI persona that proposed this skill */ + @TextField({ index: true }) + createdById!: UUID; + + // ── Specification ───────────────────────────────────────── + + /** Full command specification (params, results, examples, implementation) */ + @JsonField() + spec!: SkillSpec; + + // ── Scope & governance ──────────────────────────────────── + + /** Who can use this skill: personal (creator only) or team (all, requires approval) */ + @EnumField({ index: true }) + scope!: SkillScope; + + /** DecisionProposal ID if team-scoped (requires governance approval) */ + @TextField({ nullable: true }) + proposalId?: UUID; + + // ── Lifecycle ───────────────────────────────────────────── + + @EnumField({ index: true }) + status!: SkillStatus; + + /** Error message if status is 'failed' */ + @TextField({ nullable: true }) + failureReason?: string; + + // ── Generation ──────────────────────────────────────────── + + /** Directory where generated files live */ + @TextField({ nullable: true }) + outputDir?: string; + + /** Paths of files created by CommandGenerator */ + @JsonField() + generatedFiles!: string[]; + + // ── Validation ──────────────────────────────────────────── + + /** Compilation and test results from sandbox validation */ + @JsonField({ nullable: true }) + validationResults?: SkillValidationResults; + + // ── Activation ──────────────────────────────────────────── + + /** When the skill was activated (registered as a command) */ + @NumberField({ nullable: true }) + activatedAt?: number; + + // ── Index signature ─────────────────────────────────────── + + [key: string]: unknown; + + // ── Constructor ─────────────────────────────────────────── + + constructor() { + super(); + + this.name = ''; + this.description = ''; + this.createdById = '' as UUID; + this.spec = { + name: '', + description: '', + params: [], + results: [], + implementation: '', + }; + this.scope = 'personal'; + this.status = 'proposed'; + this.generatedFiles = []; + } + + // ── BaseEntity implementation ───────────────────────────── + + get collection(): string { + return SkillEntity.collection; + } + + static override getPaginationConfig(): { + defaultSortField: string; + defaultSortDirection: 'asc' | 'desc'; + defaultPageSize: number; + cursorField: string; + } { + return { + defaultSortField: 'createdAt', + defaultSortDirection: 'desc', + defaultPageSize: 20, + cursorField: 'createdAt', + }; + } + + validate(): { success: boolean; error?: string } { + if (!this.name?.trim()) { + return { success: false, error: 'Skill name is required' }; + } + + // Validate command naming convention: category/name or just name + if (!/^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*$/.test(this.name)) { + return { success: false, error: `Skill name must follow command naming convention (e.g., 'analysis/complexity'): ${this.name}` }; + } + + if (!this.description?.trim()) { + return { success: false, error: 'Skill description is required' }; + } + + if (!this.createdById?.trim()) { + return { success: false, error: 'Skill createdById is required' }; + } + + if (!this.spec || typeof this.spec !== 'object') { + return { success: false, error: 'Skill spec is required' }; + } + + if (!this.spec.name?.trim()) { + return { success: false, error: 'Skill spec.name is required' }; + } + + if (this.spec.name !== this.name) { + return { success: false, error: `Skill spec.name (${this.spec.name}) must match entity name (${this.name})` }; + } + + if (!this.spec.implementation?.trim()) { + return { success: false, error: 'Skill spec.implementation is required (natural language description)' }; + } + + if (!Array.isArray(this.spec.params)) { + return { success: false, error: 'Skill spec.params must be an array' }; + } + + if (!Array.isArray(this.spec.results)) { + return { success: false, error: 'Skill spec.results must be an array' }; + } + + const validScopes: SkillScope[] = ['personal', 'team']; + if (!validScopes.includes(this.scope)) { + return { success: false, error: `Skill scope must be one of: ${validScopes.join(', ')}` }; + } + + const validStatuses: SkillStatus[] = [ + 'proposed', 'approved', 'generated', 'validated', 'active', 'failed', 'deprecated', + ]; + if (!validStatuses.includes(this.status)) { + return { success: false, error: `Skill status must be one of: ${validStatuses.join(', ')}` }; + } + + return { success: true }; + } + + // ── Convenience properties ──────────────────────────────── + + /** Whether this skill has been activated and is available for use */ + get isActive(): boolean { + return this.status === 'active'; + } + + /** Whether this skill requires team approval */ + get requiresApproval(): boolean { + return this.scope === 'team'; + } + + /** Whether this skill can proceed to the next lifecycle stage */ + get canAdvance(): boolean { + switch (this.status) { + case 'proposed': return this.scope === 'personal' || !!this.proposalId; + case 'approved': return true; // Can generate + case 'generated': return true; // Can validate + case 'validated': return true; // Can activate + default: return false; + } + } + + /** The next expected status in the lifecycle */ + get nextStatus(): SkillStatus | undefined { + switch (this.status) { + case 'proposed': return this.scope === 'personal' ? 'generated' : 'approved'; + case 'approved': return 'generated'; + case 'generated': return 'validated'; + case 'validated': return 'active'; + default: return undefined; + } + } +} diff --git a/src/debug/jtag/system/data/entities/UserEntity.ts b/src/debug/jtag/system/data/entities/UserEntity.ts index c11324e55..51359fe46 100644 --- a/src/debug/jtag/system/data/entities/UserEntity.ts +++ b/src/debug/jtag/system/data/entities/UserEntity.ts @@ -124,6 +124,7 @@ export class UserEntity extends BaseEntity { capabilities?: readonly string[]; ragCertified?: boolean; // Has this model been tested with our complex RAG system? requiresExplicitMention?: boolean; // If true, persona only responds when explicitly mentioned + toolCapability?: 'native' | 'xml' | 'none'; // Override provider-based tool capability detection }; // Media configuration (for AI users that can process images/audio/video) diff --git a/src/debug/jtag/system/orchestration/SystemOrchestrator.ts b/src/debug/jtag/system/orchestration/SystemOrchestrator.ts index a8582b6c4..1209da019 100644 --- a/src/debug/jtag/system/orchestration/SystemOrchestrator.ts +++ b/src/debug/jtag/system/orchestration/SystemOrchestrator.ts @@ -685,61 +685,90 @@ export class SystemOrchestrator extends EventEmitter { return true; } - // Check if browser is already connected using ping - let browserConnected = false; + await this.detectAndManageBrowser(options); + + await milestoneEmitter.completeMilestone( + SYSTEM_MILESTONES.BROWSER_LAUNCH_INITIATED, + this.currentEntryPoint + ); + return true; + } + + /** + * Single source of truth for browser detection and management. + * + * Flow: + * 1. Ping server to check if a browser is already connected + * 2. If connected β†’ refresh it (interface/navigate, fallback to location.reload()) + * 3. If not connected β†’ open a new tab + * + * Called from: + * - executeBrowserLaunch() during fresh startup milestone chain + * - ensureBrowserOpened() when all milestones already complete + */ + /** + * Ping the server and check if a browser is connected. + * Returns true if browser is detected, false otherwise. + */ + private async pingForBrowser(): Promise { try { - const { stdout } = await execAsync('./jtag ping'); + const { stdout } = await execAsync('./jtag ping', { timeout: 5000 }); const pingResponse = JSON.parse(stdout); + return !!(pingResponse.success && pingResponse.browser); + } catch { + return false; + } + } - if (pingResponse.success && pingResponse.browser) { - browserConnected = true; - console.log('πŸ”„ Browser already connected - triggering reload to pick up new code'); + private async detectAndManageBrowser(options: OrchestrationOptions): Promise { + // Step 1: Check if browser is already connected. + // After a server restart, existing browser tabs need a few seconds to + // reconnect their WebSocket. Retry ping up to 3 times with delays + // before concluding no browser is present. + let browserConnected = await this.pingForBrowser(); - // Trigger reload in browser - try { - await execAsync('./jtag interface/navigate'); - console.log('βœ… Browser reloaded'); - } catch (navError) { - console.warn('⚠️ Could not navigate browser, trying page reload'); - // Fallback: try to execute a reload in the browser - try { - await execAsync('./jtag development/exec --code="location.reload()"'); - } catch (reloadError) { - console.warn('⚠️ Browser reload failed - will open browser'); - browserConnected = false; // Force open since reload failed - } - } + if (!browserConnected) { + // Wait and retry β€” the browser tab may be reconnecting after restart + for (let attempt = 1; attempt <= 2; attempt++) { + console.log(`πŸ” No browser on attempt ${attempt} β€” waiting 3s for reconnect...`); + await new Promise(resolve => setTimeout(resolve, 3000)); + browserConnected = await this.pingForBrowser(); + if (browserConnected) break; } - } catch (error) { - // Ping failed or no browser - proceed with launch - console.debug('πŸ” No browser connected - will launch new tab'); } - // Only open browser if not already connected - // Opening localhost:9000 creates a NEW tab, doesn't focus existing - if (!browserConnected) { - console.log('🌐 Opening browser...'); - const browserUrl = options.browserUrl || await this.getDefaultBrowserUrl(); - + // Step 2a: Browser found β€” refresh it + if (browserConnected) { + console.log('πŸ”„ Browser connected β€” refreshing to pick up new code'); try { - spawn('open', [browserUrl], { - detached: true, - stdio: 'ignore' - }).unref(); - console.log(`βœ… Browser launched: ${browserUrl}`); - } catch (error) { - console.warn(`⚠️ Failed to auto-open browser: ${error}`); - console.debug(`πŸ‘‰ Manually open: ${browserUrl}`); + await execAsync('./jtag interface/navigate', { timeout: 5000 }); + console.log('βœ… Browser refreshed'); + } catch { + console.warn('⚠️ interface/navigate failed, trying location.reload()'); + try { + await execAsync('./jtag development/exec --code="location.reload()"', { timeout: 5000 }); + console.log('βœ… Browser reloaded via exec'); + } catch { + console.warn('⚠️ Browser reload also failed'); + } } - } else { - console.log('βœ… Browser already connected - skipped opening new tab'); + console.log('βœ… Browser already connected β€” no new tab needed'); + return; } - await milestoneEmitter.completeMilestone( - SYSTEM_MILESTONES.BROWSER_LAUNCH_INITIATED, - this.currentEntryPoint - ); - return true; + // Step 2b: No browser detected after retries β€” open new tab + console.log('🌐 No browser detected β€” opening new tab'); + const browserUrl = options.browserUrl || await this.getDefaultBrowserUrl(); + + try { + spawn('open', [browserUrl], { + detached: true, + stdio: 'ignore' + }).unref(); + console.log(`βœ… Browser launched: ${browserUrl}`); + } catch (error) { + console.warn(`⚠️ Failed to auto-open browser: ${error}`); + } } private async executeBrowserProcess(): Promise { @@ -838,8 +867,8 @@ export class SystemOrchestrator extends EventEmitter { } /** - * Ensure browser is opened for entry points that require browser interaction - * This is called even when browser milestones are already completed + * Ensure browser is opened for entry points that require browser interaction. + * Delegates to detectAndManageBrowser() β€” single source of truth for browser detection. */ private async ensureBrowserOpened(options: OrchestrationOptions): Promise { if (options.skipBrowser) { @@ -847,32 +876,7 @@ export class SystemOrchestrator extends EventEmitter { return; } - // Check if browser is already connected before opening a new tab - try { - const systemReady = await this.signaler.checkSystemReady(1000); - if (systemReady?.browserReady) { - console.debug('⏭️ Browser already connected - skipping launch'); - return; - } - } catch (error) { - // Signal check failed - proceed with launch - console.debug('πŸ” Could not verify browser status - will launch new tab'); - } - - console.debug('🌐 Ensuring browser is opened...'); - - const browserUrl = options.browserUrl || await this.getDefaultBrowserUrl(); - - try { - spawn('open', [browserUrl], { - detached: true, - stdio: 'ignore' - }).unref(); - console.debug(`βœ… Browser opened: ${browserUrl}`); - } catch (error) { - console.warn(`⚠️ Failed to auto-open browser: ${error}`); - console.debug(`πŸ‘‰ Manually open: ${browserUrl}`); - } + await this.detectAndManageBrowser(options); } /** diff --git a/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts b/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts index e65831bce..19498c02a 100644 --- a/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts +++ b/src/debug/jtag/system/rag/builders/ChatRAGBuilder.ts @@ -21,6 +21,7 @@ import type { PersonaMemory, RecipeStrategy } from '../shared/RAGTypes'; +import type { RecipeToolDeclaration } from '../../recipes/shared/RecipeTypes'; import type { UUID } from '../../core/types/CrossPlatformUUID'; import { DataDaemon } from '../../../daemons/data-daemon/shared/DataDaemon'; import { ChatMessageEntity } from '../../data/entities/ChatMessageEntity'; @@ -43,7 +44,9 @@ import { WidgetContextSource, PersonaIdentitySource, GlobalAwarenessSource, - SocialMediaRAGSource + SocialMediaRAGSource, + CodeToolSource, + ProjectContextSource } from '../sources'; /** @@ -58,6 +61,58 @@ export class ChatRAGBuilder extends RAGBuilder { private composer: RAGComposer | null = null; private useModularSources = true; // Feature flag for gradual migration + // Per-operation timing for legacy phase diagnostics + private _lastArtifactMs?: number; + private _lastRecipeMs?: number; + private _lastLearningMs?: number; + + // ── Static caches ──────────────────────────────────────────────── + // Room entity cache β€” shared across all persona RAG builds. + // Rooms don't change during normal operation. 60s TTL is safety net only. + private static _roomCache: Map = new Map(); + private static readonly ROOM_CACHE_TTL_MS = 60_000; + + // Single-flight coalescing for room reads β€” prevents duplicate DB calls + // when loadRecipeContext + loadLearningConfig hit getCachedRoom simultaneously. + private static _roomInflight: Map> = new Map(); + + // User display name cache β€” persona identities are stable within a session. + private static _userNameCache: Map = new Map(); + + // Message cache for artifact extraction β€” avoids re-querying what ConversationHistorySource loaded. + private static _artifactMessageCache: Map = new Map(); + private static readonly ARTIFACT_CACHE_TTL_MS = 3000; + + /** + * Get a room entity from cache or DB with single-flight coalescing. + * Multiple concurrent callers for the same roomId share one DB read. + */ + private static async getCachedRoom(roomId: UUID): Promise { + const cached = ChatRAGBuilder._roomCache.get(roomId); + if (cached && Date.now() - cached.cachedAt < ChatRAGBuilder.ROOM_CACHE_TTL_MS) { + return cached.entity; + } + + // Single-flight: if another call is already reading this room, piggyback on it + const inflight = ChatRAGBuilder._roomInflight.get(roomId); + if (inflight) return inflight; + + const promise = (async () => { + const room = await DataDaemon.read(RoomEntity.collection, roomId); + if (room) { + ChatRAGBuilder._roomCache.set(roomId, { entity: room, cachedAt: Date.now() }); + } + return room; + })(); + + ChatRAGBuilder._roomInflight.set(roomId, promise); + try { + return await promise; + } finally { + ChatRAGBuilder._roomInflight.delete(roomId); + } + } + constructor(logger?: (message: string, ...args: any[]) => void) { super(); // Default to console.log if no logger provided (for tests) @@ -77,9 +132,11 @@ export class ChatRAGBuilder extends RAGBuilder { new ConversationHistorySource(), // Priority 80: Chat messages (uses queryWithJoin!) new WidgetContextSource(), // Priority 75: UI state from Positron new SemanticMemorySource(), // Priority 60: Long-term memories - new SocialMediaRAGSource() // Priority 55: Social media HUD (engagement duty) + new ProjectContextSource(), // Priority 70: Project workspace context (git, team, build) + new SocialMediaRAGSource(), // Priority 55: Social media HUD (engagement duty) + new CodeToolSource() // Priority 50: Coding workflow guidance ]); - this.log('πŸ”§ ChatRAGBuilder: Initialized RAGComposer with 6 sources'); + this.log('πŸ”§ ChatRAGBuilder: Initialized RAGComposer with 8 sources'); } return this.composer; } @@ -95,6 +152,8 @@ export class ChatRAGBuilder extends RAGBuilder { widgetContext: string | null; globalAwareness: string | null; socialAwareness: string | null; + codeToolGuidance: string | null; + projectContext: string | null; } { let identity: PersonaIdentity | null = null; let conversationHistory: LLMMessage[] = []; @@ -102,6 +161,8 @@ export class ChatRAGBuilder extends RAGBuilder { let widgetContext: string | null = null; let globalAwareness: string | null = null; let socialAwareness: string | null = null; + let codeToolGuidance: string | null = null; + let projectContext: string | null = null; for (const section of result.sections) { if (section.identity) { @@ -125,9 +186,17 @@ export class ChatRAGBuilder extends RAGBuilder { // Social media HUD β€” engagement awareness and duty socialAwareness = section.systemPromptSection; } + if (section.systemPromptSection && section.sourceName === 'code-tools') { + // Coding workflow guidance β€” code/* tool awareness + codeToolGuidance = section.systemPromptSection; + } + if (section.systemPromptSection && section.sourceName === 'project-context') { + // Project workspace context β€” git status, team activity, build status + projectContext = section.systemPromptSection; + } } - return { identity, conversationHistory, memories, widgetContext, globalAwareness, socialAwareness }; + return { identity, conversationHistory, memories, widgetContext, globalAwareness, socialAwareness, codeToolGuidance, projectContext }; } /** @@ -155,10 +224,15 @@ export class ChatRAGBuilder extends RAGBuilder { let artifacts: RAGArtifact[]; let privateMemories: PersonaMemory[]; let recipeStrategy: RecipeStrategy | undefined; + let recipeTools: RecipeToolDeclaration[] | undefined; let learningConfig: { learningMode?: 'fine-tuning' | 'inference-only'; genomeId?: UUID; participantRole?: string } | undefined; let widgetContext: string | null; let globalAwareness: string | null; let socialAwareness: string | null; + let codeToolGuidance: string | null; + let projectContext: string | null; + let composeMs: number | undefined; + let legacyMs: number | undefined; if (this.useModularSources) { // NEW PATH: Use RAGComposer for modular, parallelized source loading @@ -190,7 +264,9 @@ export class ChatRAGBuilder extends RAGBuilder { }; // Load core sources via composer (parallel) + const composeStart = performance.now(); const composition = await composer.compose(sourceContext); + composeMs = performance.now() - composeStart; const extracted = this.extractFromComposition(composition); // Use composed data, with fallbacks for missing pieces @@ -203,18 +279,28 @@ export class ChatRAGBuilder extends RAGBuilder { widgetContext = extracted.widgetContext; globalAwareness = extracted.globalAwareness; socialAwareness = extracted.socialAwareness; + codeToolGuidance = extracted.codeToolGuidance; + projectContext = extracted.projectContext; // Still load these via legacy methods (not yet extracted to sources) - const [extractedArtifacts, extractedRecipeStrategy, extractedLearningConfig] = await Promise.all([ - includeArtifacts ? this.extractArtifacts(contextId, maxMessages) : Promise.resolve([]), - this.loadRecipeStrategy(contextId), - this.loadLearningConfig(contextId, personaId) + const legacyStart = performance.now(); + const artifactStart = performance.now(); + const extractedArtifactsPromise = includeArtifacts ? this.extractArtifacts(contextId, maxMessages) : Promise.resolve([]); + const recipePromise = this.loadRecipeContext(contextId); + const learningPromise = this.loadLearningConfig(contextId, personaId); + + const [extractedArtifacts, extractedRecipeContext, extractedLearningConfig] = await Promise.all([ + extractedArtifactsPromise.then(r => { this._lastArtifactMs = performance.now() - artifactStart; return r; }), + recipePromise.then(r => { this._lastRecipeMs = performance.now() - artifactStart; return r; }), + learningPromise.then(r => { this._lastLearningMs = performance.now() - artifactStart; return r; }) ]); + legacyMs = performance.now() - legacyStart; artifacts = extractedArtifacts; - recipeStrategy = extractedRecipeStrategy; + recipeStrategy = extractedRecipeContext?.strategy; + recipeTools = extractedRecipeContext?.tools; learningConfig = extractedLearningConfig; - this.log(`πŸ”§ ChatRAGBuilder: Composed from ${composition.sections.length} sources in ${composition.totalLoadTimeMs.toFixed(1)}ms`); + this.log(`πŸ”§ ChatRAGBuilder: Composed from ${composition.sections.length} sources in ${composition.totalLoadTimeMs.toFixed(1)}ms (compose=${composeMs.toFixed(1)}ms, legacy=${legacyMs.toFixed(1)}ms [artifacts=${this._lastArtifactMs?.toFixed(1)}ms, recipe=${this._lastRecipeMs?.toFixed(1)}ms, learning=${this._lastLearningMs?.toFixed(1)}ms])`); } else { // LEGACY PATH: Direct parallel loading (fallback) @@ -225,7 +311,7 @@ export class ChatRAGBuilder extends RAGBuilder { loadedConversationHistory, loadedArtifacts, loadedPrivateMemories, - loadedRecipeStrategy, + loadedRecipeContext, loadedLearningConfig, loadedWidgetContext ] = await Promise.all([ @@ -248,8 +334,8 @@ export class ChatRAGBuilder extends RAGBuilder { options?.currentMessage?.content // ← Semantic query: use current message for relevant memory recall ) : Promise.resolve([]), - // 5. Load room's recipe strategy (conversation governance rules) - this.loadRecipeStrategy(contextId), + // 5. Load room's recipe context (strategy + tool highlights) + this.loadRecipeContext(contextId), // 6. Load learning configuration (Phase 2: Per-participant learning mode) this.loadLearningConfig(contextId, personaId), @@ -262,16 +348,21 @@ export class ChatRAGBuilder extends RAGBuilder { conversationHistory = loadedConversationHistory; artifacts = loadedArtifacts; privateMemories = loadedPrivateMemories; - recipeStrategy = loadedRecipeStrategy; + recipeStrategy = loadedRecipeContext?.strategy; + recipeTools = loadedRecipeContext?.tools; learningConfig = loadedLearningConfig; widgetContext = loadedWidgetContext; globalAwareness = null; // Legacy path doesn't use GlobalAwarenessSource socialAwareness = null; // Legacy path doesn't use SocialMediaRAGSource + codeToolGuidance = null; // Legacy path doesn't use CodeToolSource + projectContext = null; // Legacy path doesn't use ProjectContextSource } // 2.3.5 Preprocess artifacts for non-vision models ("So the blind can see") // If target model can't see images, generate text descriptions + const preprocessStart = performance.now(); const processedArtifacts = await this.preprocessArtifactsForModel(artifacts, options); + const preprocessMs = performance.now() - preprocessStart; // 2.4. Inject widget context into system prompt if available // This enables AI to be aware of what the user is currently viewing @@ -298,6 +389,20 @@ export class ChatRAGBuilder extends RAGBuilder { this.log('πŸ“± ChatRAGBuilder: Injected social media HUD into system prompt'); } + // 2.4.7. Inject code tool workflow guidance (coding capabilities) + if (codeToolGuidance) { + finalIdentity.systemPrompt = finalIdentity.systemPrompt + + `\n\n${codeToolGuidance}`; + this.log('πŸ’» ChatRAGBuilder: Injected code tool guidance into system prompt'); + } + + // 2.4.8. Inject project workspace context (git status, team activity, build info) + if (projectContext) { + finalIdentity.systemPrompt = finalIdentity.systemPrompt + + `\n\n${projectContext}`; + this.log('πŸ“¦ ChatRAGBuilder: Injected project workspace context into system prompt'); + } + // NOTE: Canvas context is now handled via the "inbox content" pattern // When strokes are added, they emit system messages to the canvas room // AIs see these in their conversation history naturally, no system prompt injection needed @@ -335,6 +440,7 @@ export class ChatRAGBuilder extends RAGBuilder { personaId, identity: finalIdentity, recipeStrategy, + recipeTools, conversationHistory: finalConversationHistory, artifacts: processedArtifacts, privateMemories, @@ -360,12 +466,22 @@ export class ChatRAGBuilder extends RAGBuilder { hasGlobalAwareness: !!globalAwareness, // Social media HUD (engagement awareness) - hasSocialAwareness: !!socialAwareness + hasSocialAwareness: !!socialAwareness, + + // Project workspace context (git, team, build) + hasProjectContext: !!projectContext } }; - // Emit cognition event for rag-build stage (FIRE-AND-FORGET: don't block on event emission) + // Log per-phase timing breakdown for performance analysis const durationMs = Date.now() - startTime; + if (this.useModularSources) { + this.log(`[TIMING] ChatRAGBuilder.buildContext: total=${durationMs}ms (compose=${composeMs!.toFixed(1)}ms, legacy=${legacyMs!.toFixed(1)}ms, preprocess=${preprocessMs.toFixed(1)}ms, msgs=${conversationHistory.length}, mems=${privateMemories.length}, arts=${processedArtifacts.length})`); + } else { + this.log(`[TIMING] ChatRAGBuilder.buildContext: total=${durationMs}ms (legacy path, preprocess=${preprocessMs.toFixed(1)}ms)`); + } + + // Emit cognition event for rag-build stage (FIRE-AND-FORGET: don't block on event emission) const totalTokens = finalConversationHistory.reduce((sum, msg) => sum + (msg.content?.length ?? 0), 0); const maxTokens = 128000; // Typical context window @@ -442,9 +558,9 @@ export class ChatRAGBuilder extends RAGBuilder { */ private async loadPersonaIdentity(personaId: UUID, roomId: UUID, options?: RAGBuildOptions): Promise { try { - const result = await DataDaemon.read(UserEntity.collection, personaId); + const user = await DataDaemon.read(UserEntity.collection, personaId); - if (!result.success || !result.data) { + if (!user) { this.log(`⚠️ ChatRAGBuilder: Could not load persona ${personaId}, using defaults`); return { name: 'AI Assistant', @@ -452,10 +568,6 @@ export class ChatRAGBuilder extends RAGBuilder { }; } - // DataDaemon.read returns DataRecord, access .data for entity - const userRecord = result.data; - const user = userRecord.data; - return { name: user.displayName, bio: user.profile?.bio, @@ -487,9 +599,11 @@ export class ChatRAGBuilder extends RAGBuilder { ? 'You respond naturally to conversations.' : 'You participate when mentioned or when the conversation is relevant.'; - // Load room name and members to provide context - const roomName = await this.loadRoomName(roomId); - const membersList = await this.loadRoomMembers(roomId); + // Load room name and members in parallel (both use getCachedRoom β€” 1 DB read max) + const [roomName, membersList] = await Promise.all([ + this.loadRoomName(roomId), + this.loadRoomMembers(roomId) + ]); // Separate self from others for clarity const otherMembers = membersList.filter(m => m !== name); @@ -656,21 +770,33 @@ LIMITS: */ private async extractArtifacts(roomId: UUID, maxMessages: number): Promise { try { - // Load messages with attachments - const result = await DataDaemon.query({ - collection: ChatMessageEntity.collection, - filter: { roomId }, - sort: [{ field: 'timestamp', direction: 'desc' }], - limit: maxMessages - }); - - if (!result.success || !result.data) { - return []; + // Priority 1: ConversationHistorySource cache β€” already loaded during compose phase + let messages: ChatMessageEntity[] | null = ConversationHistorySource.getCachedRawMessages(roomId) as ChatMessageEntity[] | null; + + // Priority 2: ChatRAGBuilder's own message cache (populated by previous calls) + if (!messages) { + const cached = ChatRAGBuilder._artifactMessageCache.get(roomId); + if (cached && Date.now() - cached.cachedAt < ChatRAGBuilder.ARTIFACT_CACHE_TTL_MS) { + messages = cached.messages; + } } - // DataDaemon.query returns DataRecord[], access .data for entities - const messageRecords = result.data; - const messages = messageRecords.map(record => record.data); + // Priority 3: DB query (cold start only β€” should be rare after caches warm) + if (!messages) { + const result = await DataDaemon.query({ + collection: ChatMessageEntity.collection, + filter: { roomId }, + sort: [{ field: 'timestamp', direction: 'desc' }], + limit: maxMessages + }); + + if (!result.success || !result.data) { + return []; + } + + messages = result.data.map(record => record.data); + ChatRAGBuilder._artifactMessageCache.set(roomId, { messages, cachedAt: Date.now() }); + } const artifacts: RAGArtifact[] = []; @@ -974,13 +1100,13 @@ LIMITS: */ private async loadRoomName(roomId: UUID): Promise { try { - const roomResult = await DataDaemon.read(RoomEntity.collection, roomId); - if (!roomResult.success || !roomResult.data) { + const room = await ChatRAGBuilder.getCachedRoom(roomId); + if (!room) { this.log(`⚠️ ChatRAGBuilder: Could not load room ${roomId} for name lookup`); return null; } - return roomResult.data.data.name; + return room.name; } catch (error) { this.log(`❌ ChatRAGBuilder: Error loading room name:`, error); return null; @@ -992,30 +1118,28 @@ LIMITS: */ private async loadRoomMembers(roomId: UUID): Promise { try { - // 1. Load room entity - const roomResult = await DataDaemon.read(RoomEntity.collection, roomId); - if (!roomResult.success || !roomResult.data) { - this.log(`⚠️ ChatRAGBuilder: Could not load room ${roomId}`); + // 1. Load room entity (from cache β€” shared with loadRoomName, loadRecipeContext, etc.) + const room = await ChatRAGBuilder.getCachedRoom(roomId); + if (!room || !room.members || room.members.length === 0) { return []; } - const room = roomResult.data.data; - if (!room.members || room.members.length === 0) { - return []; - } + // 2. Load user display names with per-user cache (users don't change at runtime) + const memberNames = await Promise.all( + room.members.map(async (member): Promise => { + const cached = ChatRAGBuilder._userNameCache.get(member.userId); + if (cached) return cached; - // 2. Load user entities for each member to get display names (PARALLELIZED) - const memberResults = await Promise.all( - room.members.map(member => - DataDaemon.read(UserEntity.collection, member.userId) - ) + const user = await DataDaemon.read(UserEntity.collection, member.userId); + if (user) { + ChatRAGBuilder._userNameCache.set(member.userId, user.displayName); + return user.displayName; + } + return null; + }) ); - const memberNames = memberResults - .filter(result => result.success && result.data) - .map(result => result.data!.data.displayName); - - return memberNames; + return memberNames.filter((name): name is string => name !== null); } catch (error) { this.log(`❌ ChatRAGBuilder: Error loading room members:`, error); return []; @@ -1023,19 +1147,18 @@ LIMITS: } /** - * Load recipe strategy from room's recipeId + * Load recipe context (strategy + tools) from room's recipeId */ - private async loadRecipeStrategy(roomId: UUID): Promise { + private async loadRecipeContext(roomId: UUID): Promise<{ strategy?: RecipeStrategy; tools?: RecipeToolDeclaration[] } | undefined> { try { - // 1. Load room to get recipeId - const roomResult = await DataDaemon.read(RoomEntity.collection, roomId); + // 1. Load room to get recipeId (from cache β€” shared with loadRoomName, loadRoomMembers, etc.) + const room = await ChatRAGBuilder.getCachedRoom(roomId); - if (!roomResult.success || !roomResult.data) { - this.log(`⚠️ ChatRAGBuilder: Could not load room ${roomId}, no recipe strategy`); + if (!room) { + this.log(`⚠️ ChatRAGBuilder: Could not load room ${roomId}, no recipe context`); return undefined; } - const room = roomResult.data.data; const recipeId = room.recipeId; if (!recipeId) { @@ -1047,15 +1170,18 @@ LIMITS: const recipeLoader = RecipeLoader.getInstance(); const recipe = await recipeLoader.loadRecipe(recipeId); - if (!recipe || !recipe.strategy) { - this.log(`⚠️ ChatRAGBuilder: Could not load recipe ${recipeId}, no strategy`); + if (!recipe) { + this.log(`⚠️ ChatRAGBuilder: Could not load recipe ${recipeId}`); return undefined; } - this.log(`βœ… ChatRAGBuilder: Loaded recipe strategy "${recipe.displayName}" (${recipeId})`); - return recipe.strategy; + this.log(`βœ… ChatRAGBuilder: Loaded recipe context "${recipe.displayName}" (${recipeId}) β€” strategy=${!!recipe.strategy}, tools=${recipe.tools?.length ?? 0}`); + return { + strategy: recipe.strategy, + tools: recipe.tools, + }; } catch (error) { - this.log(`❌ ChatRAGBuilder: Error loading recipe strategy:`, error); + this.log(`❌ ChatRAGBuilder: Error loading recipe context:`, error); return undefined; } } @@ -1069,15 +1195,13 @@ LIMITS: personaId: UUID ): Promise<{ learningMode?: 'fine-tuning' | 'inference-only'; genomeId?: UUID; participantRole?: string } | undefined> { try { - // 1. Load room entity - const roomResult = await DataDaemon.read(RoomEntity.collection, roomId); - if (!roomResult.success || !roomResult.data) { + // 1. Load room entity (from cache β€” shared with loadRoomName, loadRoomMembers, etc.) + const room = await ChatRAGBuilder.getCachedRoom(roomId); + if (!room) { this.log(`⚠️ ChatRAGBuilder: Could not load room ${roomId} for learning config`); return undefined; } - const room = roomResult.data.data; - // 2. Find this persona's membership const member = room.members.find(m => m.userId === personaId); if (!member) { diff --git a/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts b/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts index 0fa395c65..fac7c1aeb 100644 --- a/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts +++ b/src/debug/jtag/system/rag/builders/CodebaseRAGBuilder.ts @@ -101,9 +101,9 @@ export class CodebaseRAGBuilder extends RAGBuilder { */ private async loadPersonaIdentity(personaId: UUID): Promise { try { - const result = await DataDaemon.read(UserEntity.collection, personaId); + const user = await DataDaemon.read(UserEntity.collection, personaId); - if (!result.success || !result.data) { + if (!user) { console.warn(`⚠️ CodebaseRAGBuilder: Could not load persona ${personaId}, using defaults`); return { name: 'Code Expert', @@ -111,8 +111,6 @@ export class CodebaseRAGBuilder extends RAGBuilder { }; } - const user = result.data.data; - return { name: user.displayName, bio: user.profile?.bio, diff --git a/src/debug/jtag/system/rag/shared/RAGTypes.ts b/src/debug/jtag/system/rag/shared/RAGTypes.ts index 05db90da8..bc2b3cffd 100644 --- a/src/debug/jtag/system/rag/shared/RAGTypes.ts +++ b/src/debug/jtag/system/rag/shared/RAGTypes.ts @@ -11,6 +11,7 @@ */ import type { UUID } from '../../core/types/CrossPlatformUUID'; +import type { RecipeToolDeclaration } from '../../recipes/shared/RecipeTypes'; /** * Domain types that can provide RAG context @@ -123,6 +124,9 @@ export interface RAGContext { // Conversation governance rules (from recipe) recipeStrategy?: RecipeStrategy; + // Recipe-highlighted tools (context for LLM, NOT a filter) + recipeTools?: RecipeToolDeclaration[]; + // Conversation history (public context) conversationHistory: LLMMessage[]; @@ -158,6 +162,9 @@ export interface RAGContext { // Social media engagement awareness hasSocialAwareness?: boolean; // Whether social media HUD was included in system prompt + + // Project workspace context (git, team, build) + hasProjectContext?: boolean; // Whether project workspace context was included in system prompt }; } diff --git a/src/debug/jtag/system/rag/sources/CodeToolSource.ts b/src/debug/jtag/system/rag/sources/CodeToolSource.ts new file mode 100644 index 000000000..d0324a1e8 --- /dev/null +++ b/src/debug/jtag/system/rag/sources/CodeToolSource.ts @@ -0,0 +1,224 @@ +/** + * CodeToolSource - Injects coding workflow awareness into persona RAG context + * + * Gives personas strategic awareness of the code/* command suite: + * - When and how to use code tools (workflow patterns) + * - Best practices (read before edit, preview with diff, undo on failure) + * - Available code/* commands grouped by purpose + * + * Does NOT duplicate tool listings β€” ToolRegistry already provides a compact + * list of all tools. This source provides the "how to code effectively" layer. + * + * Priority 50 - Medium. Valuable context for coding tasks, but not critical + * for conversational interactions. Token cost is low (~200 tokens). + */ + +import type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; +import { PersonaToolRegistry } from '../../user/server/modules/PersonaToolRegistry'; +import { Logger } from '../../core/logging/Logger'; + +const log = Logger.create('CodeToolSource', 'rag'); + +/** + * Code tool categories for workflow documentation. + * Each group maps to a workflow step that only appears if the persona has + * at least one of the group's commands. + */ +interface CodeToolGroup { + readonly label: string; + readonly commands: string[]; + readonly hint: string; + readonly workflowStep: string; +} + +/** + * Static code tool groups β€” the workflow map for personas. + * workflowStep is the numbered instruction shown in the workflow. + */ +const CODE_TOOL_GROUPS: readonly CodeToolGroup[] = [ + { + label: 'Discovery', + commands: ['code/tree', 'code/search'], + hint: 'Understand the codebase structure before making changes.', + workflowStep: '**Discover** β€” Use code/tree and code/search to understand structure', + }, + { + label: 'Reading', + commands: ['code/read'], + hint: 'Read file contents and line ranges. Always read before editing.', + workflowStep: '**Read** β€” Always read files before editing (code/read)', + }, + { + label: 'Writing', + commands: ['code/write', 'code/edit'], + hint: 'Create files or edit with search-replace, line-range, insert, or append.', + workflowStep: '**Edit** β€” Apply changes with code/write or code/edit', + }, + { + label: 'Review', + commands: ['code/diff'], + hint: 'Preview edits as unified diff before applying. Use this to verify correctness.', + workflowStep: '**Preview** β€” Use code/diff to see your changes before applying', + }, + { + label: 'History', + commands: ['code/undo', 'code/history'], + hint: 'Undo changes or view the change graph. Every edit is tracked.', + workflowStep: '**Undo** β€” If something breaks, code/undo reverts any change', + }, +] as const; + +export class CodeToolSource implements RAGSource { + readonly name = 'code-tools'; + readonly priority = 50; // Medium β€” below conversation/widget, above learning config + readonly defaultBudgetPercent = 8; + + private static _cachedPrompt: string | null = null; + private static _cacheGeneratedAt = 0; + private static readonly CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + + isApplicable(context: RAGSourceContext): boolean { + // Only include if persona has at least one code/* permission + const registry = PersonaToolRegistry.sharedInstance(); + const tools = registry.listToolsForPersona(context.personaId); + return tools.some(t => t.name.startsWith('code/')); + } + + async load(context: RAGSourceContext, allocatedBudget: number): Promise { + const startTime = performance.now(); + + try { + const prompt = this.getOrBuildPrompt(context); + + // Respect budget β€” if prompt exceeds allocation, return a minimal version + const tokenCount = this.estimateTokens(prompt); + const budgetTokens = Math.floor(allocatedBudget); + + const finalPrompt = tokenCount > budgetTokens + ? this.buildMinimalPrompt() + : prompt; + + const finalTokens = this.estimateTokens(finalPrompt); + + log.debug(`Loaded code tool guidance (${finalTokens} tokens) for persona ${context.personaId.slice(0, 8)}`); + + return { + sourceName: this.name, + tokenCount: finalTokens, + loadTimeMs: performance.now() - startTime, + systemPromptSection: finalPrompt, + metadata: { + codeToolCount: this.countCodeTools(context), + budgetRespected: finalTokens <= budgetTokens, + }, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log.error(`Failed to load code tool context: ${message}`); + return this.emptySection(startTime, message); + } + } + + /** + * Build or retrieve cached prompt + */ + private getOrBuildPrompt(context: RAGSourceContext): string { + const now = Date.now(); + if ( + CodeToolSource._cachedPrompt && + (now - CodeToolSource._cacheGeneratedAt) < CodeToolSource.CACHE_TTL_MS + ) { + return CodeToolSource._cachedPrompt; + } + + const prompt = this.buildFullPrompt(context); + CodeToolSource._cachedPrompt = prompt; + CodeToolSource._cacheGeneratedAt = now; + return prompt; + } + + /** + * Full coding methodology prompt β€” injected into system prompt. + * Only includes workflow steps for tool groups the persona has access to. + */ + private buildFullPrompt(context: RAGSourceContext): string { + const registry = PersonaToolRegistry.sharedInstance(); + const tools = registry.listToolsForPersona(context.personaId); + const codeTools = tools.filter(t => t.name.startsWith('code/')); + + // Determine which capabilities are available + const hasDiscovery = codeTools.some(t => t.name === 'code/tree' || t.name === 'code/search'); + const hasRead = codeTools.some(t => t.name === 'code/read'); + const hasWrite = codeTools.some(t => t.name === 'code/write' || t.name === 'code/edit'); + const hasVerify = codeTools.some(t => t.name === 'code/verify'); + const hasDiff = codeTools.some(t => t.name === 'code/diff'); + const hasUndo = codeTools.some(t => t.name === 'code/undo'); + const hasGit = codeTools.some(t => t.name === 'code/git'); + + // Build available tool listing + const toolNames = codeTools.map(t => t.name).join(', '); + + // Build workflow steps based on available tools + const steps: string[] = []; + if (hasDiscovery) steps.push('1. **Understand first**: code/tree to see structure, code/search for patterns across files'); + if (hasRead) steps.push(`${steps.length + 1}. **Read before editing**: ALWAYS code/read a file before modifying it`); + if (hasWrite) steps.push(`${steps.length + 1}. **Make targeted changes**: code/edit for surgical modifications, code/write for new files`); + if (hasVerify) steps.push(`${steps.length + 1}. **Verify every change**: code/verify after EVERY edit β€” if it fails, read errors, fix, verify again`); + if (hasDiff || hasGit) steps.push(`${steps.length + 1}. **Review**: ${hasDiff ? 'code/diff to see changes' : ''}${hasDiff && hasGit ? ', ' : ''}${hasGit ? 'code/git status before committing' : ''}`); + + const workflowSteps = steps.join('\n'); + + // Build rules section + const rules: string[] = []; + if (hasRead && hasWrite) rules.push('- NEVER edit a file you haven\'t read β€” always code/read first'); + if (hasWrite && hasVerify) rules.push('- After code/write or code/edit, ALWAYS run code/verify'); + if (hasVerify) rules.push('- When verify fails: read the error output, code/read the failing file, fix it, verify again'); + if (hasDiscovery) rules.push('- Use code/search to find all references before renaming or refactoring'); + if (hasUndo) rules.push('- code/undo if something goes wrong β€” every change is tracked'); + + const rulesSection = rules.length > 0 ? `\n### Rules\n${rules.join('\n')}` : ''; + + // Anti-patterns section (only if they have write tools) + const antiPatterns = hasWrite ? `\n### Anti-Patterns +- Writing a file without reading the existing content first +- Skipping verification after changes +- Making multiple edits before verifying any of them +- Guessing at file paths β€” use code/tree and code/search` : ''; + + return `## Coding Methodology + +Tools: ${toolNames} + +### Workflow: Read β†’ Edit β†’ Verify β†’ Iterate +${workflowSteps} +${rulesSection}${antiPatterns}`.trim(); + } + + /** + * Minimal prompt when budget is tight β€” just list available tool names + */ + private buildMinimalPrompt(): string { + // List all known code commands from the groups (static β€” no registry call needed) + const allCommands = CODE_TOOL_GROUPS.flatMap(g => g.commands); + return `Code tools available: ${allCommands.join(', ')}. Read before editing. Use code/diff to preview.`; + } + + private countCodeTools(context: RAGSourceContext): number { + const registry = PersonaToolRegistry.sharedInstance(); + const tools = registry.listToolsForPersona(context.personaId); + return tools.filter(t => t.name.startsWith('code/')).length; + } + + private emptySection(startTime: number, error?: string): RAGSection { + return { + sourceName: this.name, + tokenCount: 0, + loadTimeMs: performance.now() - startTime, + metadata: error ? { error } : { hasCodeTools: false }, + }; + } + + private estimateTokens(text: string): number { + return Math.ceil(text.length / 4); + } +} diff --git a/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts b/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts index 4a22d6a52..300d71d44 100644 --- a/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts +++ b/src/debug/jtag/system/rag/sources/ConversationHistorySource.ts @@ -13,6 +13,7 @@ import type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSourc import type { LLMMessage } from '../shared/RAGTypes'; import { DataDaemon } from '../../../daemons/data-daemon/shared/DataDaemon'; import { ChatMessageEntity } from '../../data/entities/ChatMessageEntity'; +import { Events } from '../../core/shared/Events'; import { Logger } from '../../core/logging/Logger'; const log = Logger.create('ConversationHistorySource', 'rag'); @@ -20,11 +21,71 @@ const log = Logger.create('ConversationHistorySource', 'rag'); // Estimate ~4 tokens per word, ~5 words per line average const TOKENS_PER_MESSAGE_ESTIMATE = 50; +type MessageWithSender = ChatMessageEntity & { sender?: { displayName: string; userType: string } }; + +/** Cache entry for room messages β€” maintained by event subscription */ +interface MessageCacheEntry { + messages: MessageWithSender[]; + fetchedAt: number; + limit: number; +} + +/** In-flight request entry for single-flight coalescing */ +interface InflightEntry { + promise: Promise; + limit: number; +} + export class ConversationHistorySource implements RAGSource { readonly name = 'conversation-history'; readonly priority = 80; // High - conversation is core context readonly defaultBudgetPercent = 40; // Gets largest share of budget + // Room message cache: event-driven freshness. 30s TTL is a safety net only. + // Primary freshness comes from event subscription updating cache entries. + private static _roomCache: Map = new Map(); + private static readonly CACHE_TTL_MS = 30_000; + + // Single-flight coalescing: when multiple personas query the same room + // simultaneously, only ONE DB query fires. Others await the same promise. + private static _inflight: Map = new Map(); + + // Event subscription for real-time cache maintenance. + // New messages update the cache immediately β€” no staleness, no DB re-query. + private static _eventSubscribed = false; + + private static initEventSubscription(): void { + if (ConversationHistorySource._eventSubscribed) return; + ConversationHistorySource._eventSubscribed = true; + + Events.subscribe(`data:${ChatMessageEntity.collection}:created`, (entity: any) => { + const msg = entity as ChatMessageEntity; + if (!msg.roomId) return; + + const cached = ConversationHistorySource._roomCache.get(msg.roomId); + if (cached) { + // Prepend new message (cache is newest-first order, reversed later for LLM) + cached.messages.unshift(msg as MessageWithSender); + if (cached.messages.length > cached.limit + 10) { + cached.messages.length = cached.limit; // Trim excess + } + cached.fetchedAt = Date.now(); // Reset TTL β€” cache is now fresh + } + }); + } + + /** + * Access cached raw messages for a room (used by extractArtifacts to avoid duplicate DB query). + * Returns null if cache is expired or empty β€” caller should fall back to DB. + */ + static getCachedRawMessages(roomId: string): MessageWithSender[] | null { + const cached = ConversationHistorySource._roomCache.get(roomId); + if (cached && (Date.now() - cached.fetchedAt) < ConversationHistorySource.CACHE_TTL_MS) { + return cached.messages; + } + return null; + } + isApplicable(_context: RAGSourceContext): boolean { // Always applicable - every RAG build needs conversation context return true; @@ -32,6 +93,7 @@ export class ConversationHistorySource implements RAGSource { async load(context: RAGSourceContext, allocatedBudget: number): Promise { const startTime = performance.now(); + ConversationHistorySource.initEventSubscription(); // Calculate max messages based on budget const budgetBasedLimit = Math.max(5, Math.floor(allocatedBudget / TOKENS_PER_MESSAGE_ESTIMATE)); @@ -44,42 +106,43 @@ export class ConversationHistorySource implements RAGSource { log.debug(`Message limit: ${maxMessages} (budget=${budgetBasedLimit}, latencyLimit=${optionsLimit ?? 'none'})`); try { - type MessageWithSender = ChatMessageEntity & { sender?: { displayName: string; userType: string } }; let messages: MessageWithSender[] = []; - // Try queryWithJoin first (4.5x faster), fall back to regular query - try { - const result = await DataDaemon.queryWithJoin({ - collection: ChatMessageEntity.collection, - filter: { roomId: context.roomId }, - joins: [{ - collection: 'users', - alias: 'sender', - localField: 'senderId', - foreignField: 'id', - type: 'left', - select: ['displayName', 'userType'] - }], - sort: [{ field: 'timestamp', direction: 'desc' }], - limit: maxMessages - }); - - if (result.success && result.data && result.data.length > 0) { - messages = result.data.map((record: { data: MessageWithSender }) => record.data); - } - } catch (joinError: any) { - // queryWithJoin not supported - fall back to regular query - log.debug(`queryWithJoin not available (${joinError.message}), using regular query`); - - const result = await DataDaemon.query({ - collection: ChatMessageEntity.collection, - filter: { roomId: context.roomId }, - sort: [{ field: 'timestamp', direction: 'desc' }], - limit: maxMessages - }); - - if (result.success && result.data && result.data.length > 0) { - messages = result.data.map((record: { data: ChatMessageEntity }) => record.data as MessageWithSender); + // Check completed cache first (2s TTL) + const cacheKey = context.roomId; + const cached = ConversationHistorySource._roomCache.get(cacheKey); + const now = Date.now(); + + if (cached && (now - cached.fetchedAt) < ConversationHistorySource.CACHE_TTL_MS && cached.limit >= maxMessages) { + messages = cached.messages.slice(0, maxMessages); + log.debug(`Cache hit for room ${context.roomId?.slice(0, 8)} (${messages.length} messages)`); + } else { + // Cache miss β€” use single-flight coalescing to prevent thundering herd. + // When 16 personas query the same room simultaneously, only the first + // triggers a DB query. The other 15 await the same promise. + const inflight = ConversationHistorySource._inflight.get(cacheKey); + if (inflight && inflight.limit >= maxMessages) { + // Another request is already in-flight for this room β€” piggyback + log.debug(`Coalescing request for room ${context.roomId?.slice(0, 8)}`); + messages = (await inflight.promise).slice(0, maxMessages); + } else { + // First request for this room β€” start DB query and register as in-flight + const fetchPromise = this.fetchMessages(context.roomId, maxMessages); + ConversationHistorySource._inflight.set(cacheKey, { + promise: fetchPromise, + limit: maxMessages + }); + try { + messages = await fetchPromise; + // Populate TTL cache for subsequent requests + ConversationHistorySource._roomCache.set(cacheKey, { + messages, + fetchedAt: Date.now(), + limit: maxMessages + }); + } finally { + ConversationHistorySource._inflight.delete(cacheKey); + } } } @@ -158,6 +221,46 @@ export class ConversationHistorySource implements RAGSource { } } + /** Fetch messages from DB (extracted for caching) */ + private async fetchMessages(roomId: string, maxMessages: number): Promise { + // Try queryWithJoin first (4.5x faster), fall back to regular query + try { + const result = await DataDaemon.queryWithJoin({ + collection: ChatMessageEntity.collection, + filter: { roomId }, + joins: [{ + collection: 'users', + alias: 'sender', + localField: 'senderId', + foreignField: 'id', + type: 'left', + select: ['displayName', 'userType'] + }], + sort: [{ field: 'timestamp', direction: 'desc' }], + limit: maxMessages + }); + + if (result.success && result.data && result.data.length > 0) { + return result.data.map((record: { data: MessageWithSender }) => record.data); + } + } catch (joinError: any) { + // queryWithJoin not supported - fall back to regular query + log.debug(`queryWithJoin not available (${joinError.message}), using regular query`); + + const result = await DataDaemon.query({ + collection: ChatMessageEntity.collection, + filter: { roomId }, + sort: [{ field: 'timestamp', direction: 'desc' }], + limit: maxMessages + }); + + if (result.success && result.data && result.data.length > 0) { + return result.data.map((record: { data: ChatMessageEntity }) => record.data as MessageWithSender); + } + } + return []; + } + private emptySection(startTime: number, error?: string): RAGSection { return { sourceName: this.name, diff --git a/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts b/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts index ed1d99bcd..15a85345a 100644 --- a/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts +++ b/src/debug/jtag/system/rag/sources/GlobalAwarenessSource.ts @@ -57,8 +57,21 @@ export class GlobalAwarenessSource implements RAGSource { readonly priority = 85; // After identity (95), before conversation (80) readonly defaultBudgetPercent = 10; + // Negative cache: when Rust returns "No memory corpus", skip IPC for 60s. + // Without this, each failing persona makes a 1-3s IPC call every RAG build + // that returns nothing but an error β€” pure waste. + private static _corpusUnavailable: Map = new Map(); + private static readonly NEGATIVE_CACHE_TTL_MS = 60_000; + isApplicable(context: RAGSourceContext): boolean { - return initializedPersonas.has(context.personaId); + if (!initializedPersonas.has(context.personaId)) return false; + + // Skip if we recently learned this persona's corpus is unavailable + const failedAt = GlobalAwarenessSource._corpusUnavailable.get(context.personaId); + if (failedAt && (Date.now() - failedAt) < GlobalAwarenessSource.NEGATIVE_CACHE_TTL_MS) { + return false; + } + return true; } async load(context: RAGSourceContext, _allocatedBudget: number): Promise { @@ -126,7 +139,13 @@ export class GlobalAwarenessSource implements RAGSource { }; } catch (error: any) { - log.error(`Failed to load global awareness: ${error.message}`); + // Negative-cache "No memory corpus" errors β€” skip IPC for 60s + if (error.message?.includes('No memory corpus')) { + GlobalAwarenessSource._corpusUnavailable.set(context.personaId, Date.now()); + log.debug(`Corpus unavailable for ${context.personaId.slice(0, 8)}, negative-cached for 60s`); + } else { + log.error(`Failed to load global awareness: ${error.message}`); + } return this.errorSection(startTime, error.message); } } diff --git a/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts b/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts index 4dc16a129..a268fcf8d 100644 --- a/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts +++ b/src/debug/jtag/system/rag/sources/PersonaIdentitySource.ts @@ -22,6 +22,43 @@ export class PersonaIdentitySource implements RAGSource { readonly priority = 95; // Critical - must be included readonly defaultBudgetPercent = 15; + // Identity never changes at runtime β€” cache per persona (indefinite TTL) + private static _identityCache: Map = new Map(); + + // Batch pre-warm: load ALL persona users in one query on first cache miss. + // Eliminates N individual reads under SQLite contention. + private static _preWarmPromise: Promise | null = null; + private static _preWarmed = false; + + private static async preWarmAll(): Promise { + if (PersonaIdentitySource._preWarmed) return; + if (PersonaIdentitySource._preWarmPromise) return PersonaIdentitySource._preWarmPromise; + + PersonaIdentitySource._preWarmPromise = (async () => { + try { + const result = await DataDaemon.query({ + collection: UserEntity.collection, + filter: { type: 'persona' }, + limit: 100 + }); + if (result.success && result.data) { + for (const record of result.data) { + const user = record.data; + PersonaIdentitySource._identityCache.set(user.id, user); + } + log.info(`Pre-warmed identity cache with ${result.data.length} personas`); + } + PersonaIdentitySource._preWarmed = true; + } catch (error: any) { + log.warn(`Failed to pre-warm identity cache: ${error.message}`); + } finally { + PersonaIdentitySource._preWarmPromise = null; + } + })(); + + return PersonaIdentitySource._preWarmPromise; + } + isApplicable(_context: RAGSourceContext): boolean { // Always applicable return true; @@ -31,16 +68,26 @@ export class PersonaIdentitySource implements RAGSource { const startTime = performance.now(); try { - const result = await DataDaemon.read(UserEntity.collection, context.personaId); + // Check cache first β€” identity is immutable at runtime + let user = PersonaIdentitySource._identityCache.get(context.personaId) ?? null; + if (!user) { + // Cache miss: batch-load ALL personas in one query (1 roundtrip vs N) + await PersonaIdentitySource.preWarmAll(); + user = PersonaIdentitySource._identityCache.get(context.personaId) ?? null; + } + if (!user) { + // Still not found after batch load β€” try individual read (edge case: new persona) + user = await DataDaemon.read(UserEntity.collection, context.personaId); + if (user) { + PersonaIdentitySource._identityCache.set(context.personaId, user); + } + } - if (!result.success || !result.data) { + if (!user) { log.warn(`Could not load persona ${context.personaId}, using defaults`); return this.defaultSection(startTime); } - const userRecord = result.data; - const user = userRecord.data; - const identity: PersonaIdentity = { name: user.displayName, bio: user.profile?.bio, diff --git a/src/debug/jtag/system/rag/sources/ProjectContextSource.ts b/src/debug/jtag/system/rag/sources/ProjectContextSource.ts new file mode 100644 index 000000000..e83f40af4 --- /dev/null +++ b/src/debug/jtag/system/rag/sources/ProjectContextSource.ts @@ -0,0 +1,276 @@ +/** + * ProjectContextSource - Injects project workspace context into persona RAG + * + * When a persona has an active project workspace (git worktree on any repo), + * this source surfaces: + * - Project type and build/test commands + * - File tree (top 2 levels) + * - Git branch + status (modified files, ahead/behind) + * - Recent commits on this branch + * - Team activity (other ai/* branches on this repo, their status) + * - Build status (last build result if tracked) + * + * This gives personas situational awareness of: + * - What they're working on (their files, their branch) + * - What the team is working on (other branches, recent commits) + * - Who might need help (merge conflicts, build failures) + * + * Priority 70 - Between semantic-memory (60) and conversation-history (80). + * Project context is important for coding activities but shouldn't displace + * conversation history or identity. + */ + +import type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; +import { WorkspaceStrategy } from '../../code/server/WorkspaceStrategy'; +import { ProjectDetector, type ProjectType } from '../../code/server/ProjectDetector'; +import { Logger } from '../../core/logging/Logger'; +import { execSync } from 'child_process'; + +const log = Logger.create('ProjectContextSource', 'rag'); + +export class ProjectContextSource implements RAGSource { + readonly name = 'project-context'; + readonly priority = 70; + readonly defaultBudgetPercent = 12; + + isApplicable(context: RAGSourceContext): boolean { + // Only include if persona has an active project workspace + return !!WorkspaceStrategy.getProjectForPersona(context.personaId); + } + + async load(context: RAGSourceContext, allocatedBudget: number): Promise { + const startTime = performance.now(); + + const wsMeta = WorkspaceStrategy.getProjectForPersona(context.personaId); + if (!wsMeta) { + return this.emptySection(startTime); + } + + try { + const gitOpts = { cwd: wsMeta.worktreeDir, stdio: 'pipe' as const, timeout: 5000 }; + + // Run git queries concurrently via Promise.all on sync operations + // These are fast (~5-10ms each) since they're local git operations + const [projectType, gitStatus, gitLog, teamBranches, fileTree] = await Promise.all([ + ProjectDetector.detect(wsMeta.worktreeDir), + this.getGitStatus(wsMeta.worktreeDir), + this.getGitLog(wsMeta.worktreeDir, 5), + this.getTeamBranches(wsMeta.repoPath), + this.getFileTree(wsMeta.worktreeDir, 2), + ]); + + // Check for team members who might need help (merge conflicts) + const teamStatus = await this.getTeamStatus(wsMeta.repoPath, wsMeta.branch); + + const formatted = this.formatProjectContext({ + projectType, + branch: wsMeta.branch, + gitStatus, + gitLog, + teamBranches, + teamStatus, + fileTree, + repoPath: wsMeta.repoPath, + }); + + // Respect budget + const tokenCount = this.estimateTokens(formatted); + const budgetTokens = Math.floor(allocatedBudget); + const finalPrompt = tokenCount > budgetTokens + ? this.formatMinimal(wsMeta.branch, projectType, gitStatus) + : formatted; + + const finalTokens = this.estimateTokens(finalPrompt); + const loadTimeMs = performance.now() - startTime; + + log.debug(`Loaded project context (${finalTokens} tokens, ${loadTimeMs.toFixed(1)}ms) for ${context.personaId.slice(0, 8)}`); + + return { + sourceName: this.name, + tokenCount: finalTokens, + loadTimeMs, + systemPromptSection: finalPrompt, + metadata: { + branch: wsMeta.branch, + repoPath: wsMeta.repoPath, + projectType: projectType.type, + teamBranchCount: teamBranches.length, + }, + }; + } catch (error: any) { + log.error(`Failed to load project context: ${error.message}`); + return this.emptySection(startTime, error.message); + } + } + + // ──────────────────────────────────────────────────────────── + // Git data extraction (fast, synchronous operations) + // ──────────────────────────────────────────────────────────── + + private async getGitStatus(dir: string): Promise { + try { + return execSync('git status --short --branch', { cwd: dir, stdio: 'pipe', timeout: 5000 }).toString().trim(); + } catch { + return ''; + } + } + + private async getGitLog(dir: string, count: number): Promise { + try { + return execSync( + `git log --oneline --no-decorate -${count}`, + { cwd: dir, stdio: 'pipe', timeout: 5000 }, + ).toString().trim(); + } catch { + return ''; + } + } + + private async getTeamBranches(repoPath: string): Promise { + try { + const output = execSync( + 'git branch --list "ai/*" --format="%(refname:short)"', + { cwd: repoPath, stdio: 'pipe', timeout: 5000 }, + ).toString().trim(); + return output ? output.split('\n') : []; + } catch { + return []; + } + } + + private async getFileTree(dir: string, maxDepth: number): Promise { + try { + // Use find to get a clean tree limited to depth, excluding .git and node_modules + return execSync( + `find . -maxdepth ${maxDepth} -not -path './.git*' -not -path '*/node_modules/*' -not -name '.DS_Store' | sort | head -50`, + { cwd: dir, stdio: 'pipe', timeout: 5000 }, + ).toString().trim(); + } catch { + return ''; + } + } + + /** + * Check team status β€” detect if anyone has merge conflicts or build failures. + * This is how smarter AIs know when to help. + */ + private async getTeamStatus(repoPath: string, ownBranch: string): Promise { + const allWorkspaces = WorkspaceStrategy.allProjectWorkspaces; + const statuses: TeamMemberStatus[] = []; + + for (const [handle, meta] of allWorkspaces) { + if (meta.repoPath !== repoPath) continue; + if (meta.branch === ownBranch) continue; // Skip self + + try { + // Quick check for merge conflicts + const conflictOutput = execSync( + 'git diff --name-only --diff-filter=U 2>/dev/null || true', + { cwd: meta.worktreeDir, stdio: 'pipe', timeout: 3000 }, + ).toString().trim(); + + const hasConflicts = conflictOutput.length > 0; + const personaId = handle.replace('project-', '').replace(/-[^-]+$/, ''); + + statuses.push({ + branch: meta.branch, + personaId, + hasConflicts, + conflictFiles: hasConflicts ? conflictOutput.split('\n') : [], + }); + } catch { + // Skip unreachable workspaces + } + } + + return statuses; + } + + // ──────────────────────────────────────────────────────────── + // Formatting + // ──────────────────────────────────────────────────────────── + + private formatProjectContext(data: { + projectType: ProjectType; + branch: string; + gitStatus: string; + gitLog: string; + teamBranches: string[]; + teamStatus: TeamMemberStatus[]; + fileTree: string; + repoPath: string; + }): string { + const sections: string[] = []; + + // Header with project type + const commands: string[] = []; + if (data.projectType.buildCommand) commands.push(`Build: ${data.projectType.buildCommand}`); + if (data.projectType.testCommand) commands.push(`Test: ${data.projectType.testCommand}`); + if (data.projectType.serveCommand) commands.push(`Serve: ${data.projectType.serveCommand}`); + sections.push(`## Project Context\nType: ${data.projectType.description}${commands.length ? ' | ' + commands.join(' | ') : ''}`); + + // Your branch status + if (data.gitStatus) { + sections.push(`### Your Branch: ${data.branch}\n${data.gitStatus}`); + } + + // Recent commits + if (data.gitLog) { + sections.push(`### Recent Commits\n${data.gitLog}`); + } + + // File tree (abbreviated) + if (data.fileTree) { + sections.push(`### File Tree\n\`\`\`\n${data.fileTree}\n\`\`\``); + } + + // Team activity + if (data.teamBranches.length > 0) { + const teamLines = data.teamBranches + .filter(b => b !== data.branch) // Exclude own branch + .map(b => `- ${b}`); + if (teamLines.length > 0) { + sections.push(`### Team Branches\n${teamLines.join('\n')}`); + } + } + + // Team members needing help + const needsHelp = data.teamStatus.filter(s => s.hasConflicts); + if (needsHelp.length > 0) { + const helpLines = needsHelp.map(s => + `- **${s.branch}** has merge conflicts in: ${s.conflictFiles.join(', ')}` + ); + sections.push(`### Team Needs Help\n${helpLines.join('\n')}\nYou can help by accessing their workspace and resolving conflicts.`); + } + + return sections.join('\n\n'); + } + + private formatMinimal(branch: string, projectType: ProjectType, gitStatus: string): string { + return `## Project: ${projectType.description}\nBranch: ${branch}\n${gitStatus}`; + } + + private emptySection(startTime: number, error?: string): RAGSection { + return { + sourceName: this.name, + tokenCount: 0, + loadTimeMs: performance.now() - startTime, + metadata: error ? { error } : { noProject: true }, + }; + } + + private estimateTokens(text: string): number { + return Math.ceil(text.length / 4); + } +} + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +interface TeamMemberStatus { + readonly branch: string; + readonly personaId: string; + readonly hasConflicts: boolean; + readonly conflictFiles: string[]; +} diff --git a/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts b/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts index 284f41b86..34bc6bce9 100644 --- a/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts +++ b/src/debug/jtag/system/rag/sources/SemanticMemorySource.ts @@ -26,8 +26,17 @@ export class SemanticMemorySource implements RAGSource { readonly priority = 60; // Medium-high - memories inform persona behavior readonly defaultBudgetPercent = 15; - isApplicable(_context: RAGSourceContext): boolean { - // Always try - will return empty if persona has no memories + // Negative cache: when Rust returns "No memory corpus", skip IPC for 60s. + // Without this, each failing persona makes a 1-3s IPC call every RAG build. + private static _corpusUnavailable: Map = new Map(); + private static readonly NEGATIVE_CACHE_TTL_MS = 60_000; + + isApplicable(context: RAGSourceContext): boolean { + // Skip if we recently learned this persona's corpus is unavailable + const failedAt = SemanticMemorySource._corpusUnavailable.get(context.personaId); + if (failedAt && (Date.now() - failedAt) < SemanticMemorySource.NEGATIVE_CACHE_TTL_MS) { + return false; + } return true; } @@ -105,7 +114,13 @@ export class SemanticMemorySource implements RAGSource { } }; } catch (error: any) { - log.error(`Failed to load memories: ${error.message}`); + // Negative-cache "No memory corpus" errors β€” skip IPC for 60s + if (error.message?.includes('No memory corpus')) { + SemanticMemorySource._corpusUnavailable.set(context.personaId, Date.now()); + log.debug(`Corpus unavailable for ${context.personaId.slice(0, 8)}, negative-cached for 60s`); + } else { + log.error(`Failed to load memories: ${error.message}`); + } return this.emptySection(startTime, error.message); } } diff --git a/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts b/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts index 6918174b1..c911f5769 100644 --- a/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts +++ b/src/debug/jtag/system/rag/sources/SocialMediaRAGSource.ts @@ -233,18 +233,18 @@ export class SocialMediaRAGSource implements RAGSource { } // Look up persona's uniqueId via DataDaemon - const userResult = await SocialMediaRAGSource.withTimeout( + const user = await SocialMediaRAGSource.withTimeout( DataDaemon.read(UserEntity.collection, personaId), SocialMediaRAGSource.API_TIMEOUT_MS, 'DataDaemon.read' ); - if (!userResult.success || !userResult.data) { + if (!user) { log.debug(`No user found for persona ${personaId.slice(0, 8)} β€” caching null`); SocialMediaRAGSource._credentialCache.set(personaId, null); return undefined; } - const personaUniqueId = userResult.data.data.uniqueId; + const personaUniqueId = user.uniqueId; log.debug(`Resolving credentials for ${personaUniqueId} (${personaId.slice(0, 8)})`); // Try each registered platform diff --git a/src/debug/jtag/system/rag/sources/index.ts b/src/debug/jtag/system/rag/sources/index.ts index 6919c3744..32c4ec0e3 100644 --- a/src/debug/jtag/system/rag/sources/index.ts +++ b/src/debug/jtag/system/rag/sources/index.ts @@ -28,6 +28,8 @@ export { PersonaIdentitySource } from './PersonaIdentitySource'; export { GlobalAwarenessSource, registerConsciousness, unregisterConsciousness, getConsciousness } from './GlobalAwarenessSource'; export { VoiceConversationSource, registerVoiceOrchestrator, unregisterVoiceOrchestrator } from './VoiceConversationSource'; export { SocialMediaRAGSource } from './SocialMediaRAGSource'; +export { CodeToolSource } from './CodeToolSource'; +export { ProjectContextSource } from './ProjectContextSource'; // Re-export types for convenience export type { RAGSource, RAGSourceContext, RAGSection } from '../shared/RAGSource'; diff --git a/src/debug/jtag/system/rag/test/unit/ChatRAGBuilder.learningMode.test.ts b/src/debug/jtag/system/rag/test/unit/ChatRAGBuilder.learningMode.test.ts index dfc0a6b6d..3ab88c057 100644 --- a/src/debug/jtag/system/rag/test/unit/ChatRAGBuilder.learningMode.test.ts +++ b/src/debug/jtag/system/rag/test/unit/ChatRAGBuilder.learningMode.test.ts @@ -36,10 +36,7 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { describe('loadLearningConfig', () => { it('should return undefined when room does not exist', async () => { // Mock DataDaemon to return no room - vi.mocked(DataDaemon.read).mockResolvedValueOnce({ - success: false, - data: undefined - } as never); + vi.mocked(DataDaemon.read).mockResolvedValueOnce(null as never); const context = await ragBuilder.buildContext(testRoomId, testPersonaId); @@ -64,10 +61,8 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -107,13 +102,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -157,13 +148,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -205,13 +192,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -256,13 +239,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -307,13 +286,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -366,13 +341,9 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }; vi.mocked(DataDaemon.read).mockImplementation(async (collection, id) => { - if (collection === 'rooms') { - return { success: true, data: { data: mockRoom } } as never; - } - if (collection === 'users' && id === testPersonaId) { - return { success: true, data: { data: mockUser } } as never; - } - return { success: false } as never; + if (collection === 'rooms') return mockRoom as never; + if (collection === 'users' && id === testPersonaId) return mockUser as never; + return null as never; }); vi.mocked(DataDaemon.query).mockResolvedValue({ @@ -411,10 +382,7 @@ describe('ChatRAGBuilder - Learning Mode (Phase 2)', () => { }); it('should handle malformed room data', async () => { - vi.mocked(DataDaemon.read).mockResolvedValue({ - success: true, - data: { data: null } - } as never); + vi.mocked(DataDaemon.read).mockResolvedValue(null as never); vi.mocked(DataDaemon.query).mockResolvedValue({ success: true, diff --git a/src/debug/jtag/system/recipes/coding.json b/src/debug/jtag/system/recipes/coding.json new file mode 100644 index 000000000..f21fc6653 --- /dev/null +++ b/src/debug/jtag/system/recipes/coding.json @@ -0,0 +1,117 @@ +{ + "uniqueId": "coding", + "name": "Collaborative Coding", + "displayName": "Code", + "description": "Collaborative software development β€” reading, writing, reviewing, and shipping code as a team", + "version": 1, + + "layout": { + "main": ["chat-widget"], + "right": null + }, + + "locked": ["layout.main"], + + "pipeline": [ + { + "command": "rag/build", + "params": { + "maxMessages": 30, + "includeParticipants": true, + "includeRoomStrategy": true + }, + "outputTo": "ragContext" + }, + { + "command": "ai/should-respond", + "params": { + "ragContext": "$ragContext", + "strategy": "coding" + }, + "outputTo": "decision" + }, + { + "command": "ai/generate", + "params": { + "ragContext": "$ragContext", + "temperature": 0.3 + }, + "condition": "decision.shouldRespond === true" + } + ], + + "ragTemplate": { + "messageHistory": { + "maxMessages": 30, + "orderBy": "chronological", + "includeTimestamps": true + }, + "participants": { + "includeRoles": true, + "includeExpertise": true, + "includeHistory": false + }, + "roomMetadata": true + }, + + "strategy": { + "conversationPattern": "collaborative", + "responseRules": [ + "This room is for DOING code work, not just discussing it. Use code/* tools.", + "Read before you write β€” always code/read a file before editing it", + "Verify your work β€” run code/verify after every edit to check compilation", + "Iterate on failures β€” when verify fails, read the errors, fix them, verify again", + "Show your work β€” share what you changed and why in the room", + "Review each other β€” use code/read and code/git diff to review teammates' changes", + "Propose before big changes β€” use collaboration/decision/propose for architectural decisions", + "Commit working code β€” only code/git commit after code/verify passes", + "Coordinate naturally β€” claim files you're working on, don't pile on the same code", + "Ask for help when stuck β€” share errors, ask teammates to look at your approach", + "Use code/shell/execute for build commands (xcodebuild, cargo build, npm run build) β€” code/verify only runs tsc", + "For long-running commands, use wait=false then code/shell/watch to stream output", + "Use code/shell/sentinel to classify build output (errors, warnings) before watching", + "Use code/shell/kill to abort runaway processes" + ], + "feedbackLoopRules": [ + "MANDATORY: After code changes, ALWAYS build and verify before committing β€” code/verify is not optional", + "MANDATORY: Use screenshot to visually verify UI changes β€” if you build UI and don't look at it, you are developing blind", + "MANDATORY: Run the test suite after significant changes β€” untested code is hypothetical code", + "MANDATORY: When building console apps, run them and check the output β€” if you can't see it, it doesn't exist", + "Check team branches before starting new work β€” code/git branches shows who is working on what", + "If a teammate has merge conflicts, offer to help β€” code/git conflicts detects this automatically", + "After compilation failure, read the FULL error output β€” do not guess, the errors tell the truth", + "When starting on a project, detect the project type first β€” code/verify knows how to build/test each type", + "Never commit code you haven't verified compiles and runs β€” this is non-negotiable" + ], + "decisionCriteria": [ + "Is there a coding task I can help with?", + "Has someone asked for a code review?", + "Did someone share an error they're stuck on?", + "Is there a verification failure I can diagnose?", + "Should I propose an architectural approach before coding?", + "Does a teammate have merge conflicts I can help resolve?", + "Has someone built something without verifying it works?" + ] + }, + + "tools": [ + { "name": "code/read", "description": "Read a file from your workspace", "enabledFor": ["ai"] }, + { "name": "code/write", "description": "Create or overwrite a file", "enabledFor": ["ai"] }, + { "name": "code/edit", "description": "Edit a file (search-replace, line-range, insert, append)", "enabledFor": ["ai"] }, + { "name": "code/search", "description": "Search for patterns across workspace files", "enabledFor": ["ai"] }, + { "name": "code/tree", "description": "View directory structure", "enabledFor": ["ai"] }, + { "name": "code/diff", "description": "Preview an edit as unified diff", "enabledFor": ["ai"] }, + { "name": "code/undo", "description": "Undo recent changes", "enabledFor": ["ai"] }, + { "name": "code/history", "description": "View change history", "enabledFor": ["ai"] }, + { "name": "code/verify", "description": "Run compilation check and tests", "enabledFor": ["ai"] }, + { "name": "code/git", "description": "Git operations (status, diff, log, add, commit)", "enabledFor": ["ai"] }, + { "name": "code/shell/execute", "description": "Run a shell command in your workspace (async or wait=true for blocking)", "enabledFor": ["ai"] }, + { "name": "code/shell/watch", "description": "Watch a running execution for classified output lines", "enabledFor": ["ai"] }, + { "name": "code/shell/sentinel", "description": "Configure output filter rules (regex classification) on a running execution", "enabledFor": ["ai"] }, + { "name": "code/shell/status", "description": "Get shell session info (cwd, active executions)", "enabledFor": ["ai"] }, + { "name": "code/shell/kill", "description": "Kill a running execution", "enabledFor": ["ai"] } + ], + + "isPublic": true, + "tags": ["coding", "development", "engineering", "collaboration"] +} diff --git a/src/debug/jtag/system/recipes/shared/RecipeTypes.ts b/src/debug/jtag/system/recipes/shared/RecipeTypes.ts index b08a5b5dd..6485bad11 100644 --- a/src/debug/jtag/system/recipes/shared/RecipeTypes.ts +++ b/src/debug/jtag/system/recipes/shared/RecipeTypes.ts @@ -76,6 +76,19 @@ export interface RecipeStrategy { decisionCriteria: string[]; // What LLM should consider } +/** + * Tool declaration in a recipe β€” HIGHLIGHTS, not filters. + * + * These tell the LLM "these tools are especially relevant for this activity." + * They do NOT restrict access β€” all 225+ tools remain available. + * The recipe just provides context about what's useful. + */ +export interface RecipeToolDeclaration { + name: string; + description: string; + enabledFor: ('ai' | 'human')[]; +} + /** * Recipe input parameter definition * Recipe = function definition, Activity = function call with arguments @@ -155,6 +168,9 @@ export interface RecipeEntity { */ locked?: string[]; + // Tool highlights β€” which tools are especially relevant for this activity + tools?: RecipeToolDeclaration[]; + // Sharing isPublic: boolean; createdBy: UUID; @@ -212,6 +228,9 @@ export interface RecipeDefinition { ragTemplate: RAGTemplate; strategy: RecipeStrategy; + // Tool highlights β€” which tools are especially relevant for this activity + tools?: RecipeToolDeclaration[]; + // UI composition (optional - defaults handled by layout system) layout?: ActivityUILayout; diff --git a/src/debug/jtag/system/recipes/terminal.json b/src/debug/jtag/system/recipes/terminal.json new file mode 100644 index 000000000..106b9196a --- /dev/null +++ b/src/debug/jtag/system/recipes/terminal.json @@ -0,0 +1,65 @@ +{ + "uniqueId": "terminal", + "name": "Terminal", + "displayName": "Terminal", + "description": "tmux-like terminal multiplexer showing AI shell sessions and live process output", + "version": 1, + + "layout": { + "main": ["terminal-widget"], + "right": { + "widgets": [ + "chat-widget" + ], + "config": { + "room": "help", + "compact": true + } + } + }, + + "pipeline": [ + { + "command": "rag/build", + "params": { + "maxMessages": 10, + "includeParticipants": false + }, + "outputTo": "ragContext" + }, + { + "command": "ai/generate", + "params": { + "ragContext": "$ragContext", + "temperature": 0.5 + } + } + ], + + "ragTemplate": { + "messageHistory": { + "maxMessages": 10, + "orderBy": "chronological", + "includeTimestamps": true + }, + "participants": { + "includeRoles": false, + "includeExpertise": false, + "includeHistory": false + } + }, + + "strategy": { + "conversationPattern": "collaborative", + "responseRules": [ + "Provide helpful guidance", + "Be concise and clear" + ], + "decisionCriteria": [ + "What is the user trying to accomplish?" + ] + }, + + "isPublic": true, + "tags": ["terminal"] +} diff --git a/src/debug/jtag/system/shared/Constants.ts b/src/debug/jtag/system/shared/Constants.ts index 3b0a41b7a..95d5acd8a 100644 --- a/src/debug/jtag/system/shared/Constants.ts +++ b/src/debug/jtag/system/shared/Constants.ts @@ -136,6 +136,15 @@ export const COLLECTIONS = { // Universal Handle System β€” persistent async operation references HANDLES: 'handles', + + // Coding Agent System (Phase 4: Multi-Agent Coordination) + CODING_PLANS: 'coding_plans', + + // Self-Modifying Skills (Phase 4B: AI-Created Commands) + SKILLS: 'skills', + + // Coding Challenges & Learning (Phase 4D: Progressive Training) + CODING_CHALLENGES: 'coding_challenges', } as const; diff --git a/src/debug/jtag/system/shared/ModelContextWindows.ts b/src/debug/jtag/system/shared/ModelContextWindows.ts index d5aef2018..5ce139066 100644 --- a/src/debug/jtag/system/shared/ModelContextWindows.ts +++ b/src/debug/jtag/system/shared/ModelContextWindows.ts @@ -8,8 +8,15 @@ * - ChatRAGBuilder (message count budgeting) * - RAGBudgetServerCommand (token budget calculation) * - PersonaUser (model capability checks) + * + * Dynamic discovery: + * ModelRegistry (populated async from provider APIs in initializeDeferred) + * is checked FIRST. Static maps below are the fallback when the registry + * hasn't discovered a model yet or the provider API is unavailable. */ +import { ModelRegistry } from './ModelRegistry'; + /** * Model context windows in tokens * @@ -29,7 +36,13 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { 'o1': 200000, 'o1-mini': 128000, - // Anthropic Models (Claude) + // Anthropic Models (Claude) β€” versioned IDs used at runtime + 'claude-sonnet-4-5-20250929': 200000, // MODEL_IDS.ANTHROPIC.SONNET_4_5 + 'claude-opus-4-20250514': 200000, // MODEL_IDS.ANTHROPIC.OPUS_4 + 'claude-3-5-haiku-20241022': 200000, // MODEL_IDS.ANTHROPIC.HAIKU_3_5 + 'claude-sonnet-4': 200000, // Alias used in UserDataSeed + 'claude-sonnet-4-5': 200000, // Date-stripped alias + // Legacy naming (kept for backward compatibility) 'claude-3-opus': 200000, 'claude-3-sonnet': 200000, 'claude-3-haiku': 200000, @@ -37,7 +50,11 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { 'claude-3-5-haiku': 200000, 'claude-opus-4': 200000, - // Meta Models (Llama) via Ollama + // Meta Models (Llama) β€” cloud API naming (dashes) + 'llama-3.1-8b-instant': 131072, // Groq LPU + 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo': 131072, // Together.ai + 'accounts/fireworks/models/llama-v3p1-8b-instruct': 131072, // Fireworks.ai + // Meta Models (Llama) β€” Ollama naming (dots + colons) 'llama3.2': 128000, 'llama3.2:3b': 128000, 'llama3.2:1b': 128000, @@ -52,11 +69,13 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { 'qwen2.5:32b': 128000, 'qwen2.5:72b': 128000, 'qwq': 128000, // Qwen reasoning model + 'qwen3-omni-flash-realtime': 128000, // Alibaba Qwen 3 Omni // Google Models 'gemini-pro': 32768, 'gemini-1.5-pro': 1000000, 'gemini-1.5-flash': 1000000, + 'gemini-2.0-flash': 1048576, // Gemini 2.0 Flash // Mistral Models 'mistral': 32768, @@ -76,6 +95,7 @@ export const MODEL_CONTEXT_WINDOWS: Readonly> = { // X.AI Models 'grok-3': 131072, + 'grok-4': 131072, }; /** @@ -104,11 +124,22 @@ export const MODEL_INFERENCE_SPEEDS: Readonly> = { 'gpt-4-turbo': 1000, 'gpt-4o': 1000, 'gpt-4o-mini': 1000, + 'claude-sonnet-4-5-20250929': 1000, + 'claude-opus-4-20250514': 1000, + 'claude-3-5-haiku-20241022': 1000, 'claude-3-opus': 1000, 'claude-3-sonnet': 1000, 'claude-3-haiku': 1000, 'claude-3-5-sonnet': 1000, 'claude-opus-4': 1000, + 'llama-3.1-8b-instant': 1000, // Groq LPU + 'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo': 1000, // Together.ai + 'accounts/fireworks/models/llama-v3p1-8b-instruct': 1000, // Fireworks.ai + 'deepseek-chat': 1000, // DeepSeek cloud + 'grok-3': 1000, // xAI cloud + 'grok-4': 1000, // xAI cloud + 'gemini-2.0-flash': 1000, // Google cloud + 'qwen3-omni-flash-realtime': 1000, // Alibaba cloud 'gemini-pro': 1000, 'gemini-1.5-pro': 1000, @@ -156,6 +187,14 @@ export const DEFAULT_TARGET_LATENCY_SECONDS = 30; * Get inference speed for a model in tokens per second */ export function getInferenceSpeed(model: string): number { + // Check ModelRegistry first (live-discovered data from provider APIs) + const registry = ModelRegistry.sharedInstance(); + const discovered = registry.get(model); + if (discovered) { + // Cloud APIs are always ~1000 TPS (network-bound) + return 1000; + } + // Direct match if (MODEL_INFERENCE_SPEEDS[model]) { return MODEL_INFERENCE_SPEEDS[model]; @@ -167,6 +206,12 @@ export function getInferenceSpeed(model: string): number { return MODEL_INFERENCE_SPEEDS[baseModel]; } + // Strip date suffix (e.g., 'claude-sonnet-4-5-20250929' β†’ 'claude-sonnet-4-5') + const dateStripped = model.replace(/-\d{8}$/, ''); + if (dateStripped !== model && MODEL_INFERENCE_SPEEDS[dateStripped]) { + return MODEL_INFERENCE_SPEEDS[dateStripped]; + } + // Try prefix matching for (const [key, value] of Object.entries(MODEL_INFERENCE_SPEEDS)) { if (model.startsWith(key) || key.startsWith(model)) { @@ -217,7 +262,11 @@ export function isSlowLocalModel(model: string): boolean { * @returns Context window size in tokens, or DEFAULT_CONTEXT_WINDOW if model not found */ export function getContextWindow(model: string): number { - // Direct match + // Check ModelRegistry first (live-discovered data from provider APIs) + const discovered = ModelRegistry.sharedInstance().contextWindow(model); + if (discovered !== undefined) return discovered; + + // Direct match in static map if (MODEL_CONTEXT_WINDOWS[model]) { return MODEL_CONTEXT_WINDOWS[model]; } @@ -228,6 +277,12 @@ export function getContextWindow(model: string): number { return MODEL_CONTEXT_WINDOWS[baseModel]; } + // Strip date suffix (e.g., 'claude-sonnet-4-5-20250929' β†’ 'claude-sonnet-4-5') + const dateStripped = model.replace(/-\d{8}$/, ''); + if (dateStripped !== model && MODEL_CONTEXT_WINDOWS[dateStripped]) { + return MODEL_CONTEXT_WINDOWS[dateStripped]; + } + // Try prefix matching for versioned models for (const [key, value] of Object.entries(MODEL_CONTEXT_WINDOWS)) { if (model.startsWith(key) || key.startsWith(model)) { diff --git a/src/debug/jtag/system/shared/ModelRegistry.ts b/src/debug/jtag/system/shared/ModelRegistry.ts new file mode 100644 index 000000000..4c883147b --- /dev/null +++ b/src/debug/jtag/system/shared/ModelRegistry.ts @@ -0,0 +1,149 @@ +/** + * ModelRegistry β€” Dynamic Model Metadata Discovery Cache + * ======================================================= + * + * Central registry for model metadata discovered from provider APIs at runtime. + * Eliminates the need to hard-code every model's context window, output limits, + * and capabilities in static maps. + * + * Architecture: + * Startup β†’ static fallbacks available immediately (ModelContextWindows.ts) + * initializeDeferred() β†’ adapters query provider APIs, push results here + * Lookups β†’ ModelRegistry checked first, static map is fallback + * + * This is fully non-blocking. Discovery runs after the daemon is ready and + * accepting requests. All I/O is async fetch() β€” no event loop blocking. + * + * Usage: + * const registry = ModelRegistry.sharedInstance(); + * const ctx = registry.contextWindow('claude-sonnet-4-5-20250929'); + * // Returns 200000 if discovered, undefined if not (caller falls back to static) + */ + +/** + * Metadata for a discovered model + */ +export interface ModelMetadata { + readonly modelId: string; + readonly contextWindow: number; + readonly maxOutputTokens?: number; + readonly provider: string; + readonly capabilities?: string[]; + readonly costPer1kTokens?: { input: number; output: number }; + readonly discoveredAt: number; +} + +/** + * ModelRegistry β€” Singleton + * + * Provides fast lookup of model metadata discovered from provider APIs. + * All normalization (date-suffix stripping, prefix matching) is built-in + * so callers don't need to handle naming variations. + */ +export class ModelRegistry { + private static _instance: ModelRegistry; + private _models: Map = new Map(); + + private constructor() {} + + static sharedInstance(): ModelRegistry { + if (!ModelRegistry._instance) { + ModelRegistry._instance = new ModelRegistry(); + } + return ModelRegistry._instance; + } + + /** + * Register a single model's metadata (overwrites if already present) + */ + register(metadata: ModelMetadata): void { + this._models.set(metadata.modelId, metadata); + } + + /** + * Register a batch of models from adapter discovery + */ + registerBatch(models: ModelMetadata[]): void { + for (const model of models) { + this._models.set(model.modelId, model); + } + } + + /** + * Lookup context window for a model. + * Returns undefined if the model is not in the registry (caller should fall back to static map). + * + * Normalization chain: + * 1. Direct lookup by exact modelId + * 2. Date-suffix stripped (e.g. 'claude-sonnet-4-5-20250929' β†’ 'claude-sonnet-4-5') + * 3. Prefix matching (e.g. 'claude-sonnet-4' matches 'claude-sonnet-4-5-20250929') + */ + contextWindow(modelId: string): number | undefined { + // 1. Direct lookup + const direct = this._models.get(modelId); + if (direct) return direct.contextWindow; + + // 2. Date-suffix normalization + const dateStripped = modelId.replace(/-\d{8}$/, ''); + if (dateStripped !== modelId) { + const stripped = this._models.get(dateStripped); + if (stripped) return stripped.contextWindow; + } + + // 3. Prefix matching β€” check if any registered model starts with or is started by this ID + for (const [registeredId, metadata] of this._models) { + if (modelId.startsWith(registeredId) || registeredId.startsWith(modelId)) { + return metadata.contextWindow; + } + } + + return undefined; + } + + /** + * Lookup full metadata for a model. + * Same normalization chain as contextWindow(). + */ + get(modelId: string): ModelMetadata | undefined { + // Direct + const direct = this._models.get(modelId); + if (direct) return direct; + + // Date-suffix + const dateStripped = modelId.replace(/-\d{8}$/, ''); + if (dateStripped !== modelId) { + const stripped = this._models.get(dateStripped); + if (stripped) return stripped; + } + + // Prefix matching + for (const [registeredId, metadata] of this._models) { + if (modelId.startsWith(registeredId) || registeredId.startsWith(modelId)) { + return metadata; + } + } + + return undefined; + } + + /** + * All registered models (read-only view) + */ + get all(): ReadonlyMap { + return this._models; + } + + /** + * Number of models in the registry + */ + get discoveredCount(): number { + return this._models.size; + } + + /** + * Clear all discovered models (mainly for testing) + */ + clear(): void { + this._models.clear(); + } +} diff --git a/src/debug/jtag/system/tools/server/ToolRegistry.ts b/src/debug/jtag/system/tools/server/ToolRegistry.ts index 93907db36..c24fadd56 100644 --- a/src/debug/jtag/system/tools/server/ToolRegistry.ts +++ b/src/debug/jtag/system/tools/server/ToolRegistry.ts @@ -114,7 +114,7 @@ export class ToolRegistry { console.log('βš™οΈ ToolRegistry: Discovering available commands...'); try { - const result = await List.execute({}) as unknown as { + const result = await List.execute({ includeDescription: true }) as unknown as { commands?: CommandSignature[]; success: boolean; error?: string; diff --git a/src/debug/jtag/system/user/server/CallerDetector.ts b/src/debug/jtag/system/user/server/CallerDetector.ts index fa5cd0d36..d32db8f44 100644 --- a/src/debug/jtag/system/user/server/CallerDetector.ts +++ b/src/debug/jtag/system/user/server/CallerDetector.ts @@ -37,15 +37,13 @@ export async function detectCallerType(context: JTAGContext, userId: UUID): Prom // 2. Look up user by userId try { - const userResult = await DataDaemon.read(COLLECTIONS.USERS, userId); + const user = await DataDaemon.read(COLLECTIONS.USERS, userId); - if (!userResult.success || !userResult.data) { + if (!user) { console.warn(`CallerDetector: User not found for userId=${userId}, defaulting to 'script'`); return 'script'; } - const user = userResult.data.data; - // 3. Map UserEntity.type to CallerType switch (user.type) { case 'persona': @@ -79,15 +77,13 @@ export async function detectCallerType(context: JTAGContext, userId: UUID): Prom */ export async function getCallerCapabilities(userId: UUID): Promise { try { - const userResult = await DataDaemon.read(COLLECTIONS.USERS, userId); + const user = await DataDaemon.read(COLLECTIONS.USERS, userId); - if (!userResult.success || !userResult.data) { + if (!user) { console.warn(`CallerDetector: User not found for userId=${userId}, returning default capabilities`); return getDefaultCapabilities(); } - const user = userResult.data.data; - // Build capabilities from user configuration const capabilities: CallerCapabilities = {}; diff --git a/src/debug/jtag/system/user/server/PersonaUser.ts b/src/debug/jtag/system/user/server/PersonaUser.ts index c1086b17f..b4f406928 100644 --- a/src/debug/jtag/system/user/server/PersonaUser.ts +++ b/src/debug/jtag/system/user/server/PersonaUser.ts @@ -48,6 +48,7 @@ import { AIDecisionService, type AIDecisionContext } from '../../ai/server/AIDec import { getModelConfigForProvider } from './config/PersonaModelConfigs'; import { CoordinationDecisionLogger, type LogDecisionParams } from '../../coordination/server/CoordinationDecisionLogger'; import type { RAGContext } from '../../data/entities/CoordinationDecisionEntity'; +import type { RAGContext as PipelineRAGContext } from '../../rag/shared/RAGTypes'; import { PersonaWorkerThread } from '../../../shared/workers/PersonaWorkerThread'; import { AI_DECISION_EVENTS, @@ -117,6 +118,7 @@ import { RustCognitionBridge, type PersonaUserForRustCognition } from './modules import { SystemPaths } from '../../core/config/SystemPaths'; import { UnifiedConsciousness } from './modules/consciousness/UnifiedConsciousness'; import { registerConsciousness, unregisterConsciousness } from '../../rag/sources/GlobalAwarenessSource'; +import { Workspace } from '../../code/server/Workspace'; import { DATA_COMMANDS } from '@commands/data/shared/DataCommandConstants'; import { DataOpen } from '../../../commands/data/open/shared/DataOpenTypes'; import type { CorpusMemory } from '../../../workers/continuum-core/bindings/CorpusMemory'; @@ -198,6 +200,10 @@ export class PersonaUser extends AIUser { // MEMORY LEAK FIX: Track event subscriptions for cleanup private _eventUnsubscribes: (() => void)[] = []; + // Workspace handles β€” lazy-created per context key, retained for session lifetime + // Keyed by context (e.g., room uniqueId) so personas can have per-room workspaces + private _workspaces: Map = new Map(); + /** * Get unified consciousness for cross-context awareness * Public for RAG sources and cognitive modules @@ -305,6 +311,52 @@ export class PersonaUser extends AIUser { return this.prefrontal.planFormulator; } + // ════════════════════════════════════════════════════════════════════════════ + // Workspace β€” per-persona code workspace (lazy-created, session-scoped) + // ════════════════════════════════════════════════════════════════════════════ + + /** Get a workspace by context key (null if not yet created for that context) */ + public getWorkspace(contextKey: string = 'default'): Workspace | null { + return this._workspaces.get(contextKey) ?? null; + } + + /** + * Ensure a workspace exists for this persona in the given context. + * Creates on first call per context key, retains for session lifetime. + * Called automatically when persona receives a code-domain task. + * + * @param options.contextKey Room uniqueId or other scope key (default: 'default') + * @param options.mode 'sandbox' for isolated, 'worktree' for real git branches + * @param options.taskSlug Used for branch naming in worktree mode + * @param options.sparsePaths Sparse checkout paths for worktree mode + */ + public async ensureWorkspace(options?: { + contextKey?: string; + mode?: 'sandbox' | 'worktree' | 'project'; + taskSlug?: string; + sparsePaths?: string[]; + repoPath?: string; + }): Promise { + const key = options?.contextKey ?? 'default'; + const existing = this._workspaces.get(key); + if (existing) return existing; + + const mode = options?.mode ?? 'sandbox'; + this.log.info(`${this.displayName}: Creating workspace (${mode} mode, context=${key})`); + const ws = await Workspace.create({ + personaId: this.id, + mode, + taskSlug: options?.taskSlug ?? key, + sparsePaths: options?.sparsePaths, + repoPath: options?.repoPath, + personaName: this.displayName, + personaUniqueId: this.entity.uniqueId, + }); + this._workspaces.set(key, ws); + this.log.info(`${this.displayName}: Workspace created β€” handle=${ws.handle}, dir=${ws.dir}, mode=${mode}${ws.branch ? `, branch=${ws.branch}` : ''}`); + return ws; + } + // BEING ARCHITECTURE: Delegate to body for toolExecutor private get toolExecutor(): PersonaToolExecutor { if (!this.motorCortex) throw new Error('Motor cortex not initialized'); @@ -428,7 +480,28 @@ export class PersonaUser extends AIUser { getSessionId: () => this.sessionId, homeDirectory: this.homeDirectory, logger: this.logger, - memory: this.memory // For accessing trained LoRA adapters during inference + memory: this.memory, // For accessing trained LoRA adapters during inference + ensureCodeWorkspace: async () => { + // Reuse any existing workspace (project or sandbox) before creating a new one. + // This allows workspaces created via explicit commands to be preserved. + const existing = this._workspaces.get('default') ?? this._workspaces.values().next().value; + if (existing) { + // Ensure shell session exists even for pre-existing workspaces. + // code/shell/* commands call CodeDaemon directly (bypass Workspace object), + // so the Rust-side shell session must be eagerly created. + await existing.ensureShell(); + return; + } + // Default to project mode: all personas get git worktree branches on the shared repo. + // This enables collaboration β€” AIs can see each other's branches, review, merge. + // WorkspaceStrategy auto-detects the git root from process.cwd(). + const ws = await this.ensureWorkspace({ + contextKey: 'default', + mode: 'project', + repoPath: process.cwd(), + }); + await ws.ensureShell(); + }, }); // RUST COGNITION: Fast-path decision engine via IPC @@ -650,16 +723,13 @@ export class PersonaUser extends AIUser { timestamp: number; targetPersonaId: UUID; }) => { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: Received voice:transcription:directed event, targetPersonaId=${transcriptionData.targetPersonaId?.slice(0, 8)}, myId=${this.id?.slice(0, 8)}`); // Only process if directed at THIS persona if (transcriptionData.targetPersonaId === this.id) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: MATCH! Processing directed voice transcription: "${transcriptionData.transcript.slice(0, 50)}..."`); this.log.info(`πŸŽ™οΈ ${this.displayName}: Received DIRECTED voice transcription`); await this.handleVoiceTranscription(transcriptionData); } }, undefined, this.id); this._eventUnsubscribes.push(unsubVoiceTranscription); - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: Subscribed to voice:transcription:directed events (personaId=${this.id?.slice(0, 8)})`); this.log.info(`πŸŽ™οΈ ${this.displayName}: Subscribed to voice:transcription:directed events`); // Subscribe to TTS audio events and inject into CallServer @@ -1153,21 +1223,16 @@ export class PersonaUser extends AIUser { language: string; timestamp?: string | number; }): Promise { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: handleVoiceTranscription CALLED with transcript: "${transcriptionData.transcript.slice(0, 50)}..."`); - // STEP 1: Ignore our own transcriptions if (transcriptionData.speakerId === this.id) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: Ignoring own transcription`); return; } this.log.debug(`🎀 ${this.displayName}: Received transcription from ${transcriptionData.speakerName}: "${transcriptionData.transcript.slice(0, 50)}..."`); // STEP 2: Deduplication - prevent evaluating same transcription multiple times - // Use transcript + timestamp as unique key const transcriptionKey = `${transcriptionData.speakerId}-${transcriptionData.timestamp || Date.now()}`; if (this.rateLimiter.hasEvaluatedMessage(transcriptionKey)) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: Deduplication - already processed this transcription`); return; } this.rateLimiter.markMessageEvaluated(transcriptionKey); @@ -1218,7 +1283,6 @@ export class PersonaUser extends AIUser { await this.inbox.enqueue(inboxMessage); this.personaState.updateInboxLoad(this.inbox.getSize()); - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.displayName}]: Enqueued voice message to inbox (priority=${boostedPriority.toFixed(2)}, voiceSessionId=${transcriptionData.sessionId?.slice(0, 8)}, inboxSize=${this.inbox.getSize()})`); this.log.info(`πŸŽ™οΈ ${this.displayName}: Enqueued voice transcription (priority=${boostedPriority.toFixed(2)}, confidence=${transcriptionData.confidence}, inbox size=${this.inbox.getSize()})`); // UNIFIED CONSCIOUSNESS: Record voice event in global timeline @@ -1340,7 +1404,8 @@ export class PersonaUser extends AIUser { */ public async respondToMessage( originalMessage: ProcessableMessage, - decisionContext?: Omit + decisionContext?: Omit, + preBuiltRagContext?: PipelineRAGContext ): Promise { // Check dormancy state before responding const shouldRespond = this.responseGenerator.shouldRespondToMessage( @@ -1353,7 +1418,7 @@ export class PersonaUser extends AIUser { return; } - const result = await this.responseGenerator.generateAndPostResponse(originalMessage, decisionContext); + const result = await this.responseGenerator.generateAndPostResponse(originalMessage, decisionContext, preBuiltRagContext); // Mark tool results as processed to prevent infinite loops if (result.success && result.storedToolResultIds.length > 0) { @@ -1960,6 +2025,17 @@ export class PersonaUser extends AIUser { // Stop autonomous servicing loop await this.autonomousLoop.stopServicing(); + // Clean up all workspaces (shell sessions + worktrees) + for (const [key, ws] of this._workspaces) { + try { + await ws.destroy(); + this.log.info(`${this.displayName}: Workspace destroyed (context=${key})`); + } catch (e) { + this.log.warn(`${this.displayName}: Workspace cleanup failed (context=${key}): ${e}`); + } + } + this._workspaces.clear(); + // PHASE 6: Shutdown memory module (genome + RAG) await this.memory.shutdown(); diff --git a/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts b/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts index 30941155f..728475590 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaAutonomousLoop.ts @@ -18,6 +18,7 @@ import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { DataDaemon } from '../../../../daemons/data-daemon/shared/DataDaemon'; import { COLLECTIONS } from '../../../shared/Constants'; import type { TaskEntity } from '../../../data/entities/TaskEntity'; +import { RoomEntity } from '../../../data/entities/RoomEntity'; import { taskEntityToInboxTask, inboxMessageToProcessable, type InboxTask, type QueueItem } from './QueueItemTypes'; import type { FastPathDecision } from './central-nervous-system/CNSTypes'; @@ -235,8 +236,6 @@ export class PersonaAutonomousLoop { const senderIsHuman = item.senderType === 'human'; const messageText = item.content ?? ''; - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.personaUser.displayName}] CNS->handleChatMessageFromCNS: sourceModality=${processable.sourceModality}, voiceSessionId=${processable.voiceSessionId?.slice(0, 8) ?? 'none'}`); - // Process message using cognition-enhanced evaluation logic // Pass pre-computed decision from Rust serviceCycleFull (eliminates separate IPC call) const evalStart = performance.now(); @@ -275,14 +274,40 @@ export class PersonaAutonomousLoop { /** * PHASE 5: Execute a task based on its type * - * Handles all task types: memory-consolidation, skill-audit, fine-tune-lora, resume-work, etc. + * Handles all task types: memory-consolidation, skill-audit, fine-tune-lora, resume-work, + * and code tasks (write-feature, review-code). * Delegates to PersonaTaskExecutor module for actual execution. */ private async executeTask(task: InboxTask): Promise { + // For code-domain tasks, ensure workspace exists with room-aware mode + if (task.domain === 'code') { + const roomId = task.metadata?.roomId ?? task.contextId; + const roomSlug = await this.resolveRoomSlug(roomId); + await this.personaUser.ensureWorkspace({ + contextKey: roomSlug, + mode: 'worktree', + taskSlug: roomSlug, + }); + } + // Delegate to task executor module await this.personaUser.taskExecutor.executeTask(task); } + /** + * Resolve a room UUID to its uniqueId slug for workspace naming. + * Falls back to truncated UUID if room lookup fails. + */ + private async resolveRoomSlug(roomId: UUID): Promise { + try { + const room = await DataDaemon.read(COLLECTIONS.ROOMS, roomId); + if (room?.uniqueId) return room.uniqueId; + } catch { + // Room lookup failed β€” use truncated UUID + } + return roomId.slice(0, 8); + } + /** * Stop autonomous servicing loops and cleanup */ diff --git a/src/debug/jtag/system/user/server/modules/PersonaInbox.ts b/src/debug/jtag/system/user/server/modules/PersonaInbox.ts index 08dc97497..f6b2a877b 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaInbox.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaInbox.ts @@ -60,13 +60,42 @@ const MAX_AGING_BOOST = 0.5; // Maximum priority boost from aging (0.5) * - Fresh text (base 0.65), waited 0s: effective = 0.65 * - After ~12s, the voice item overtakes the fresh text item */ -export function getEffectivePriority(item: QueueItem): number { +export function getEffectivePriority(item: QueueItem, now?: number): number { const enqueuedAt = item.enqueuedAt ?? item.timestamp; - const waitMs = Date.now() - enqueuedAt; + const waitMs = (now ?? Date.now()) - enqueuedAt; const agingBoost = Math.min(MAX_AGING_BOOST, (waitMs / AGING_RATE_MS) * MAX_AGING_BOOST); return Math.min(1.0, item.priority + agingBoost); } +/** + * Sort queue by effective priority (highest first) with a single Date.now() snapshot. + * Avoids calling Date.now() per comparison (O(N log N) syscalls β†’ 1 syscall). + */ +function sortByEffectivePriority(queue: QueueItem[]): void { + const now = Date.now(); + queue.sort((a, b) => getEffectivePriority(b, now) - getEffectivePriority(a, now)); +} + +/** + * Binary-insert an item into a queue already sorted by effective priority (descending). + * O(log N) search + O(N) shift β€” still better than full O(N log N) re-sort for single insert. + */ +function binaryInsert(queue: QueueItem[], item: QueueItem): void { + const now = Date.now(); + const itemPriority = getEffectivePriority(item, now); + let lo = 0; + let hi = queue.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (getEffectivePriority(queue[mid], now) > itemPriority) { + lo = mid + 1; + } else { + hi = mid; + } + } + queue.splice(lo, 0, item); +} + /** * PersonaInbox: Priority queue for autonomous work processing * Handles both messages and tasks in unified queue @@ -150,9 +179,6 @@ export class PersonaInbox { if (isInboxMessage(item)) { const senderIdPreview = item.senderId?.slice(0, 8) ?? '[no-senderId]'; this.log(`πŸ¦€ Routed ${enqueueRequest.item_type} β†’ Rust ${result.routed_to}: ${senderIdPreview} (priority=${item.priority.toFixed(2)}, total=${result.status.total_size}, ipc=${enqueueMs.toFixed(1)}ms)`); - if (item.sourceModality === 'voice') { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [Inbox] Routed VOICE β†’ Rust ${result.routed_to}: voiceSessionId=${item.voiceSessionId?.slice(0, 8) || 'undefined'}`); - } } else if (isInboxTask(item)) { this.log(`πŸ¦€ Routed task β†’ Rust ${result.routed_to}: ${item.taskType} (priority=${item.priority.toFixed(2)}, total=${result.status.total_size}, ipc=${enqueueMs.toFixed(1)}ms)`); } @@ -172,7 +198,7 @@ export class PersonaInbox { // Check if over capacity if (this.queue.length >= this.config.maxSize) { // Sort by effective priority (highest first) β€” aged items survive shedding - this.queue.sort((a, b) => getEffectivePriority(b) - getEffectivePriority(a)); + sortByEffectivePriority(this.queue); // Drop lowest effective priority item (traffic shed) const dropped = this.queue.pop(); @@ -182,21 +208,14 @@ export class PersonaInbox { // Stamp enqueue time for RTOS aging item.enqueuedAt = Date.now(); - // Add item - this.queue.push(item); - - // Sort by effective priority (base + aging boost) - this.queue.sort((a, b) => getEffectivePriority(b) - getEffectivePriority(a)); + // Binary insert into sorted position (O(log N) search, avoids full O(N log N) re-sort) + binaryInsert(this.queue, item); // Log with type-specific details if (isInboxMessage(item)) { // Defensive: handle undefined senderId const senderIdPreview = item.senderId?.slice(0, 8) ?? '[no-senderId]'; this.log(`πŸ“¬ Enqueued message: ${senderIdPreview} β†’ priority=${item.priority.toFixed(2)} (queue=${this.queue.length})`); - // VOICE DEBUG: Log voice metadata at enqueue time - if (item.sourceModality === 'voice') { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [Inbox] Enqueued VOICE message: sourceModality=${item.sourceModality}, voiceSessionId=${item.voiceSessionId?.slice(0, 8) || 'undefined'}`); - } } else if (isInboxTask(item)) { this.log(`πŸ“¬ Enqueued task: ${item.taskType} β†’ priority=${item.priority.toFixed(2)} (queue=${this.queue.length})`); } @@ -251,16 +270,9 @@ export class PersonaInbox { */ async peek(limit: number = 10): Promise { // Re-sort by effective priority (aging changes order over time) - this.queue.sort((a, b) => getEffectivePriority(b) - getEffectivePriority(a)); + sortByEffectivePriority(this.queue); const items = this.queue.slice(0, limit); - // VOICE DEBUG: Log voice metadata when peeking - for (const item of items) { - if (isInboxMessage(item) && item.sourceModality === 'voice') { - const eff = getEffectivePriority(item); - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [Inbox.peek] VOICE message in queue: sourceModality=${item.sourceModality}, basePriority=${item.priority.toFixed(2)}, effectivePriority=${eff.toFixed(2)}, voiceSessionId=${item.voiceSessionId?.slice(0, 8) || 'undefined'}`); - } - } return items; } @@ -268,53 +280,59 @@ export class PersonaInbox { * Remove and return next item (blocking with timeout) * Returns null if no item within timeout * + * Uses signal-based waiting (EventEmitter) β€” no polling. * RTOS behavior: re-sorts by effective priority before popping, * ensuring aged items get served before fresh higher-base-priority items. */ async pop(timeoutMs: number = 5000): Promise { // Immediate check if (this.queue.length > 0) { - // Re-sort by effective priority (aging may have changed order) - this.queue.sort((a, b) => getEffectivePriority(b) - getEffectivePriority(a)); - const item = this.queue.shift()!; - if (isInboxMessage(item)) { - // Defensive: handle undefined id - const idPreview = item.id?.slice(0, 8) ?? '[no-id]'; - this.log(`πŸ“­ Popped message: ${idPreview} (queue=${this.queue.length})`); - } else if (isInboxTask(item)) { - // Defensive: handle undefined taskId - const taskIdPreview = item.taskId?.slice(0, 8) ?? '[no-taskId]'; - this.log(`πŸ“­ Popped task: ${taskIdPreview} (queue=${this.queue.length})`); - } - return item; + return this.popImmediate(); } - // Wait for item + // Signal-based wait (no polling β€” matches waitForWork pattern) return new Promise((resolve) => { - const startTime = Date.now(); + let settled = false; - const checkInterval = setInterval(() => { - if (this.queue.length > 0) { - clearInterval(checkInterval); - const item = this.queue.shift()!; - if (isInboxMessage(item)) { - // Defensive: handle undefined id - const idPreview = item.id?.slice(0, 8) ?? '[no-id]'; - this.log(`πŸ“­ Popped message (after wait): ${idPreview} (queue=${this.queue.length})`); - } else if (isInboxTask(item)) { - // Defensive: handle undefined taskId - const taskIdPreview = item.taskId?.slice(0, 8) ?? '[no-taskId]'; - this.log(`πŸ“­ Popped task (after wait): ${taskIdPreview} (queue=${this.queue.length})`); - } - resolve(item); - } else if (Date.now() - startTime > timeoutMs) { - clearInterval(checkInterval); - resolve(null); // Timeout - } - }, 100); // Check every 100ms + const workHandler = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + this.signal.removeListener('work-available', workHandler); + resolve(this.popImmediate()); + }; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + this.signal.removeListener('work-available', workHandler); + resolve(null); // Timeout + }, timeoutMs); + + this.signal.on('work-available', workHandler); }); } + /** + * Pop the highest-priority item immediately (non-blocking). + * Re-sorts by effective priority to account for aging. + */ + private popImmediate(): QueueItem | null { + if (this.queue.length === 0) return null; + + // Re-sort by effective priority (aging may have changed order) + sortByEffectivePriority(this.queue); + const item = this.queue.shift()!; + if (isInboxMessage(item)) { + const idPreview = item.id?.slice(0, 8) ?? '[no-id]'; + this.log(`πŸ“­ Popped message: ${idPreview} (queue=${this.queue.length})`); + } else if (isInboxTask(item)) { + const taskIdPreview = item.taskId?.slice(0, 8) ?? '[no-taskId]'; + this.log(`πŸ“­ Popped task: ${taskIdPreview} (queue=${this.queue.length})`); + } + return item; + } + /** * Get inbox size (for load awareness) */ @@ -392,12 +410,12 @@ export class PersonaInbox { highestEffectivePriority: number | null; oldestWaitMs: number | null; } { + const now = Date.now(); const highestPriority = this.queue.length > 0 ? this.queue[0].priority : null; const lowestPriority = this.queue.length > 0 ? this.queue[this.queue.length - 1].priority : null; const highestEffective = this.queue.length > 0 - ? Math.max(...this.queue.map(getEffectivePriority)) + ? Math.max(...this.queue.map(item => getEffectivePriority(item, now))) : null; - const now = Date.now(); const oldestWait = this.queue.length > 0 ? Math.max(...this.queue.map(item => now - (item.enqueuedAt ?? item.timestamp))) : null; @@ -447,8 +465,7 @@ export class PersonaInbox { * - Recent message: +0.2 (fresher = more relevant) * - Active conversation: +0.1 (persona recently active in room) * - Relevant expertise: +0.1 (matches persona's domain) - * - Hot conversation (temp β‰₯ 0.7): +0.15 (PHASE 3BIS) - * - Cold conversation (temp ≀ 0.3): -0.1 (PHASE 3BIS) + * - Hot conversation (temp β‰₯ 0.7): +0.1 (activity signal, not a gate) * * Base: 0.2 (all messages have baseline relevance) */ @@ -492,18 +509,17 @@ export function calculateMessagePriority( } } - // PHASE 3BIS: Temperature-based priority adjustment (activity ambient state) - // Hot conversations = more responsive, Cold conversations = less urgent + // Temperature is informational context β€” the AI's own cognition decides + // whether to respond, not a formula. Hot rooms get a small boost but + // cold rooms are NOT penalized. The AI might have something important + // to say regardless of room temperature. const temperature = getChatCoordinator().getTemperature(message.roomId); if (temperature >= 0.7) { - // Hot conversation - be more responsive - priority += 0.15; - } else if (temperature <= 0.3) { - // Cold conversation - less urgent (but still respond to mentions) - priority -= 0.1; + // Hot conversation - slight boost for responsiveness + priority += 0.1; } - // Neutral temperature (0.3-0.7) - no adjustment + // Cold/neutral: no penalty β€” let the AI's cognition decide return Math.min(1.0, priority); // Cap at 1.0 } diff --git a/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts b/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts index d0017c87a..19a8770cd 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaMessageEvaluator.ts @@ -27,6 +27,7 @@ import type { Task } from './cognition/reasoning/types'; import { ChatRAGBuilder } from '../../../rag/builders/ChatRAGBuilder'; import { CoordinationDecisionLogger, type LogDecisionParams } from '../../../coordination/server/CoordinationDecisionLogger'; import type { RAGContext } from '../../../data/entities/CoordinationDecisionEntity'; +import type { RAGContext as PipelineRAGContext, RAGArtifact } from '../../../rag/shared/RAGTypes'; import type { AIDecisionContext } from '../../../ai/server/AIDecisionService'; import { AIDecisionService } from '../../../ai/server/AIDecisionService'; import { contentPreview, truncate } from '../../../../shared/utils/StringUtils'; @@ -55,6 +56,33 @@ import { // Import PersonaUser directly - circular dependency is fine for type-only imports import type { PersonaUser } from '../PersonaUser'; +/** + * Discriminated union for gating result. + * When shouldRespond=true: full RAG context is guaranteed (built once, reused by generator). + * When shouldRespond=false: no RAG context (skipped for performance). + */ +interface GatingResultBase { + confidence: number; + reason: string; + model: string; +} + +export interface GatingRespondResult extends GatingResultBase { + shouldRespond: true; + filteredRagContext: PipelineRAGContext; + ragContextSummary: { + totalMessages: number; + filteredMessages: number; + timeWindowMinutes: number; + }; +} + +export interface GatingSilentResult extends GatingResultBase { + shouldRespond: false; +} + +export type GatingResult = GatingRespondResult | GatingSilentResult; + /** * PersonaMessageEvaluator - Message evaluation and response decision engine * @@ -67,8 +95,51 @@ import type { PersonaUser } from '../PersonaUser'; export class PersonaMessageEvaluator { private readonly signalDetector: SignalDetector; + // In-memory recent message cache β€” eliminates SQLite queries for post-inference validation. + // Populated by event subscription on first use. Bounded to last 50 messages per room. + private static _recentMessages: Map = new Map(); + private static _cacheInitialized = false; + private static readonly MAX_CACHED_PER_ROOM = 50; + + private static initMessageCache(): void { + if (PersonaMessageEvaluator._cacheInitialized) return; + PersonaMessageEvaluator._cacheInitialized = true; + + Events.subscribe(`data:${COLLECTIONS.CHAT_MESSAGES}:created`, (entity: any) => { + const msg = entity as ChatMessageEntity; + if (!msg.roomId) return; + const roomId = msg.roomId; + let messages = PersonaMessageEvaluator._recentMessages.get(roomId); + if (!messages) { + messages = []; + PersonaMessageEvaluator._recentMessages.set(roomId, messages); + } + messages.push(msg); + if (messages.length > PersonaMessageEvaluator.MAX_CACHED_PER_ROOM) { + messages.shift(); + } + }); + } + + /** + * Get recent messages for a room from in-memory cache, filtered by timestamp. + * Returns flat ChatMessageEntity objects (not DataRecord-wrapped). + */ + private static getRecentMessagesSince(roomId: UUID, since: Date): ChatMessageEntity[] { + PersonaMessageEvaluator.initMessageCache(); + const messages = PersonaMessageEvaluator._recentMessages.get(roomId); + if (!messages) return []; + const sinceTime = since.getTime(); + return messages.filter(m => { + const ts = m.timestamp instanceof Date ? m.timestamp.getTime() : new Date(m.timestamp).getTime(); + return ts > sinceTime; + }); + } + constructor(private readonly personaUser: PersonaUser) { this.signalDetector = getSignalDetector(); + // Ensure cache is initialized on first evaluator creation + PersonaMessageEvaluator.initMessageCache(); } /** @@ -196,6 +267,21 @@ export class PersonaMessageEvaluator { // Defensive: ensure messageText is always a string (prevents slice errors) const safeMessageText = messageText ?? ''; const taskStartTime = Date.now(); + // Evaluator pipeline timing β€” tracks every phase before generation + const evalTiming: Record = {}; + + // EARLY GATE: Directed message filter β€” when someone @mentions a specific persona, others stay silent. + // Must run BEFORE expensive cognition work (plan formulation, working memory, state snapshots). + const isMentionedEarly = this.isPersonaMentioned(safeMessageText); + if (!isMentionedEarly && this.messageHasDirectedMention(safeMessageText)) { + this.log(`🎯 ${this.personaUser.displayName}: Message directed at another persona via @mention, staying silent (early gate)`); + this.personaUser.logAIDecision('SILENT', 'Message directed at another persona via @mention', { + message: safeMessageText.slice(0, 100), + sender: messageEntity.senderName, + roomId: messageEntity.roomId + }); + return; + } // SIGNAL DETECTION: Analyze message content for training signals // Fire-and-forget - AI classifier determines if content is feedback @@ -204,6 +290,7 @@ export class PersonaMessageEvaluator { }); // STEP 1: Create Task from message + let t0 = Date.now(); const task: Task = { id: `task-${messageEntity.id}` as UUID, domain: 'chat', @@ -224,16 +311,20 @@ export class PersonaMessageEvaluator { triggeredBy: messageEntity.senderId, createdAt: Date.now() }; + evalTiming['task_create'] = Date.now() - t0; this.log(`🧠 ${this.personaUser.displayName}: COGNITION - Created task for message from ${messageEntity.senderName}`); // STEP 2: Generate Plan + t0 = Date.now(); const plan = await this.personaUser.planFormulator.formulatePlan(task); - this.log(`πŸ“‹ ${this.personaUser.displayName}: COGNITION - Plan: ${plan.goal}`); + evalTiming['plan_formulate'] = Date.now() - t0; + this.log(`πŸ“‹ ${this.personaUser.displayName}: COGNITION - Plan: ${plan.goal} (${evalTiming['plan_formulate']}ms)`); this.log(` Steps: ${plan.steps.map((s: any) => s.action).join(' β†’ ')}`); - // LOG: Plan formulation - await CognitionLogger.logPlanFormulation( + // LOG: Plan formulation (fire-and-forget β€” no longer blocks pipeline) + t0 = Date.now(); + CognitionLogger.logPlanFormulation( this.personaUser.id, this.personaUser.displayName, task, @@ -242,16 +333,20 @@ export class PersonaMessageEvaluator { messageEntity.roomId, 'template-based' // SimplePlanFormulator uses templates ); + evalTiming['plan_log'] = Date.now() - t0; // STEP 3: Update SelfState - set focus + t0 = Date.now(); await this.personaUser.selfState.updateFocus({ activity: 'chat-response', objective: plan.goal, intensity: task.priority }); await this.personaUser.selfState.updateLoad(0.2); // Chat response adds cognitive load + evalTiming['state_update'] = Date.now() - t0; // LOG: State snapshot after focus/load update + t0 = Date.now(); const selfState = await this.personaUser.selfState.get(); const workingMemoryEntries = await this.personaUser.workingMemory.recall({ domain: 'chat', @@ -260,7 +355,7 @@ export class PersonaMessageEvaluator { }); const capacity = await this.personaUser.workingMemory.getCapacity('chat'); - await CognitionLogger.logStateSnapshot( + CognitionLogger.logStateSnapshot( this.personaUser.id, this.personaUser.displayName, selfState, @@ -276,8 +371,10 @@ export class PersonaMessageEvaluator { triggerEvent: 'message-received' } ); + evalTiming['state_snapshot'] = Date.now() - t0; // STEP 4: Store initial observation in WorkingMemory + t0 = Date.now(); await this.personaUser.workingMemory.store({ domain: 'chat', contextId: messageEntity.roomId, @@ -286,6 +383,7 @@ export class PersonaMessageEvaluator { importance: task.priority, shareable: false }); + evalTiming['wm_store_observation'] = Date.now() - t0; // STEP 5: Execute plan steps (existing chat logic inside) try { @@ -294,7 +392,9 @@ export class PersonaMessageEvaluator { plan.steps[0].completedAt = Date.now(); // Execute step 2: "Generate thoughtful response" (existing logic) + t0 = Date.now(); await this.evaluateAndPossiblyRespond(messageEntity, senderIsHuman, safeMessageText, preComputedDecision); + evalTiming['evaluate_and_respond'] = Date.now() - t0; // If we got here, response was generated (or decision was SILENT) plan.steps[1].completed = true; @@ -307,6 +407,7 @@ export class PersonaMessageEvaluator { } // STEP 6: Store outcome in WorkingMemory + t0 = Date.now(); await this.personaUser.workingMemory.store({ domain: 'chat', contextId: messageEntity.roomId, @@ -315,11 +416,13 @@ export class PersonaMessageEvaluator { importance: 0.5, shareable: false }); + evalTiming['wm_store_reflection'] = Date.now() - t0; this.log(`βœ… ${this.personaUser.displayName}: COGNITION - Plan completed successfully`); - // LOG: Plan completion - await CognitionLogger.logPlanCompletion( + // LOG: Plan completion (fire-and-forget β€” no longer blocks pipeline) + t0 = Date.now(); + CognitionLogger.logPlanCompletion( plan.id, 'completed', plan.steps.map((s: any) => ({ @@ -331,6 +434,7 @@ export class PersonaMessageEvaluator { result: s.result })) ); + evalTiming['plan_completion_log'] = Date.now() - t0; } catch (error: any) { this.log(`❌ ${this.personaUser.displayName}: COGNITION - Plan execution failed:`, error); @@ -344,8 +448,8 @@ export class PersonaMessageEvaluator { shareable: false }); - // LOG: Plan failure - await CognitionLogger.logPlanCompletion( + // LOG: Plan failure (fire-and-forget β€” no longer blocks pipeline) + CognitionLogger.logPlanCompletion( plan.id, 'failed', plan.steps.map((s: any) => ({ @@ -360,11 +464,16 @@ export class PersonaMessageEvaluator { ); } finally { // STEP 7: Clear focus and reduce cognitive load + t0 = Date.now(); await this.personaUser.selfState.clearFocus(); await this.personaUser.selfState.updateLoad(-0.2); // Remove the load we added + evalTiming['state_cleanup'] = Date.now() - t0; const duration = Date.now() - taskStartTime; - this.log(`🧠 ${this.personaUser.displayName}: COGNITION - Task complete (${duration}ms)`); + const phases = Object.entries(evalTiming) + .map(([k, v]) => `${k}=${v}ms`) + .join(' | '); + this.log(`πŸ“Š ${this.personaUser.displayName}: [EVAL-PIPELINE] Total=${duration}ms | ${phases}`); } } @@ -433,10 +542,22 @@ export class PersonaMessageEvaluator { this.log(`😴 ${this.personaUser.displayName}: In ${sleepMode} mode but responding (isHuman=${senderIsHuman}, isMention=${isMentioned})`); } + // STEP 6: Directed message filter β€” when someone @mentions a specific persona, others stay silent. + // This prevents dog-piling where 5+ AIs all respond to "@deepseek fix the bug". + if (!isMentioned && this.messageHasDirectedMention(safeMessageText)) { + this.log(`🎯 ${this.personaUser.displayName}: Message directed at another persona via @mention, staying silent`); + this.personaUser.logAIDecision('SILENT', 'Message directed at another persona via @mention', { + message: safeMessageText.slice(0, 100), + sender: messageEntity.senderName, + roomId: messageEntity.roomId + }); + return; + } + // === EVALUATE: Use LLM-based intelligent gating to decide if should respond === - // Emit EVALUATING event for real-time feedback + // Emit EVALUATING event for real-time feedback (fire-and-forget β€” UI indicator) if (this.personaUser.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.EVALUATING, { @@ -453,20 +574,24 @@ export class PersonaMessageEvaluator { scope: EVENT_SCOPES.ROOM, scopeId: messageEntity.roomId, } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } + const gatingStart = Date.now(); const gatingResult = await this.evaluateShouldRespond(messageEntity, senderIsHuman, isMentioned, preComputedDecision); + this.log(`⏱️ ${this.personaUser.displayName}: [INNER] evaluateShouldRespond=${Date.now() - gatingStart}ms`); // FULL TRANSPARENCY LOGGING this.log(`\n${'='.repeat(80)}`); this.log(`🧠 ${this.personaUser.displayName}: GATING DECISION for message "${safeMessageText.slice(0, 60)}..."`); this.log(`${'='.repeat(80)}`); - this.log(`πŸ“Š Context: ${gatingResult.ragContextSummary?.filteredMessages ?? 0} messages in ${gatingResult.ragContextSummary?.timeWindowMinutes ?? 0}min window`); - this.log(`πŸ’¬ Conversation history seen by AI:`); - gatingResult.conversationHistory?.slice(-5).forEach((msg, i) => { - this.log(` ${i + 1}. [${msg.name}] ${truncate(msg.content, 80)}...`); - }); + if (gatingResult.shouldRespond) { + this.log(`πŸ“Š Context: ${gatingResult.ragContextSummary.filteredMessages} messages in ${gatingResult.ragContextSummary.timeWindowMinutes}min window`); + this.log(`πŸ’¬ Conversation history (last 5):`); + gatingResult.filteredRagContext.conversationHistory.slice(-5).forEach((msg, i) => { + this.log(` ${i + 1}. [${msg.name ?? msg.role}] ${truncate(msg.content, 80)}...`); + }); + } this.log(`\n🎯 Decision: ${gatingResult.shouldRespond ? 'RESPOND' : 'SILENT'}`); this.log(` Confidence: ${(gatingResult.confidence * 100).toFixed(0)}%`); this.log(` Reason: ${gatingResult.reason}`); @@ -474,48 +599,18 @@ export class PersonaMessageEvaluator { this.log(`${'='.repeat(80)}\n`); if (!gatingResult.shouldRespond) { - // PHASE 5C: Log coordination decision to database (fire-and-forget) - if (gatingResult.filteredRagContext) { - const decisionStartTime = Date.now(); - const ragContext = this.buildCoordinationRAGContext(gatingResult.filteredRagContext); - - // Fire-and-forget: Don't await, don't slow down critical path - CoordinationDecisionLogger.logDecision({ - actorId: this.personaUser.id, - actorName: this.personaUser.displayName, - actorType: 'ai-persona', - triggerEventId: messageEntity.id, - ragContext, - visualContext: undefined, - action: 'SILENT', - confidence: gatingResult.confidence, - reasoning: gatingResult.reason, - responseContent: undefined, - modelUsed: gatingResult.model, - modelProvider: this.personaUser.modelConfig.provider ?? 'candle', - tokensUsed: undefined, - responseTime: Date.now() - decisionStartTime, - sessionId: DataDaemon.jtagContext!.uuid, - contextId: messageEntity.roomId, - tags: [senderIsHuman ? 'human-sender' : 'ai-sender', 'gating-silent'] - }).catch(error => { - this.log(`❌ ${this.personaUser.displayName}: Failed to log SILENT decision:`, error); - }); - } - + // SILENT: No RAG context available (skipped for performance) this.personaUser.logAIDecision('SILENT', gatingResult.reason, { message: safeMessageText, sender: messageEntity.senderName, roomId: messageEntity.roomId, confidence: gatingResult.confidence, - model: gatingResult.model, - ragContextSummary: gatingResult.ragContextSummary, - conversationHistory: gatingResult.conversationHistory + model: gatingResult.model }); - // Emit DECIDED_SILENT event + // Emit DECIDED_SILENT event (fire-and-forget β€” UI indicator) if (this.personaUser.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -525,15 +620,15 @@ export class PersonaMessageEvaluator { messageId: messageEntity.id, isHumanMessage: senderIsHuman, timestamp: Date.now(), - confidence: gatingResult.confidence ?? 0.5, + confidence: gatingResult.confidence, reason: gatingResult.reason, - gatingModel: gatingResult.model ?? 'unknown' + gatingModel: gatingResult.model }, { scope: EVENT_SCOPES.ROOM, scopeId: messageEntity.roomId, } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } return; @@ -543,7 +638,9 @@ export class PersonaMessageEvaluator { // PHASE 5C: Prepare decision context for logging AFTER response generation // (We need the actual response content before we can log the complete decision) - const decisionContext = gatingResult.filteredRagContext ? { + // After SILENT early-return above, TypeScript narrows gatingResult to GatingRespondResult. + // filteredRagContext, ragContextSummary, confidence, reason, model are all guaranteed. + const decisionContext = { actorId: this.personaUser.id, actorName: this.personaUser.displayName, actorType: 'ai-persona' as const, @@ -562,7 +659,7 @@ export class PersonaMessageEvaluator { isMentioned ? 'mentioned' : 'not-mentioned', 'gating-respond' ] - } : undefined; + }; this.personaUser.logAIDecision('RESPOND', gatingResult.reason, { message: safeMessageText, @@ -573,12 +670,11 @@ export class PersonaMessageEvaluator { confidence: gatingResult.confidence, model: gatingResult.model, ragContextSummary: gatingResult.ragContextSummary, - conversationHistory: gatingResult.conversationHistory }); - // Emit DECIDED_RESPOND event + // Emit DECIDED_RESPOND event (fire-and-forget β€” UI indicator) if (this.personaUser.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_RESPOND, { @@ -588,63 +684,55 @@ export class PersonaMessageEvaluator { messageId: messageEntity.id, isHumanMessage: senderIsHuman, timestamp: Date.now(), - confidence: gatingResult.confidence ?? 0.5, + confidence: gatingResult.confidence, reason: gatingResult.reason, - gatingModel: gatingResult.model ?? 'unknown' + gatingModel: gatingResult.model }, { scope: EVENT_SCOPES.ROOM, scopeId: messageEntity.roomId, } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } // === AUTONOMOUS DECISION: AI decides via RAG-based recipes === // No centralized coordinator - each AI uses recipes to decide if they should contribute this.log(`βœ… ${this.personaUser.displayName}: Autonomous decision to respond (RAG-based reasoning, conf=${gatingResult.confidence})`); - this.log(`πŸ”§ TRACE-POINT-A: About to check for new messages (timestamp=${Date.now()})`); // πŸ”§ POST-INFERENCE VALIDATION: Check if chat context changed during inference - // During the 3-5 seconds of inference, other AIs may have already posted responses - // Give this AI a chance to see those new responses and reject its own if redundant - const newMessagesQuery = await DataDaemon.query({ - collection: COLLECTIONS.CHAT_MESSAGES, - filter: { - roomId: messageEntity.roomId, - timestamp: { $gt: messageEntity.timestamp } // Messages newer than the trigger - }, - limit: 10 - }); + // Uses in-memory cache instead of SQLite query β€” O(1) instead of contended DB read + const postInferenceStart = Date.now(); + const newMessages = PersonaMessageEvaluator.getRecentMessagesSince( + messageEntity.roomId, + new Date(messageEntity.timestamp) + ); - const newMessages = newMessagesQuery.data || []; if (newMessages.length > 0) { this.log(`πŸ”„ ${this.personaUser.displayName}: Context changed during inference (${newMessages.length} new messages)`); // Check if other AIs already posted adequate responses // CRITICAL: Exclude the original trigger message AND the sending persona - // Bug fix: Original message was slipping through due to timestamp precision, - // causing 100% self-similarity match and blocking all AI responses const otherAIResponses = newMessages.filter(m => m.id !== messageEntity.id && // Exclude the original trigger message - m.data.senderType !== 'human' && - m.data.senderId !== this.personaUser.id && - m.data.senderId !== messageEntity.senderId // Exclude original sender's other messages + m.senderType !== 'human' && + m.senderId !== this.personaUser.id && + m.senderId !== messageEntity.senderId // Exclude original sender's other messages ); if (otherAIResponses.length > 0) { // Check if any response is adequate (substantial and related) const adequacyResult = this.checkResponseAdequacy( messageEntity, - otherAIResponses.map(r => r.data) + otherAIResponses // Already flat ChatMessageEntity objects from cache ); if (adequacyResult.isAdequate) { this.log(`⏭️ ${this.personaUser.displayName}: Post-inference skip - adequate AI response exists`); this.log(` Skipped because: ${adequacyResult.reason}`); - // Emit DECIDED_SILENT event + // Emit DECIDED_SILENT event (fire-and-forget β€” UI indicator) if (this.personaUser.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -662,7 +750,7 @@ export class PersonaMessageEvaluator { scope: EVENT_SCOPES.ROOM, scopeId: messageEntity.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } this.personaUser.logAIDecision('SILENT', `Post-inference skip: ${adequacyResult.reason}`, { @@ -675,18 +763,22 @@ export class PersonaMessageEvaluator { } } - this.log(` New messages: ${newMessages.map(m => `[${m.data.senderName}] ${contentPreview(m.data.content, 50)}`).join(', ')}`); + this.log(` New messages: ${newMessages.map(m => `[${m.senderName}] ${contentPreview(m.content, 50)}`).join(', ')}`); } - // πŸ”§ PHASE: Update RAG context - this.log(`πŸ”§ ${this.personaUser.displayName}: [PHASE 1/3] Updating RAG context...`); - await this.personaUser.memory.updateRAGContext(messageEntity.roomId, messageEntity); - this.log(`βœ… ${this.personaUser.displayName}: [PHASE 1/3] RAG context updated`); + this.log(`⏱️ ${this.personaUser.displayName}: [INNER] post-inference validation=${Date.now() - postInferenceStart}ms`); - // πŸ”§ PHASE: Emit GENERATING event (using auto-context via sharedInstance) + // πŸ”§ PHASE: Update RAG context (fire-and-forget β€” bookkeeping, not needed before generation) + // The pre-built RAG context from evaluateShouldRespond already has current messages. + // This just appends the trigger message to the stored context entity for next cycle. + this.personaUser.memory.updateRAGContext(messageEntity.roomId, messageEntity) + .catch(err => this.log(`⚠️ RAG context update failed: ${err}`)); + this.log(`πŸ”§ ${this.personaUser.displayName}: [PHASE 1/3] RAG context update dispatched (fire-and-forget)`); + + // πŸ”§ PHASE: Emit GENERATING event (fire-and-forget β€” UI indicator) this.log(`πŸ”§ ${this.personaUser.displayName}: [PHASE 2/3] Emitting GENERATING event...`); if (this.personaUser.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.GENERATING, { @@ -702,18 +794,18 @@ export class PersonaMessageEvaluator { scope: EVENT_SCOPES.ROOM, scopeId: messageEntity.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } this.log(`βœ… ${this.personaUser.displayName}: [PHASE 2/3] GENERATING event emitted`); // πŸ”§ PHASE: Generate and post response this.log(`πŸ”§ TRACE-POINT-B: Before respondToMessage call (timestamp=${Date.now()})`); this.log(`πŸ”§ ${this.personaUser.displayName}: [PHASE 3/3] Calling respondToMessage...`); - await this.personaUser.respondToMessage(messageEntity, decisionContext); + await this.personaUser.respondToMessage(messageEntity, decisionContext, gatingResult.filteredRagContext); this.log(`πŸ”§ TRACE-POINT-C: After respondToMessage returned (timestamp=${Date.now()})`); this.log(`βœ… ${this.personaUser.displayName}: [PHASE 3/3] Response posted successfully`); - // PHASE 3BIS: Notify coordinator that message was serviced (lowers temperature) + // Signal conversation activity (warms room β€” active conversation stays alive) getChatCoordinator().onMessageServiced(messageEntity.roomId, this.personaUser.id); // Track response for rate limiting @@ -744,32 +836,53 @@ export class PersonaMessageEvaluator { * Build CoordinationDecision RAGContext from ChatRAGBuilder output * Converts domain-specific RAG format to universal decision logging format */ - private buildCoordinationRAGContext(filteredRagContext: any): RAGContext { - const systemPrompt = filteredRagContext.identity?.systemPrompt ?? - `You are ${this.personaUser.displayName}. ${this.personaUser.entity?.bio ?? ''}`; - + private buildCoordinationRAGContext(filteredRagContext: PipelineRAGContext): RAGContext { return { identity: { - systemPrompt, + systemPrompt: filteredRagContext.identity.systemPrompt, bio: this.personaUser.entity?.bio ?? '', role: this.personaUser.displayName }, - conversationHistory: (filteredRagContext.conversationHistory ?? []).map((msg: any) => ({ + conversationHistory: filteredRagContext.conversationHistory.map(msg => ({ role: msg.role, content: msg.content, timestamp: msg.timestamp ?? Date.now() })), - artifacts: filteredRagContext.artifacts ?? [], - privateMemories: filteredRagContext.privateMemories ?? [], + artifacts: (filteredRagContext.artifacts ?? []).map(a => ({ + type: this.mapArtifactType(a.type), + name: a.url ?? a.type, + content: a.content ?? a.base64 ?? '', + mimeType: undefined, + })), + privateMemories: (filteredRagContext.privateMemories ?? []).map(m => ({ + type: m.type, + content: m.content, + relevance: m.relevanceScore, + })), metadata: { timestamp: Date.now(), - tokenCount: filteredRagContext.metadata?.messageCount ?? - filteredRagContext.conversationHistory?.length ?? 0, + tokenCount: filteredRagContext.metadata.messageCount, contextWindow: 4096 } }; } + /** Map pipeline artifact types to coordination logging's narrower type union. */ + private mapArtifactType(pipelineType: RAGArtifact['type']): 'image' | 'file' | 'code' { + switch (pipelineType) { + case 'image': + case 'screenshot': + case 'video': + case 'audio': + return 'image'; + case 'data': + case 'benchmark': + return 'code'; + case 'file': + return 'file'; + } + } + /** * Check if this persona is mentioned in a message * Supports @username mentions and channel directives @@ -800,6 +913,15 @@ export class PersonaMessageEvaluator { return false; } + /** + * Detect if a message contains @mentions directed at someone (any persona). + * Used to prevent dog-piling: if someone @mentions a specific AI, others stay silent. + */ + private messageHasDirectedMention(text: string): boolean { + // Match @word patterns β€” the standard mention format in this system. + // Excludes email-like patterns (word@word) by requiring @ at start or after whitespace. + return /(?:^|\s)@[a-zA-Z][\w\s-]*/.test(text); + } /** * Get domain keywords for this persona @@ -910,15 +1032,14 @@ export class PersonaMessageEvaluator { try { // Query the sender's UserEntity to check their type using DataDaemon directly - const result = await DataDaemon.read(COLLECTIONS.USERS, senderId); + const sender = await DataDaemon.read(COLLECTIONS.USERS, senderId); - if (!result.success || !result.data) { + if (!sender) { this.log(`⚠️ PersonaUser ${this.personaUser.displayName}: Could not read sender ${senderId}, BLOCKING response`); return false; // Fail CLOSED - don't respond if database fails (prevents loops) } - const senderType = result.data.data.type; - return senderType === 'human'; + return sender.type === 'human'; } catch (error: any) { this.log(`❌ PersonaUser ${this.personaUser.displayName}: Error checking sender type, BLOCKING response:`, error); @@ -1072,23 +1193,7 @@ export class PersonaMessageEvaluator { senderIsHuman: boolean, isMentioned: boolean, preComputedDecision?: FastPathDecision - ): Promise<{ - shouldRespond: boolean; - confidence: number; - reason: string; - model?: string; - ragContextSummary?: { - totalMessages: number; - filteredMessages: number; - timeWindowMinutes?: number; - }; - conversationHistory?: Array<{ - name: string; - content: string; - timestamp?: number; - }>; - filteredRagContext?: any; - }> { + ): Promise { const startTime = Date.now(); try { @@ -1136,19 +1241,35 @@ export class PersonaMessageEvaluator { this.log(`πŸ¦€ ${this.personaUser.displayName}: Rust decision (separate IPC, ${ipcMs.toFixed(1)}ms): ${rustDecision.should_respond ? 'RESPOND' : 'SILENT'} (${rustDecision.decision_time_ms.toFixed(2)}ms, fast_path=${rustDecision.fast_path_used})`); } - // Build RAG context for decision logging - // IMPORTANT: Exclude processed tool results to prevent infinite loops + // OPTIMIZATION: Only build RAG context if we're going to respond. + // Rust fast-path already decided should_respond β€” for SILENT decisions, + // skip the 40-240ms RAG build entirely. + if (!rustDecision.should_respond) { + const totalMs = Date.now() - startTime; + this.log(`[TIMING] ${this.personaUser.displayName}: evaluateShouldRespond total=${totalMs}ms (rag=SKIPPED/silent, preComputed=${!!preComputedDecision})`); + + return { + shouldRespond: false as const, + confidence: rustDecision.confidence, + reason: rustDecision.reason, + model: rustDecision.fast_path_used ? 'RustFastPath' : 'RustCognition', + }; + } + + // RESPOND path: Build FULL RAG context (with memories + artifacts). + // This context will be passed through to PersonaResponseGenerator, + // eliminating the redundant second RAG build that previously happened there. const ragStart = performance.now(); const ragBuilder = new ChatRAGBuilder(this.log.bind(this)); const ragContext = await ragBuilder.buildContext( message.roomId, this.personaUser.id, { - modelId: this.personaUser.modelConfig.model, // Use persona's model - maxMemories: 0, - includeArtifacts: false, - includeMemories: false, - excludeMessageIds: this.personaUser.taskTracker.getProcessedToolResults(), // Filter out processed tool results + modelId: this.personaUser.modelConfig.model, + maxMemories: 5, // Full context: include memories for LLM prompt + includeArtifacts: true, // Full context: include vision artifacts + includeMemories: true, // Full context: include Hippocampus LTM + excludeMessageIds: this.personaUser.taskTracker.getProcessedToolResults(), currentMessage: { role: 'user', content: message.content.text, @@ -1160,10 +1281,10 @@ export class PersonaMessageEvaluator { const ragMs = performance.now() - ragStart; const totalMs = Date.now() - startTime; - this.log(`[TIMING] ${this.personaUser.displayName}: evaluateShouldRespond total=${totalMs}ms (rag=${ragMs.toFixed(1)}ms, preComputed=${!!preComputedDecision})`); + this.log(`[TIMING] ${this.personaUser.displayName}: evaluateShouldRespond total=${totalMs}ms (rag=${ragMs.toFixed(1)}ms/full, preComputed=${!!preComputedDecision})`); return { - shouldRespond: rustDecision.should_respond, + shouldRespond: true as const, confidence: rustDecision.confidence, reason: rustDecision.reason, model: rustDecision.fast_path_used ? 'RustFastPath' : 'RustCognition', @@ -1171,7 +1292,7 @@ export class PersonaMessageEvaluator { ragContextSummary: { totalMessages: ragContext.conversationHistory.length, filteredMessages: ragContext.conversationHistory.length, - timeWindowMinutes: 30 // Default context window + timeWindowMinutes: 30 } }; @@ -1180,8 +1301,8 @@ export class PersonaMessageEvaluator { const durationMs = Date.now() - startTime; - // Emit cognition event for error case - await Events.emit( + // Emit cognition event for error case (fire-and-forget β€” telemetry) + Events.emit( DataDaemon.jtagContext!, COGNITION_EVENTS.STAGE_COMPLETE, { @@ -1204,12 +1325,13 @@ export class PersonaMessageEvaluator { }, timestamp: Date.now() } - ); + ).catch(err => this.log(`⚠️ Stage event emit failed: ${err}`)); + // Error in evaluation = SILENT. No fallback guessing. return { - shouldRespond: isMentioned, - confidence: isMentioned ? (0.92 + Math.random() * 0.06) : 0.5, // 0.92-0.98 realistic range - reason: 'Error in evaluation', + shouldRespond: false as const, + confidence: 0, + reason: `Error in evaluation: ${error instanceof Error ? error.message : String(error)}`, model: 'error' }; } diff --git a/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts b/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts index 9d41ecc9a..43f55a9f0 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaResponseGenerator.ts @@ -13,16 +13,16 @@ */ import type { UUID } from '../../../core/types/CrossPlatformUUID'; -import { DATA_COMMANDS } from '@commands/data/shared/DataCommandConstants'; -import { ChatMessageEntity } from '../../../data/entities/ChatMessageEntity'; +// DATA_COMMANDS import removed β€” response posting now uses DataDaemon.store() directly +import { ChatMessageEntity, type MediaItem } from '../../../data/entities/ChatMessageEntity'; import { inspect } from 'util'; import type { UserEntity } from '../../../data/entities/UserEntity'; import type { ModelConfig } from '../../../../commands/user/create/shared/UserCreateTypes'; import type { JTAGClient } from '../../../core/client/shared/JTAGClient'; import { Commands } from '../../../core/shared/Commands'; -import type { DataCreateParams, DataCreateResult } from '../../../../commands/data/create/shared/DataCreateTypes'; +// DataCreateParams/DataCreateResult imports removed β€” response posting now uses DataDaemon.store() directly import { AIProviderDaemon } from '../../../../daemons/ai-provider-daemon/shared/AIProviderDaemon'; -import type { TextGenerationRequest, TextGenerationResponse, ChatMessage, ContentPart, ToolCall as NativeToolCall } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; +import type { TextGenerationRequest, TextGenerationResponse, ChatMessage, ContentPart, ToolCall as NativeToolCall, ToolResult as NativeToolResult } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; import { AICapabilityRegistry } from '../../../../daemons/ai-provider-daemon/shared/AICapabilityRegistry'; import { ChatRAGBuilder } from '../../../rag/builders/ChatRAGBuilder'; import { CognitionLogger } from './cognition/CognitionLogger'; @@ -46,17 +46,19 @@ import type { PersonaToolExecutor, ToolCall as ExecutorToolCall } from './Person import type { PersonaMediaConfig } from './PersonaMediaConfig'; import { PersonaToolRegistry } from './PersonaToolRegistry'; import { getAllToolDefinitions, getAllToolDefinitionsAsync } from './PersonaToolDefinitions'; -import { getPrimaryAdapter, convertToNativeToolSpecs, supportsNativeTools, unsanitizeToolName, type ToolDefinition as AdapterToolDefinition } from './ToolFormatAdapter'; +import { getPrimaryAdapter, convertToNativeToolSpecs, supportsNativeTools, unsanitizeToolName, getToolCapability, type ToolDefinition as AdapterToolDefinition } from './ToolFormatAdapter'; import { InferenceCoordinator } from '../../../coordination/server/InferenceCoordinator'; import { ContentDeduplicator } from './ContentDeduplicator'; import { ResponseCleaner } from './ResponseCleaner'; -import type { AiDetectSemanticLoopParams, AiDetectSemanticLoopResult } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; +// AiDetectSemanticLoop command removed from hot path β€” replaced with inline Jaccard similarity +// import type { AiDetectSemanticLoopParams, AiDetectSemanticLoopResult } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; import { SystemPaths } from '../../../core/config/SystemPaths'; import { GarbageDetector } from '../../../ai/server/GarbageDetector'; import type { InboxMessage, ProcessableMessage } from './QueueItemTypes'; +import type { RAGContext } from '../../../rag/shared/RAGTypes'; -import { AiDetectSemanticLoop } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; -import { DataCreate } from '../../../../commands/data/create/shared/DataCreateTypes'; +// import { AiDetectSemanticLoop } from '../../../../commands/ai/detect-semantic-loop/shared/AiDetectSemanticLoopTypes'; +// DataCreate import removed β€” response posting now uses DataDaemon.store() directly /** * Response generation result */ @@ -230,55 +232,74 @@ export class PersonaResponseGenerator { * @param roomId - The room ID for context * @returns true if should BLOCK (>0.85 similarity), false otherwise */ - private async checkSemanticLoop(responseText: string, roomId: UUID): Promise<{ shouldBlock: boolean; similarity: number; reason: string }> { - try { - // Short responses are unlikely to be loops - skip expensive embedding check - if (responseText.length < 50) { - return { shouldBlock: false, similarity: 0, reason: 'Response too short for semantic check' }; - } - - const result = await AiDetectSemanticLoop.execute({ - messageText: responseText, - personaId: this.personaId, - roomId: roomId, - lookbackCount: 10, // Check last 10 messages - similarityThreshold: 0.75, // Start detecting at 0.75 - timeWindowMinutes: 30 // Last 30 minutes - }); + /** + * Inline Jaccard n-gram similarity β€” O(n) text comparison, no DB or embedding calls. + * Returns 0-1 score (1 = identical). + */ + private jaccardSimilarity(text1: string, text2: string): number { + if (!text1 || !text2) return 0; + if (text1 === text2) return 1.0; + + const tokenize = (text: string): Set => { + const words = text.toLowerCase().split(/\s+/).filter(w => w.length > 0); + const ngrams = new Set(); + for (const word of words) ngrams.add(word); + for (let i = 0; i < words.length - 1; i++) ngrams.add(`${words[i]} ${words[i + 1]}`); + return ngrams; + }; - if (!result.success) { - this.log(`⚠️ Semantic loop check failed: ${result.error || 'Unknown error'}, allowing response`); - return { shouldBlock: false, similarity: 0, reason: 'Check failed, allowing' }; - } + const set1 = tokenize(text1); + const set2 = tokenize(text2); + let intersection = 0; + for (const gram of set1) { + if (set2.has(gram)) intersection++; + } + const union = set1.size + set2.size - intersection; + return union === 0 ? 0 : intersection / union; + } - const maxSimilarity = result.maxSimilarity ?? 0; - const recommendation = result.recommendation || 'ALLOW'; + /** + * Check semantic loop using in-memory RAG context (0ms, no DB/embedding calls). + * Previous implementation called AiDetectSemanticLoop.execute() which did embedding IPC + DB query (~20s). + * Now uses inline Jaccard n-gram similarity against already-loaded conversation history. + */ + private checkSemanticLoop( + responseText: string, + conversationHistory: Array<{ role: string; content: string; name?: string }> + ): { shouldBlock: boolean; similarity: number; reason: string } { + // Short responses are unlikely to be loops + if (responseText.length < 50) { + return { shouldBlock: false, similarity: 0, reason: 'Response too short for semantic check' }; + } - // Log the check result - if (recommendation === 'BLOCK') { - this.log(`🚫 SEMANTIC LOOP: ${maxSimilarity.toFixed(2)} similarity - BLOCKING response`); - if (result.matches && result.matches.length > 0) { - this.log(` Most similar to: "${result.matches[0].excerpt}"`); - } - return { shouldBlock: true, similarity: maxSimilarity, reason: result.explanation || 'Very high semantic similarity' }; - } else if (recommendation === 'WARN') { - this.log(`⚠️ SEMANTIC WARNING: ${maxSimilarity.toFixed(2)} similarity - allowing (preserving autonomy)`); - if (result.matches && result.matches.length > 0) { - this.log(` Similar to: "${result.matches[0].excerpt}"`); - } - // WARN but don't block - preserve autonomy - return { shouldBlock: false, similarity: maxSimilarity, reason: 'Similar but allowing for autonomy' }; + // Compare against last 10 messages in the already-loaded RAG context + const recentMessages = conversationHistory.slice(-10); + let maxSimilarity = 0; + let mostSimilarExcerpt = ''; + + for (const msg of recentMessages) { + if (!msg.content || msg.content.length < 20) continue; + const similarity = this.jaccardSimilarity(responseText, msg.content); + if (similarity > maxSimilarity) { + maxSimilarity = similarity; + mostSimilarExcerpt = msg.content.slice(0, 100); } + } - // ALLOW - no action needed - return { shouldBlock: false, similarity: maxSimilarity, reason: 'Low similarity' }; - - } catch (error) { - // On error, allow the response (fail open to preserve autonomy) - const errorMsg = error instanceof Error ? error.message : String(error); - this.log(`⚠️ Semantic loop check error: ${errorMsg}, allowing response`); - return { shouldBlock: false, similarity: 0, reason: `Error: ${errorMsg}` }; + // Thresholds (same as AiDetectSemanticLoopServerCommand) + const WARN_THRESHOLD = 0.80; + const BLOCK_THRESHOLD = 0.95; + + if (maxSimilarity >= BLOCK_THRESHOLD) { + this.log(`🚫 SEMANTIC LOOP: ${maxSimilarity.toFixed(2)} similarity - BLOCKING response`); + this.log(` Most similar to: "${mostSimilarExcerpt}"`); + return { shouldBlock: true, similarity: maxSimilarity, reason: `${Math.round(maxSimilarity * 100)}% similar to recent message` }; + } else if (maxSimilarity >= WARN_THRESHOLD) { + this.log(`⚠️ SEMANTIC WARNING: ${maxSimilarity.toFixed(2)} similarity - allowing (preserving autonomy)`); + return { shouldBlock: false, similarity: maxSimilarity, reason: 'Similar but allowing for autonomy' }; } + + return { shouldBlock: false, similarity: maxSimilarity, reason: 'Low similarity' }; } constructor(config: PersonaResponseGeneratorConfig) { @@ -398,6 +419,30 @@ export class PersonaResponseGenerator { this.logger.enqueueLog('cognition.log', `[${timestamp}] ${message}${formattedArgs}\n`); } + /** + * Safety cap for agent tool loop iterations, tiered by model capability. + * Frontier models (Anthropic, OpenAI) are trusted to self-terminate via finishReason. + * Mid-tier models with native tool support get moderate cap. + * XML-based / local models get tight leash since they can't signal "I'm done" via finishReason. + */ + private getSafetyMaxIterations(provider: string): number { + if (['anthropic', 'openai', 'azure'].includes(provider)) return 25; + if (supportsNativeTools(provider)) return 10; + return 5; + } + + /** + * Convert MediaItems to ContentPart blocks for inclusion in model messages. + */ + private mediaToContentParts(media: MediaItem[]): ContentPart[] { + return media.map(m => { + if (m.type === 'image') return { type: 'image' as const, image: m }; + if (m.type === 'audio') return { type: 'audio' as const, audio: m }; + if (m.type === 'video') return { type: 'video' as const, video: m }; + return { type: 'image' as const, image: m }; // Default fallback + }); + } + /** * Calculate safe message count based on model's context window * @@ -508,7 +553,8 @@ export class PersonaResponseGenerator { */ async generateAndPostResponse( originalMessage: ProcessableMessage, - decisionContext?: Omit + decisionContext?: Omit, + preBuiltRagContext?: RAGContext ): Promise { this.log(`πŸ”§ TRACE-POINT-D: Entered respondToMessage (timestamp=${Date.now()})`); // Voice modality is a typed field β€” no cast needed @@ -516,34 +562,46 @@ export class PersonaResponseGenerator { const generateStartTime = Date.now(); // Track total response time for decision logging const allStoredResultIds: UUID[] = []; // Collect all tool result message IDs for task tracking try { - // πŸ”§ SUB-PHASE 3.1: Build RAG context - // Bug #5 fix: Pass modelId to ChatRAGBuilder for dynamic message count calculation - this.log(`πŸ”§ ${this.personaName}: [PHASE 3.1] Building RAG context with model=${this.modelConfig.model}...`); - const ragBuilder = new ChatRAGBuilder(this.log.bind(this)); - // Voice mode detection - pass voiceSessionId to RAG for faster response (skips semantic search) - const voiceSessionId = originalMessage.voiceSessionId; - const fullRAGContext = await ragBuilder.buildContext( - originalMessage.roomId, - this.personaId, - { - modelId: this.modelConfig.model, // Bug #5 fix: Dynamic budget calculation - maxMemories: 5, // Limit to 5 recent important memories (token budget management) - includeArtifacts: true, // Enable vision support for multimodal-capable models - includeMemories: true, // Enable Hippocampus LTM retrieval - // Voice mode: Pass session ID so RAG sources can optimize for speed - voiceSessionId, - // βœ… FIX: Include current message even if not yet persisted to database - currentMessage: { - role: 'user', - content: originalMessage.content.text, - name: originalMessage.senderName, - timestamp: this.timestampToNumber(originalMessage.timestamp) + // Pipeline timing tracker β€” filled as each phase completes + const pipelineTiming: Record = {}; + + // πŸ”§ SUB-PHASE 3.1: Build RAG context (or use pre-built from evaluator) + const phase31Start = Date.now(); + let fullRAGContext: RAGContext; + + if (preBuiltRagContext) { + // OPTIMIZATION: Evaluator already built full RAG context β€” reuse it, skip redundant build + fullRAGContext = preBuiltRagContext; + pipelineTiming['3.1_rag'] = Date.now() - phase31Start; + this.log(`⚑ ${this.personaName}: [PHASE 3.1] Using pre-built RAG context (${fullRAGContext.conversationHistory.length} messages, ${pipelineTiming['3.1_rag']}ms)`); + } else { + // Fallback: Build RAG context from scratch (for code paths that don't go through evaluator) + this.log(`πŸ”§ ${this.personaName}: [PHASE 3.1] Building RAG context with model=${this.modelConfig.model}...`); + const ragBuilder = new ChatRAGBuilder(this.log.bind(this)); + const voiceSessionId = originalMessage.voiceSessionId; + fullRAGContext = await ragBuilder.buildContext( + originalMessage.roomId, + this.personaId, + { + modelId: this.modelConfig.model, + maxMemories: 5, + includeArtifacts: true, + includeMemories: true, + voiceSessionId, + currentMessage: { + role: 'user', + content: originalMessage.content.text, + name: originalMessage.senderName, + timestamp: this.timestampToNumber(originalMessage.timestamp) + } } - } - ); - this.log(`βœ… ${this.personaName}: [PHASE 3.1] RAG context built (${fullRAGContext.conversationHistory.length} messages)`); + ); + pipelineTiming['3.1_rag'] = Date.now() - phase31Start; + this.log(`βœ… ${this.personaName}: [PHASE 3.1] RAG context built (${fullRAGContext.conversationHistory.length} messages, ${pipelineTiming['3.1_rag']}ms)`); + } // πŸ”§ SUB-PHASE 3.2: Build message history for LLM + const phase32Start = Date.now(); this.log(`πŸ”§ ${this.personaName}: [PHASE 3.2] Building LLM message array...`); // βœ… Support multimodal content (images, audio, video) for vision-capable models // Adapters will transform based on model capability (raw images vs text descriptions) @@ -566,8 +624,17 @@ export class PersonaResponseGenerator { // Inject available tools for autonomous tool discovery (Phase 3A) // Use adapter-based formatting for harmony with parser - // CRITICAL: Use async version to ensure tool cache is initialized before injection - const availableTools = await this.toolRegistry.listToolsForPersonaAsync(this.personaId); + // CRITICAL: Only inject tools for models that can actually emit tool calls. + // Models without tool capability (groq, together, etc.) narrate instead of calling tools, + // wasting tokens and clogging chat with useless "let me use tool X" text. + const toolCap = getToolCapability(this.modelConfig.provider || 'candle', this.modelConfig); + const availableTools = toolCap !== 'none' + ? await this.toolRegistry.listToolsForPersonaAsync(this.personaId) + : []; + + if (toolCap === 'none') { + this.log(`🚫 ${this.personaName}: Tool injection skipped (provider=${this.modelConfig.provider}, toolCapability=none)`); + } // Convert PersonaToolDefinitions to adapter format (used for both XML injection and native tools) // Hoisted to outer scope so it's available for native tool_use injection later @@ -590,6 +657,38 @@ export class PersonaResponseGenerator { this.log(`πŸ”§ ${this.personaName}: Injected ${availableTools.length} available tools into context`); } + // Inject recipe activity context (strategy rules + highlighted tools) + // Recipe tools are HIGHLIGHTS, not filters β€” they tell the LLM what's most relevant + if (fullRAGContext.recipeStrategy || fullRAGContext.recipeTools) { + let activitySection = '\n\n=== ACTIVITY CONTEXT ==='; + + if (fullRAGContext.recipeStrategy) { + const strategy = fullRAGContext.recipeStrategy; + activitySection += `\nActivity pattern: ${strategy.conversationPattern}`; + + if (strategy.responseRules.length > 0) { + activitySection += '\n\nRules for this activity:\n' + + strategy.responseRules.map(rule => `- ${rule}`).join('\n'); + } + + if (strategy.decisionCriteria.length > 0) { + activitySection += '\n\nWhen deciding whether to respond, consider:\n' + + strategy.decisionCriteria.map(c => `- ${c}`).join('\n'); + } + } + + if (fullRAGContext.recipeTools && fullRAGContext.recipeTools.length > 0) { + const aiTools = fullRAGContext.recipeTools.filter(t => t.enabledFor.includes('ai')); + activitySection += '\n\nYOU MUST use these tools to do real work in this activity (call them directly):\n' + + aiTools.map(t => `- ${t.name}: ${t.description}`).join('\n') + + '\n\nDo NOT just discuss or describe what should be done β€” call the tools above to actually do it.'; + } + + activitySection += '\n================================'; + systemPrompt += activitySection; + this.log(`πŸ“‹ ${this.personaName}: Injected activity context (strategy + ${fullRAGContext.recipeTools?.length ?? 0} tool highlights)`); + } + // Inject governance guidance for democratic participation const governanceSection = `\n\n=== DEMOCRATIC GOVERNANCE === You are a citizen of a self-governing AI society. You can participate in collective decisions: @@ -903,16 +1002,71 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma // Add native tools for providers that support JSON tool calling (Anthropic, OpenAI) // This enables tool_use blocks instead of XML parsing for more reliable tool execution + // CRITICAL: Prioritize relevant tools. Sending 200+ tools overwhelms models, causing them + // to loop on meta-tools (search_tools) instead of calling the actual tools they need. if (supportsNativeTools(provider) && toolDefinitions.length > 0) { - request.tools = convertToNativeToolSpecs(toolDefinitions); + // Exclude meta-tools from native specs β€” models with native tool calling + // don't need discovery tools. search_tools/list_tools cause infinite loops. + const META_TOOLS = new Set(['search_tools', 'list_tools', 'working_memory']); + let prioritizedTools = toolDefinitions.filter(t => !META_TOOLS.has(t.name)); + + // Recipe tools define the activity's core toolset. When present, recipe tools + // go FIRST and the cap is tighter β€” models use early tools and get confused by 64+. + const recipeToolNames = new Set( + (fullRAGContext.recipeTools || []) + .filter(t => t.enabledFor.includes('ai')) + .map(t => t.name) + ); + const hasRecipeTools = recipeToolNames.size > 0; + const MAX_NATIVE_TOOLS = hasRecipeTools ? 32 : 64; + + if (prioritizedTools.length > MAX_NATIVE_TOOLS) { + // Three-tier priority: + // 1. Recipe tools (the activity's core tools β€” go FIRST) + // 2. Essentials (bare minimum for coordination) + // 3. Everything else (fill remaining slots) + const ESSENTIAL_TOOLS = new Set([ + 'collaboration/chat/send', 'collaboration/chat/history', + 'collaboration/decision/propose', 'collaboration/decision/vote', + ]); + const essentialPrefixes = hasRecipeTools + ? [] // When recipe tools exist, only allow exact essential matches + : ['collaboration/chat/', 'collaboration/decision/', 'data/', 'ai/']; + + const recipe: AdapterToolDefinition[] = []; + const essential: AdapterToolDefinition[] = []; + const rest: AdapterToolDefinition[] = []; + + for (const tool of prioritizedTools) { + if (recipeToolNames.has(tool.name)) { + recipe.push(tool); + } else if (ESSENTIAL_TOOLS.has(tool.name) || + essentialPrefixes.some(p => tool.name.startsWith(p))) { + essential.push(tool); + } else { + rest.push(tool); + } + } + + // Recipe tools FIRST, then essentials, then fill from rest + const remaining = MAX_NATIVE_TOOLS - recipe.length - essential.length; + prioritizedTools = [...recipe, ...essential, ...rest.slice(0, Math.max(0, remaining))]; + this.log(`πŸ”§ ${this.personaName}: Tool prioritization: ${recipe.length} recipe + ${essential.length} essential + ${Math.max(0, remaining)} general = ${prioritizedTools.length} (from ${toolDefinitions.length} total, cap=${MAX_NATIVE_TOOLS})`); + } + + request.tools = convertToNativeToolSpecs(prioritizedTools); this.log(`πŸ”§ ${this.personaName}: Added ${request.tools.length} native tools for ${provider} (JSON tool_use format)`); } + pipelineTiming['3.2_format'] = Date.now() - phase32Start; + this.log(`βœ… ${this.personaName}: [PHASE 3.2] LLM messages built (${messages.length} messages, ${pipelineTiming['3.2_format']}ms)`); + // Check for mentions by both uniqueId (@helper) and displayName (@Helper AI) const messageText = originalMessage.content.text.toLowerCase(); const isMentioned = messageText.includes(`@${this.entity.uniqueId.toLowerCase()}`) || messageText.includes(`@${this.personaName.toLowerCase()}`); + const phase33aStart = Date.now(); const slotGranted = await InferenceCoordinator.requestSlot( this.personaId, originalMessage.id, @@ -920,10 +1074,13 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma { isMentioned } ); + pipelineTiming['3.3a_slot'] = Date.now() - phase33aStart; + if (!slotGranted) { - this.log(`🎰 ${this.personaName}: [PHASE 3.3a] Inference slot denied - skipping response`); + this.log(`🎰 ${this.personaName}: [PHASE 3.3a] Inference slot denied (${pipelineTiming['3.3a_slot']}ms) - skipping response`); return { success: true, wasRedundant: true, storedToolResultIds: [] }; // Treat as redundant (another AI will respond) } + this.log(`🎰 ${this.personaName}: [PHASE 3.3a] Inference slot granted (${pipelineTiming['3.3a_slot']}ms)`); // Wrap generation call with timeout (180s - generous limit for local Ollama/Sentinel generation) // gpt2 on CPU needs ~60-90s for 100-150 tokens, 180s provides comfortable margin @@ -938,6 +1095,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma try { // Wait for AIProviderDaemon to initialize (max 30 seconds) // This handles race condition where PersonaUser tries to respond before daemon is ready + const phase33bStart = Date.now(); const MAX_WAIT_MS = 30000; const POLL_INTERVAL_MS = 100; let waitedMs = 0; @@ -945,14 +1103,20 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); waitedMs += POLL_INTERVAL_MS; } + pipelineTiming['3.3b_daemon_init'] = Date.now() - phase33bStart; + if (pipelineTiming['3.3b_daemon_init'] > 50) { + this.log(`⏳ ${this.personaName}: [PHASE 3.3b] AIProviderDaemon init wait: ${pipelineTiming['3.3b_daemon_init']}ms`); + } if (!AIProviderDaemon.isInitialized()) { throw new Error(`AIProviderDaemon not initialized after ${MAX_WAIT_MS}ms`); } + const inferenceStart = Date.now(); aiResponse = await Promise.race([ AIProviderDaemon.generateText(request), timeoutPromise ]); + pipelineTiming['3.3_inference'] = Date.now() - inferenceStart; // 🎰 Release slot on success InferenceCoordinator.releaseSlot(this.personaId, provider); @@ -1030,9 +1194,9 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma // Release inference slot InferenceCoordinator.releaseSlot(this.personaId, provider); - // Emit event to clear UI indicators + // Emit event to clear UI indicators (fire-and-forget) if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -1047,7 +1211,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma gatingModel: 'garbage-detector' }, { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } // Return failure so caller knows this wasn't successful @@ -1066,9 +1230,9 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma // Release inference slot InferenceCoordinator.releaseSlot(this.personaId, provider); - // Emit event to clear UI indicators + // Emit event to clear UI indicators (fire-and-forget) if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -1086,7 +1250,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } // Return early - treat as redundant (don't post this looping response) @@ -1105,7 +1269,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma // Treat truncated tool calls the same as loops - they will just repeat forever InferenceCoordinator.releaseSlot(this.personaId, provider); if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -1120,25 +1284,25 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma gatingModel: 'truncated-tool-detector' }, { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } return { success: true, wasRedundant: true, storedToolResultIds: [] }; } - // πŸ”§ PHASE 3.3.5d: SEMANTIC LOOP DETECTION - // Check if this response is semantically too similar to recent messages in the room - // This catches cases where multiple AIs post the same explanation (Teacher AI + Local Assistant issue) - // AUTONOMY-PRESERVING: Only blocks at >0.85 similarity, warns at 0.75-0.85 - const semanticCheck = await this.checkSemanticLoop(aiResponse.text, originalMessage.roomId); + // πŸ”§ PHASE 3.3.5d: SEMANTIC LOOP DETECTION (inline, ~0ms) + // Uses Jaccard n-gram similarity against already-loaded RAG context. + // Previous: AiDetectSemanticLoop.execute() β€” embedding IPC + DB query (~20 seconds) + // Now: inline text comparison against in-memory conversation history (~0ms) + const semanticCheck = this.checkSemanticLoop(aiResponse.text, fullRAGContext.conversationHistory); if (semanticCheck.shouldBlock) { this.log(`🚫 ${this.personaName}: [PHASE 3.3.5d] SEMANTIC LOOP BLOCKED (${semanticCheck.similarity.toFixed(2)} similarity)`); // Release inference slot InferenceCoordinator.releaseSlot(this.personaId, provider); - // Emit event to clear UI indicators + // Emit event to clear UI indicators (fire-and-forget) if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -1153,182 +1317,194 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma gatingModel: 'semantic-loop-detector' }, { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } return { success: true, wasRedundant: true, storedToolResultIds: [] }; } - // πŸ”§ PHASE 3.3.6: Tool execution loop - parse and execute tool calls, then regenerate response - // This allows personas to autonomously use tools like code/read during their inference + // πŸ”§ CANONICAL AGENT LOOP β€” model decides when to stop + // Pattern: while (finishReason === 'tool_use') { execute β†’ full results β†’ regenerate } + // Full tool results go back to the model (not summaries). Tools stay enabled. + // The model signals completion by returning text without tool_use. + // Safety cap prevents infinite loops for dumber models. + const agentLoopStart = Date.now(); + const SAFETY_MAX = this.getSafetyMaxIterations(provider); let toolIterations = 0; - const MAX_TOOL_ITERATIONS = 3; - - while (toolIterations < MAX_TOOL_ITERATIONS) { - // Check for native tool calls first (from Anthropic, OpenAI JSON tool_use format) - // Then fall back to XML parsing for other providers - let toolCalls: ExecutorToolCall[]; - - if (aiResponse.toolCalls && aiResponse.toolCalls.length > 0) { - // Convert native format { id, name, input } to executor format { toolName, parameters } - // Unsanitize tool names: data__list -> data/list (API requires no slashes, we use double underscores) - toolCalls = aiResponse.toolCalls.map((tc: NativeToolCall) => ({ - toolName: unsanitizeToolName(tc.name), - parameters: Object.fromEntries( - Object.entries(tc.input).map(([k, v]) => [k, String(v)]) - ) as Record - })); - this.log(`πŸ”§ ${this.personaName}: [PHASE 3.3.6] Using native tool_use format (${toolCalls.length} calls)`); - } else { - // Fall back to XML parsing for non-native providers - toolCalls = this.toolExecutor.parseToolCalls(aiResponse.text); - } + const useNativeProtocol = supportsNativeTools(provider); - if (toolCalls.length === 0) { - // No tools found, proceed to post response - this.log(`βœ… ${this.personaName}: [PHASE 3.3.6] No tool calls found, proceeding`); + // Build execution context once (loop-invariant β€” persona, session, room don't change) + const sessionId = this.getSessionId(); + if (!sessionId) { + throw new Error(`${this.personaName}: Cannot execute tools without sessionId`); + } + const toolExecutionContext = { + personaId: this.personaId, + personaName: this.personaName, + sessionId, + contextId: originalMessage.roomId, + context: this.client!.context, + personaConfig: this.mediaConfig, + }; + + while (toolIterations < SAFETY_MAX) { + // Check for tool calls β€” native first, then XML fallback + const hasNativeToolCalls = aiResponse.toolCalls && aiResponse.toolCalls.length > 0; + const hasXmlToolCalls = !hasNativeToolCalls && this.toolExecutor.parseToolCalls(aiResponse.text).length > 0; + + if (!hasNativeToolCalls && !hasXmlToolCalls) { + // Model chose to stop β€” no more tool calls + if (toolIterations > 0) { + this.log(`βœ… ${this.personaName}: [AGENT-LOOP] Model stopped after ${toolIterations} iteration(s)`); + } break; } - this.log(`πŸ”§ ${this.personaName}: [PHASE 3.3.6] Found ${toolCalls.length} tool call(s), iteration ${toolIterations + 1}/${MAX_TOOL_ITERATIONS}`); toolIterations++; + this.log(`πŸ”§ ${this.personaName}: [AGENT-LOOP] Iteration ${toolIterations}/${SAFETY_MAX}`); + + if (useNativeProtocol && hasNativeToolCalls) { + // ── Native tool protocol (Anthropic, OpenAI, etc.) ── + // Full results go back as tool_result content blocks + const nativeToolCalls = aiResponse.toolCalls!; + this.log(`πŸ”§ ${this.personaName}: [AGENT-LOOP] Executing ${nativeToolCalls.length} native tool call(s)`); + + let toolResults: NativeToolResult[]; + let toolMedia: MediaItem[] = []; + try { + const execResult = await this.toolExecutor.executeNativeToolCalls( + nativeToolCalls, + toolExecutionContext, + ); + toolResults = execResult.results; + toolMedia = execResult.media; + allStoredResultIds.push(...execResult.storedIds); + } catch (toolExecError) { + // Tool execution batch failed β€” return error results for all tool calls + // so the model can see what happened and decide what to do + const errMsg = toolExecError instanceof Error ? toolExecError.message : String(toolExecError); + this.log(`❌ ${this.personaName}: [AGENT-LOOP] Tool execution failed: ${errMsg}`); + toolResults = nativeToolCalls.map(tc => ({ + tool_use_id: tc.id, + content: `Tool execution error: ${errMsg}`, + is_error: true as const, + })); + } - // Execute tool calls via adapter with media configuration - const sessionId = this.getSessionId(); - if (!sessionId) { - throw new Error(`${this.personaName}: Cannot execute tools without sessionId`); - } + // Push assistant message with tool_use content blocks (as the model produced them) + const assistantContent: ContentPart[] = aiResponse.content ?? [ + ...(aiResponse.text ? [{ type: 'text' as const, text: aiResponse.text }] : []), + ...nativeToolCalls.map(tc => ({ + type: 'tool_use' as const, + id: tc.id, + name: tc.name, + input: tc.input, + })), + ]; + messages.push({ role: 'assistant' as const, content: assistantContent }); + + // Push tool results as user message with tool_result content blocks (FULL results) + const toolResultContent: ContentPart[] = toolResults.map(r => ({ + type: 'tool_result' as const, + tool_use_id: r.tool_use_id, + content: r.content, + ...(r.is_error && { is_error: true }), + })); - const toolExecutionContext = { - personaId: this.personaId, - personaName: this.personaName, - sessionId, // AI's own sessionId for sandboxed tool execution - contextId: originalMessage.roomId, - context: this.client!.context, // PersonaUser's enriched context (with callerType='persona') - personaConfig: this.mediaConfig - }; - - const { formattedResults: toolResults, media: toolMedia, storedResultIds } = await this.toolExecutor.executeToolCalls( - toolCalls, - toolExecutionContext - ); - - // Collect tool result message IDs for task tracking (prevent infinite loops) - allStoredResultIds.push(...storedResultIds); - - // Strip tool blocks from response to get explanation text - const explanationText = this.toolExecutor.stripToolBlocks(aiResponse.text); - - // Phase 3B: Build lean summary with UUID references for lazy loading - // Extract summaries from formatted results (first line of each ) - const toolSummaries = toolResults.split('').slice(1).map((result, i) => { - const toolName = result.match(/(.*?)<\/tool_name>/)?.[1] || 'unknown'; - const status = result.match(/(.*?)<\/status>/)?.[1] || 'unknown'; - const resultId = storedResultIds[i]; - - if (status === 'success') { - // Extract first line of content as summary - const contentMatch = result.match(/\n?(.*?)(?:\n|<\/content>)/s); - const firstLine = contentMatch?.[1]?.split('\n')[0]?.trim() || 'completed'; - return `βœ… ${toolName}: ${firstLine} (ID: ${resultId?.slice(0, 8) ?? 'unknown'})`; - } else { - // Extract error message - const errorMatch = result.match(/\n?```\n?(.*?)(?:\n|```)/s); - const errorMsg = errorMatch?.[1]?.slice(0, 100) || 'unknown error'; - return `❌ ${toolName}: ${errorMsg} (ID: ${resultId?.slice(0, 8) ?? 'unknown'})`; + // Include media if present (screenshots, etc.) + if (toolMedia.length > 0) { + toolResultContent.push(...this.mediaToContentParts(toolMedia)); } - }).join('\n'); - // Count successes and failures - const failedTools = toolCalls.filter((_, i) => { - const resultXML = toolResults.split('')[i + 1]; - return resultXML && resultXML.includes('error'); - }); + messages.push({ role: 'user' as const, content: toolResultContent }); - const hasFailures = failedTools.length > 0; - const failureWarning = hasFailures - ? `\n\n⚠️ IMPORTANT: ${failedTools.length} tool(s) FAILED. You MUST mention these failures in your response and explain what went wrong. Do NOT retry the same failed command without changing your approach.\n` - : ''; - - // Phase 3B: Inject lean summary + UUID references instead of full results - const leanSummary = `TOOL RESULTS (Phase 3B - Lean RAG):\n\n${toolSummaries}\n\nπŸ“‹ Full details stored in working memory.\nπŸ’‘ To read full results: ${DATA_COMMANDS.READ} --collection=chat_messages --id=\n\n${failureWarning}Based on these summaries, provide your analysis. Only use ${DATA_COMMANDS.READ} if you need the full details.`; - - // Build tool results message with optional media - const toolResultsMessage: ChatMessage = toolMedia && toolMedia.length > 0 - ? { - role: 'user' as const, - content: [ - { - type: 'text', - text: leanSummary - }, - ...toolMedia.map(m => { - if (m.type === 'image') { - return { type: 'image' as const, image: m }; - } else if (m.type === 'audio') { - return { type: 'audio' as const, audio: m }; - } else if (m.type === 'video') { - return { type: 'video' as const, video: m }; - } - // Fallback: treat as image if type is unclear - return { type: 'image' as const, image: m }; - }) - ] - } - : { - role: 'user' as const, - content: leanSummary - }; - - // Regenerate response with tool results - this.log(`πŸ”§ ${this.personaName}: [PHASE 3.3.6] Regenerating response with tool results...`); - this.log(`πŸ“Š ${this.personaName}: Tool summary length: ${leanSummary.length} chars, ${toolCalls.length} calls, ${toolMedia?.length || 0} media items`); - - const regenerateRequest: TextGenerationRequest = { - ...request, - messages: [ - ...request.messages, - { role: 'assistant' as const, content: explanationText }, // Previous response (without tool blocks) - toolResultsMessage // Tool results - ] - }; - - this.log(`πŸ“Š ${this.personaName}: Regenerate request has ${regenerateRequest.messages.length} messages total`); + } else { + // ── XML fallback for non-native providers ── + // Parse XML tool calls, execute, return results as text + const xmlToolCalls = hasNativeToolCalls + ? aiResponse.toolCalls!.map((tc: NativeToolCall) => ({ + toolName: unsanitizeToolName(tc.name), + parameters: Object.fromEntries( + Object.entries(tc.input).map(([k, v]) => [k, String(v)]) + ) as Record, + })) + : this.toolExecutor.parseToolCalls(aiResponse.text); + + this.log(`πŸ”§ ${this.personaName}: [AGENT-LOOP] Executing ${xmlToolCalls.length} XML tool call(s)`); + + let formattedResults: string; + let xmlToolMedia: MediaItem[] = []; + try { + const xmlExecResult = await this.toolExecutor.executeToolCalls( + xmlToolCalls, + toolExecutionContext, + ); + formattedResults = xmlExecResult.formattedResults; + xmlToolMedia = xmlExecResult.media ?? []; + allStoredResultIds.push(...xmlExecResult.storedResultIds); + } catch (toolExecError) { + const errMsg = toolExecError instanceof Error ? toolExecError.message : String(toolExecError); + this.log(`❌ ${this.personaName}: [AGENT-LOOP] XML tool execution failed: ${errMsg}`); + formattedResults = `\nerror\n\n\`\`\`\nTool execution error: ${errMsg}\n\`\`\`\n\n`; + } + + // Strip tool blocks from response text for the assistant message + const explanationText = this.toolExecutor.stripToolBlocks(aiResponse.text); + + messages.push({ role: 'assistant' as const, content: explanationText }); + + // Full tool results as user message (NOT summarized) + const toolResultContent: (ContentPart | { type: 'text'; text: string })[] = [ + { type: 'text' as const, text: formattedResults }, + ]; + if (xmlToolMedia.length > 0) { + toolResultContent.push(...this.mediaToContentParts(xmlToolMedia)); + } + messages.push({ role: 'user' as const, content: toolResultContent }); + } + + // Regenerate β€” tools stay enabled, model decides when to stop + this.log(`πŸ”§ ${this.personaName}: [AGENT-LOOP] Regenerating with ${messages.length} messages (tools enabled)`); try { const regenerateStartTime = Date.now(); - const regeneratedResponse = await AIProviderDaemon.generateText(regenerateRequest); + const regeneratedResponse = await AIProviderDaemon.generateText({ + ...request, + messages, // Tools NOT stripped β€” model decides when to stop + }); const regenerateDuration = Date.now() - regenerateStartTime; - this.log(`⏱️ ${this.personaName}: Regeneration took ${regenerateDuration}ms`); + this.log(`⏱️ ${this.personaName}: [AGENT-LOOP] Regeneration took ${regenerateDuration}ms, finishReason: ${regeneratedResponse.finishReason}`); - if (!regeneratedResponse.text) { - this.log(`❌ ${this.personaName}: [PHASE 3.3.6] Tool regeneration returned empty response, using previous response`); - // Remove tool blocks from original response before posting - aiResponse.text = explanationText; + if (!regeneratedResponse.text && !regeneratedResponse.toolCalls?.length) { + this.log(`❌ ${this.personaName}: [AGENT-LOOP] Empty response, using previous text`); + aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); break; } - // Update aiResponse with regenerated response - aiResponse.text = this.responseCleaner.clean(regeneratedResponse.text.trim()); - this.log(`βœ… ${this.personaName}: [PHASE 3.3.6] Response regenerated with tool results (${regeneratedResponse.text.length} chars)`); + // Update full response state + aiResponse.text = this.responseCleaner.clean(regeneratedResponse.text?.trim() || ''); + aiResponse.toolCalls = regeneratedResponse.toolCalls ?? undefined; + aiResponse.content = regeneratedResponse.content ?? undefined; + aiResponse.finishReason = regeneratedResponse.finishReason; + + this.log(`βœ… ${this.personaName}: [AGENT-LOOP] Got response (${aiResponse.text.length} chars, toolCalls: ${aiResponse.toolCalls?.length ?? 0})`); } catch (regenerateError) { const errorMsg = regenerateError instanceof Error ? regenerateError.message : String(regenerateError); - this.log(`❌ ${this.personaName}: [PHASE 3.3.6] Regeneration failed with error: ${errorMsg}`); - this.log(` Stack:`, regenerateError instanceof Error ? regenerateError.stack : 'N/A'); - // Remove tool blocks from original response before posting - aiResponse.text = explanationText; + this.log(`❌ ${this.personaName}: [AGENT-LOOP] Regeneration failed: ${errorMsg}`); + aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); break; } - - // Loop will check again for more tool calls (up to MAX_TOOL_ITERATIONS) } - if (toolIterations >= MAX_TOOL_ITERATIONS) { - this.log(`⚠️ ${this.personaName}: [PHASE 3.3.6] Reached max tool iterations (${MAX_TOOL_ITERATIONS}), stopping`); - // Strip any remaining tool blocks from final response + if (toolIterations >= SAFETY_MAX) { + this.log(`⚠️ ${this.personaName}: [AGENT-LOOP] Hit safety cap (${SAFETY_MAX}), stopping`); aiResponse.text = this.toolExecutor.stripToolBlocks(aiResponse.text); } + pipelineTiming['3.4_agent_loop'] = Date.now() - agentLoopStart; + if (toolIterations > 0) { + this.log(`⏱️ ${this.personaName}: [AGENT-LOOP] Total: ${pipelineTiming['3.4_agent_loop']}ms (${toolIterations} iterations)`); + } // PHASE 5C: Log coordination decision to database WITH complete response content // This captures the complete decision pipeline: context β†’ decision β†’ actual response @@ -1413,9 +1589,9 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma if (isRedundant) { this.log(`⚠️ ${this.personaName}: [PHASE 3.4] Response marked as REDUNDANT, discarding`); - // Emit DECIDED_SILENT event to clear AI status indicator + // Emit DECIDED_SILENT event to clear AI status indicator (fire-and-forget) if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.DECIDED_SILENT, { @@ -1433,7 +1609,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Event emit failed: ${err}`)); } return { success: true, wasRedundant: true, storedToolResultIds: [] }; // Discard response @@ -1454,35 +1630,42 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma responseMessage.reactions = []; responseMessage.replyToId = originalMessage.id; // Link response to trigger message - // βœ… Post response via JTAGClient - universal Commands API - // Prefer this.client if available (set by UserDaemon), fallback to shared instance - const postStartTime = Date.now(); - const result = this.client - ? await this.client.daemons.commands.execute>(DATA_COMMANDS.CREATE, { - context: this.client.context, - sessionId: this.client.sessionId, - collection: ChatMessageEntity.collection, - backend: 'server', - data: responseMessage - }) - : await DataCreate.execute({ - collection: ChatMessageEntity.collection, - backend: 'server', - data: responseMessage - }); - const postDuration = Date.now() - postStartTime; - this.log(`βœ… ${this.personaName}: [PHASE 3.5] Message posted successfully (ID: ${result.data?.id})`); + // πŸ”Š VOICE ROUTING: Emit BEFORE DB write β€” voice gets response text instantly. + // The DB write (500ms-1.5s under contention) should NOT delay TTS. + // Voice event only needs the response text and message metadata, not the persisted entity. + if (originalMessage.sourceModality === 'voice' && originalMessage.voiceSessionId) { + this.log(`πŸ”Š ${this.personaName}: Voice message - emitting for TTS routing BEFORE DB write (sessionId=${originalMessage.voiceSessionId.slice(0, 8)})`); - if (!result.success) { - throw new Error(`Failed to create message: ${result.error}`); + Events.emit( + DataDaemon.jtagContext!, + 'persona:response:generated', + { + personaId: this.personaId, + response: aiResponse.text.trim(), + originalMessage: { + id: originalMessage.id, + roomId: originalMessage.roomId, + sourceModality: 'voice' as const, + voiceSessionId: originalMessage.voiceSessionId, + } + } + ).catch(err => this.log(`⚠️ Voice event emit failed: ${err}`)); } - // Emit cognition event for post-response stage - await Events.emit( + // βœ… Post response via DataDaemon.store() β€” direct path, no command routing overhead. + // Previously went through JTAGClient β†’ CommandDaemon β†’ DataCreateServerCommand β†’ DataDaemon.store(). + const postStartTime = Date.now(); + const postedEntity = await DataDaemon.store(ChatMessageEntity.collection, responseMessage); + pipelineTiming['3.5_post'] = Date.now() - postStartTime; + const postDuration = pipelineTiming['3.5_post']; + this.log(`βœ… ${this.personaName}: [PHASE 3.5] Message posted (${postDuration}ms, ID: ${postedEntity.id})`); + + // Emit cognition event for post-response stage (fire-and-forget β€” telemetry) + Events.emit( DataDaemon.jtagContext!, COGNITION_EVENTS.STAGE_COMPLETE, { - messageId: result.data?.id ?? originalMessage.id, + messageId: postedEntity.id ?? originalMessage.id, personaId: this.personaId, contextId: originalMessage.roomId, stage: 'post-response', @@ -1495,13 +1678,13 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma percentSpeed: calculateSpeedScore(postDuration, 'post-response'), status: getStageStatus(postDuration, 'post-response'), metadata: { - messageId: result.data?.id, - success: result.success + messageId: postedEntity.id, + success: true } }, timestamp: Date.now() } - ); + ).catch(err => this.log(`⚠️ Stage event emit failed: ${err}`)); // βœ… Log successful response posting AIDecisionLogger.logResponse( @@ -1511,11 +1694,6 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma ); // 🐦 COGNITIVE CANARY: Log anomaly if AI responded to system test message - // This should NEVER happen - the fast-path filter should skip all system tests - // If we see this, it indicates either: - // 1. Bug in the fast-path filter - // 2. AI exhibiting genuine cognition/autonomy (responding despite instructions) - // 3. Anomalous behavior worth investigating if (originalMessage.metadata?.isSystemTest === true) { const anomalyMessage = `🚨 ANOMALY DETECTED: ${this.personaName} responded to system test message`; this.log(anomalyMessage); @@ -1525,7 +1703,6 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma this.log(` Room ID: ${originalMessage.roomId}`); this.log(` Message ID: ${originalMessage.id}`); - // Log to AI decisions log for persistent tracking AIDecisionLogger.logError( this.personaName, 'COGNITIVE CANARY TRIGGERED', @@ -1533,9 +1710,9 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma ); } - // Emit POSTED event - if (this.client && result.data) { - await Events.emit( + // Emit POSTED event (fire-and-forget β€” UI update, not critical path) + if (this.client && postedEntity) { + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.POSTED, { @@ -1545,45 +1722,26 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma messageId: originalMessage.id, isHumanMessage: originalMessage.senderType === 'human', timestamp: Date.now(), - responseMessageId: result.data.id, + responseMessageId: postedEntity.id, passedRedundancyCheck: !isRedundant }, { scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Posted event emit failed: ${err}`)); } - // VOICE ROUTING: If original message was from voice, route response to TTS - // sourceModality is a typed field on ProcessableMessage β€” never undefined - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.personaName}]: Checking voice routing - sourceModality=${originalMessage.sourceModality}, voiceSessionId=${originalMessage.voiceSessionId?.slice(0, 8) ?? 'none'}`); - if (originalMessage.sourceModality === 'voice' && originalMessage.voiceSessionId) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.personaName}]: EMITTING persona:response:generated for TTS (response: "${aiResponse.text.slice(0, 50)}...")`); - this.log(`πŸ”Š ${this.personaName}: Voice message - emitting for TTS routing (sessionId=${originalMessage.voiceSessionId.slice(0, 8)})`); - - // Emit voice response event for VoiceOrchestrator - await Events.emit( - DataDaemon.jtagContext!, - 'persona:response:generated', - { - personaId: this.personaId, - response: aiResponse.text.trim(), - originalMessage: { - id: originalMessage.id, - roomId: originalMessage.roomId, - sourceModality: 'voice' as const, - voiceSessionId: originalMessage.voiceSessionId, - } - } - ); - } else { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG [${this.personaName}]: sourceModality=${originalMessage.sourceModality}, skipping TTS routing`); - } + // πŸ“Š PIPELINE SUMMARY β€” single line with all phase timings + const totalPipeline = Date.now() - generateStartTime; + const phases = Object.entries(pipelineTiming) + .map(([k, v]) => `${k}=${v}ms`) + .join(' | '); + this.log(`πŸ“Š ${this.personaName}: [PIPELINE] Total=${totalPipeline}ms | ${phases}`); return { success: true, - messageId: result.data?.id, + messageId: postedEntity.id, storedToolResultIds: allStoredResultIds // Always return array, even if empty }; } catch (error) { @@ -1594,9 +1752,9 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma error instanceof Error ? error.message : String(error) ); - // Emit ERROR event + // Emit ERROR event (fire-and-forget β€” UI indicator) if (this.client) { - await Events.emit( + Events.emit( DataDaemon.jtagContext!, AI_DECISION_EVENTS.ERROR, { @@ -1613,7 +1771,7 @@ Remember: This is voice chat, not a written essay. Be brief, be natural, be huma scope: EVENT_SCOPES.ROOM, scopeId: originalMessage.roomId } - ); + ).catch(err => this.log(`⚠️ Error event emit failed: ${err}`)); } return { diff --git a/src/debug/jtag/system/user/server/modules/PersonaTaskExecutor.ts b/src/debug/jtag/system/user/server/modules/PersonaTaskExecutor.ts index 7e79b8ba7..8c86a1564 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaTaskExecutor.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaTaskExecutor.ts @@ -78,6 +78,11 @@ export class PersonaTaskExecutor { outcome = await this.executeFineTuneLora(task); break; + case 'write-feature': + case 'review-code': + outcome = await this.executeCodeTask(task); + break; + default: outcome = `Unknown task type: ${task.taskType}`; status = 'failed'; @@ -572,6 +577,25 @@ export class PersonaTaskExecutor { } } + /** + * Code task execution (write-feature, review-code) + * + * Infrastructure hook for code-domain tasks. The workspace is guaranteed to exist + * by the time this runs (PersonaAutonomousLoop.ensureWorkspace called beforehand). + * + * The actual coding agent loop (readβ†’reasonβ†’editβ†’verifyβ†’commit) is driven by the + * persona's tool execution pipeline with code/* tools β€” not by this method. + * This method logs the task and returns, allowing the recipe pipeline to handle execution. + */ + private async executeCodeTask(task: InboxTask): Promise { + this.log(`πŸ’» ${this.displayName}: Code task received β€” ${task.taskType}: ${task.description}`); + + const roomId = task.metadata?.roomId ?? task.contextId; + this.log(`πŸ’» ${this.displayName}: Code task for room=${roomId}, workspace ensured by caller`); + + return `Code task acknowledged: ${task.taskType} β€” ${task.description}`; + } + /** * Collect training examples from recent chat interactions * diff --git a/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts b/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts index 4072ef8aa..cd1e4cd1e 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaToolDefinitions.ts @@ -12,6 +12,7 @@ import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { Commands } from '../../../core/shared/Commands'; import type { CommandSignature, ListResult } from '../../../../commands/list/shared/ListTypes'; import { ToolRegistry } from '../../../tools/server/ToolRegistry'; +import { ToolNameCodec } from './ToolFormatAdapter'; import { List } from '../../../../commands/list/shared/ListTypes'; /** @@ -247,13 +248,79 @@ export async function refreshToolDefinitions(): Promise { log(`ToolRegistry not ready (will retry): ${registryError}`); } + // Register all tool names with the codec for bidirectional encoding/decoding. + // This populates the reverse map so that any model-produced variant of a tool name + // (e.g. code_write, $FUNCTIONS.code_write, code-write) resolves to the original. + ToolNameCodec.instance.registerAll(toolCache); + lastRefreshTime = Date.now(); - log(`Refreshed ${toolCache.length} tools from Commands system`); + log(`Refreshed ${toolCache.length} tools from Commands system (codec registered)`); } catch (error) { log(`❌ Error refreshing tools: ${error}`); } } +/** + * Rich parameter descriptions for critical tools. + * The schema generator produces generic descriptions like "filePath parameter". + * These overrides provide meaningful descriptions so LLMs know what to pass. + */ +const PARAM_DESCRIPTION_OVERRIDES: Record> = { + 'code/write': { + filePath: 'Relative path to file within workspace (e.g. "index.html", "src/app.js")', + content: 'Complete file content to write (the actual code/text, not a description)', + description: 'Brief description of what this change does', + }, + 'code/read': { + filePath: 'Relative path to file within workspace to read', + startLine: 'Optional starting line number', + endLine: 'Optional ending line number', + }, + 'code/edit': { + filePath: 'Relative path to file within workspace to edit', + editMode: 'Edit mode object: {editType: "search_replace", search: "old text", replace: "new text"} or {editType: "line_range", startLine: 1, endLine: 5, content: "new content"}', + description: 'Brief description of what this edit does', + }, + 'code/tree': { + path: 'Relative directory path within workspace (default: root ".")', + maxDepth: 'Maximum directory depth to display', + }, + 'code/search': { + pattern: 'Search pattern (regex supported)', + fileGlob: 'File glob pattern to filter (e.g. "*.ts", "src/**/*.js")', + maxResults: 'Maximum number of results to return', + }, + 'code/git': { + operation: 'Git operation: "status", "diff", "log", "add", "commit"', + message: 'Commit message (required for "commit" operation)', + paths: 'File paths for "add" operation (JSON array of strings)', + staged: 'Show staged changes only (for "diff" operation)', + count: 'Number of log entries to show (for "log" operation)', + }, + 'code/verify': { + typeCheck: 'Run type checking (boolean)', + testFiles: 'Specific test files to run (JSON array of strings)', + }, + 'code/shell/execute': { + cmd: 'Shell command to execute (e.g. "npm run build", "cargo test", "ls -la src/")', + wait: 'Wait for completion: true = blocking (returns stdout/stderr), false = async (returns executionId). Default: false', + timeoutMs: 'Timeout in milliseconds for blocking mode (default: 30000). Ignored in async mode', + }, + 'code/shell/watch': { + executionId: 'Execution ID returned by code/shell/execute (async mode) to stream output from', + }, + 'code/shell/status': { + _noParams: 'No parameters needed β€” returns session info for your workspace', + }, + 'code/shell/sentinel': { + executionId: 'Execution ID to configure filter rules on', + rules: 'JSON array of sentinel rules: [{"pattern": "error.*", "classification": "Error"}, {"pattern": "warning", "classification": "Warning"}]', + }, + 'code/shell/kill': { + executionId: 'Execution ID of the running process to kill', + }, +}; + /** * Convert CommandSignature to ToolDefinition */ @@ -265,11 +332,14 @@ function convertCommandToTool(cmd: CommandSignature): ToolDefinition { const properties: Record = {}; const required: string[] = []; + // Look up rich descriptions for this command + const descOverrides = PARAM_DESCRIPTION_OVERRIDES[cmd.name]; + if (cmd.params) { for (const [paramName, paramInfo] of Object.entries(cmd.params)) { properties[paramName] = { type: paramInfo.type as any, // Trust the type from command signature - description: paramInfo.description || `${paramName} parameter`, + description: descOverrides?.[paramName] || paramInfo.description || `${paramName} parameter`, required: paramInfo.required }; @@ -279,9 +349,20 @@ function convertCommandToTool(cmd: CommandSignature): ToolDefinition { } } + // Clean JSDoc artifacts from description (schema generator captures raw comment blocks) + // "Foo Types\n *\n * Real description" β†’ "Real description" + const rawDesc = cmd.description || `Execute ${cmd.name} command`; + const cleanedDesc = rawDesc + .replace(/^[^*]*\*\s*/gm, '') // Strip leading " * " from JSDoc lines + .replace(/\n\s*\n/g, '\n') // Collapse multiple newlines + .trim(); + // Use the last meaningful sentence if first line is just a title (e.g. "Foo Types") + const descLines = cleanedDesc.split('\n').filter(l => l.trim().length > 0); + const description = descLines.length > 1 ? descLines.slice(1).join(' ').trim() || descLines[0] : descLines[0] || rawDesc; + return { name: cmd.name, - description: cmd.description || `Execute ${cmd.name} command`, + description, category, permissions: [category + ':execute'], parameters: { @@ -463,7 +544,7 @@ You have ${tools.length} tools available. Here they ALL are, organized by catego // Show essential tools with full details const essentialTools = tools.filter(t => ['screenshot', 'help', 'collaboration/chat/send', 'collaboration/wall/write', - 'development/code/read', 'development/code/pattern-search'].includes(t.name) + 'code/read', 'code/search'].includes(t.name) ); output += `=== FREQUENTLY USED TOOLS (with parameters) ===\n`; diff --git a/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts b/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts index 2ad4363bb..1912900f2 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaToolExecutor.ts @@ -8,6 +8,11 @@ * - Uses ToolRegistry for ALL command execution (no hardcoded handlers) * - XML parsing only (no command-specific logic) * - Logging and metrics + * + * KEY METHODS: + * - executeSingleTool() β€” core per-tool pipeline (corrections, execution, storage, media) + * - executeToolCalls() β€” XML-formatted batch execution (for XML fallback path) + * - executeNativeToolCalls() β€” structured batch execution (for native tool_result protocol) */ import { CognitionLogger } from './cognition/CognitionLogger'; @@ -18,16 +23,16 @@ import { ToolRegistry } from '../../../tools/server/ToolRegistry'; import type { MediaItem } from '../../../data/entities/ChatMessageEntity'; import { ChatMessageEntity } from '../../../data/entities/ChatMessageEntity'; import type { PersonaMediaConfig } from './PersonaMediaConfig'; -import { Commands } from '../../../core/shared/Commands'; -import type { DataCreateParams, DataCreateResult } from '../../../../commands/data/create/shared/DataCreateTypes'; import { getToolFormatAdapters, type ToolFormatAdapter } from './ToolFormatAdapter'; -import { Logger, FileMode } from '../../../core/logging/Logger'; -import { SystemPaths } from '../../../core/config/SystemPaths'; +import { unsanitizeToolName } from './ToolFormatAdapter'; +import { Logger } from '../../../core/logging/Logger'; import { RoomResolver } from '../../../core/server/RoomResolver'; -import * as fs from 'fs'; -import * as path from 'path'; import { DataCreate } from '../../../../commands/data/create/shared/DataCreateTypes'; +import type { + ToolCall as NativeToolCall, + ToolResult as NativeToolResult, +} from '@daemons/ai-provider-daemon/shared/AIProviderTypesV2'; /** * Parsed tool call from AI response */ @@ -60,8 +65,15 @@ export interface ToolResult { } /** - * PersonaToolExecutor - Clean tool execution via ToolRegistry + * Result from executing a single tool through the full pipeline. + * Used internally by executeToolCalls and executeNativeToolCalls. */ +export interface SingleToolExecution { + result: ToolResult; + resultId: UUID; + media: MediaItem[]; +} + /** * Minimal persona info needed by PersonaToolExecutor */ @@ -72,10 +84,87 @@ export interface PersonaUserForToolExecutor { readonly entity: { readonly uniqueId: string; }; + /** Auto-bootstrap workspace when code/* tools are invoked. Called once per context. */ + readonly ensureCodeWorkspace?: () => Promise; } export class PersonaToolExecutor { - private static readonly COGNITION_LOG_PATH = path.join(process.cwd(), '.continuum/jtag/logs/system/cognition.log'); + + /** + * Tool name corrections: LLMs sometimes confuse similarly-named tools. + * workspace/tree shows the JTAG command hierarchy, code/tree shows workspace files. + */ + private static readonly TOOL_CORRECTIONS: Record = { + 'workspace/tree': 'code/tree', + }; + + /** + * Parameter name corrections per command prefix. + * LLMs guess wrong parameter names when tool descriptions are generic. + * Maps { wrongName β†’ correctName } for each command prefix. + */ + private static readonly PARAM_CORRECTIONS: Record> = { + 'code/write': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'file_name': 'filePath', + 'name': 'filePath', + 'contents': 'content', + 'text': 'content', + 'body': 'content', + 'data': 'content', + 'code': 'content', + 'html': 'content', + 'source': 'content', + }, + 'code/read': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'name': 'filePath', + 'start': 'startLine', + 'end': 'endLine', + 'from': 'startLine', + 'to': 'endLine', + }, + 'code/edit': { + 'path': 'filePath', + 'file': 'filePath', + 'file_path': 'filePath', + 'filepath': 'filePath', + 'filename': 'filePath', + 'name': 'filePath', + 'mode': 'editMode', + 'type': 'editMode', + }, + 'code/search': { + 'query': 'pattern', + 'search': 'pattern', + 'term': 'pattern', + 'regex': 'pattern', + 'glob': 'fileGlob', + 'filter': 'fileGlob', + }, + 'code/tree': { + 'directory': 'path', + 'dir': 'path', + 'folder': 'path', + 'depth': 'maxDepth', + }, + 'code/git': { + 'subcommand': 'operation', + 'command': 'operation', + 'action': 'operation', + 'op': 'operation', + 'msg': 'message', + 'files': 'paths', + }, + }; /** * LOOP DETECTION: Track recent tool calls per persona to detect infinite loops @@ -84,12 +173,13 @@ export class PersonaToolExecutor { */ private static readonly recentToolCalls: Map> = new Map(); private static readonly LOOP_DETECTION_WINDOW_MS = 60000; // 60 seconds - private static readonly LOOP_DETECTION_THRESHOLD = 3; // Block after 3 identical calls + private static readonly LOOP_DETECTION_THRESHOLD = 2; // Block after 2 identical calls private persona: PersonaUserForToolExecutor; private toolRegistry: ToolRegistry; private formatAdapters: ToolFormatAdapter[]; private log: ReturnType; + private workspaceBootstrapped = false; constructor(personaUser: PersonaUserForToolExecutor) { this.persona = personaUser; @@ -105,16 +195,6 @@ export class PersonaToolExecutor { ); } - /** - * Log to dedicated cognition file (separate from main logs) - * @deprecated Use Logger instead for categorized logging - */ - private static logToCognitionFile(message: string): void { - const timestamp = new Date().toISOString(); - const logLine = `[${timestamp}] ${message}\n`; - fs.appendFileSync(PersonaToolExecutor.COGNITION_LOG_PATH, logLine, 'utf8'); - } - /** * LOOP DETECTION: Create a hash of a tool call for comparison */ @@ -149,7 +229,6 @@ export class PersonaToolExecutor { // Block if threshold exceeded if (duplicateCount >= PersonaToolExecutor.LOOP_DETECTION_THRESHOLD) { this.log.warn(`πŸ” LOOP DETECTED: ${toolCall.toolName} called ${duplicateCount + 1}x in ${PersonaToolExecutor.LOOP_DETECTION_WINDOW_MS / 1000}s - BLOCKING`); - PersonaToolExecutor.logToCognitionFile(`πŸ” ${this.persona.displayName}: [LOOP BLOCKED] ${toolCall.toolName} (${duplicateCount + 1}x identical)`); return true; } @@ -178,9 +257,249 @@ export class PersonaToolExecutor { return toolCalls; } + // ────────────────────────────────────────────── + // Core Pipeline: Batch Preparation + Single Tool Execution + // ────────────────────────────────────────────── + + /** + * Prepare a batch of tool calls for execution. + * Handles loop detection filtering and workspace auto-bootstrap. + */ + private async prepareBatch( + toolCalls: ToolCall[], + context: ToolExecutionContext, + ): Promise { + // Filter out looping tool calls before execution + const filtered = toolCalls.filter(toolCall => { + if (this.isLoopDetected(toolCall)) { + this.log.warn(`Skipping looping tool call: ${toolCall.toolName}`); + return false; + } + return true; + }); + + // Auto-bootstrap workspace if any code/* tools are being called + if (!this.workspaceBootstrapped && this.persona.ensureCodeWorkspace) { + const hasCodeTools = filtered.some(tc => tc.toolName.startsWith('code/')); + if (hasCodeTools) { + try { + this.log.info('πŸ”§ Auto-bootstrapping workspace for code/* tool execution'); + await this.persona.ensureCodeWorkspace(); + this.workspaceBootstrapped = true; + } catch (err: any) { + this.log.error(`Failed to bootstrap workspace: ${err.message}`); + } + } + } + + return filtered; + } + /** - * Execute tool calls and return formatted results + optional media - * Phase 3B: Now also stores results as ChatMessageEntity and returns UUIDs + * Execute a single tool call through the full pipeline. + * + * Handles: name/param correction, room resolution, ToolRegistry execution, + * logging, result storage, and media collection. + */ + private async executeSingleTool( + toolCall: ToolCall, + context: ToolExecutionContext, + ): Promise { + const startTime = Date.now(); + + // Redirect common tool name confusion (workspace/* β†’ code/*) + const correctedToolName = PersonaToolExecutor.TOOL_CORRECTIONS[toolCall.toolName] ?? toolCall.toolName; + if (correctedToolName !== toolCall.toolName) { + this.log.info(`β†ͺ Redirected ${toolCall.toolName} β†’ ${correctedToolName}`); + toolCall = { ...toolCall, toolName: correctedToolName }; + } + + // Correct common parameter name mismatches (LLMs guess wrong names) + const paramCorrections = PersonaToolExecutor.PARAM_CORRECTIONS[toolCall.toolName]; + if (paramCorrections) { + const correctedParams = { ...toolCall.parameters }; + for (const [wrongName, correctName] of Object.entries(paramCorrections)) { + if (correctedParams[wrongName] !== undefined && correctedParams[correctName] === undefined) { + correctedParams[correctName] = correctedParams[wrongName]; + delete correctedParams[wrongName]; + this.log.info(`β†ͺ Param corrected: ${wrongName} β†’ ${correctName}`); + } + } + toolCall = { ...toolCall, parameters: correctedParams }; + } + + // Clean up code/write content: CDATA wrappers, HTML entities + // Models encode HTML differently when writing code β€” normalize before execution + if (toolCall.toolName === 'code/write' && toolCall.parameters.content) { + let content = toolCall.parameters.content; + let cleaned = false; + + // Strip CDATA wrappers (Together wraps HTML in for XML safety) + const cdataMatch = content.match(/^$/); + if (cdataMatch) { + content = cdataMatch[1]; + cleaned = true; + } + + // Decode HTML entities in a single pass (Groq double-escapes HTML as <html>) + const NAMED: Record = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'", nbsp: ' ' }; + const decoded = content.replace(/&(#\d+|#x[\da-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => { + if (NAMED[entity]) return NAMED[entity]; + if (entity.startsWith('#x')) return String.fromCharCode(parseInt(entity.slice(2), 16)); + if (entity.startsWith('#')) return String.fromCharCode(parseInt(entity.slice(1), 10)); + return match; + }); + if (decoded !== content) { content = decoded; cleaned = true; } + + if (cleaned) { + toolCall = { ...toolCall, parameters: { ...toolCall.parameters, content } }; + this.log.info('β†ͺ Cleaned code/write content (CDATA/entity normalization)'); + } + } + + // Resolve "current" room parameter to actual room name + const resolvedParams = await this.resolveRoomParameters(toolCall.parameters, context.contextId); + + // Inject userId (standard CommandParams field) and contextId + const paramsWithCaller = { + ...resolvedParams, + userId: context.personaId, // Standard CommandParams.userId β€” THE identity field + personaId: context.personaId, // Backward compat (ai/sleep, ai/should-respond-fast) + contextId: context.contextId // Room/context scope + }; + + // Log tool call with clean params formatting (not array-wrapped) + const paramsJson = JSON.stringify(paramsWithCaller, null, 2); + this.log.info(`β”Œβ”€ CALL: ${toolCall.toolName}`); + this.log.info(`β”‚ params: ${paramsJson.replace(/\n/g, '\nβ”‚ ')}`); + + // Use ToolRegistry for ALL commands - no special cases + // NO try-catch - let exceptions bubble to PersonaResponseGenerator + // ToolRegistry returns {success: false, error} for expected failures + const registryResult = await this.toolRegistry.executeTool( + toolCall.toolName, + paramsWithCaller, // Pass params with callerId injected + context.sessionId, // Pass AI's sessionId for proper attribution + context.contextId, + context.context // Pass PersonaUser's enriched context (with callerType='persona') + ); + + const result: ToolResult = { + toolName: registryResult.toolName, + success: registryResult.success, + content: registryResult.content, + media: registryResult.media, // ← Preserve structured media + error: registryResult.error + }; + + const duration = Date.now() - startTime; + + // Log result with clear visual structure + if (result.success) { + // Parse result for better display (show key fields if JSON) + let resultSummary = result.content?.slice(0, 500) || 'no content'; + try { + const parsed = JSON.parse(result.content || ''); + // Extract key fields for readable summary + const keyFields = ['success', 'message', 'newMode', 'previousMode', 'count', 'items', 'data']; + const summary: Record = {}; + for (const key of keyFields) { + if (parsed[key] !== undefined) { + summary[key] = Array.isArray(parsed[key]) ? `[${parsed[key].length} items]` : parsed[key]; + } + } + if (Object.keys(summary).length > 0) { + resultSummary = JSON.stringify(summary); + } + } catch { /* not JSON, use raw */ } + + this.log.info(`└─ RESULT: βœ“ ${duration}ms`); + this.log.info(` ${resultSummary}${result.content && result.content.length > 500 ? '...' : ''}`); + if (result.media && result.media.length > 0) { + this.log.info(` media: ${result.media.map(m => `${m.type} (${m.mimeType})`).join(', ')}`); + } + } else { + this.log.error(`└─ RESULT: βœ— ${duration}ms`); + this.log.error(` error: ${result.error || 'unknown error'}`); + } + + // Store tool result in working memory and get UUID + this.log.debugIf(() => [`${toolCall.toolName} returned media:`, result.media ? `${result.media.length} items` : 'NONE']); + if (result.media && result.media.length > 0) { + this.log.debugIf(() => ['Media details:', result.media!.map(m => ({ + type: m.type, + hasBase64: !!m.base64, + base64Length: m.base64?.length, + mimeType: m.mimeType, + hasUrl: !!m.url + }))]); + } + + // Store tool result (awaited to get UUID, but could be fire-and-forget if needed) + const resultId = await this.storeToolResult( + toolCall.toolName, + toolCall.parameters, + { + success: result.success, + data: result.content, // Store full content in metadata + error: result.error, + media: result.media // Pass media for storage and RAG context + }, + context.contextId // Use contextId (room) for storage + ); + this.log.debug(`Stored tool result #${resultId.slice(0, 8)} with ${result.media?.length || 0} media`); + + // Collect media for this tool + const collectedMedia: MediaItem[] = []; + + // Check if THIS persona wants media + // IMPORTANT: If AI explicitly called screenshot tool, they want the image! + // So we pass through media for screenshot regardless of autoLoadMedia config + const isScreenshotTool = toolCall.toolName === 'screenshot' || toolCall.toolName === 'interface/screenshot'; + const shouldLoadMedia = context.personaConfig.autoLoadMedia || isScreenshotTool; + + if (result.media && shouldLoadMedia) { + // Filter by supported types (unless it's screenshot - then pass through images) + const supportedMedia = result.media.filter(m => + isScreenshotTool || context.personaConfig.supportedMediaTypes.includes(m.type) + ); + + if (supportedMedia.length > 0) { + this.log.info(`Loading ${supportedMedia.length} media (types: ${supportedMedia.map(m => m.type).join(', ')})${isScreenshotTool ? ' [screenshot override]' : ''}`); + collectedMedia.push(...supportedMedia); + } + } else if (result.media && result.media.length > 0) { + this.log.debug(`Skipping ${result.media.length} media (autoLoadMedia=false)`); + } + + // Fire-and-forget: Log tool execution to cognition database (non-blocking) + // This is telemetry - don't block the response pipeline for it + CognitionLogger.logToolExecution( + this.persona.id, + this.persona.displayName, + toolCall.toolName, + toolCall.parameters, + result.success ? 'success' : 'error', + duration, + 'chat', // Domain + context.contextId, + { + toolResult: result.content?.slice(0, 1000), // First 1000 chars of result + errorMessage: result.error, + storedResultId: resultId // Phase 3B: Link to stored result + } + ).catch(err => this.log.error('Failed to log tool execution:', err)); + + return { result, resultId, media: collectedMedia }; + } + + // ────────────────────────────────────────────── + // Public API: Batch Tool Execution + // ────────────────────────────────────────────── + + /** + * Execute tool calls and return XML-formatted results + optional media. + * Used by the XML fallback path for non-native providers. * * @param toolCalls - Array of parsed tool calls * @param context - Execution context with media configuration @@ -192,203 +511,112 @@ export class PersonaToolExecutor { ): Promise<{ formattedResults: string; media?: MediaItem[]; - storedResultIds: UUID[]; // Phase 3B: UUIDs for lazy loading + storedResultIds: UUID[]; }> { if (toolCalls.length === 0) { return { formattedResults: '', storedResultIds: [] }; } this.log.info(`Executing ${toolCalls.length} tool(s): ${toolCalls.map(t => t.toolName).join(', ')}`); - PersonaToolExecutor.logToCognitionFile(`πŸ”§ ${this.persona.displayName}: [TOOL] Executing ${toolCalls.length} tool(s): ${toolCalls.map(t => t.toolName).join(', ')}`); - - // Filter out looping tool calls before execution - const filteredToolCalls = toolCalls.filter(toolCall => { - if (this.isLoopDetected(toolCall)) { - this.log.warn(`Skipping looping tool call: ${toolCall.toolName}`); - return false; - } - return true; - }); - if (filteredToolCalls.length === 0) { + const filtered = await this.prepareBatch(toolCalls, context); + if (filtered.length === 0) { this.log.warn('All tool calls blocked by loop detection'); return { formattedResults: '[All tool calls blocked - infinite loop detected]', storedResultIds: [] }; } - // PARALLELIZED: Execute all tools concurrently instead of sequentially - // This reduces tool execution time from O(sum of all tool times) to O(max tool time) - // Example: 3 tools Γ— 500ms each = 1500ms sequential β†’ 500ms parallel (3x speedup) - const toolExecutionPromises = filteredToolCalls.map(async (toolCall) => { - const startTime = Date.now(); - - // Resolve "current" room parameter to actual room name - // This handles wall/*, chat/*, and any other room-scoped commands - const resolvedParams = await this.resolveRoomParameters(toolCall.parameters, context.contextId); - - // Inject callerId, personaId, and contextId so tools can identify the persona and context - // This is how ai/sleep knows which persona to put to sleep when no explicit personaId is provided - // And ai/should-respond-fast needs personaId + contextId to check room activity - const paramsWithCaller = { - ...resolvedParams, - callerId: context.personaId, // Always inject the calling persona's userId - personaId: context.personaId, // Also as personaId for tools that expect it - contextId: context.contextId // Always inject the room/context ID - }; - - // Log tool call with clean params formatting (not array-wrapped) - const paramsJson = JSON.stringify(paramsWithCaller, null, 2); - this.log.info(`β”Œβ”€ CALL: ${toolCall.toolName}`); - this.log.info(`β”‚ params: ${paramsJson.replace(/\n/g, '\nβ”‚ ')}`); - PersonaToolExecutor.logToCognitionFile(`πŸ”§ ${this.persona.displayName}: [TOOL CALL] ${toolCall.toolName} | params: ${JSON.stringify(paramsWithCaller)}`); - - // Use ToolRegistry for ALL commands - no special cases - // NO try-catch - let exceptions bubble to PersonaResponseGenerator - // ToolRegistry returns {success: false, error} for expected failures - const registryResult = await this.toolRegistry.executeTool( - toolCall.toolName, - paramsWithCaller, // Pass params with callerId injected - context.sessionId, // Pass AI's sessionId for proper attribution - context.contextId, - context.context // Pass PersonaUser's enriched context (with callerType='persona') - ); + // Execute all tools concurrently β€” O(max tool time) instead of O(sum) + const executions = await Promise.all(filtered.map(tc => this.executeSingleTool(tc, context))); - const result: ToolResult = { - toolName: registryResult.toolName, - success: registryResult.success, - content: registryResult.content, - media: registryResult.media, // ← Preserve structured media - error: registryResult.error - }; + const allMedia = executions.flatMap(e => e.media); + const storedResultIds = executions.map(e => e.resultId); + const successCount = executions.filter(e => e.result.success).length; + this.log.info(`Complete: ${successCount}/${toolCalls.length} successful, ${allMedia.length} media loaded, ${storedResultIds.length} stored`); - const duration = Date.now() - startTime; + return { + formattedResults: executions.map(e => this.formatToolResult(e.result)).join('\n\n'), + media: allMedia.length > 0 ? allMedia : undefined, + storedResultIds, + }; + } - // Log result with clear visual structure - if (result.success) { - // Parse result for better display (show key fields if JSON) - let resultSummary = result.content?.slice(0, 500) || 'no content'; - try { - const parsed = JSON.parse(result.content || ''); - // Extract key fields for readable summary - const keyFields = ['success', 'message', 'newMode', 'previousMode', 'count', 'items', 'data']; - const summary: Record = {}; - for (const key of keyFields) { - if (parsed[key] !== undefined) { - summary[key] = Array.isArray(parsed[key]) ? `[${parsed[key].length} items]` : parsed[key]; - } - } - if (Object.keys(summary).length > 0) { - resultSummary = JSON.stringify(summary); - } - } catch { /* not JSON, use raw */ } + /** + * Execute native tool calls from the canonical agent loop. + * Returns per-tool ToolResult objects with full content and tool_use_id correlation. + * + * Calls executeSingleTool directly β€” no XML serialization/deserialization round-trip. + * Full content is returned (not summaries). Truncated honestly if too large. + * + * @param nativeToolCalls - Tool calls from AI provider (with id, name, input) + * @param context - Execution context with persona/session info + * @param maxResultChars - Maximum characters per tool result (truncated honestly) + * @returns Per-tool results, media, and stored IDs + */ + async executeNativeToolCalls( + nativeToolCalls: NativeToolCall[], + context: ToolExecutionContext, + maxResultChars = 30_000, + ): Promise<{ + results: NativeToolResult[]; + media: MediaItem[]; + storedIds: UUID[]; + }> { + if (nativeToolCalls.length === 0) { + return { results: [], media: [], storedIds: [] }; + } - this.log.info(`└─ RESULT: βœ“ ${duration}ms`); - this.log.info(` ${resultSummary}${result.content && result.content.length > 500 ? '...' : ''}`); - if (result.media && result.media.length > 0) { - this.log.info(` media: ${result.media.map(m => `${m.type} (${m.mimeType})`).join(', ')}`); - } - } else { - this.log.error(`└─ RESULT: βœ— ${duration}ms`); - this.log.error(` error: ${result.error || 'unknown error'}`); + // Convert native format β†’ executor format (decode sanitized names, stringify params) + const executorCalls: ToolCall[] = nativeToolCalls.map(tc => ({ + toolName: unsanitizeToolName(tc.name), + parameters: Object.fromEntries( + Object.entries(tc.input).map(([k, v]) => [k, String(v)]) + ) as Record, + })); + + // Prepare batch (loop detection + workspace bootstrap) + const filtered = await this.prepareBatch(executorCalls, context); + + // Execute filtered tools in parallel + const executions = await Promise.all(filtered.map(tc => this.executeSingleTool(tc, context))); + + // Map results back to native tool calls with tool_use_id correlation. + // Tools blocked by loop detection get error results. + const filteredSet = new Set(filtered); + const results: NativeToolResult[] = []; + let execIdx = 0; + + for (let i = 0; i < nativeToolCalls.length; i++) { + if (!filteredSet.has(executorCalls[i])) { + // Tool was blocked by loop detection + results.push({ + tool_use_id: nativeToolCalls[i].id, + content: 'Tool call blocked by loop detection.', + is_error: true, + }); + continue; } - PersonaToolExecutor.logToCognitionFile(`${result.success ? 'βœ…' : '❌'} ${this.persona.displayName}: [TOOL RESULT] ${toolCall.toolName} ${result.success ? 'success' : 'failed'} (${duration}ms, ${result.content?.length || 0} chars, media: ${result.media?.length || 0})`); - // Phase 3B: Store tool result in working memory and get UUID - // Fire-and-forget pattern: storage is non-critical, don't block on it - this.log.debugIf(() => [`${toolCall.toolName} returned media:`, result.media ? `${result.media.length} items` : 'NONE']); - if (result.media && result.media.length > 0) { - this.log.debugIf(() => ['Media details:', result.media!.map(m => ({ - type: m.type, - hasBase64: !!m.base64, - base64Length: m.base64?.length, - mimeType: m.mimeType, - hasUrl: !!m.url - }))]); - } + const exec = executions[execIdx++]; + let content = exec.result.success + ? (exec.result.content || 'No content returned') + : (exec.result.error || 'Unknown error'); - // Store tool result (awaited to get UUID, but could be fire-and-forget if needed) - const resultId = await this.storeToolResult( - toolCall.toolName, - toolCall.parameters, - { - success: result.success, - data: result.content, // Store full content in metadata - error: result.error, - media: result.media // Pass media for storage and RAG context - }, - context.contextId // Use contextId (room) for storage - ); - this.log.debug(`Stored tool result #${resultId.slice(0, 8)} with ${result.media?.length || 0} media`); - - // Collect media for this tool - const collectedMedia: MediaItem[] = []; - - // Check if THIS persona wants media - // IMPORTANT: If AI explicitly called screenshot tool, they want the image! - // So we pass through media for screenshot regardless of autoLoadMedia config - const isScreenshotTool = toolCall.toolName === 'screenshot' || toolCall.toolName === 'interface/screenshot'; - const shouldLoadMedia = context.personaConfig.autoLoadMedia || isScreenshotTool; - - if (result.media && shouldLoadMedia) { - // Filter by supported types (unless it's screenshot - then pass through images) - const supportedMedia = result.media.filter(m => - isScreenshotTool || context.personaConfig.supportedMediaTypes.includes(m.type) - ); - - if (supportedMedia.length > 0) { - this.log.info(`Loading ${supportedMedia.length} media (types: ${supportedMedia.map(m => m.type).join(', ')})${isScreenshotTool ? ' [screenshot override]' : ''}`); - collectedMedia.push(...supportedMedia); - } - } else if (result.media && result.media.length > 0) { - this.log.debug(`Skipping ${result.media.length} media (autoLoadMedia=false)`); + // Truncate honestly (not summarize) if too large + if (content.length > maxResultChars) { + content = content.slice(0, maxResultChars) + `\n[...truncated, ${content.length} chars total]`; } - // Fire-and-forget: Log tool execution to cognition database (non-blocking) - // This is telemetry - don't block the response pipeline for it - CognitionLogger.logToolExecution( - this.persona.id, - this.persona.displayName, - toolCall.toolName, - toolCall.parameters, - result.success ? 'success' : 'error', - duration, - 'chat', // Domain - context.contextId, - { - toolResult: result.content?.slice(0, 1000), // First 1000 chars of result - errorMessage: result.error, - storedResultId: resultId // Phase 3B: Link to stored result - } - ).catch(err => this.log.error('Failed to log tool execution:', err)); - - return { - result, - resultId, - media: collectedMedia, - formattedResult: this.formatToolResult(result) - }; - }); - - // Wait for all tool executions to complete in parallel - const toolResults = await Promise.all(toolExecutionPromises); - - // Aggregate results maintaining original order - const results: string[] = []; - const allMedia: MediaItem[] = []; - const storedResultIds: UUID[] = []; - - for (const { result, resultId, media, formattedResult } of toolResults) { - results.push(formattedResult); - storedResultIds.push(resultId); - allMedia.push(...media); + results.push({ + tool_use_id: nativeToolCalls[i].id, + content, + is_error: !exec.result.success || undefined, + }); } - const successCount = toolResults.filter(r => r.result.success).length; - this.log.info(`Complete: ${successCount}/${toolCalls.length} successful, ${allMedia.length} media loaded, ${storedResultIds.length} stored`); - return { - formattedResults: results.join('\n\n'), - media: allMedia.length > 0 ? allMedia : undefined, - storedResultIds // Phase 3B: Return UUIDs for lazy loading + results, + media: executions.flatMap(e => e.media), + storedIds: executions.map(e => e.resultId), }; } @@ -547,57 +775,51 @@ ${result.error || 'Unknown error'} result: { success: boolean; data: unknown; error?: unknown } ): string { if (!result.success) { - // Don't truncate error messages - AIs need full context to debug - // IMPORTANT: Properly stringify error objects to avoid [object Object] const errorMessage = this.stringifyError(result.error); return `Tool '${toolName}' failed: ${errorMessage}`; } - // Tool-specific summarization logic const data = result.data; - if (toolName === 'grep' || toolName === 'code/pattern-search') { - const text = typeof data === 'string' ? data : JSON.stringify(data); - const lines = text.split('\n').filter(l => l.trim()).length; - return `grep found ${lines} match${lines !== 1 ? 'es' : ''}`; - } + // Action label from tool name: "code/write" β†’ "write", "collaboration/decision/vote" β†’ "vote" + const action = toolName.split('/').pop() ?? toolName; - if (toolName === 'screenshot') { - const img = data as any; - if (img?.width && img?.height) { - return `Screenshot captured (${img.width}x${img.height}px)`; - } - return 'Screenshot captured'; + // Data-shape-driven summary β€” extract what the data reveals, not what tool produced it + if (Array.isArray(data)) { + return `${action}: ${data.length} item${data.length !== 1 ? 's' : ''}`; } - if (toolName === DATA_COMMANDS.LIST) { - const items = data as any[]; - const count = Array.isArray(items) ? items.length : 0; - return `${DATA_COMMANDS.LIST} returned ${count} item${count !== 1 ? 's' : ''}`; + if (typeof data === 'string') { + const lines = data.split('\n').filter(l => l.trim()).length; + return lines > 1 ? `${action}: ${lines} lines` : `${action}: ${data.slice(0, 120)}`; } - if (toolName === DATA_COMMANDS.READ) { - // When fetching tool results from working memory, don't output raw JSON - // Just acknowledge the retrieval - return 'Retrieved data from working memory'; - } + if (data && typeof data === 'object') { + const obj = data as Record; + const parts: string[] = []; - if (toolName === 'code/read' || toolName === 'file/load') { - const text = typeof data === 'string' ? data : JSON.stringify(data); - const lines = text.split('\n').length; - return `Read ${lines} lines from file`; - } + // File path (most common structured field) + const filePath = obj.filePath ?? obj.file_path ?? obj.path ?? obj.fileName ?? obj.file_name; + if (filePath) parts.push(String(filePath)); + + // Size / count metrics + const bytes = obj.bytesWritten ?? obj.bytes_written ?? obj.size ?? obj.byteLength; + if (typeof bytes === 'number') parts.push(`${bytes} bytes`); + + const count = obj.count ?? obj.total ?? obj.matches ?? obj.length; + if (typeof count === 'number') parts.push(`${count} items`); + + // Dimensions + const width = obj.width; + const height = obj.height; + if (typeof width === 'number' && typeof height === 'number') parts.push(`${width}x${height}`); - if (toolName === 'bash' || toolName === 'shell/execute') { - const output = typeof data === 'string' ? data : JSON.stringify(data); - const lines = output.split('\n').length; - return `Command executed (${lines} lines of output)`; + if (parts.length > 0) return `${action}: ${parts.join(', ')}`; } - // Generic summary for unknown tools - give AIs enough context to work with - const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - const preview = dataStr.slice(0, 500); - return `Tool '${toolName}' completed: ${preview}${dataStr.length > 500 ? '...' : ''}`; + // Compact fallback β€” tool name + truncated preview + const dataStr = typeof data === 'string' ? data : JSON.stringify(data); + return `${action}: ${dataStr.slice(0, 120)}${dataStr.length > 120 ? '...' : ''}`; } /** diff --git a/src/debug/jtag/system/user/server/modules/PersonaToolRegistry.ts b/src/debug/jtag/system/user/server/modules/PersonaToolRegistry.ts index 1bd470052..8850e0217 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaToolRegistry.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaToolRegistry.ts @@ -175,30 +175,33 @@ When you need information, use tools instead of making assumptions. private getDefaultPermissionsForRole(role: string): string[] { switch (role) { case 'admin': + // Admin: unrestricted access to all tool categories return [ - 'file:read', - 'file:write', - 'code:search', - 'code:modify', + 'file:execute', + 'code:execute', + 'data:execute', + 'media:execute', 'system:execute', - 'ui:screenshot', - 'data:read', - 'data:write' ]; case 'assistant': + // Assistant: full tool access (tools are the persona's hands) + // Permission scoping happens at the command level (PRIVILEGED_COMMANDS, ADMIN_COMMANDS) + // not at the category level β€” personas need all categories to function. return [ - 'file:read', - 'code:search', - 'system:execute', // Limited bash commands - 'ui:screenshot', - 'data:read' + 'file:execute', + 'code:execute', + 'data:execute', + 'media:execute', + 'system:execute', ]; case 'restricted': + // Restricted: read-only, no code execution return [ - 'file:read', - 'code:search' + 'file:execute', + 'data:execute', + 'system:execute', ]; default: diff --git a/src/debug/jtag/system/user/server/modules/PersonaTrainingManager.ts b/src/debug/jtag/system/user/server/modules/PersonaTrainingManager.ts index df43ceafa..60d41156d 100644 --- a/src/debug/jtag/system/user/server/modules/PersonaTrainingManager.ts +++ b/src/debug/jtag/system/user/server/modules/PersonaTrainingManager.ts @@ -1,18 +1,31 @@ /** * PersonaTrainingManager - Handles continuous learning for PersonaUser * - * REFACTORING: Extracted from PersonaUser.ts (lines 1918-2004) - * Pure function extraction - no behavioral changes + * Monitors training data accumulation and triggers LoRA fine-tuning + * when thresholds are reached. Wires into the genome/job-create command + * for real training execution via provider-specific adapters. */ +import * as fs from 'fs'; +import * as path from 'path'; import type { UUID } from '../../../core/types/CrossPlatformUUID'; import { Events } from '../../../core/shared/Events'; -import type { TrainingDataAccumulator } from './TrainingDataAccumulator'; +import type { TrainingDataAccumulator, TrainingExample as AccumulatorExample } from './TrainingDataAccumulator'; import type { UserStateEntity } from '../../../data/entities/UserStateEntity'; +import { TrainingDatasetBuilder } from '../../../genome/fine-tuning/server/TrainingDatasetBuilder'; +import { GenomeJobCreate } from '../../../../commands/genome/job-create/shared/GenomeJobCreateTypes'; +import { + TrainingMethod, + TrainOnInputs, + LRSchedulerType, +} from '../../../../daemons/data-daemon/shared/entities/FineTuningTypes'; +import type { TrainingDataset, TrainingExample } from '../../../genome/fine-tuning/shared/FineTuningTypes'; +import type { TraitType } from '../../../genome/entities/GenomeLayerEntity'; import { AI_LEARNING_EVENTS, type AITrainingStartedEventData, - type AITrainingCompleteEventData + type AITrainingCompleteEventData, + type AITrainingErrorEventData } from '../../../events/shared/AILearningEvents'; /** @@ -48,7 +61,7 @@ export class PersonaTrainingManager { * This enables continuous learning: PersonaUsers improve through recipe execution * without manual intervention. */ - async checkTrainingReadiness(): Promise { + async checkTrainingReadiness(forceDomain?: string): Promise { try { const domains = this.trainingAccumulator.getDomains(); @@ -57,7 +70,8 @@ export class PersonaTrainingManager { } for (const domain of domains) { - if (this.trainingAccumulator.shouldMicroTune(domain)) { + const isForced = domain === forceDomain; + if (isForced || this.trainingAccumulator.shouldMicroTune(domain)) { const bufferSize = this.trainingAccumulator.getBufferSize(domain); const threshold = this.trainingAccumulator.getBatchThreshold(domain); @@ -93,47 +107,177 @@ export class PersonaTrainingManager { // Consume training data from buffer const examples = await this.trainingAccumulator.consumeTrainingData(domain); + if (examples.length === 0) { + this.log(`πŸ“Š No examples after consumption for ${domain}, skipping`); + state.learningState.isLearning = false; + await this.saveState(); + continue; + } this.log(`πŸ“Š Consumed ${examples.length} examples for ${domain} training`); - // TODO Phase 7.5.1: Trigger genome/train command - // For now, just log that we would train - this.log(`πŸš€ Would train ${domain} adapter with ${examples.length} examples`); + // Convert accumulator examples to fine-tuning format + const ftExamples = this.convertAccumulatorExamples(examples); + + // Execute real training via genome/job-create + await this.executeTraining(domain as TraitType, ftExamples, provider); - // Clear learning state + // Clear learning state after training submitted state.learningState.isLearning = false; state.learningState.domain = undefined; state.learningState.provider = undefined; state.learningState.startedAt = undefined; state.learningState.exampleCount = undefined; state.learningState.estimatedCompletion = undefined; - await this.saveState(); // Persist state to database - - // Simulate training completion for UI feedback - const trainingCompleteData: AITrainingCompleteEventData = { - personaId: this.personaId, - personaName: this.displayName ?? 'AI Assistant', - domain, - provider, - examplesProcessed: examples.length, - trainingTime: examples.length * 25, - finalLoss: 0.5, - timestamp: Date.now() - }; - await Events.emit(AI_LEARNING_EVENTS.TRAINING_COMPLETE, trainingCompleteData); - - // Future implementation: - // await Commands.execute('genome/train', { - // personaId: this.personaId, - // provider: 'unsloth', - // domain, - // trainingExamples: examples, - // dryRun: false - // }); + await this.saveState(); } } } catch (error) { this.log(`❌ Error checking training readiness: ${error}`); } } + + /** + * Convert accumulator-format examples (input/output) to fine-tuning format (messages[]). + * The accumulator stores raw I/O pairs; the training pipeline expects chat completion format. + */ + private convertAccumulatorExamples(accExamples: AccumulatorExample[]): TrainingExample[] { + return accExamples.map(ex => ({ + messages: [ + { role: 'user' as const, content: ex.input }, + { role: 'assistant' as const, content: ex.output }, + ], + metadata: { + timestamp: ex.timestamp.getTime(), + confidence: ex.feedback?.rating, + }, + })); + } + + /** + * Execute real LoRA fine-tuning via genome/job-create. + * + * Flow: examples β†’ JSONL file on disk β†’ genome/job-create β†’ provider adapter β†’ training job + */ + private async executeTraining( + traitType: TraitType, + examples: TrainingExample[], + provider: string, + ): Promise { + try { + // Build dataset from accumulated examples + const dataset: TrainingDataset = { + examples, + metadata: { + personaId: this.personaId, + personaName: this.displayName ?? 'AI Assistant', + traitType, + createdAt: Date.now(), + source: 'conversations', + totalExamples: examples.length, + }, + }; + + // Validate dataset quality before training + const validation = TrainingDatasetBuilder.validateDataset(dataset); + if (!validation.valid) { + this.log(`❌ Dataset validation failed: ${validation.errors.join(', ')}`); + await Events.emit(AI_LEARNING_EVENTS.TRAINING_ERROR, { + personaId: this.personaId, + personaName: this.displayName ?? 'AI Assistant', + domain: traitType, + error: `Dataset validation failed: ${validation.errors.join(', ')}`, + phase: 'preparation', + timestamp: Date.now(), + } satisfies AITrainingErrorEventData); + return; + } + + if (validation.warnings.length > 0) { + this.log(`⚠️ Dataset warnings: ${validation.warnings.join(', ')}`); + } + + // Export to JSONL and write to disk + const jsonlContent = TrainingDatasetBuilder.exportToJSONL(dataset); + const jsonlPath = await this.writeTrainingFile(traitType, jsonlContent); + + this.log(`πŸ“ Training data written to ${jsonlPath} (${examples.length} examples)`); + + // Create fine-tuning job via the working command + const result = await GenomeJobCreate.execute({ + personaId: this.personaId, + provider, + trainingFileId: jsonlPath, + configuration: { + model: { baseModel: 'llama3.2' }, + datasets: { trainingFileId: jsonlPath }, + method: { + type: TrainingMethod.LORA, + loraConfig: { rank: 16, alpha: 32, dropout: 0, trainableModules: 'all-linear' }, + }, + schedule: { + epochs: 3, + batchSize: 4, + sequenceLength: 2048, + gradientAccumulation: 1, + checkpoints: 1, + evaluations: 1, + trainOnInputs: TrainOnInputs.DISABLED, + }, + optimizer: { + learningRate: 0.0001, + scheduler: { type: LRSchedulerType.COSINE, minLRRatio: 0, warmupRatio: 0.1 }, + weightDecay: 0, + maxGradientNorm: 1, + }, + optimizations: { enabled: [] }, + output: {}, + metadata: {}, + }, + }); + + if (result.success && result.job) { + this.log(`πŸš€ Training job created: ${result.job.jobId} (provider: ${provider})`); + // TRAINING_STARTED already emitted above; completion will be + // emitted by the training job when it finishes asynchronously + } else { + this.log(`❌ Training job creation failed: ${result.error}`); + await Events.emit(AI_LEARNING_EVENTS.TRAINING_ERROR, { + personaId: this.personaId, + personaName: this.displayName ?? 'AI Assistant', + domain: traitType, + error: result.error ?? 'Unknown error creating training job', + phase: 'preparation', + timestamp: Date.now(), + } satisfies AITrainingErrorEventData); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + this.log(`❌ Training execution failed: ${errorMsg}`); + await Events.emit(AI_LEARNING_EVENTS.TRAINING_ERROR, { + personaId: this.personaId, + personaName: this.displayName ?? 'AI Assistant', + domain: traitType, + error: errorMsg, + phase: 'preparation', + timestamp: Date.now(), + } satisfies AITrainingErrorEventData); + } + } + + /** + * Write JSONL training data to disk. + * Returns the file path for genome/job-create. + */ + private async writeTrainingFile(traitType: TraitType, jsonlContent: string): Promise { + const trainingDir = path.resolve('.continuum', 'training', 'auto', this.personaId); + await fs.promises.mkdir(trainingDir, { recursive: true }); + + const timestamp = Date.now(); + const filename = `${traitType}-${timestamp}.jsonl`; + const filePath = path.join(trainingDir, filename); + + await fs.promises.writeFile(filePath, jsonlContent, 'utf-8'); + return filePath; + } } diff --git a/src/debug/jtag/system/user/server/modules/QueueItemTypes.ts b/src/debug/jtag/system/user/server/modules/QueueItemTypes.ts index a0aa0e93f..0ef1c5380 100644 --- a/src/debug/jtag/system/user/server/modules/QueueItemTypes.ts +++ b/src/debug/jtag/system/user/server/modules/QueueItemTypes.ts @@ -181,6 +181,29 @@ export function fromRustServiceItem(json: Record): QueueItem | return msg; } + if (itemType === 'code') { + // Map Rust CodeQueueItem β†’ TS InboxTask with domain='code' + const task: InboxTask = { + id: json.id as UUID, + type: 'task', + taskId: json.id as UUID, + assigneeId: json.persona_id as UUID ?? json.personaId as UUID, + createdBy: json.persona_id as UUID ?? json.personaId as UUID, + domain: 'code' as TaskDomain, + taskType: (json.is_review ?? json.isReview) ? 'review-code' as TaskType : 'write-feature' as TaskType, + contextId: json.room_id as UUID ?? json.roomId as UUID, + description: json.task_description as string ?? json.taskDescription as string ?? '', + priority: json.priority as number, + status: 'pending' as TaskStatus, + timestamp: json.timestamp as number, + enqueuedAt: json.timestamp as number, + metadata: { + roomId: json.room_id as UUID ?? json.roomId as UUID, + }, + }; + return task; + } + if (itemType === 'task') { const task: InboxTask = { id: json.id as UUID, diff --git a/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts b/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts index 39a93e020..6cb5eb774 100644 --- a/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts +++ b/src/debug/jtag/system/user/server/modules/ToolFormatAdapter.ts @@ -432,21 +432,104 @@ export function getPrimaryAdapter(): ToolFormatAdapter { import type { NativeToolSpec } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; /** - * Sanitize tool name for Anthropic API + * Sanitize tool name for Anthropic/OpenAI API * API requires: ^[a-zA-Z0-9_-]{1,128}$ - * Our tools have slashes like 'data/list', 'collaboration/chat/send' + * Our tools have slashes: code/write β†’ code_write */ export function sanitizeToolName(name: string): string { - // Replace slashes with double underscores (reversible) - return name.replace(/\//g, '__'); + return ToolNameCodec.instance.encode(name); } /** - * Restore original tool name from sanitized version + * Restore original tool name from sanitized version (legacy β€” prefer ToolNameCodec) */ export function unsanitizeToolName(sanitizedName: string): string { - // Restore slashes from double underscores - return sanitizedName.replace(/__/g, '/'); + return ToolNameCodec.instance.decode(sanitizedName); +} + +/** + * Bidirectional encoder/decoder for tool names sent over APIs. + * + * API constraint: Anthropic/OpenAI require tool names matching [a-zA-Z0-9_-]{1,64}. + * Our tools use slashes: code/write, collaboration/chat/send. + * + * Encode: code/write β†’ code_write (slashes β†’ underscore) + * Decode: ANY model-produced variant β†’ original name (via reverse lookup) + * + * Models mangle names in unpredictable ways: + * code__write, $FUNCTIONS.code_write, code_write, code-write, etc. + * The codec handles all of these by registering normalized variants at startup. + */ +export class ToolNameCodec { + private static _instance: ToolNameCodec | null = null; + private readonly originals: Set = new Set(); + private readonly reverseMap: Map = new Map(); + + static get instance(): ToolNameCodec { + if (!ToolNameCodec._instance) { + ToolNameCodec._instance = new ToolNameCodec(); + } + return ToolNameCodec._instance; + } + + /** Register a tool name and all plausible encoded/mangled variants for reverse lookup */ + register(toolName: string): void { + this.originals.add(toolName); + this.reverseMap.set(toolName, toolName); + + // Canonical encoded form: slashes β†’ single underscore (standard snake_case) + const encoded = toolName.replace(/\//g, '_'); + this.reverseMap.set(encoded, toolName); + + // Legacy double-underscore encoding (backwards compat with old sessions) + const doubleEncoded = toolName.replace(/\//g, '__'); + this.reverseMap.set(doubleEncoded, toolName); + + // Hyphen variant: code/write β†’ code-write + this.reverseMap.set(toolName.replace(/\//g, '-'), toolName); + + // Dot variant: code/write β†’ code.write + this.reverseMap.set(toolName.replace(/\//g, '.'), toolName); + } + + /** Register all tool names from a tool definitions array */ + registerAll(tools: Array<{ name: string }>): void { + for (const tool of tools) { + this.register(tool.name); + } + } + + /** Encode a tool name for API transmission: slashes β†’ underscores */ + encode(toolName: string): string { + return toolName.replace(/\//g, '_'); + } + + /** Decode any model-produced tool name variant back to the original */ + decode(raw: string): string { + // 1. Exact match (fastest path) + const exact = this.reverseMap.get(raw); + if (exact) return exact; + + // 2. Strip known prefixes models add ($FUNCTIONS., functions., $tools.) + let cleaned = raw.replace(/^\$?(?:functions|tools)\./i, ''); + const prefixMatch = this.reverseMap.get(cleaned); + if (prefixMatch) return prefixMatch; + + // 3. Normalize separators to underscore and try lookup + const normalized = cleaned.replace(/[-.__]/g, '_').toLowerCase(); + const normMatch = this.reverseMap.get(normalized); + if (normMatch) return normMatch; + + // 4. Try reconstructing with slashes: replace __ first, then remaining _ + const doubleUnderscored = cleaned.replace(/__/g, '/'); + if (this.originals.has(doubleUnderscored)) return doubleUnderscored; + + const singleUnderscored = cleaned.replace(/_/g, '/'); + if (this.originals.has(singleUnderscored)) return singleUnderscored; + + // 5. Last resort: best-effort reconstruction via double underscore + return doubleUnderscored; + } } /** @@ -456,6 +539,11 @@ export function unsanitizeToolName(sanitizedName: string): string { * This enables native tool_use instead of XML parsing, which is more reliable. */ export function convertToNativeToolSpecs(tools: ToolDefinition[]): NativeToolSpec[] { + // Register all tools with the codec before encoding β€” ensures the reverse map + // has entries for every tool name we send to the API so decode() can resolve + // any model-produced variant (e.g. $FUNCTIONS.code_write) back to code/write. + ToolNameCodec.instance.registerAll(tools); + return tools.map(tool => { // Convert our ToolDefinition to Anthropic's input_schema format const properties: Record = {}; @@ -474,7 +562,7 @@ export function convertToNativeToolSpecs(tools: ToolDefinition[]): NativeToolSpe } return { - // Sanitize name for API (data/list -> data__list) + // Sanitize name for API (data/list -> data_list) name: sanitizeToolName(tool.name), description: tool.description, input_schema: { @@ -494,3 +582,31 @@ export function supportsNativeTools(provider: string): boolean { const nativeToolProviders = ['anthropic', 'openai', 'azure']; return nativeToolProviders.includes(provider.toLowerCase()); } + +/** + * Tool capability tier for a given provider/model combination. + * - 'native': JSON tool_use blocks (Anthropic, OpenAI, Azure) + * - 'xml': XML tool calls parsed by ToolCallParser (DeepSeek β€” proven to work) + * - 'none': Model narrates instead of calling tools β€” don't inject tools + */ +export type ToolCapability = 'native' | 'xml' | 'none'; + +/** + * Determine a model's tool-calling capability. + * Provider-based auto-detection with per-persona override via modelConfig.toolCapability. + */ +export function getToolCapability( + provider: string, + modelConfig?: { toolCapability?: ToolCapability } +): ToolCapability { + if (modelConfig?.toolCapability) return modelConfig.toolCapability; + + if (supportsNativeTools(provider)) return 'native'; + + // Proven XML-capable providers (model emits well-formed tool call blocks) + const xmlCapable = ['deepseek']; + if (xmlCapable.includes(provider.toLowerCase())) return 'xml'; + + // Everything else: groq, together, xai, fireworks, candle, sentinel, ollama + return 'none'; +} diff --git a/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts b/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts index 4f8b8d32c..5eda0dfec 100644 --- a/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts +++ b/src/debug/jtag/system/user/server/modules/being/MotorCortex.ts @@ -26,6 +26,8 @@ export interface PersonaUserForMotorCortex { readonly homeDirectory: string; readonly logger: import('../PersonaLogger').PersonaLogger; readonly memory: { genome: import('../PersonaGenome').PersonaGenome }; // For trained LoRA adapter access + /** Auto-bootstrap workspace when code/* tools are invoked */ + readonly ensureCodeWorkspace?: () => Promise; } export class MotorCortex { @@ -42,7 +44,11 @@ export class MotorCortex { this.logger.info('Motor cortex initializing...'); // Create toolExecutor and toolRegistry first - this.toolExecutor = new PersonaToolExecutor(personaUser); + // Pass ensureCodeWorkspace callback so code/* tools auto-bootstrap a workspace + this.toolExecutor = new PersonaToolExecutor({ + ...personaUser, + ensureCodeWorkspace: personaUser.ensureCodeWorkspace, + }); this.toolRegistry = new PersonaToolRegistry(); this.toolRegistry.registerPersona(personaUser.id, 'assistant'); // Default to assistant role diff --git a/src/debug/jtag/system/user/server/modules/being/logging/SubsystemLogger.ts b/src/debug/jtag/system/user/server/modules/being/logging/SubsystemLogger.ts index bef1da8b5..8e9abda15 100644 --- a/src/debug/jtag/system/user/server/modules/being/logging/SubsystemLogger.ts +++ b/src/debug/jtag/system/user/server/modules/being/logging/SubsystemLogger.ts @@ -95,21 +95,31 @@ export class SubsystemLogger { } } - // Delegate all logging methods to ComponentLogger + // Delegate logging methods to ComponentLogger, gated by LoggingConfig. + // When persona logging is OFF, no log files are created or written. + // Errors still surface in server.log via console capture. + + private get _enabled(): boolean { + return LoggingConfig.isEnabled(this.uniqueId, this.subsystem); + } debug(message: string, ...args: unknown[]): void { + if (!this._enabled) return; this.logger.debug(message, ...args); } info(message: string, ...args: unknown[]): void { + if (!this._enabled) return; this.logger.info(message, ...args); } warn(message: string, ...args: unknown[]): void { + if (!this._enabled) return; this.logger.warn(message, ...args); } error(message: string, ...args: unknown[]): void { + if (!this._enabled) return; this.logger.error(message, ...args); } @@ -117,6 +127,7 @@ export class SubsystemLogger { * Conditional debug logging (only executes if debug level enabled) */ debugIf(messageFn: () => [string, ...any[]]): void { + if (!this._enabled) return; this.logger.debugIf(messageFn); } diff --git a/src/debug/jtag/system/user/server/modules/cognition/CognitionLogger.ts b/src/debug/jtag/system/user/server/modules/cognition/CognitionLogger.ts index b757d6a8c..8f21b8519 100644 --- a/src/debug/jtag/system/user/server/modules/cognition/CognitionLogger.ts +++ b/src/debug/jtag/system/user/server/modules/cognition/CognitionLogger.ts @@ -129,14 +129,15 @@ export class CognitionLogger { sequenceNumber }; - // Store to database (fire-and-forget) - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_STATE_SNAPSHOTS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger state snapshot write failed:', err)); } catch (error) { console.error(`❌ CognitionLogger: Failed to log state snapshot:`, error); // Don't throw - logging failures shouldn't break persona functionality @@ -223,14 +224,15 @@ export class CognitionLogger { modelUsed }; - // Store to database (fire-and-forget) - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_PLAN_RECORDS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger plan formulation write failed:', err)); } catch (error) { console.error(`❌ CognitionLogger: Failed to log plan formulation:`, error); } @@ -240,130 +242,134 @@ export class CognitionLogger { * Log plan completion * Called when a plan finishes (success or failure) */ - static async logPlanCompletion( + static logPlanCompletion( planId: UUID, status: 'completed' | 'failed' | 'aborted', steps: PlanStepSnapshot[], evaluation?: Evaluation - ): Promise { - try { - // Find the plan record in database - const planRecords = await DataList.execute({ - collection: COLLECTIONS.COGNITION_PLAN_RECORDS, - filter: { planId }, - limit: 1, - backend: 'server', - context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }) as DataListResult; - - if (!planRecords.items || planRecords.items.length === 0) { - console.warn(`⚠️ CognitionLogger: No plan record found for planId=${planId}`); - return; - } - - const planRecord = planRecords.items[0]; - - // Build evaluation snapshot if provided - let evaluationSnapshot: PlanEvaluation | undefined; - if (evaluation) { - evaluationSnapshot = { - meetsSuccessCriteria: evaluation.meetsSuccessCriteria, - criteriaBreakdown: evaluation.criteriaBreakdown, - whatWorked: evaluation.whatWorked, - mistakes: evaluation.mistakes, - improvements: evaluation.improvements, - extractedPattern: evaluation.extractedPattern, - evaluatedAt: evaluation.evaluatedAt, - duration: evaluation.duration, - stepsExecuted: evaluation.stepsExecuted, - replansRequired: evaluation.replansRequired - }; + ): void { + // Fire-and-forget: run the async chain but don't block caller + (async () => { + try { + // Find the plan record in database + const planRecords = await DataList.execute({ + collection: COLLECTIONS.COGNITION_PLAN_RECORDS, + filter: { planId }, + limit: 1, + backend: 'server', + context: DataDaemon.jtagContext!, + sessionId: DataDaemon.jtagContext!.uuid + }) as DataListResult; + + if (!planRecords.items || planRecords.items.length === 0) { + return; + } + + const planRecord = planRecords.items[0]; + + // Build evaluation snapshot if provided + let evaluationSnapshot: PlanEvaluation | undefined; + if (evaluation) { + evaluationSnapshot = { + meetsSuccessCriteria: evaluation.meetsSuccessCriteria, + criteriaBreakdown: evaluation.criteriaBreakdown, + whatWorked: evaluation.whatWorked, + mistakes: evaluation.mistakes, + improvements: evaluation.improvements, + extractedPattern: evaluation.extractedPattern, + evaluatedAt: evaluation.evaluatedAt, + duration: evaluation.duration, + stepsExecuted: evaluation.stepsExecuted, + replansRequired: evaluation.replansRequired + }; + } + + // Update plan record + const completedAt = Date.now(); + const totalDuration = completedAt - planRecord.startedAt; + + await DataUpdate.execute({ + collection: COLLECTIONS.COGNITION_PLAN_RECORDS, + id: planRecord.id, + data: { + status, + steps, + completedAt, + totalDuration, + evaluation: evaluationSnapshot + }, + backend: 'server', + context: DataDaemon.jtagContext!, + sessionId: DataDaemon.jtagContext!.uuid + }); + } catch (error) { + console.error(`❌ CognitionLogger: Failed to log plan completion:`, error); } - - // Update plan record - const completedAt = Date.now(); - const totalDuration = completedAt - planRecord.startedAt; - - await DataUpdate.execute({ - collection: COLLECTIONS.COGNITION_PLAN_RECORDS, - id: planRecord.id, - data: { - status, - steps, - completedAt, - totalDuration, - evaluation: evaluationSnapshot - }, - backend: 'server', - context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); - } catch (error) { - console.error(`❌ CognitionLogger: Failed to log plan completion:`, error); - } + })(); } /** * Log plan adjustment * Called when a plan is adjusted mid-execution */ - static async logPlanAdjustment( + static logPlanAdjustment( planId: UUID, adjustment: PlanAdjustment - ): Promise { - try { - // Find the plan record in database - const planRecords = await DataList.execute({ - collection: COLLECTIONS.COGNITION_PLAN_RECORDS, - filter: { planId }, - limit: 1, - backend: 'server', - context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }) as DataListResult; + ): void { + // Fire-and-forget: run the async chain but don't block caller + (async () => { + try { + // Find the plan record in database + const planRecords = await DataList.execute({ + collection: COLLECTIONS.COGNITION_PLAN_RECORDS, + filter: { planId }, + limit: 1, + backend: 'server', + context: DataDaemon.jtagContext!, + sessionId: DataDaemon.jtagContext!.uuid + }) as DataListResult; + + if (!planRecords.items || planRecords.items.length === 0) { + return; + } + + const planRecord = planRecords.items[0]; + + // Build adjustment snapshot + const adjustmentSnapshot: PlanAdjustmentSnapshot = { + timestamp: Date.now(), + reason: `Plan adjustment: ${adjustment.action}`, + action: adjustment.action, + updatedSteps: adjustment.updatedPlan.steps.map(s => ({ + stepNumber: s.stepNumber, + action: s.action, + expectedOutcome: s.expectedOutcome, + completed: s.completed, + completedAt: s.completedAt, + result: s.result + })), + reasoning: adjustment.reasoning + }; - if (!planRecords.items || planRecords.items.length === 0) { - console.warn(`⚠️ CognitionLogger: No plan record found for planId=${planId}`); - return; + // Update plan record + const updatedAdjustments = [...planRecord.adjustments, adjustmentSnapshot]; + + await DataUpdate.execute({ + collection: COLLECTIONS.COGNITION_PLAN_RECORDS, + id: planRecord.id, + data: { + adjustments: updatedAdjustments, + steps: adjustmentSnapshot.updatedSteps, + currentStep: adjustment.updatedPlan.currentStep + }, + backend: 'server', + context: DataDaemon.jtagContext!, + sessionId: DataDaemon.jtagContext!.uuid + }); + } catch (error) { + console.error(`❌ CognitionLogger: Failed to log plan adjustment:`, error); } - - const planRecord = planRecords.items[0]; - - // Build adjustment snapshot - const adjustmentSnapshot: PlanAdjustmentSnapshot = { - timestamp: Date.now(), - reason: `Plan adjustment: ${adjustment.action}`, - action: adjustment.action, - updatedSteps: adjustment.updatedPlan.steps.map(s => ({ - stepNumber: s.stepNumber, - action: s.action, - expectedOutcome: s.expectedOutcome, - completed: s.completed, - completedAt: s.completedAt, - result: s.result - })), - reasoning: adjustment.reasoning - }; - - // Update plan record - const updatedAdjustments = [...planRecord.adjustments, adjustmentSnapshot]; - - await DataUpdate.execute({ - collection: COLLECTIONS.COGNITION_PLAN_RECORDS, - id: planRecord.id, - data: { - adjustments: updatedAdjustments, - steps: adjustmentSnapshot.updatedSteps, - currentStep: adjustment.updatedPlan.currentStep - }, - backend: 'server', - context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); - } catch (error) { - console.error(`❌ CognitionLogger: Failed to log plan adjustment:`, error); - } + })(); } /** @@ -429,14 +435,15 @@ export class CognitionLogger { sequenceNumber }; - // Store to database (fire-and-forget) - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.TOOL_EXECUTION_LOGS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger tool execution write failed:', err)); } catch (error) { console.error(`❌ CognitionLogger: Failed to log tool execution:`, error); } @@ -494,14 +501,15 @@ export class CognitionLogger { sequenceNumber }; - // Store to database (fire-and-forget) - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.ADAPTER_DECISION_LOGS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger adapter decision write failed:', err)); } catch (error) { console.error(`❌ CognitionLogger: Failed to log adapter decision:`, error); } @@ -578,14 +586,15 @@ export class CognitionLogger { sequenceNumber }; - // Store to database (fire-and-forget) - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.RESPONSE_GENERATION_LOGS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger response generation write failed:', err)); } catch (error) { console.error(`❌ CognitionLogger: Failed to log response generation:`, error); } @@ -636,13 +645,15 @@ export class CognitionLogger { sequenceNumber }; - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_PLAN_STEP_EXECUTIONS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger plan step write failed:', err)); // Success log removed - data already persisted } catch (error) { @@ -689,13 +700,15 @@ export class CognitionLogger { sequenceNumber }; - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_SELF_STATE_UPDATES, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger self-state update write failed:', err)); // Success log removed - data already persisted } catch (error) { @@ -746,13 +759,15 @@ export class CognitionLogger { sequenceNumber }; - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_MEMORY_OPERATIONS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger memory operation write failed:', err)); // Success log removed - data already persisted } catch (error) { @@ -801,13 +816,15 @@ export class CognitionLogger { sequenceNumber }; - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.ADAPTER_REASONING_LOGS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger adapter reasoning write failed:', err)); // Success log removed - data already persisted } catch (error) { @@ -864,13 +881,15 @@ export class CognitionLogger { modelUsed }; - await DataCreate.execute({ + // Fire-and-forget: cognition logs are observability, not user-facing + DataCreate.execute({ collection: COLLECTIONS.COGNITION_PLAN_REPLANS, data: entityData, backend: 'server', context: DataDaemon.jtagContext!, - sessionId: DataDaemon.jtagContext!.uuid - }); + sessionId: DataDaemon.jtagContext!.uuid, + suppressEvents: true + }).catch(err => console.error('CognitionLogger plan replan write failed:', err)); // Success log removed - data already persisted } catch (error) { diff --git a/src/debug/jtag/system/user/server/modules/cognition/DecisionAdapterChain.ts b/src/debug/jtag/system/user/server/modules/cognition/DecisionAdapterChain.ts index 3cc2eeb03..8dd873fb9 100644 --- a/src/debug/jtag/system/user/server/modules/cognition/DecisionAdapterChain.ts +++ b/src/debug/jtag/system/user/server/modules/cognition/DecisionAdapterChain.ts @@ -84,9 +84,9 @@ export class DecisionAdapterChain { this.log(` βœ… ${adapter.name} handled decision: ${decision.shouldRespond ? 'RESPOND' : 'SILENT'} (confidence: ${decision.confidence.toFixed(2)})`); this.log(` πŸ’­ Reason: ${decision.reason}`); - // Log adapter decision to cognition database + // Log adapter decision (fire-and-forget β€” no longer blocks pipeline) const adapterDecision: AdapterDecision = decision.shouldRespond ? 'RESPOND' : 'SILENT'; - await CognitionLogger.logAdapterDecision( + CognitionLogger.logAdapterDecision( context.personaId, context.personaDisplayName, adapter.name, @@ -103,8 +103,8 @@ export class DecisionAdapterChain { } else { this.log(` ⏭️ ${adapter.name} returned null - trying next adapter`); - // Log PASS decision (adapter chose not to handle) - await CognitionLogger.logAdapterDecision( + // Log PASS decision (fire-and-forget β€” no longer blocks pipeline) + CognitionLogger.logAdapterDecision( context.personaId, context.personaDisplayName, adapter.name, diff --git a/src/debug/jtag/system/user/server/modules/cognition/memory/InboxObserver.ts b/src/debug/jtag/system/user/server/modules/cognition/memory/InboxObserver.ts index 1586578cb..5b1cc0b25 100644 --- a/src/debug/jtag/system/user/server/modules/cognition/memory/InboxObserver.ts +++ b/src/debug/jtag/system/user/server/modules/cognition/memory/InboxObserver.ts @@ -36,13 +36,6 @@ export class InboxObserver { * Get inbox depth (how many items are queued) */ async getDepth(): Promise { - try { - // Get current queue size - const items = await this.inbox.peek(1000); // Peek all - return items.length; - } catch (error) { - this.log(`❌ Error getting inbox depth: ${error}`); - return 0; - } + return this.inbox.getSize(); } } diff --git a/src/debug/jtag/system/user/server/modules/cognitive/memory/Hippocampus.ts b/src/debug/jtag/system/user/server/modules/cognitive/memory/Hippocampus.ts index 70e1d5e8e..68b84e261 100644 --- a/src/debug/jtag/system/user/server/modules/cognitive/memory/Hippocampus.ts +++ b/src/debug/jtag/system/user/server/modules/cognitive/memory/Hippocampus.ts @@ -488,7 +488,7 @@ export class Hippocampus extends PersonaContinuousSubprocess { } } - // Append to Rust corpus β€” keeps in-memory cache coherent with longterm.db + // Append to Rust corpus (fire-and-forget β€” cache coherence, not blocking) // Without this, Rust recall is blind to memories created after startup. const bridge = this.persona.rustCognitionBridge; if (bridge) { @@ -513,7 +513,9 @@ export class Hippocampus extends PersonaContinuousSubprocess { }, embedding: memory.embedding ?? null, }; - await bridge.memoryAppendMemory(corpusMemory); + bridge.memoryAppendMemory(corpusMemory).catch(err => + this.log(`⚠️ Rust corpus append failed for ${memory.id}: ${err}`) + ); } } else { failedCount++; diff --git a/src/debug/jtag/system/user/server/modules/cognitive/memory/PersonaMemory.ts b/src/debug/jtag/system/user/server/modules/cognitive/memory/PersonaMemory.ts index bde9486a2..ce7537d4a 100644 --- a/src/debug/jtag/system/user/server/modules/cognitive/memory/PersonaMemory.ts +++ b/src/debug/jtag/system/user/server/modules/cognitive/memory/PersonaMemory.ts @@ -86,7 +86,7 @@ export class PersonaMemory { // Check if record exists const existing = await DataDaemon.read(PERSONA_RAG_CONTEXTS_COLLECTION, recordId); - if (existing.success && existing.data) { + if (existing) { // Update existing record (DataDaemon handles updatedAt) await DataDaemon.update(PERSONA_RAG_CONTEXTS_COLLECTION, recordId, record as any); } else { @@ -108,16 +108,14 @@ export class PersonaMemory { const recordId = `rag-${this.personaId}-${roomId}`; try { - const result = await DataDaemon.read(PERSONA_RAG_CONTEXTS_COLLECTION, recordId); + const entity = await DataDaemon.read(PERSONA_RAG_CONTEXTS_COLLECTION, recordId); - if (!result.success || !result.data) { + if (!entity) { return null; } - // Parse the stored JSON context from the data.data.contextJson field - // DataRecord structure: { id, collection, data: { ...entityFields }, ... } - const entityData = result.data.data as any; - const storedContext = entityData?.contextJson; + // Parse the stored JSON context from the entity's contextJson field + const storedContext = (entity as Record).contextJson as string | undefined; if (typeof storedContext === 'string') { return JSON.parse(storedContext) as PersonaRAGContext; diff --git a/src/debug/jtag/system/user/server/modules/consciousness/UnifiedConsciousness.ts b/src/debug/jtag/system/user/server/modules/consciousness/UnifiedConsciousness.ts index 4bfb57732..c70acc919 100644 --- a/src/debug/jtag/system/user/server/modules/consciousness/UnifiedConsciousness.ts +++ b/src/debug/jtag/system/user/server/modules/consciousness/UnifiedConsciousness.ts @@ -177,7 +177,9 @@ export class UnifiedConsciousness { }, embedding: event.embedding ?? null, }; - await this._rustBridge.memoryAppendEvent(corpusEvent); + this._rustBridge.memoryAppendEvent(corpusEvent).catch(err => + this.log.warn(`⚠️ Rust corpus event append failed: ${err}`) + ); } // Update focus tracking if we're switching contexts diff --git a/src/debug/jtag/system/user/shared/BaseUser.ts b/src/debug/jtag/system/user/shared/BaseUser.ts index 0d680b8d8..fed0f571d 100644 --- a/src/debug/jtag/system/user/shared/BaseUser.ts +++ b/src/debug/jtag/system/user/shared/BaseUser.ts @@ -391,14 +391,12 @@ export abstract class BaseUser { displayName: string ): Promise { // Read current room - const roomResult = await DataDaemon.read(COLLECTIONS.ROOMS, roomId); - if (!roomResult.success || !roomResult.data) { + const room = await DataDaemon.read(COLLECTIONS.ROOMS, roomId); + if (!room) { console.warn(`⚠️ ${this.name}.create: Room ${roomId} not found`); return; } - const room = roomResult.data.data; - // Check if already a member if (room.members.some((m: { userId: UUID }) => m.userId === userId)) { console.log(`ℹ️ ${this.name}.create: ${displayName} already member of room ${room.name}`); diff --git a/src/debug/jtag/system/user/storage/server/SQLiteStateBackend.ts b/src/debug/jtag/system/user/storage/server/SQLiteStateBackend.ts index 04d136eb0..043dbb7fc 100644 --- a/src/debug/jtag/system/user/storage/server/SQLiteStateBackend.ts +++ b/src/debug/jtag/system/user/storage/server/SQLiteStateBackend.ts @@ -46,7 +46,7 @@ export class SQLiteStateBackend implements IUserStateStorage { // Use DataDaemon static interface (avoids JTAGClient recursion during initialization) const existing = await DataDaemon.read(UserStateEntity.collection, state.id); - if (existing.success && existing.data) { + if (existing) { // Update existing state await DataDaemon.update(UserStateEntity.collection, state.id, state); } else { diff --git a/src/debug/jtag/system/voice/server/AIAudioBridge.ts b/src/debug/jtag/system/voice/server/AIAudioBridge.ts index 70e86adf1..654fd05e5 100644 --- a/src/debug/jtag/system/voice/server/AIAudioBridge.ts +++ b/src/debug/jtag/system/voice/server/AIAudioBridge.ts @@ -225,7 +225,6 @@ export class AIAudioBridge { * The Rust adapter's resolve_voice() handles all mapping. */ async speak(callId: string, userId: UUID, text: string, voice?: string): Promise { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: AIAudioBridge.speak CALLED - userId=${userId?.slice(0, 8)}, text="${text.slice(0, 50)}..."`); const key = `${callId}-${userId}`; const connection = this.connections.get(key); @@ -257,7 +256,6 @@ export class AIAudioBridge { // Pass userId as voice identifier β€” Rust adapter's resolve_voice() handles mapping // This ensures each AI always gets a consistent unique voice per adapter const voiceId = voice ?? userId; - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: AIAudioBridge calling VoiceService.synthesizeSpeech with voiceId=${voiceId.slice(0, 8)}...`); // Use VoiceService (handles TTS synthesis) const voiceService = getVoiceService(); @@ -271,7 +269,6 @@ export class AIAudioBridge { // result.audioSamples is already i16 array ready to send const samples = result.audioSamples; const audioDurationSec = samples.length / 16000; - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: AIAudioBridge TTS result - samples=${samples.length}, duration=${audioDurationSec.toFixed(2)}s`); // SERVER-SIDE BUFFERING: Send ALL audio at once // Rust server has a 60-second ring buffer per AI participant diff --git a/src/debug/jtag/system/voice/server/VoiceOrchestrator.ts b/src/debug/jtag/system/voice/server/VoiceOrchestrator.ts index e22125aa3..152c8871f 100644 --- a/src/debug/jtag/system/voice/server/VoiceOrchestrator.ts +++ b/src/debug/jtag/system/voice/server/VoiceOrchestrator.ts @@ -330,10 +330,7 @@ export class VoiceOrchestrator { const THINKING_BUFFER_MS = 3000; // 3 seconds for AI to start responding (reduced from 10s) this.lastSpeechEndTime.set(sessionId, Date.now() + THINKING_BUFFER_MS); - console.log(`πŸŽ™οΈ VoiceOrchestrator: Arbiter selected ${selectedResponder.displayName} to respond (blocking for 3s while thinking)`); - // Send directed event ONLY to the selected responder - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: Emitting voice:transcription:directed to ${selectedResponder.displayName} (targetPersonaId=${selectedResponder.userId?.slice(0, 8)})`); Events.emit('voice:transcription:directed', { sessionId: event.sessionId, speakerId: event.speakerId, @@ -354,7 +351,7 @@ export class VoiceOrchestrator { private trackVoiceResponder(sessionId: UUID, personaId: UUID): void { this.voiceResponders.set(sessionId, personaId); - console.log(`πŸŽ™οΈ VoiceOrchestrator: Tracking ${personaId.slice(0, 8)} as voice responder for session ${sessionId.slice(0, 8)}`); + // Voice responder tracked for session } /** @@ -383,17 +380,13 @@ export class VoiceOrchestrator { response: string, originalMessage: InboxMessage ): Promise { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: onPersonaResponse CALLED - personaId=${personaId?.slice(0, 8)}, response="${response.slice(0, 50)}..."`); // Only handle voice messages if (originalMessage.sourceModality !== 'voice' || !originalMessage.voiceSessionId) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: onPersonaResponse - NOT a voice message, returning early`); return; } const sessionId = originalMessage.voiceSessionId; - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: onPersonaResponse - Routing to TTS for session ${sessionId.slice(0, 8)}`); - // Clean up pending response this.pendingResponses.delete(originalMessage.id); @@ -439,10 +432,7 @@ export class VoiceOrchestrator { response: string; originalMessage: InboxMessage; }) => { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: VoiceOrchestrator RECEIVED persona:response:generated from ${event.personaId?.slice(0, 8)}`); - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: isVoiceMessage=${this.isVoiceMessage(event.originalMessage)}, sourceModality=${event.originalMessage?.sourceModality}, voiceSessionId=${event.originalMessage?.voiceSessionId?.slice(0, 8) || 'undefined'}`); if (this.isVoiceMessage(event.originalMessage)) { - console.log(`πŸŽ™οΈπŸ”Š VOICE-DEBUG: Routing to TTS - calling onPersonaResponse`); await this.onPersonaResponse(event.personaId, event.response, event.originalMessage); } }); diff --git a/src/debug/jtag/system/voice/server/VoiceService.ts b/src/debug/jtag/system/voice/server/VoiceService.ts index efd40fc29..37e89a102 100644 --- a/src/debug/jtag/system/voice/server/VoiceService.ts +++ b/src/debug/jtag/system/voice/server/VoiceService.ts @@ -10,6 +10,7 @@ import type { VoiceConfig, TTSAdapter } from '../shared/VoiceConfig'; import { DEFAULT_VOICE_CONFIG } from '../shared/VoiceConfig'; import { AUDIO_SAMPLE_RATE } from '../../../shared/AudioConstants'; import { VoiceSynthesize } from '../../../commands/voice/synthesize/shared/VoiceSynthesizeTypes'; +import { VoiceTranscribe } from '../../../commands/voice/transcribe/shared/VoiceTranscribeTypes'; export interface SynthesizeSpeechRequest { text: string; userId?: string; // For per-user preferences @@ -171,11 +172,24 @@ export class VoiceService { } /** - * Transcribe audio to text (future - not implemented yet) + * Transcribe audio to text via voice/transcribe command (Rust Whisper STT) */ async transcribeAudio(audioSamples: Int16Array, sampleRate: number): Promise { - // TODO: Implement STT via voice/transcribe command - throw new Error('Not implemented yet'); + // Convert Int16Array to base64 for the command + const buffer = Buffer.from(audioSamples.buffer, audioSamples.byteOffset, audioSamples.byteLength); + const audio = buffer.toString('base64'); + + const result = await VoiceTranscribe.execute({ + audio, + format: 'pcm16', + language: 'auto', + }); + + if (!result.success) { + throw new Error(result.error?.message ?? 'Transcription failed'); + } + + return result.text; } } diff --git a/src/debug/jtag/system/voice/server/adapters/GeminiLiveAdapter.ts b/src/debug/jtag/system/voice/server/adapters/GeminiLiveAdapter.ts index a37199803..d78380555 100644 --- a/src/debug/jtag/system/voice/server/adapters/GeminiLiveAdapter.ts +++ b/src/debug/jtag/system/voice/server/adapters/GeminiLiveAdapter.ts @@ -213,11 +213,24 @@ export class GeminiLiveAdapter implements IAudioNativeAdapter { /** * Cancel the current response + * + * Gemini Live API does not have an explicit response.cancel event. + * Interruption is handled server-side via VAD (when new audio input arrives, + * the server interrupts the current response). We clear local state and + * send an empty realtimeInput to signal the client wants to interrupt. */ cancelResponse(): void { - // Send interrupt/cancel message if supported - // Gemini may use a different mechanism - console.log('πŸ”Š Gemini Live: Cancel not yet implemented'); + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; + } + + // Send empty realtimeInput to signal interruption intent + // The server's VAD will handle the actual interruption + this.ws.send(JSON.stringify({ + clientContent: { + turnComplete: true, + }, + })); } /** diff --git a/src/debug/jtag/tests/unit/code/CodeGitCommand.test.ts b/src/debug/jtag/tests/unit/code/CodeGitCommand.test.ts new file mode 100644 index 000000000..4db76cd23 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/CodeGitCommand.test.ts @@ -0,0 +1,57 @@ +/** + * Code Git Command Unit Tests + * + * Tests SecurityTier integration, PlanFormulator tool schema, + * and CodingAction/ACTION_TO_COMMAND for the commit action. + */ + +import { describe, it, expect } from 'vitest'; +import { getTier } from '../../../system/code/server/SecurityTier'; + +describe('CodeGitCommand', () => { + describe('SecurityTier integration', () => { + it('code/git is allowed at read tier', () => { + const tier = getTier('read'); + expect(tier.allowedCommands).toContain('code/git'); + }); + + it('code/git is allowed at write tier (inherited from read)', () => { + const tier = getTier('write'); + expect(tier.allowedCommands).toContain('code/git'); + }); + + it('code/git is NOT allowed at discovery tier', () => { + const tier = getTier('discovery'); + expect(tier.allowedCommands).not.toContain('code/git'); + }); + + it('code/git is allowed at system tier (wildcard)', () => { + const tier = getTier('system'); + expect(tier.allowedCommands).toContain('*'); + }); + }); + + describe('CodingAction commit type', () => { + it('commit is a valid CodingAction', () => { + // Type check β€” if this compiles, the type exists + const action: import('../../../system/code/shared/CodingTypes').CodingAction = 'commit'; + expect(action).toBe('commit'); + }); + }); + + describe('operation validation', () => { + const VALID_OPS = ['status', 'diff', 'log', 'add', 'commit', 'push']; + + for (const op of VALID_OPS) { + it(`'${op}' is a valid operation`, () => { + expect(VALID_OPS).toContain(op); + }); + } + + it('invalid operations are rejected', () => { + expect(VALID_OPS).not.toContain('rebase'); + expect(VALID_OPS).not.toContain('merge'); + expect(VALID_OPS).not.toContain(''); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/CodeVerifyCommand.test.ts b/src/debug/jtag/tests/unit/code/CodeVerifyCommand.test.ts new file mode 100644 index 000000000..4d311eb28 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/CodeVerifyCommand.test.ts @@ -0,0 +1,132 @@ +/** + * Code Verify Command Unit Tests + * + * Tests the code/verify types, SecurityTier integration, and PlanFormulator + * tool schema registration. The actual server command logic is tested + * indirectly through CodeAgentOrchestrator (auto-verify) and via + * integration tests against the running system. + * + * Direct server command testing requires vitest path alias resolution + * for @daemons/* imports β€” planned when vitest.config.ts is added. + */ + +import { describe, it, expect } from 'vitest'; +import { getTier } from '../../../system/code/server/SecurityTier'; + +describe('code/verify β€” SecurityTier integration', () => { + it('code/verify is allowed at write tier', () => { + const tier = getTier('write'); + expect(tier.allowedCommands).toContain('code/verify'); + }); + + it('code/verify is NOT allowed at discovery tier', () => { + const tier = getTier('discovery'); + expect(tier.allowedCommands).not.toContain('code/verify'); + }); + + it('code/verify is NOT allowed at read tier', () => { + const tier = getTier('read'); + expect(tier.allowedCommands).not.toContain('code/verify'); + }); + + it('code/verify is allowed at system tier (wildcard)', () => { + const tier = getTier('system'); + expect(tier.allowedCommands).toContain('*'); + }); +}); + +describe('code/verify β€” TypeScript error parsing', () => { + // Test the regex pattern used by CodeVerifyServerCommand + const TS_ERROR_REGEX = /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$/gm; + + function parseErrors(output: string): Array<{ file: string; line: number; column: number; code: string; message: string }> { + const errors: Array<{ file: string; line: number; column: number; code: string; message: string }> = []; + TS_ERROR_REGEX.lastIndex = 0; + let match; + while ((match = TS_ERROR_REGEX.exec(output)) !== null) { + errors.push({ + file: match[1], + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + code: match[4], + message: match[5], + }); + } + return errors; + } + + it('parses single TypeScript error', () => { + const output = "src/utils.ts(10,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'."; + const errors = parseErrors(output); + + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual({ + file: 'src/utils.ts', + line: 10, + column: 5, + code: 'TS2345', + message: "Argument of type 'string' is not assignable to parameter of type 'number'.", + }); + }); + + it('parses multiple errors', () => { + const output = [ + "src/utils.ts(10,5): error TS2345: Type error A.", + "src/main.ts(42,12): error TS2304: Cannot find name 'foo'.", + "lib/helpers.ts(1,1): error TS1005: Missing semicolon.", + ].join('\n'); + + const errors = parseErrors(output); + expect(errors).toHaveLength(3); + expect(errors[0].file).toBe('src/utils.ts'); + expect(errors[1].file).toBe('src/main.ts'); + expect(errors[2].file).toBe('lib/helpers.ts'); + }); + + it('handles empty output (no errors)', () => { + const errors = parseErrors(''); + expect(errors).toHaveLength(0); + }); + + it('handles mixed output with non-error lines', () => { + const output = [ + 'Starting TypeScript compilation...', + "src/index.ts(5,3): error TS7006: Parameter 'x' implicitly has an 'any' type.", + 'Found 1 error.', + ].join('\n'); + + const errors = parseErrors(output); + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe('TS7006'); + }); + + it('parses file paths with spaces', () => { + const output = "src/my module/file.ts(3,7): error TS2322: Type mismatch."; + const errors = parseErrors(output); + + expect(errors).toHaveLength(1); + expect(errors[0].file).toBe('src/my module/file.ts'); + }); +}); + +describe('code/verify β€” PlanFormulator tool schema', () => { + // Verify the action β†’ command mapping includes code/verify + it('verify action maps to code/verify in plan', () => { + // The ACTION_TO_COMMAND map in PlanFormulator maps 'verify' β†’ 'code/verify' + // We test this indirectly through the PlanFormulator test suite + // This test validates the expected behavior at the plan level + const ACTION_TO_COMMAND: Record = { + discover: 'code/tree', + search: 'code/search', + read: 'code/read', + write: 'code/write', + edit: 'code/edit', + diff: 'code/diff', + undo: 'code/undo', + verify: 'code/verify', + report: 'code/history', + }; + + expect(ACTION_TO_COMMAND.verify).toBe('code/verify'); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/CodingModelSelector.test.ts b/src/debug/jtag/tests/unit/code/CodingModelSelector.test.ts new file mode 100644 index 000000000..61edbbb38 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/CodingModelSelector.test.ts @@ -0,0 +1,168 @@ +/** + * CodingModelSelector Unit Tests + * + * Tests model routing for different coding task types. + * Validates: + * - Default tier selection for each task type + * - Provider fallback when preferred provider unavailable + * - Edge cases: no providers, single provider + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CodingModelSelector } from '../../../system/code/server/CodingModelSelector'; +import type { CodingTaskType } from '../../../system/code/shared/CodingTypes'; + +describe('CodingModelSelector', () => { + let selector: CodingModelSelector; + + beforeEach(() => { + // Full set of SOTA providers + selector = new CodingModelSelector(new Set([ + 'anthropic', 'openai', 'deepseek', 'groq', 'xai', 'google', 'together', 'fireworks', + ])); + }); + + describe('default tier selection', () => { + it('selects anthropic for planning tasks', () => { + const tier = selector.select('planning'); + expect(tier.provider).toBe('anthropic'); + expect(tier.taskType).toBe('planning'); + expect(tier.temperature).toBeLessThanOrEqual(0.5); + }); + + it('selects anthropic for generation tasks', () => { + const tier = selector.select('generation'); + expect(tier.provider).toBe('anthropic'); + expect(tier.taskType).toBe('generation'); + }); + + it('selects anthropic for editing tasks with low temperature', () => { + const tier = selector.select('editing'); + expect(tier.provider).toBe('anthropic'); + expect(tier.temperature).toBeLessThanOrEqual(0.3); + }); + + it('selects deepseek for review tasks', () => { + const tier = selector.select('review'); + expect(tier.provider).toBe('deepseek'); + expect(tier.taskType).toBe('review'); + }); + + it('selects groq for quick-fix tasks', () => { + const tier = selector.select('quick-fix'); + expect(tier.provider).toBe('groq'); + expect(tier.taskType).toBe('quick-fix'); + }); + + it('selects groq for discovery tasks', () => { + const tier = selector.select('discovery'); + expect(tier.provider).toBe('groq'); + expect(tier.taskType).toBe('discovery'); + }); + }); + + describe('all task types return valid tiers', () => { + const taskTypes: CodingTaskType[] = [ + 'planning', 'generation', 'editing', 'review', 'quick-fix', 'discovery', + ]; + + for (const taskType of taskTypes) { + it(`returns valid tier for "${taskType}"`, () => { + const tier = selector.select(taskType); + expect(tier.taskType).toBe(taskType); + expect(tier.provider).toBeTruthy(); + expect(tier.model).toBeTruthy(); + expect(tier.temperature).toBeGreaterThanOrEqual(0); + expect(tier.temperature).toBeLessThanOrEqual(1); + expect(tier.maxTokens).toBeGreaterThan(0); + expect(tier.description).toBeTruthy(); + }); + } + }); + + describe('provider fallback', () => { + it('falls back when preferred provider is unavailable', () => { + // Only openai available β€” planning defaults to anthropic, should fallback + const limited = new CodingModelSelector(new Set(['openai'])); + const tier = limited.select('planning'); + expect(tier.provider).toBe('openai'); + expect(tier.taskType).toBe('planning'); + }); + + it('falls through fallback order correctly', () => { + // Only groq available + const groqOnly = new CodingModelSelector(new Set(['groq'])); + const tier = groqOnly.select('planning'); + expect(tier.provider).toBe('groq'); + }); + + it('preserves temperature and maxTokens from default tier on fallback', () => { + const limited = new CodingModelSelector(new Set(['deepseek'])); + const tier = limited.select('editing'); + // Should keep editing's low temperature even on fallback + expect(tier.temperature).toBeLessThanOrEqual(0.3); + expect(tier.provider).toBe('deepseek'); + }); + + it('marks fallback in description', () => { + const limited = new CodingModelSelector(new Set(['openai'])); + const tier = limited.select('review'); + // review defaults to deepseek, should fallback to openai + expect(tier.description).toContain('fallback'); + }); + + it('returns default tier when no providers available', () => { + const empty = new CodingModelSelector(new Set()); + const tier = empty.select('planning'); + // Returns default (may fail at runtime), but returns a tier + expect(tier.taskType).toBe('planning'); + expect(tier.provider).toBeTruthy(); + }); + }); + + describe('hasFrontierModel', () => { + it('returns true when frontier providers available', () => { + expect(selector.hasFrontierModel).toBe(true); + }); + + it('returns false when no frontier providers available', () => { + const empty = new CodingModelSelector(new Set()); + expect(empty.hasFrontierModel).toBe(false); + }); + + it('returns true with even a single frontier provider', () => { + const single = new CodingModelSelector(new Set(['groq'])); + expect(single.hasFrontierModel).toBe(true); + }); + + it('returns false with only non-frontier providers', () => { + const local = new CodingModelSelector(new Set(['ollama', 'candle'])); + expect(local.hasFrontierModel).toBe(false); + }); + }); + + describe('available providers update', () => { + it('reflects updated providers in selection', () => { + const limited = new CodingModelSelector(new Set(['groq'])); + expect(limited.select('planning').provider).toBe('groq'); + + // Add anthropic + limited.availableProviders = new Set(['groq', 'anthropic']); + expect(limited.select('planning').provider).toBe('anthropic'); + }); + }); + + describe('allTiers', () => { + it('returns all configured tiers', () => { + const tiers = selector.allTiers; + expect(tiers.length).toBe(6); // 6 task types + const taskTypes = tiers.map(t => t.taskType); + expect(taskTypes).toContain('planning'); + expect(taskTypes).toContain('generation'); + expect(taskTypes).toContain('editing'); + expect(taskTypes).toContain('review'); + expect(taskTypes).toContain('quick-fix'); + expect(taskTypes).toContain('discovery'); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/ExecutionSandbox.test.ts b/src/debug/jtag/tests/unit/code/ExecutionSandbox.test.ts new file mode 100644 index 000000000..221ed7d9d --- /dev/null +++ b/src/debug/jtag/tests/unit/code/ExecutionSandbox.test.ts @@ -0,0 +1,286 @@ +/** + * ExecutionSandbox Unit Tests + * + * Tests process-isolated code execution: + * - Command allowlist enforcement + * - Successful execution with output capture + * - Timeout enforcement (SIGTERM β†’ SIGKILL) + * - Output size truncation + * - Restricted environment variables + * - Spawn error handling + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExecutionSandbox, type SandboxConfig, type SandboxResult } from '../../../system/code/server/ExecutionSandbox'; +import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; + +// Mock Logger +vi.mock('../../../system/core/logging/Logger', () => ({ + Logger: { + create: () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }), + }, +})); + +function makeConfig(overrides?: Partial): SandboxConfig { + return { + command: 'node', + args: ['-e', 'console.log("hello")'], + cwd: '/tmp', + timeoutMs: 5000, + maxOutputBytes: 10240, + personaId: 'test-persona-0001' as UUID, + ...overrides, + }; +} + +describe('ExecutionSandbox', () => { + let sandbox: ExecutionSandbox; + + beforeEach(() => { + sandbox = new ExecutionSandbox(); + }); + + describe('command allowlist', () => { + it('rejects commands not in allowlist', async () => { + const config = makeConfig({ command: 'rm' }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(-1); + expect(result.error).toContain('not in the sandbox allowlist'); + expect(result.error).toContain('rm'); + }); + + it('rejects arbitrary shell commands', async () => { + const config = makeConfig({ command: 'bash' }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(false); + expect(result.error).toContain('not in the sandbox allowlist'); + }); + + it('rejects curl/wget', async () => { + for (const cmd of ['curl', 'wget']) { + const config = makeConfig({ command: cmd }); + const result = await sandbox.execute(config); + expect(result.success).toBe(false); + expect(result.error).toContain('not in the sandbox allowlist'); + } + }); + + it('allows node', async () => { + const config = makeConfig({ command: 'node', args: ['-e', 'process.exit(0)'] }); + const result = await sandbox.execute(config); + // May fail if node not at expected path, but should NOT fail with allowlist error + expect(result.error ?? '').not.toContain('not in the sandbox allowlist'); + }); + + it('allows npx', async () => { + const config = makeConfig({ command: 'npx', args: ['--version'] }); + const result = await sandbox.execute(config); + expect(result.error ?? '').not.toContain('not in the sandbox allowlist'); + }); + + it('allows tsc', async () => { + const config = makeConfig({ command: 'tsc', args: ['--version'] }); + const result = await sandbox.execute(config); + expect(result.error ?? '').not.toContain('not in the sandbox allowlist'); + }); + + it('allows npm', async () => { + const config = makeConfig({ command: 'npm', args: ['--version'] }); + const result = await sandbox.execute(config); + expect(result.error ?? '').not.toContain('not in the sandbox allowlist'); + }); + + it('extracts basename for path commands', async () => { + // /usr/local/bin/node should still match "node" in allowlist + const config = makeConfig({ command: '/usr/local/bin/node', args: ['-e', 'process.exit(0)'] }); + const result = await sandbox.execute(config); + expect(result.error ?? '').not.toContain('not in the sandbox allowlist'); + }); + }); + + describe('successful execution', () => { + it('captures stdout', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log("sandbox-output")'], + }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('sandbox-output'); + expect(result.timedOut).toBe(false); + expect(result.truncated).toBe(false); + }); + + it('captures stderr', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.error("err-msg")'], + }); + const result = await sandbox.execute(config); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('err-msg'); + }); + + it('tracks duration', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'setTimeout(() => {}, 50)'], + }); + const result = await sandbox.execute(config); + + expect(result.durationMs).toBeGreaterThan(0); + }); + + it('reports non-zero exit code as failure', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'process.exit(42)'], + }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(42); + expect(result.timedOut).toBe(false); + }); + }); + + describe('timeout enforcement', () => { + it('kills process on timeout', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'setTimeout(() => {}, 60000)'], // Would run 60s + timeoutMs: 500, // Kill after 500ms + }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(false); + expect(result.timedOut).toBe(true); + expect(result.error).toContain('Timed out'); + }, 10_000); + }); + + describe('output size limits', () => { + it('truncates output exceeding maxOutputBytes', async () => { + // Generate output larger than limit + const config = makeConfig({ + command: 'node', + args: ['-e', `for(let i=0;i<500;i++) console.log("x".repeat(100))`], + maxOutputBytes: 1024, // 1KB limit + }); + const result = await sandbox.execute(config); + + expect(result.truncated).toBe(true); + // stdout should be capped near maxOutputBytes + expect(result.stdout.length).toBeLessThanOrEqual(1200); // some tolerance + }); + }); + + describe('environment isolation', () => { + it('sets SANDBOX_EXECUTION env var', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log(process.env.SANDBOX_EXECUTION)'], + }); + const result = await sandbox.execute(config); + + expect(result.stdout).toContain('true'); + }); + + it('sets NODE_ENV to sandbox', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log(process.env.NODE_ENV)'], + }); + const result = await sandbox.execute(config); + + expect(result.stdout).toContain('sandbox'); + }); + + it('sets PERSONA_ID', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log(process.env.PERSONA_ID)'], + personaId: 'test-persona-xyz' as UUID, + }); + const result = await sandbox.execute(config); + + expect(result.stdout).toContain('test-persona-xyz'); + }); + + it('restricts PATH', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log(process.env.PATH)'], + }); + const result = await sandbox.execute(config); + + // PATH should only contain restricted locations + const pathDirs = result.stdout.trim().split(':'); + const allowedDirs = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']; + for (const dir of pathDirs) { + expect(allowedDirs).toContain(dir); + } + }); + + it('merges custom env vars', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log(process.env.CUSTOM_VAR)'], + env: { CUSTOM_VAR: 'test-value' }, + }); + const result = await sandbox.execute(config); + + expect(result.stdout).toContain('test-value'); + }); + }); + + describe('result structure', () => { + it('returns all required fields on success', async () => { + const config = makeConfig({ + command: 'node', + args: ['-e', 'console.log("ok")'], + }); + const result = await sandbox.execute(config); + + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('exitCode'); + expect(result).toHaveProperty('stdout'); + expect(result).toHaveProperty('stderr'); + expect(result).toHaveProperty('durationMs'); + expect(result).toHaveProperty('truncated'); + expect(result).toHaveProperty('timedOut'); + expect(typeof result.success).toBe('boolean'); + expect(typeof result.exitCode).toBe('number'); + expect(typeof result.stdout).toBe('string'); + expect(typeof result.stderr).toBe('string'); + expect(typeof result.durationMs).toBe('number'); + expect(typeof result.truncated).toBe('boolean'); + expect(typeof result.timedOut).toBe('boolean'); + }); + + it('returns all required fields on allowlist rejection', async () => { + const config = makeConfig({ command: 'forbidden-cmd' }); + const result = await sandbox.execute(config); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(-1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + expect(result.durationMs).toBe(0); + expect(result.truncated).toBe(false); + expect(result.timedOut).toBe(false); + expect(result.error).toBeTruthy(); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/SecurityTier.test.ts b/src/debug/jtag/tests/unit/code/SecurityTier.test.ts new file mode 100644 index 000000000..0c7211ea5 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/SecurityTier.test.ts @@ -0,0 +1,205 @@ +/** + * SecurityTier Unit Tests + * + * Tests the risk-based access control tier system: + * - Tier definitions (discovery, read, write, system) + * - Tier lookups and ordering + * - Risk β†’ tier mapping + * - Risk β†’ approval requirement mapping + */ + +import { describe, it, expect } from 'vitest'; +import { + getTier, + tierAtLeast, + riskToTier, + riskRequiresApproval, + TIER_LEVELS, + type SecurityTierLevel, + type RiskLevel, +} from '../../../system/code/server/SecurityTier'; + +describe('SecurityTier', () => { + describe('getTier()', () => { + it('returns discovery tier', () => { + const tier = getTier('discovery'); + expect(tier.level).toBe('discovery'); + expect(tier.allowProcessSpawn).toBe(false); + expect(tier.allowNetworkAccess).toBe(false); + expect(tier.requiresApproval).toBe(false); + expect(tier.maxFileSizeBytes).toBe(0); + }); + + it('returns read tier', () => { + const tier = getTier('read'); + expect(tier.level).toBe('read'); + expect(tier.allowProcessSpawn).toBe(false); + expect(tier.maxFileSizeBytes).toBe(0); + }); + + it('returns write tier', () => { + const tier = getTier('write'); + expect(tier.level).toBe('write'); + expect(tier.allowProcessSpawn).toBe(false); + expect(tier.maxFileSizeBytes).toBeGreaterThan(0); + }); + + it('returns system tier', () => { + const tier = getTier('system'); + expect(tier.level).toBe('system'); + expect(tier.allowProcessSpawn).toBe(true); + expect(tier.allowNetworkAccess).toBe(true); + expect(tier.requiresApproval).toBe(true); + }); + }); + + describe('tier allowlists', () => { + it('discovery tier allows only read-type commands', () => { + const tier = getTier('discovery'); + expect(tier.allowedCommands).toContain('code/tree'); + expect(tier.allowedCommands).toContain('code/search'); + expect(tier.allowedCommands).toContain('code/read'); + expect(tier.allowedCommands).toContain('code/history'); + expect(tier.allowedCommands).not.toContain('code/write'); + expect(tier.allowedCommands).not.toContain('code/edit'); + }); + + it('discovery tier explicitly denies write and system commands', () => { + const tier = getTier('discovery'); + expect(tier.deniedCommands).toContain('code/write'); + expect(tier.deniedCommands).toContain('code/edit'); + expect(tier.deniedCommands).toContain('development/*'); + expect(tier.deniedCommands).toContain('system/*'); + }); + + it('read tier extends discovery with analysis commands', () => { + const tier = getTier('read'); + expect(tier.allowedCommands).toContain('code/tree'); + expect(tier.allowedCommands).toContain('code/diff'); + expect(tier.allowedCommands).toContain('data/list'); + expect(tier.allowedCommands).toContain('data/read'); + expect(tier.allowedCommands).not.toContain('code/write'); + }); + + it('write tier adds mutation commands', () => { + const tier = getTier('write'); + expect(tier.allowedCommands).toContain('code/write'); + expect(tier.allowedCommands).toContain('code/edit'); + expect(tier.allowedCommands).toContain('code/undo'); + }); + + it('write tier includes code/verify for build verification', () => { + const tier = getTier('write'); + expect(tier.allowedCommands).toContain('code/verify'); + }); + + it('write tier denies shell and system commands', () => { + const tier = getTier('write'); + expect(tier.deniedCommands).toContain('development/exec'); + expect(tier.deniedCommands).toContain('development/sandbox-execute'); + expect(tier.deniedCommands).toContain('system/*'); + }); + + it('system tier allows everything', () => { + const tier = getTier('system'); + expect(tier.allowedCommands).toContain('*'); + expect(tier.deniedCommands).toEqual([]); + }); + }); + + describe('tier budgets', () => { + it('discovery tier has moderate budget', () => { + const tier = getTier('discovery'); + expect(tier.maxToolCalls).toBe(30); + expect(tier.maxDurationMs).toBe(60_000); + }); + + it('write tier has tighter tool call budget', () => { + const tier = getTier('write'); + expect(tier.maxToolCalls).toBe(20); + expect(tier.maxDurationMs).toBe(120_000); + }); + + it('system tier has generous budget', () => { + const tier = getTier('system'); + expect(tier.maxToolCalls).toBe(50); + expect(tier.maxDurationMs).toBe(300_000); + }); + }); + + describe('TIER_LEVELS ordering', () => { + it('lists tiers in ascending privilege order', () => { + expect(TIER_LEVELS).toEqual(['discovery', 'read', 'write', 'system']); + }); + }); + + describe('tierAtLeast()', () => { + it('same tier is at least itself', () => { + for (const level of TIER_LEVELS) { + expect(tierAtLeast(level, level)).toBe(true); + } + }); + + it('system is at least every tier', () => { + for (const level of TIER_LEVELS) { + expect(tierAtLeast('system', level)).toBe(true); + } + }); + + it('discovery is not at least write', () => { + expect(tierAtLeast('discovery', 'write')).toBe(false); + }); + + it('write is at least read', () => { + expect(tierAtLeast('write', 'read')).toBe(true); + }); + + it('read is not at least write', () => { + expect(tierAtLeast('read', 'write')).toBe(false); + }); + }); + + describe('riskToTier()', () => { + it('low risk maps to write tier', () => { + expect(riskToTier('low')).toBe('write'); + }); + + it('medium risk maps to write tier', () => { + expect(riskToTier('medium')).toBe('write'); + }); + + it('high risk maps to write tier (governance decides approval)', () => { + expect(riskToTier('high')).toBe('write'); + }); + + it('critical risk maps to system tier', () => { + expect(riskToTier('critical')).toBe('system'); + }); + }); + + describe('riskRequiresApproval()', () => { + it('low risk single-agent does not require approval', () => { + expect(riskRequiresApproval('low', false)).toBe(false); + }); + + it('medium risk single-agent does not require approval', () => { + expect(riskRequiresApproval('medium', false)).toBe(false); + }); + + it('high risk single-agent requires approval', () => { + expect(riskRequiresApproval('high', false)).toBe(true); + }); + + it('critical risk always requires approval', () => { + expect(riskRequiresApproval('critical', false)).toBe(true); + expect(riskRequiresApproval('critical', true)).toBe(true); + }); + + it('multi-agent always requires approval regardless of risk', () => { + const risks: RiskLevel[] = ['low', 'medium', 'high', 'critical']; + for (const risk of risks) { + expect(riskRequiresApproval(risk, true)).toBe(true); + } + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/ToolAllowlistEnforcer.test.ts b/src/debug/jtag/tests/unit/code/ToolAllowlistEnforcer.test.ts new file mode 100644 index 000000000..6ca2e7d4a --- /dev/null +++ b/src/debug/jtag/tests/unit/code/ToolAllowlistEnforcer.test.ts @@ -0,0 +1,281 @@ +/** + * ToolAllowlistEnforcer Unit Tests + * + * Tests the per-tier tool filtering gateway: + * - Denied commands always blocked + * - Allowed commands checked via glob matching + * - Process spawn restrictions + * - File size limits for write operations + * - Audit logging + * - Throwing vs non-throwing check modes + */ + +import { describe, it, expect } from 'vitest'; +import { ToolAllowlistEnforcer, ToolDeniedError } from '../../../system/code/server/ToolAllowlistEnforcer'; +import { getTier } from '../../../system/code/server/SecurityTier'; +import type { SecurityTier } from '../../../system/code/server/SecurityTier'; + +describe('ToolAllowlistEnforcer', () => { + describe('discovery tier', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + + it('allows code/read', () => { + expect(() => enforcer.enforce('code/read')).not.toThrow(); + }); + + it('allows code/tree', () => { + expect(() => enforcer.enforce('code/tree')).not.toThrow(); + }); + + it('allows code/search', () => { + expect(() => enforcer.enforce('code/search')).not.toThrow(); + }); + + it('allows code/history', () => { + expect(() => enforcer.enforce('code/history')).not.toThrow(); + }); + + it('blocks code/write (explicit deny)', () => { + expect(() => enforcer.enforce('code/write')).toThrow(ToolDeniedError); + }); + + it('blocks code/edit (explicit deny)', () => { + expect(() => enforcer.enforce('code/edit')).toThrow(ToolDeniedError); + }); + + it('blocks development/* (glob deny)', () => { + expect(() => enforcer.enforce('development/exec')).toThrow(ToolDeniedError); + expect(() => enforcer.enforce('development/sandbox-execute')).toThrow(ToolDeniedError); + }); + + it('blocks system/* (glob deny)', () => { + expect(() => enforcer.enforce('system/anything')).toThrow(ToolDeniedError); + }); + + it('blocks unknown commands not in allowlist', () => { + expect(() => enforcer.enforce('data/list')).toThrow(ToolDeniedError); + }); + }); + + describe('read tier', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('read')); + + it('allows discovery commands', () => { + expect(() => enforcer.enforce('code/read')).not.toThrow(); + expect(() => enforcer.enforce('code/tree')).not.toThrow(); + }); + + it('allows data/list and data/read', () => { + expect(() => enforcer.enforce('data/list')).not.toThrow(); + expect(() => enforcer.enforce('data/read')).not.toThrow(); + }); + + it('allows code/diff', () => { + expect(() => enforcer.enforce('code/diff')).not.toThrow(); + }); + + it('blocks code/write', () => { + expect(() => enforcer.enforce('code/write')).toThrow(ToolDeniedError); + }); + }); + + describe('write tier', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + + it('allows read + write commands', () => { + expect(() => enforcer.enforce('code/read')).not.toThrow(); + expect(() => enforcer.enforce('code/write')).not.toThrow(); + expect(() => enforcer.enforce('code/edit')).not.toThrow(); + expect(() => enforcer.enforce('code/undo')).not.toThrow(); + }); + + it('blocks development/exec (explicit deny)', () => { + expect(() => enforcer.enforce('development/exec')).toThrow(ToolDeniedError); + }); + + it('blocks development/sandbox-execute (explicit deny)', () => { + expect(() => enforcer.enforce('development/sandbox-execute')).toThrow(ToolDeniedError); + }); + + it('blocks system/* commands', () => { + expect(() => enforcer.enforce('system/shell')).toThrow(ToolDeniedError); + }); + }); + + describe('system tier', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('system')); + + it('allows everything (wildcard)', () => { + expect(() => enforcer.enforce('code/read')).not.toThrow(); + expect(() => enforcer.enforce('code/write')).not.toThrow(); + expect(() => enforcer.enforce('development/exec')).not.toThrow(); + expect(() => enforcer.enforce('system/anything')).not.toThrow(); + expect(() => enforcer.enforce('whatever/command')).not.toThrow(); + }); + }); + + describe('file size enforcement', () => { + it('write tier blocks oversized writes', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + const oversizedContent = 'x'.repeat(2_000_000); // 2MB > 1MB limit + + const result = enforcer.check('code/write', { content: oversizedContent }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('exceeds tier limit'); + }); + + it('write tier allows content within size limit', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + const content = 'x'.repeat(1000); + + const result = enforcer.check('code/write', { content }); + expect(result.allowed).toBe(true); + }); + + it('code/edit also checks file size', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + const oversizedContent = 'x'.repeat(2_000_000); + + const result = enforcer.check('code/edit', { content: oversizedContent }); + expect(result.allowed).toBe(false); + }); + + it('discovery tier skips size check (no writes allowed anyway)', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + // code/write is denied in discovery, so even a small write is blocked + const result = enforcer.check('code/write', { content: 'small' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('denied'); + }); + }); + + describe('process spawn restriction', () => { + it('write tier blocks process spawn commands', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + // development/exec is already in denied list for write tier, but also checked via allowProcessSpawn + const result = enforcer.check('development/exec'); + expect(result.allowed).toBe(false); + }); + + it('system tier allows process spawn', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('system')); + const result = enforcer.check('development/exec'); + expect(result.allowed).toBe(true); + }); + }); + + describe('check() (non-throwing)', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + + it('returns allowed=true for permitted commands', () => { + const result = enforcer.check('code/read'); + expect(result.allowed).toBe(true); + expect(result.toolName).toBe('code/read'); + expect(result.tierLevel).toBe('discovery'); + }); + + it('returns allowed=false for denied commands', () => { + const result = enforcer.check('code/write'); + expect(result.allowed).toBe(false); + expect(result.toolName).toBe('code/write'); + expect(result.tierLevel).toBe('discovery'); + expect(result.reason).toBeTruthy(); + }); + }); + + describe('audit log', () => { + it('records every enforce() call', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('write')); + + enforcer.enforce('code/read'); + enforcer.enforce('code/write'); + try { enforcer.enforce('development/exec'); } catch { /* expected */ } + + expect(enforcer.auditLog).toHaveLength(3); + expect(enforcer.auditLog[0].allowed).toBe(true); + expect(enforcer.auditLog[0].toolName).toBe('code/read'); + expect(enforcer.auditLog[1].allowed).toBe(true); + expect(enforcer.auditLog[1].toolName).toBe('code/write'); + expect(enforcer.auditLog[2].allowed).toBe(false); + expect(enforcer.auditLog[2].toolName).toBe('development/exec'); + }); + + it('check() does NOT record to audit log', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + + enforcer.check('code/read'); + enforcer.check('code/write'); + + expect(enforcer.auditLog).toHaveLength(0); + }); + }); + + describe('ToolDeniedError', () => { + it('has correct properties', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + + try { + enforcer.enforce('code/write'); + expect.unreachable('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ToolDeniedError); + const denied = error as ToolDeniedError; + expect(denied.toolName).toBe('code/write'); + expect(denied.tierLevel).toBe('discovery'); + expect(denied.message).toContain('code/write'); + expect(denied.message).toContain('denied'); + } + }); + }); + + describe('glob matching', () => { + it('exact match works', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + const result = enforcer.check('code/read'); + expect(result.allowed).toBe(true); + }); + + it('wildcard * matches everything', () => { + const enforcer = new ToolAllowlistEnforcer(getTier('system')); + const result = enforcer.check('literally/anything'); + expect(result.allowed).toBe(true); + }); + + it('prefix/* matches prefix/anything', () => { + // discovery tier denies development/* + const enforcer = new ToolAllowlistEnforcer(getTier('discovery')); + expect(enforcer.check('development/exec').allowed).toBe(false); + expect(enforcer.check('development/build').allowed).toBe(false); + expect(enforcer.check('development/sandbox-execute').allowed).toBe(false); + }); + + it('prefix/* does not match the prefix itself', () => { + // Create a custom tier for testing + const customTier: SecurityTier = { + level: 'write', + allowedCommands: ['code/*'], + deniedCommands: [], + maxToolCalls: 10, + maxDurationMs: 60_000, + maxFileSizeBytes: 0, + allowProcessSpawn: false, + allowNetworkAccess: false, + requiresApproval: false, + }; + const enforcer = new ToolAllowlistEnforcer(customTier); + + // 'code/*' should match 'code/read' but NOT 'code' itself + expect(enforcer.check('code/read').allowed).toBe(true); + expect(enforcer.check('code').allowed).toBe(false); + }); + }); + + describe('tier property access', () => { + it('exposes the tier', () => { + const tier = getTier('write'); + const enforcer = new ToolAllowlistEnforcer(tier); + expect(enforcer.tier).toBe(tier); + expect(enforcer.tier.level).toBe('write'); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/Workspace.test.ts b/src/debug/jtag/tests/unit/code/Workspace.test.ts new file mode 100644 index 000000000..2313effc9 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/Workspace.test.ts @@ -0,0 +1,701 @@ +/** + * Workspace Unit Tests + * + * Tests that the Workspace class: + * - Creates via WorkspaceStrategy and returns a bound handle + * - Delegates all operations to CodeDaemon with the retained handle + * - Provides fromExisting() for resuming previously created workspaces + * - Cleans up via WorkspaceStrategy.cleanup() + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Workspace } from '../../../system/code/server/Workspace'; +import { WorkspaceStrategy } from '../../../system/code/server/WorkspaceStrategy'; +import { CodeDaemon } from '../../../daemons/code-daemon/shared/CodeDaemon'; +import { CodeVerify } from '../../../commands/code/verify/shared/CodeVerifyTypes'; + +// ── Mock dependencies ────────────────────────────────────── + +vi.mock('../../../system/code/server/WorkspaceStrategy', () => ({ + WorkspaceStrategy: { + create: vi.fn(), + cleanup: vi.fn(), + }, +})); + +vi.mock('../../../daemons/code-daemon/shared/CodeDaemon', () => ({ + CodeDaemon: { + workspaceRead: vi.fn(), + workspaceWrite: vi.fn(), + workspaceEdit: vi.fn(), + workspaceDelete: vi.fn(), + workspaceDiff: vi.fn(), + workspaceSearch: vi.fn(), + workspaceTree: vi.fn(), + workspaceUndo: vi.fn(), + workspaceHistory: vi.fn(), + workspaceGitStatus: vi.fn(), + workspaceGitDiff: vi.fn(), + workspaceGitLog: vi.fn(), + workspaceGitAdd: vi.fn(), + workspaceGitCommit: vi.fn(), + workspaceGitPush: vi.fn(), + // Shell session methods + shellCreate: vi.fn(), + shellExecute: vi.fn(), + shellPoll: vi.fn(), + shellKill: vi.fn(), + shellCd: vi.fn(), + shellStatus: vi.fn(), + shellDestroy: vi.fn(), + // Shell watch + sentinel + shellWatch: vi.fn(), + shellSentinel: vi.fn(), + }, +})); + +vi.mock('../../../commands/code/verify/shared/CodeVerifyTypes', () => ({ + CodeVerify: { + execute: vi.fn(), + }, +})); + +// ── Helpers ──────────────────────────────────────────────── + +const PERSONA_ID = 'test-persona-abc'; +const WORKSPACE_DIR = '/tmp/workspace/test'; +const HANDLE = `worktree-${PERSONA_ID}-fix-auth`; +const BRANCH = 'ai/fix-auth'; + +function mockWorkspaceCreate() { + vi.mocked(WorkspaceStrategy.create).mockResolvedValue({ + handle: HANDLE, + workspaceDir: WORKSPACE_DIR, + mode: 'worktree', + branch: BRANCH, + }); +} + +// ── Tests ────────────────────────────────────────────────── + +describe('Workspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('creation', () => { + it('creates via WorkspaceStrategy and exposes handle, dir, mode, branch', async () => { + mockWorkspaceCreate(); + + const ws = await Workspace.create({ + personaId: PERSONA_ID, + mode: 'worktree', + taskSlug: 'fix-auth', + sparsePaths: ['src/'], + }); + + expect(WorkspaceStrategy.create).toHaveBeenCalledWith({ + personaId: PERSONA_ID, + mode: 'worktree', + taskSlug: 'fix-auth', + sparsePaths: ['src/'], + }); + + expect(ws.handle).toBe(HANDLE); + expect(ws.dir).toBe(WORKSPACE_DIR); + expect(ws.mode).toBe('worktree'); + expect(ws.branch).toBe(BRANCH); + }); + + it('creates sandbox workspace without branch', async () => { + vi.mocked(WorkspaceStrategy.create).mockResolvedValue({ + handle: PERSONA_ID, + workspaceDir: '/tmp/sandbox', + mode: 'sandbox', + }); + + const ws = await Workspace.create({ personaId: PERSONA_ID, mode: 'sandbox' }); + + expect(ws.handle).toBe(PERSONA_ID); + expect(ws.mode).toBe('sandbox'); + expect(ws.branch).toBeUndefined(); + }); + + it('fromExisting creates without calling WorkspaceStrategy', () => { + const ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + + expect(ws.handle).toBe(HANDLE); + expect(ws.dir).toBe(WORKSPACE_DIR); + expect(ws.mode).toBe('worktree'); + expect(ws.branch).toBe(BRANCH); + expect(WorkspaceStrategy.create).not.toHaveBeenCalled(); + }); + }); + + describe('file operations', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + }); + + it('read delegates to CodeDaemon.workspaceRead with handle', async () => { + const mockResult = { content: 'file contents', lineCount: 10, filePath: 'src/auth.ts' }; + vi.mocked(CodeDaemon.workspaceRead).mockResolvedValue(mockResult as any); + + const result = await ws.read('src/auth.ts', 1, 10); + + expect(CodeDaemon.workspaceRead).toHaveBeenCalledWith(HANDLE, 'src/auth.ts', 1, 10); + expect(result).toBe(mockResult); + }); + + it('write delegates to CodeDaemon.workspaceWrite with handle', async () => { + const mockResult = { changeId: 'ch-1', filePath: 'new.ts' }; + vi.mocked(CodeDaemon.workspaceWrite).mockResolvedValue(mockResult as any); + + const result = await ws.write('new.ts', 'content', 'Created new file'); + + expect(CodeDaemon.workspaceWrite).toHaveBeenCalledWith(HANDLE, 'new.ts', 'content', 'Created new file'); + expect(result).toBe(mockResult); + }); + + it('edit delegates to CodeDaemon.workspaceEdit with handle', async () => { + const editMode = { editType: 'search_replace' as const, search: 'old', replace: 'new' }; + vi.mocked(CodeDaemon.workspaceEdit).mockResolvedValue({ changeId: 'ch-2' } as any); + + await ws.edit('src/auth.ts', editMode as any, 'Fix token check'); + + expect(CodeDaemon.workspaceEdit).toHaveBeenCalledWith(HANDLE, 'src/auth.ts', editMode, 'Fix token check'); + }); + + it('delete delegates to CodeDaemon.workspaceDelete with handle', async () => { + vi.mocked(CodeDaemon.workspaceDelete).mockResolvedValue({ changeId: 'ch-3' } as any); + + await ws.delete('old-file.ts', 'Removed unused file'); + + expect(CodeDaemon.workspaceDelete).toHaveBeenCalledWith(HANDLE, 'old-file.ts', 'Removed unused file'); + }); + + it('diff delegates to CodeDaemon.workspaceDiff with handle', async () => { + const editMode = { editType: 'search_replace' as const, search: 'a', replace: 'b' }; + vi.mocked(CodeDaemon.workspaceDiff).mockResolvedValue({ success: true, unified: '--- a\n+++ b' }); + + const result = await ws.diff('file.ts', editMode as any); + + expect(CodeDaemon.workspaceDiff).toHaveBeenCalledWith(HANDLE, 'file.ts', editMode); + expect(result.unified).toContain('---'); + }); + }); + + describe('search and discovery', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + }); + + it('search delegates to CodeDaemon.workspaceSearch with handle', async () => { + vi.mocked(CodeDaemon.workspaceSearch).mockResolvedValue({ matches: [], totalMatches: 0 } as any); + + await ws.search('TODO', '*.ts', 50); + + expect(CodeDaemon.workspaceSearch).toHaveBeenCalledWith(HANDLE, 'TODO', '*.ts', 50); + }); + + it('tree delegates to CodeDaemon.workspaceTree with handle', async () => { + vi.mocked(CodeDaemon.workspaceTree).mockResolvedValue({ root: { name: '.' } } as any); + + await ws.tree('src/', 3, false); + + expect(CodeDaemon.workspaceTree).toHaveBeenCalledWith(HANDLE, 'src/', 3, false); + }); + }); + + describe('change tracking', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + }); + + it('undo delegates to CodeDaemon.workspaceUndo with handle', async () => { + vi.mocked(CodeDaemon.workspaceUndo).mockResolvedValue({ undone: 1 } as any); + + await ws.undo('ch-1'); + + expect(CodeDaemon.workspaceUndo).toHaveBeenCalledWith(HANDLE, 'ch-1', undefined); + }); + + it('history delegates to CodeDaemon.workspaceHistory with handle', async () => { + vi.mocked(CodeDaemon.workspaceHistory).mockResolvedValue({ changes: [] } as any); + + await ws.history('src/auth.ts', 5); + + expect(CodeDaemon.workspaceHistory).toHaveBeenCalledWith(HANDLE, 'src/auth.ts', 5); + }); + }); + + describe('verification', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + }); + + it('verify delegates to CodeVerify.execute with handle as userId', async () => { + vi.mocked(CodeVerify.execute).mockResolvedValue({ success: true } as any); + + await ws.verify(true, ['tests/auth.test.ts']); + + expect(CodeVerify.execute).toHaveBeenCalledWith({ + userId: HANDLE, + typeCheck: true, + testFiles: ['tests/auth.test.ts'], + }); + }); + }); + + describe('git operations', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + }); + + it('gitStatus delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitStatus).mockResolvedValue({ branch: BRANCH } as any); + await ws.gitStatus(); + expect(CodeDaemon.workspaceGitStatus).toHaveBeenCalledWith(HANDLE); + }); + + it('gitDiff delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitDiff).mockResolvedValue({ success: true, diff: '' }); + await ws.gitDiff(true); + expect(CodeDaemon.workspaceGitDiff).toHaveBeenCalledWith(HANDLE, true); + }); + + it('gitLog delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitLog).mockResolvedValue({ success: true, log: '' }); + await ws.gitLog(10); + expect(CodeDaemon.workspaceGitLog).toHaveBeenCalledWith(HANDLE, 10); + }); + + it('gitAdd delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitAdd).mockResolvedValue({ staged: ['.'] }); + await ws.gitAdd(['.']); + expect(CodeDaemon.workspaceGitAdd).toHaveBeenCalledWith(HANDLE, ['.']); + }); + + it('gitCommit delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitCommit).mockResolvedValue({ hash: 'abc123' }); + const result = await ws.gitCommit('Fix auth'); + expect(CodeDaemon.workspaceGitCommit).toHaveBeenCalledWith(HANDLE, 'Fix auth'); + expect(result.hash).toBe('abc123'); + }); + + it('gitPush delegates with handle', async () => { + vi.mocked(CodeDaemon.workspaceGitPush).mockResolvedValue({ output: 'pushed' }); + await ws.gitPush('origin', BRANCH); + expect(CodeDaemon.workspaceGitPush).toHaveBeenCalledWith(HANDLE, 'origin', BRANCH); + }); + }); + + describe('shell session', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + vi.mocked(CodeDaemon.shellCreate).mockResolvedValue({ + session_id: 'sess-1', + persona_id: HANDLE, + cwd: WORKSPACE_DIR, + active_executions: 0, + total_executions: 0, + } as any); + }); + + it('exec auto-creates shell session on first call', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-1', + status: 'completed', + stdout: 'ok', + stderr: null, + exit_code: 0, + } as any); + + await ws.exec('echo hello'); + + expect(CodeDaemon.shellCreate).toHaveBeenCalledWith(HANDLE, WORKSPACE_DIR); + expect(CodeDaemon.shellExecute).toHaveBeenCalledWith(HANDLE, 'echo hello', { + timeoutMs: 30000, + wait: true, + }); + }); + + it('exec only creates shell session once', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-1', status: 'completed', + } as any); + + await ws.exec('echo 1'); + await ws.exec('echo 2'); + + expect(CodeDaemon.shellCreate).toHaveBeenCalledTimes(1); + expect(CodeDaemon.shellExecute).toHaveBeenCalledTimes(2); + }); + + it('exec passes custom timeout', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-1', status: 'completed', + } as any); + + await ws.exec('cargo build', 120000); + + expect(CodeDaemon.shellExecute).toHaveBeenCalledWith(HANDLE, 'cargo build', { + timeoutMs: 120000, + wait: true, + }); + }); + + it('execAsync returns handle immediately (wait=false)', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-long', + status: 'running', + stdout: null, + stderr: null, + exit_code: null, + } as any); + + const result = await ws.execAsync('npm run build'); + + expect(CodeDaemon.shellExecute).toHaveBeenCalledWith(HANDLE, 'npm run build', { + timeoutMs: undefined, + wait: false, + }); + expect(result.execution_id).toBe('exec-long'); + expect(result.status).toBe('running'); + }); + + it('shellPoll delegates to CodeDaemon.shellPoll with handle', async () => { + vi.mocked(CodeDaemon.shellPoll).mockResolvedValue({ + execution_id: 'exec-1', + status: 'running', + new_stdout: ['line 1', 'line 2'], + new_stderr: [], + exit_code: null, + finished: false, + } as any); + + const result = await ws.shellPoll('exec-1'); + + expect(CodeDaemon.shellPoll).toHaveBeenCalledWith(HANDLE, 'exec-1'); + expect(result.new_stdout).toEqual(['line 1', 'line 2']); + expect(result.finished).toBe(false); + }); + + it('shellKill delegates to CodeDaemon.shellKill with handle', async () => { + vi.mocked(CodeDaemon.shellKill).mockResolvedValue(); + + await ws.shellKill('exec-1'); + + expect(CodeDaemon.shellKill).toHaveBeenCalledWith(HANDLE, 'exec-1'); + }); + + it('shellCd auto-creates session and delegates', async () => { + vi.mocked(CodeDaemon.shellCd).mockResolvedValue({ cwd: '/tmp/workspace/test/src' }); + + const result = await ws.shellCd('src'); + + expect(CodeDaemon.shellCreate).toHaveBeenCalledWith(HANDLE, WORKSPACE_DIR); + expect(CodeDaemon.shellCd).toHaveBeenCalledWith(HANDLE, 'src'); + expect(result.cwd).toBe('/tmp/workspace/test/src'); + }); + + it('shellStatus auto-creates session and delegates', async () => { + vi.mocked(CodeDaemon.shellStatus).mockResolvedValue({ + session_id: 'sess-1', + persona_id: HANDLE, + cwd: WORKSPACE_DIR, + active_executions: 0, + total_executions: 3, + } as any); + + const result = await ws.shellStatus(); + + expect(CodeDaemon.shellCreate).toHaveBeenCalledWith(HANDLE, WORKSPACE_DIR); + expect(CodeDaemon.shellStatus).toHaveBeenCalledWith(HANDLE); + expect(result.total_executions).toBe(3); + }); + }); + + describe('shell watch + sentinel', () => { + let ws: Workspace; + + beforeEach(() => { + ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + vi.mocked(CodeDaemon.shellCreate).mockResolvedValue({ + session_id: 'sess-1', + persona_id: HANDLE, + cwd: WORKSPACE_DIR, + active_executions: 0, + total_executions: 0, + } as any); + }); + + it('sentinel delegates to CodeDaemon.shellSentinel with handle', async () => { + vi.mocked(CodeDaemon.shellSentinel).mockResolvedValue({ applied: true, ruleCount: 2 }); + + const rules = [ + { pattern: '^error', classification: 'Error' as const, action: 'Emit' as const }, + { pattern: '.*', classification: 'Verbose' as const, action: 'Suppress' as const }, + ]; + + const result = await ws.sentinel('exec-1', rules); + + expect(CodeDaemon.shellSentinel).toHaveBeenCalledWith(HANDLE, 'exec-1', rules); + expect(result.applied).toBe(true); + expect(result.ruleCount).toBe(2); + }); + + it('watch auto-creates shell and delegates to CodeDaemon.shellWatch', async () => { + const watchResponse = { + execution_id: 'exec-1', + lines: [ + { text: 'Compiling...', classification: 'Info', line_number: 0, stream: 'stdout', timestamp: Date.now() }, + ], + finished: false, + exit_code: undefined, + }; + vi.mocked(CodeDaemon.shellWatch).mockResolvedValue(watchResponse as any); + + const result = await ws.watch('exec-1'); + + expect(CodeDaemon.shellCreate).toHaveBeenCalledWith(HANDLE, WORKSPACE_DIR); + expect(CodeDaemon.shellWatch).toHaveBeenCalledWith(HANDLE, 'exec-1'); + expect(result.lines).toHaveLength(1); + expect(result.lines[0].text).toBe('Compiling...'); + expect(result.finished).toBe(false); + }); + + it('execWatch composes exec β†’ sentinel β†’ watch loop', async () => { + // Mock execAsync + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-build', + status: 'running', + stdout: null, + stderr: null, + exit_code: null, + } as any); + + // Mock sentinel + vi.mocked(CodeDaemon.shellSentinel).mockResolvedValue({ applied: true, ruleCount: 1 }); + + // Mock watch β€” first call returns output, second returns finished + vi.mocked(CodeDaemon.shellWatch) + .mockResolvedValueOnce({ + execution_id: 'exec-build', + lines: [ + { text: 'Building...', classification: 'Info', line_number: 0, stream: 'stdout', timestamp: Date.now() }, + ], + finished: false, + } as any) + .mockResolvedValueOnce({ + execution_id: 'exec-build', + lines: [ + { text: 'Done', classification: 'Success', line_number: 1, stream: 'stdout', timestamp: Date.now() }, + ], + finished: true, + exit_code: 0, + } as any); + + const rules = [ + { pattern: '.*', classification: 'Info' as const, action: 'Emit' as const }, + ]; + const collectedLines: any[] = []; + + const result = await ws.execWatch('cargo build', rules, (line) => { + collectedLines.push(line); + }); + + // Verify composition: exec β†’ sentinel β†’ watch loop + expect(CodeDaemon.shellExecute).toHaveBeenCalledWith(HANDLE, 'cargo build', { + timeoutMs: undefined, + wait: false, + }); + expect(CodeDaemon.shellSentinel).toHaveBeenCalledWith(HANDLE, 'exec-build', rules); + expect(CodeDaemon.shellWatch).toHaveBeenCalledTimes(2); + + // Verify all lines were collected + expect(collectedLines).toHaveLength(2); + expect(collectedLines[0].text).toBe('Building...'); + expect(collectedLines[1].text).toBe('Done'); + + // Verify final response + expect(result.finished).toBe(true); + expect(result.exit_code).toBe(0); + }); + + it('execWatch works without sentinel rules', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-quick', + status: 'running', + } as any); + + vi.mocked(CodeDaemon.shellWatch).mockResolvedValueOnce({ + execution_id: 'exec-quick', + lines: [], + finished: true, + exit_code: 0, + } as any); + + const result = await ws.execWatch('echo hello'); + + // No sentinel should be called + expect(CodeDaemon.shellSentinel).not.toHaveBeenCalled(); + expect(result.finished).toBe(true); + }); + + it('execWatch works without onLine callback', async () => { + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ + execution_id: 'exec-silent', + status: 'running', + } as any); + + vi.mocked(CodeDaemon.shellWatch).mockResolvedValueOnce({ + execution_id: 'exec-silent', + lines: [ + { text: 'output', classification: 'Info', line_number: 0, stream: 'stdout', timestamp: Date.now() }, + ], + finished: true, + exit_code: 0, + } as any); + + // Should not throw even without onLine callback + const result = await ws.execWatch('echo hello'); + expect(result.finished).toBe(true); + }); + }); + + describe('lifecycle', () => { + it('destroy delegates to WorkspaceStrategy.cleanup', async () => { + vi.mocked(WorkspaceStrategy.cleanup).mockResolvedValue(); + + const ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + await ws.destroy({ force: true, deleteBranch: true }); + + expect(WorkspaceStrategy.cleanup).toHaveBeenCalledWith(HANDLE, { + force: true, + deleteBranch: true, + }); + }); + + it('destroy cleans up shell session if one was created', async () => { + vi.mocked(CodeDaemon.shellCreate).mockResolvedValue({} as any); + vi.mocked(CodeDaemon.shellExecute).mockResolvedValue({ execution_id: 'e1' } as any); + vi.mocked(CodeDaemon.shellDestroy).mockResolvedValue(); + vi.mocked(WorkspaceStrategy.cleanup).mockResolvedValue(); + + const ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + // Trigger shell creation + await ws.exec('echo hi'); + // Now destroy + await ws.destroy(); + + expect(CodeDaemon.shellDestroy).toHaveBeenCalledWith(HANDLE); + expect(WorkspaceStrategy.cleanup).toHaveBeenCalledWith(HANDLE, undefined); + }); + + it('destroy skips shell cleanup if no shell was created', async () => { + vi.mocked(WorkspaceStrategy.cleanup).mockResolvedValue(); + + const ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + await ws.destroy(); + + expect(CodeDaemon.shellDestroy).not.toHaveBeenCalled(); + expect(WorkspaceStrategy.cleanup).toHaveBeenCalledWith(HANDLE, undefined); + }); + }); + + describe('multi-workspace isolation', () => { + it('two workspaces from different create calls have independent handles', async () => { + vi.mocked(WorkspaceStrategy.create) + .mockResolvedValueOnce({ + handle: 'worktree-persona-room-a', + workspaceDir: '/tmp/workspace/room-a', + mode: 'worktree', + branch: 'ai/helper/room-a', + }) + .mockResolvedValueOnce({ + handle: 'worktree-persona-room-b', + workspaceDir: '/tmp/workspace/room-b', + mode: 'worktree', + branch: 'ai/helper/room-b', + }); + + const wsA = await Workspace.create({ personaId: PERSONA_ID, mode: 'worktree', taskSlug: 'room-a' }); + const wsB = await Workspace.create({ personaId: PERSONA_ID, mode: 'worktree', taskSlug: 'room-b' }); + + expect(wsA.handle).toBe('worktree-persona-room-a'); + expect(wsB.handle).toBe('worktree-persona-room-b'); + expect(wsA.handle).not.toBe(wsB.handle); + expect(wsA.dir).not.toBe(wsB.dir); + expect(wsA.branch).not.toBe(wsB.branch); + }); + + it('operations on workspace A do not affect workspace B', async () => { + const wsA = Workspace.fromExisting('handle-a', '/tmp/ws-a', 'worktree', 'branch-a'); + const wsB = Workspace.fromExisting('handle-b', '/tmp/ws-b', 'worktree', 'branch-b'); + + vi.mocked(CodeDaemon.workspaceRead).mockResolvedValue({} as any); + vi.mocked(CodeDaemon.workspaceWrite).mockResolvedValue({} as any); + + await wsA.read('file.ts'); + await wsB.write('other.ts', 'content'); + + expect(vi.mocked(CodeDaemon.workspaceRead).mock.calls[0][0]).toBe('handle-a'); + expect(vi.mocked(CodeDaemon.workspaceWrite).mock.calls[0][0]).toBe('handle-b'); + }); + + it('destroying one workspace does not affect another', async () => { + vi.mocked(WorkspaceStrategy.cleanup).mockResolvedValue(); + + const wsA = Workspace.fromExisting('handle-a', '/tmp/ws-a', 'worktree', 'branch-a'); + const wsB = Workspace.fromExisting('handle-b', '/tmp/ws-b', 'worktree', 'branch-b'); + + await wsA.destroy(); + + // wsB should still be usable + vi.mocked(CodeDaemon.workspaceRead).mockResolvedValue({} as any); + await wsB.read('file.ts'); + + expect(WorkspaceStrategy.cleanup).toHaveBeenCalledWith('handle-a', undefined); + expect(CodeDaemon.workspaceRead).toHaveBeenCalledWith('handle-b', 'file.ts', undefined, undefined); + }); + }); + + describe('handle consistency', () => { + it('every operation uses the same handle β€” no handle drift', async () => { + const ws = Workspace.fromExisting(HANDLE, WORKSPACE_DIR, 'worktree', BRANCH); + + // Call several operations + vi.mocked(CodeDaemon.workspaceRead).mockResolvedValue({} as any); + vi.mocked(CodeDaemon.workspaceWrite).mockResolvedValue({} as any); + vi.mocked(CodeDaemon.workspaceSearch).mockResolvedValue({} as any); + vi.mocked(CodeDaemon.workspaceGitAdd).mockResolvedValue({ staged: [] }); + vi.mocked(CodeDaemon.workspaceGitCommit).mockResolvedValue({ hash: '' }); + + await ws.read('a.ts'); + await ws.write('b.ts', 'content'); + await ws.search('pattern'); + await ws.gitAdd(['.']); + await ws.gitCommit('msg'); + + // Every call should have used the exact same handle + expect(vi.mocked(CodeDaemon.workspaceRead).mock.calls[0][0]).toBe(HANDLE); + expect(vi.mocked(CodeDaemon.workspaceWrite).mock.calls[0][0]).toBe(HANDLE); + expect(vi.mocked(CodeDaemon.workspaceSearch).mock.calls[0][0]).toBe(HANDLE); + expect(vi.mocked(CodeDaemon.workspaceGitAdd).mock.calls[0][0]).toBe(HANDLE); + expect(vi.mocked(CodeDaemon.workspaceGitCommit).mock.calls[0][0]).toBe(HANDLE); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/code/WorkspaceStrategy.test.ts b/src/debug/jtag/tests/unit/code/WorkspaceStrategy.test.ts new file mode 100644 index 000000000..ddde533b2 --- /dev/null +++ b/src/debug/jtag/tests/unit/code/WorkspaceStrategy.test.ts @@ -0,0 +1,334 @@ +/** + * WorkspaceStrategy Unit Tests + * + * Tests workspace creation routing (sandbox vs worktree), + * handle tracking, deduplication, and cleanup. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { WorkspaceStrategy } from '../../../system/code/server/WorkspaceStrategy'; +import type { WorkspaceConfig } from '../../../system/code/server/WorkspaceStrategy'; + +// Mock Commands.execute (used for worktree init/clean) +const mockExecute = vi.fn(); +vi.mock('../../../system/core/shared/Commands', () => ({ + Commands: { + execute: (...args: unknown[]) => mockExecute(...args), + }, +})); + +// Mock CodeDaemon.createWorkspace +const mockCreateWorkspace = vi.fn().mockResolvedValue(undefined); +vi.mock('../../../daemons/code-daemon/shared/CodeDaemon', () => ({ + CodeDaemon: { + createWorkspace: (...args: unknown[]) => mockCreateWorkspace(...args), + }, +})); + +// Mock Logger +vi.mock('../../../system/core/logging/Logger', () => ({ + Logger: { + create: () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }), + }, +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), +})); + +describe('WorkspaceStrategy', () => { + beforeEach(() => { + mockExecute.mockReset(); + mockCreateWorkspace.mockReset().mockResolvedValue(undefined); + WorkspaceStrategy.resetTracking(); + }); + + describe('sandbox mode', () => { + it('creates sandbox workspace with correct handle and path', async () => { + const config: WorkspaceConfig = { + personaId: 'persona-123', + mode: 'sandbox', + }; + + const result = await WorkspaceStrategy.create(config); + + expect(result.mode).toBe('sandbox'); + expect(result.handle).toBe('persona-123'); + expect(result.workspaceDir).toContain('.continuum/personas/persona-123/workspace'); + expect(result.branch).toBeUndefined(); + }); + + it('registers with CodeDaemon including jtagRoot as read root', async () => { + const config: WorkspaceConfig = { + personaId: 'persona-456', + mode: 'sandbox', + }; + + await WorkspaceStrategy.create(config); + + expect(mockCreateWorkspace).toHaveBeenCalledTimes(1); + const [handle, workspaceDir, readRoots] = mockCreateWorkspace.mock.calls[0]; + expect(handle).toBe('persona-456'); + expect(workspaceDir).toContain('.continuum/personas/persona-456/workspace'); + expect(readRoots).toHaveLength(1); + expect(readRoots[0]).toBe(process.cwd()); + }); + + it('deduplicates β€” second call returns cached result without re-registering', async () => { + const config: WorkspaceConfig = { + personaId: 'persona-789', + mode: 'sandbox', + }; + + const first = await WorkspaceStrategy.create(config); + const second = await WorkspaceStrategy.create(config); + + expect(first.handle).toBe(second.handle); + expect(first.workspaceDir).toBe(second.workspaceDir); + // CodeDaemon.createWorkspace only called once + expect(mockCreateWorkspace).toHaveBeenCalledTimes(1); + }); + + it('tracks initialized state via isInitialized', async () => { + expect(WorkspaceStrategy.isInitialized('persona-abc')).toBe(false); + + await WorkspaceStrategy.create({ + personaId: 'persona-abc', + mode: 'sandbox', + }); + + expect(WorkspaceStrategy.isInitialized('persona-abc')).toBe(true); + }); + }); + + describe('worktree mode', () => { + it('creates worktree workspace via workspace/git/workspace/init', async () => { + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-branch', + branch: 'ai/fix-bug', + }); + + const config: WorkspaceConfig = { + personaId: 'persona-wt', + mode: 'worktree', + taskSlug: 'fix-bug', + sparsePaths: ['src/system/code/', 'docs/'], + }; + + const result = await WorkspaceStrategy.create(config); + + expect(result.mode).toBe('worktree'); + expect(result.handle).toBe('worktree-persona-wt-fix-bug'); + expect(result.workspaceDir).toBe('/tmp/worktrees/ai-branch'); + expect(result.branch).toBe('ai/fix-bug'); + }); + + it('calls workspace/git/workspace/init with correct params', async () => { + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-work', + branch: 'ai/work', + }); + + await WorkspaceStrategy.create({ + personaId: 'persona-wt2', + mode: 'worktree', + taskSlug: 'work', + sparsePaths: ['src/'], + }); + + expect(mockExecute).toHaveBeenCalledWith( + 'workspace/git/workspace/init', + { + personaId: 'persona-wt2', + branch: 'ai/work', + paths: ['src/'], + } + ); + }); + + it('registers with CodeDaemon with empty read roots (worktree IS the repo)', async () => { + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-test', + branch: 'ai/test', + }); + + await WorkspaceStrategy.create({ + personaId: 'persona-wt3', + mode: 'worktree', + taskSlug: 'test', + sparsePaths: ['src/'], + }); + + expect(mockCreateWorkspace).toHaveBeenCalledWith( + 'worktree-persona-wt3-test', + '/tmp/worktrees/ai-test', + [] + ); + }); + + it('throws when sparsePaths is empty', async () => { + await expect( + WorkspaceStrategy.create({ + personaId: 'persona-fail', + mode: 'worktree', + sparsePaths: [], + }) + ).rejects.toThrow('worktree mode requires sparsePaths'); + }); + + it('throws when sparsePaths is undefined', async () => { + await expect( + WorkspaceStrategy.create({ + personaId: 'persona-fail2', + mode: 'worktree', + }) + ).rejects.toThrow('worktree mode requires sparsePaths'); + }); + + it('throws when workspace/git/workspace/init fails', async () => { + mockExecute.mockResolvedValue({ + success: false, + error: { message: 'Git worktree creation failed: branch already exists' }, + }); + + await expect( + WorkspaceStrategy.create({ + personaId: 'persona-fail3', + mode: 'worktree', + sparsePaths: ['src/'], + }) + ).rejects.toThrow('worktree creation failed'); + }); + + it('defaults taskSlug to work when not provided', async () => { + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-work', + branch: 'ai/work', + }); + + const result = await WorkspaceStrategy.create({ + personaId: 'persona-default', + mode: 'worktree', + sparsePaths: ['src/'], + }); + + expect(result.handle).toBe('worktree-persona-default-work'); + expect(mockExecute).toHaveBeenCalledWith( + 'workspace/git/workspace/init', + expect.objectContaining({ branch: 'ai/work' }) + ); + }); + + it('deduplicates worktree workspaces', async () => { + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-dedup', + branch: 'ai/dedup', + }); + + const config: WorkspaceConfig = { + personaId: 'persona-dedup', + mode: 'worktree', + taskSlug: 'dedup', + sparsePaths: ['src/'], + }; + + await WorkspaceStrategy.create(config); + const second = await WorkspaceStrategy.create(config); + + // Only one init call + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(second.mode).toBe('worktree'); + }); + }); + + describe('cleanup', () => { + it('calls workspace/git/workspace/clean for worktree handles', async () => { + mockExecute.mockResolvedValue({ success: true }); + + await WorkspaceStrategy.cleanup('worktree-persona-abc-task'); + + expect(mockExecute).toHaveBeenCalledWith( + 'workspace/git/workspace/clean', + { force: false, deleteBranch: false } + ); + }); + + it('passes force and deleteBranch options', async () => { + mockExecute.mockResolvedValue({ success: true }); + + await WorkspaceStrategy.cleanup('worktree-persona-abc-task', { + force: true, + deleteBranch: true, + }); + + expect(mockExecute).toHaveBeenCalledWith( + 'workspace/git/workspace/clean', + { force: true, deleteBranch: true } + ); + }); + + it('skips cleanup for non-worktree handles', async () => { + await WorkspaceStrategy.cleanup('persona-123'); + + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('removes handle from tracking after cleanup', async () => { + // First create a worktree workspace + mockExecute.mockResolvedValue({ + success: true, + workspacePath: '/tmp/worktrees/ai-cleanup', + branch: 'ai/cleanup', + }); + + await WorkspaceStrategy.create({ + personaId: 'persona-cleanup', + mode: 'worktree', + taskSlug: 'cleanup', + sparsePaths: ['src/'], + }); + + expect(WorkspaceStrategy.isInitialized('worktree-persona-cleanup-cleanup')).toBe(true); + + // Now clean up + mockExecute.mockResolvedValue({ success: true }); + await WorkspaceStrategy.cleanup('worktree-persona-cleanup-cleanup'); + + expect(WorkspaceStrategy.isInitialized('worktree-persona-cleanup-cleanup')).toBe(false); + }); + + it('handles cleanup errors gracefully without throwing', async () => { + mockExecute.mockRejectedValue(new Error('Git error')); + + // Should not throw + await WorkspaceStrategy.cleanup('worktree-persona-err-task'); + }); + }); + + describe('resetTracking', () => { + it('clears all tracked workspaces', async () => { + await WorkspaceStrategy.create({ + personaId: 'persona-reset', + mode: 'sandbox', + }); + + expect(WorkspaceStrategy.isInitialized('persona-reset')).toBe(true); + + WorkspaceStrategy.resetTracking(); + + expect(WorkspaceStrategy.isInitialized('persona-reset')).toBe(false); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/rag/CodeToolSource.test.ts b/src/debug/jtag/tests/unit/rag/CodeToolSource.test.ts new file mode 100644 index 000000000..650f162c1 --- /dev/null +++ b/src/debug/jtag/tests/unit/rag/CodeToolSource.test.ts @@ -0,0 +1,307 @@ +/** + * CodeToolSource Unit Tests + * + * Tests the CodeToolSource RAGSource in isolation by mocking PersonaToolRegistry. + * Validates: + * - isApplicable() based on persona tool permissions + * - load() generates correct coding workflow prompt + * - Budget-aware: falls back to minimal prompt when budget is tight + * - Caching: repeated calls use cached prompt + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CodeToolSource } from '../../../system/rag/sources/CodeToolSource'; +import type { RAGSourceContext } from '../../../system/rag/shared/RAGSource'; +import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; + +// Mock PersonaToolRegistry +const mockToolsForPersona = vi.fn(); + +vi.mock('../../../system/user/server/modules/PersonaToolRegistry', () => ({ + PersonaToolRegistry: { + sharedInstance: () => ({ + listToolsForPersona: mockToolsForPersona, + }), + }, +})); + +// Mock Logger (avoid real logging in tests) +vi.mock('../../../system/core/logging/Logger', () => ({ + Logger: { + create: () => ({ + debug: () => {}, + error: () => {}, + warn: () => {}, + info: () => {}, + }), + }, +})); + +/** + * Helper to create a fake tool definition + */ +function fakeTool(name: string, description = `${name} command`) { + return { + name, + description, + category: name.startsWith('code/') ? 'code' as const : 'system' as const, + permissions: ['code:search'], + parameters: { type: 'object' as const, properties: {}, required: [] }, + examples: [], + }; +} + +/** + * Helper to build a RAGSourceContext + */ +function makeContext(overrides?: Partial): RAGSourceContext { + return { + personaId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as UUID, + roomId: '11111111-2222-3333-4444-555555555555' as UUID, + options: {}, + totalBudget: 2000, + ...overrides, + }; +} + +describe('CodeToolSource', () => { + let source: CodeToolSource; + + beforeEach(() => { + source = new CodeToolSource(); + mockToolsForPersona.mockReset(); + // Clear the static cache between tests + (CodeToolSource as any)._cachedPrompt = null; + (CodeToolSource as any)._cacheGeneratedAt = 0; + }); + + describe('interface properties', () => { + it('has correct name', () => { + expect(source.name).toBe('code-tools'); + }); + + it('has medium priority (50)', () => { + expect(source.priority).toBe(50); + }); + + it('has 5% default budget', () => { + expect(source.defaultBudgetPercent).toBe(5); + }); + }); + + describe('isApplicable', () => { + it('returns true when persona has code/* tools', () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('collaboration/chat/send'), + ]); + + expect(source.isApplicable(makeContext())).toBe(true); + }); + + it('returns false when persona has no code/* tools', () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('collaboration/chat/send'), + fakeTool('data/list'), + fakeTool('screenshot'), + ]); + + expect(source.isApplicable(makeContext())).toBe(false); + }); + + it('returns false when persona has zero tools', () => { + mockToolsForPersona.mockReturnValue([]); + + expect(source.isApplicable(makeContext())).toBe(false); + }); + }); + + describe('load', () => { + it('returns coding workflow guidance when persona has code tools', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('code/edit'), + fakeTool('code/tree'), + fakeTool('code/search'), + fakeTool('code/diff'), + fakeTool('code/undo'), + fakeTool('code/history'), + ]); + + const section = await source.load(makeContext(), 500); + + expect(section.sourceName).toBe('code-tools'); + expect(section.tokenCount).toBeGreaterThan(0); + expect(section.loadTimeMs).toBeGreaterThanOrEqual(0); + expect(section.systemPromptSection).toBeDefined(); + }); + + it('includes workflow steps matching available tool groups', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('code/edit'), + fakeTool('code/tree'), + fakeTool('code/search'), + fakeTool('code/diff'), + fakeTool('code/undo'), + fakeTool('code/history'), + ]); + + const section = await source.load(makeContext(), 500); + const prompt = section.systemPromptSection!; + + // Each tool group has a corresponding workflow step + expect(prompt).toContain('**Discover**'); + expect(prompt).toContain('**Read**'); + expect(prompt).toContain('**Preview**'); + expect(prompt).toContain('**Edit**'); + expect(prompt).toContain('**Undo**'); + // Numbered steps + expect(prompt).toMatch(/1\. \*\*Discover\*\*/); + expect(prompt).toMatch(/2\. \*\*Read\*\*/); + }); + + it('includes code/* command names in grouped sections', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/tree'), + fakeTool('code/search'), + fakeTool('code/edit'), + fakeTool('code/diff'), + fakeTool('code/undo'), + fakeTool('code/history'), + ]); + + const section = await source.load(makeContext(), 500); + const prompt = section.systemPromptSection!; + + // Check grouped tool names + expect(prompt).toContain('code/tree'); + expect(prompt).toContain('code/search'); + expect(prompt).toContain('code/read'); + expect(prompt).toContain('code/edit'); + expect(prompt).toContain('code/diff'); + expect(prompt).toContain('code/undo'); + expect(prompt).toContain('code/history'); + }); + + it('only includes tools the persona has access to', async () => { + // Persona only has read and search β€” no write/edit/diff/undo/history + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/search'), + fakeTool('code/tree'), + ]); + + const section = await source.load(makeContext(), 500); + const prompt = section.systemPromptSection!; + + // Available tools appear in grouped sections + expect(prompt).toContain('code/read'); + expect(prompt).toContain('code/search'); + expect(prompt).toContain('code/tree'); + + // Unavailable tool groups should not appear β€” neither in groups nor workflow steps + expect(prompt).not.toContain('code/write'); + expect(prompt).not.toContain('code/edit'); + expect(prompt).not.toContain('code/diff'); + expect(prompt).not.toContain('code/undo'); + expect(prompt).not.toContain('code/history'); + + // Change graph note should not appear for read-only personas + expect(prompt).not.toContain('change graph'); + }); + + it('includes metadata with code tool count', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('code/edit'), + fakeTool('collaboration/chat/send'), // not a code tool + ]); + + const section = await source.load(makeContext(), 500); + + expect(section.metadata).toBeDefined(); + expect(section.metadata!.codeToolCount).toBe(3); + }); + + it('returns minimal prompt when budget is very tight', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('code/edit'), + fakeTool('code/tree'), + fakeTool('code/search'), + fakeTool('code/diff'), + fakeTool('code/undo'), + fakeTool('code/history'), + ]); + + // Allocate almost zero budget β€” forces minimal prompt + const section = await source.load(makeContext(), 10); + const prompt = section.systemPromptSection!; + + // Minimal prompt should be a compact one-liner + expect(prompt.length).toBeLessThan(200); + expect(prompt).toContain('Code tools available'); + expect(prompt).toContain('Read before editing'); + }); + + it('returns empty section on error', async () => { + mockToolsForPersona.mockImplementation(() => { + throw new Error('Registry unavailable'); + }); + + const section = await source.load(makeContext(), 500); + + expect(section.sourceName).toBe('code-tools'); + expect(section.tokenCount).toBe(0); + expect(section.metadata).toHaveProperty('error'); + }); + }); + + describe('caching', () => { + it('caches the prompt on first load', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + ]); + + const section1 = await source.load(makeContext(), 500); + const section2 = await source.load(makeContext(), 500); + + // Both should have identical content + expect(section1.systemPromptSection).toBe(section2.systemPromptSection); + // Second load should be faster (cache hit) + // Not strictly testing timing, but verifying the cache path works + }); + + it('invalidates cache after TTL', async () => { + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + ]); + + await source.load(makeContext(), 500); + + // Force cache expiry + (CodeToolSource as any)._cacheGeneratedAt = Date.now() - 11 * 60 * 1000; // 11 min ago + + // Now add more tools + mockToolsForPersona.mockReturnValue([ + fakeTool('code/read'), + fakeTool('code/write'), + fakeTool('code/edit'), + ]); + + const section = await source.load(makeContext(), 500); + + // Should reflect the new tools + expect(section.systemPromptSection).toContain('code/write'); + expect(section.systemPromptSection).toContain('code/edit'); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/skill/SkillEntity.test.ts b/src/debug/jtag/tests/unit/skill/SkillEntity.test.ts new file mode 100644 index 000000000..772779422 --- /dev/null +++ b/src/debug/jtag/tests/unit/skill/SkillEntity.test.ts @@ -0,0 +1,438 @@ +/** + * SkillEntity Unit Tests + * + * Tests the self-modifying skill entity: + * - Construction and default values + * - Validation (required fields, naming convention, spec consistency) + * - Status lifecycle transitions + * - Computed properties (isActive, requiresApproval, canAdvance, nextStatus) + * - Collection and pagination config + */ + +import { describe, it, expect } from 'vitest'; +import { + SkillEntity, + type SkillSpec, + type SkillStatus, + type SkillScope, + type SkillParamSpec, + type SkillResultSpec, + type SkillValidationResults, +} from '../../../system/data/entities/SkillEntity'; +import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; +import { COLLECTIONS } from '../../../system/shared/Constants'; + +function makeSpec(overrides?: Partial): SkillSpec { + return { + name: 'analysis/complexity', + description: 'Analyzes code complexity metrics', + params: [ + { name: 'filePath', type: 'string', description: 'Path to analyze' }, + ], + results: [ + { name: 'complexity', type: 'number', description: 'Cyclomatic complexity score' }, + { name: 'message', type: 'string', description: 'Human-readable summary' }, + ], + implementation: 'Parse the file AST and count decision branches for cyclomatic complexity.', + accessLevel: 'ai-safe', + ...overrides, + }; +} + +function makeSkill(overrides?: Partial): SkillEntity { + const entity = new SkillEntity(); + entity.name = 'analysis/complexity'; + entity.description = 'Analyzes code complexity metrics'; + entity.createdById = '11111111-2222-3333-4444-555555555555' as UUID; + entity.spec = makeSpec(); + entity.scope = 'personal'; + entity.status = 'proposed'; + + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + (entity as Record)[key] = value; + } + } + + return entity; +} + +describe('SkillEntity', () => { + describe('construction and defaults', () => { + it('creates with default values', () => { + const skill = new SkillEntity(); + + expect(skill.name).toBe(''); + expect(skill.description).toBe(''); + expect(skill.createdById).toBe(''); + expect(skill.scope).toBe('personal'); + expect(skill.status).toBe('proposed'); + expect(skill.generatedFiles).toEqual([]); + expect(skill.proposalId).toBeUndefined(); + expect(skill.outputDir).toBeUndefined(); + expect(skill.validationResults).toBeUndefined(); + expect(skill.activatedAt).toBeUndefined(); + expect(skill.failureReason).toBeUndefined(); + }); + + it('has default spec with empty fields', () => { + const skill = new SkillEntity(); + + expect(skill.spec.name).toBe(''); + expect(skill.spec.description).toBe(''); + expect(skill.spec.params).toEqual([]); + expect(skill.spec.results).toEqual([]); + expect(skill.spec.implementation).toBe(''); + }); + }); + + describe('collection and pagination', () => { + it('has correct static collection', () => { + expect(SkillEntity.collection).toBe(COLLECTIONS.SKILLS); + }); + + it('has correct instance collection', () => { + const skill = new SkillEntity(); + expect(skill.collection).toBe(COLLECTIONS.SKILLS); + }); + + it('returns pagination config', () => { + const config = SkillEntity.getPaginationConfig(); + expect(config.defaultSortField).toBe('createdAt'); + expect(config.defaultSortDirection).toBe('desc'); + expect(config.defaultPageSize).toBe(20); + expect(config.cursorField).toBe('createdAt'); + }); + }); + + describe('validation', () => { + it('validates a well-formed personal skill', () => { + const skill = makeSkill(); + const result = skill.validate(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('validates a well-formed team skill', () => { + const skill = makeSkill({ scope: 'team' }); + const result = skill.validate(); + expect(result.success).toBe(true); + }); + + it('rejects missing name', () => { + const skill = makeSkill({ name: '' }); + skill.spec = makeSpec({ name: '' }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('name'); + }); + + it('rejects invalid naming convention', () => { + const skill = makeSkill({ name: 'InvalidName' }); + skill.spec = makeSpec({ name: 'InvalidName' }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('naming convention'); + }); + + it('accepts simple names without slashes', () => { + const skill = makeSkill({ name: 'lint' }); + skill.spec = makeSpec({ name: 'lint' }); + const result = skill.validate(); + expect(result.success).toBe(true); + }); + + it('accepts multi-level names', () => { + const skill = makeSkill({ name: 'code/analysis/deep' }); + skill.spec = makeSpec({ name: 'code/analysis/deep' }); + const result = skill.validate(); + expect(result.success).toBe(true); + }); + + it('rejects missing description', () => { + const skill = makeSkill({ description: '' }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('description'); + }); + + it('rejects missing createdById', () => { + const skill = makeSkill({ createdById: '' as UUID }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('createdById'); + }); + + it('rejects mismatched spec.name and entity name', () => { + const skill = makeSkill(); + skill.spec = makeSpec({ name: 'different/name' }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('must match'); + }); + + it('rejects missing implementation in spec', () => { + const skill = makeSkill(); + skill.spec = makeSpec({ implementation: '' }); + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('implementation'); + }); + + it('rejects invalid scope', () => { + const skill = makeSkill(); + (skill as Record).scope = 'invalid'; + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('scope'); + }); + + it('rejects invalid status', () => { + const skill = makeSkill(); + (skill as Record).status = 'invalid'; + const result = skill.validate(); + expect(result.success).toBe(false); + expect(result.error).toContain('status'); + }); + + it('validates all valid statuses', () => { + const statuses: SkillStatus[] = [ + 'proposed', 'approved', 'generated', 'validated', 'active', 'failed', 'deprecated', + ]; + for (const status of statuses) { + const skill = makeSkill({ status }); + const result = skill.validate(); + expect(result.success).toBe(true); + } + }); + + it('validates all valid scopes', () => { + const scopes: SkillScope[] = ['personal', 'team']; + for (const scope of scopes) { + const skill = makeSkill({ scope }); + const result = skill.validate(); + expect(result.success).toBe(true); + } + }); + }); + + describe('computed properties', () => { + it('isActive returns true for active skills', () => { + const skill = makeSkill({ status: 'active' }); + expect(skill.isActive).toBe(true); + }); + + it('isActive returns false for non-active skills', () => { + const statuses: SkillStatus[] = ['proposed', 'approved', 'generated', 'validated', 'failed', 'deprecated']; + for (const status of statuses) { + const skill = makeSkill({ status }); + expect(skill.isActive).toBe(false); + } + }); + + it('requiresApproval returns true for team scope', () => { + const skill = makeSkill({ scope: 'team' }); + expect(skill.requiresApproval).toBe(true); + }); + + it('requiresApproval returns false for personal scope', () => { + const skill = makeSkill({ scope: 'personal' }); + expect(skill.requiresApproval).toBe(false); + }); + + describe('canAdvance', () => { + it('personal proposed can advance', () => { + const skill = makeSkill({ status: 'proposed', scope: 'personal' }); + expect(skill.canAdvance).toBe(true); + }); + + it('team proposed without proposal cannot advance', () => { + const skill = makeSkill({ status: 'proposed', scope: 'team' }); + expect(skill.canAdvance).toBe(false); + }); + + it('team proposed with proposal can advance', () => { + const skill = makeSkill({ + status: 'proposed', + scope: 'team', + proposalId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as UUID, + }); + expect(skill.canAdvance).toBe(true); + }); + + it('approved can advance', () => { + const skill = makeSkill({ status: 'approved' }); + expect(skill.canAdvance).toBe(true); + }); + + it('generated can advance', () => { + const skill = makeSkill({ status: 'generated' }); + expect(skill.canAdvance).toBe(true); + }); + + it('validated can advance', () => { + const skill = makeSkill({ status: 'validated' }); + expect(skill.canAdvance).toBe(true); + }); + + it('active cannot advance', () => { + const skill = makeSkill({ status: 'active' }); + expect(skill.canAdvance).toBe(false); + }); + + it('failed cannot advance', () => { + const skill = makeSkill({ status: 'failed' }); + expect(skill.canAdvance).toBe(false); + }); + + it('deprecated cannot advance', () => { + const skill = makeSkill({ status: 'deprecated' }); + expect(skill.canAdvance).toBe(false); + }); + }); + + describe('nextStatus', () => { + it('personal proposed β†’ generated', () => { + const skill = makeSkill({ status: 'proposed', scope: 'personal' }); + expect(skill.nextStatus).toBe('generated'); + }); + + it('team proposed β†’ approved', () => { + const skill = makeSkill({ status: 'proposed', scope: 'team' }); + expect(skill.nextStatus).toBe('approved'); + }); + + it('approved β†’ generated', () => { + const skill = makeSkill({ status: 'approved' }); + expect(skill.nextStatus).toBe('generated'); + }); + + it('generated β†’ validated', () => { + const skill = makeSkill({ status: 'generated' }); + expect(skill.nextStatus).toBe('validated'); + }); + + it('validated β†’ active', () => { + const skill = makeSkill({ status: 'validated' }); + expect(skill.nextStatus).toBe('active'); + }); + + it('active has no next status', () => { + const skill = makeSkill({ status: 'active' }); + expect(skill.nextStatus).toBeUndefined(); + }); + + it('failed has no next status', () => { + const skill = makeSkill({ status: 'failed' }); + expect(skill.nextStatus).toBeUndefined(); + }); + + it('deprecated has no next status', () => { + const skill = makeSkill({ status: 'deprecated' }); + expect(skill.nextStatus).toBeUndefined(); + }); + }); + }); + + describe('spec types', () => { + it('supports param specs with optional fields', () => { + const params: SkillParamSpec[] = [ + { name: 'required', type: 'string' }, + { name: 'optional', type: 'number', optional: true, description: 'An optional param' }, + ]; + const skill = makeSkill(); + skill.spec = makeSpec({ params }); + const result = skill.validate(); + expect(result.success).toBe(true); + expect(skill.spec.params).toHaveLength(2); + expect(skill.spec.params[1].optional).toBe(true); + }); + + it('supports result specs', () => { + const results: SkillResultSpec[] = [ + { name: 'output', type: 'string', description: 'The output' }, + ]; + const skill = makeSkill(); + skill.spec = makeSpec({ results }); + const result = skill.validate(); + expect(result.success).toBe(true); + expect(skill.spec.results).toHaveLength(1); + }); + + it('supports examples in spec', () => { + const skill = makeSkill(); + skill.spec = makeSpec({ + examples: [ + { + description: 'Analyze a simple file', + command: 'skill/execute --name=analysis/complexity --filePath=utils.ts', + expectedResult: 'Complexity: 3', + }, + ], + }); + const result = skill.validate(); + expect(result.success).toBe(true); + expect(skill.spec.examples).toHaveLength(1); + }); + + it('supports different access levels', () => { + for (const level of ['ai-safe', 'internal', 'system'] as const) { + const skill = makeSkill(); + skill.spec = makeSpec({ accessLevel: level }); + const result = skill.validate(); + expect(result.success).toBe(true); + } + }); + }); + + describe('validation results', () => { + it('stores validation results', () => { + const validation: SkillValidationResults = { + compiled: true, + testsRun: 5, + testsPassed: 4, + errors: ['Test 3 failed: expected 42 got 41'], + durationMs: 1200, + }; + const skill = makeSkill({ validationResults: validation }); + expect(skill.validationResults).toEqual(validation); + expect(skill.validationResults!.compiled).toBe(true); + expect(skill.validationResults!.testsRun).toBe(5); + expect(skill.validationResults!.testsPassed).toBe(4); + expect(skill.validationResults!.errors).toHaveLength(1); + }); + }); + + describe('lifecycle tracking fields', () => { + it('tracks generated files', () => { + const files = ['/path/to/ServerCommand.ts', '/path/to/Types.ts']; + const skill = makeSkill({ generatedFiles: files }); + expect(skill.generatedFiles).toEqual(files); + }); + + it('tracks output directory', () => { + const skill = makeSkill({ outputDir: '/tmp/generated/analysis/complexity' }); + expect(skill.outputDir).toBe('/tmp/generated/analysis/complexity'); + }); + + it('tracks activation timestamp', () => { + const now = Date.now(); + const skill = makeSkill({ activatedAt: now }); + expect(skill.activatedAt).toBe(now); + }); + + it('tracks failure reason', () => { + const skill = makeSkill({ + status: 'failed', + failureReason: 'Compilation error: missing import', + }); + expect(skill.failureReason).toBe('Compilation error: missing import'); + }); + + it('tracks proposal ID for team skills', () => { + const proposalId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as UUID; + const skill = makeSkill({ scope: 'team', proposalId }); + expect(skill.proposalId).toBe(proposalId); + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/skill/SkillLifecycle.test.ts b/src/debug/jtag/tests/unit/skill/SkillLifecycle.test.ts new file mode 100644 index 000000000..e2d204b13 --- /dev/null +++ b/src/debug/jtag/tests/unit/skill/SkillLifecycle.test.ts @@ -0,0 +1,331 @@ +/** + * Skill Lifecycle Tests + * + * Tests the skill lifecycle state machine: + * - Personal skill: proposed β†’ generated β†’ validated β†’ active + * - Team skill: proposed β†’ approved β†’ generated β†’ validated β†’ active + * - Failure paths at each stage + * - Validation results tracking + * - Scope and governance rules + */ + +import { describe, it, expect } from 'vitest'; +import { + SkillEntity, + type SkillSpec, + type SkillStatus, + type SkillValidationResults, +} from '../../../system/data/entities/SkillEntity'; +import type { UUID } from '../../../system/core/types/CrossPlatformUUID'; + +// ── Helpers ────────────────────────────────────────────────── + +const PERSONA_ID = '11111111-2222-3333-4444-555555555555' as UUID; +const PROPOSAL_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as UUID; + +function makeSpec(name = 'analysis/complexity'): SkillSpec { + return { + name, + description: 'Analyzes code complexity', + params: [{ name: 'filePath', type: 'string' }], + results: [{ name: 'complexity', type: 'number' }], + implementation: 'Count decision branches in AST', + accessLevel: 'ai-safe', + }; +} + +function makeSkillEntity(status: SkillStatus = 'proposed', scope: 'personal' | 'team' = 'personal'): SkillEntity { + const entity = new SkillEntity(); + entity.name = 'analysis/complexity'; + entity.description = 'Analyzes code complexity'; + entity.createdById = PERSONA_ID; + entity.spec = makeSpec(); + entity.scope = scope; + entity.status = status; + return entity; +} + +// ── Tests ──────────────────────────────────────────────────── + +describe('Skill Lifecycle', () => { + describe('personal skill: full lifecycle', () => { + it('proposed β†’ generated β†’ validated β†’ active', () => { + const entity = makeSkillEntity('proposed', 'personal'); + + // Step 1: proposed + expect(entity.status).toBe('proposed'); + expect(entity.canAdvance).toBe(true); + expect(entity.nextStatus).toBe('generated'); + expect(entity.requiresApproval).toBe(false); + + // Step 2: generate + entity.status = 'generated'; + entity.outputDir = '/tmp/skills/analysis/complexity'; + entity.generatedFiles = ['ServerCommand.ts', 'Types.ts', 'BrowserCommand.ts']; + expect(entity.canAdvance).toBe(true); + expect(entity.nextStatus).toBe('validated'); + expect(entity.generatedFiles).toHaveLength(3); + + // Step 3: validate + entity.status = 'validated'; + entity.validationResults = { + compiled: true, + testsRun: 3, + testsPassed: 3, + errors: [], + durationMs: 500, + }; + expect(entity.canAdvance).toBe(true); + expect(entity.nextStatus).toBe('active'); + + // Step 4: activate + entity.status = 'active'; + entity.activatedAt = Date.now(); + expect(entity.isActive).toBe(true); + expect(entity.canAdvance).toBe(false); + expect(entity.nextStatus).toBeUndefined(); + + // Entity still validates at every stage + expect(entity.validate().success).toBe(true); + }); + }); + + describe('team skill: full lifecycle with governance', () => { + it('proposed β†’ approved β†’ generated β†’ validated β†’ active', () => { + const entity = makeSkillEntity('proposed', 'team'); + + // Step 1: proposed β€” cannot advance without proposal + expect(entity.requiresApproval).toBe(true); + expect(entity.canAdvance).toBe(false); + expect(entity.nextStatus).toBe('approved'); + + // Set proposal ID β†’ now can advance + entity.proposalId = PROPOSAL_ID; + expect(entity.canAdvance).toBe(true); + + // Step 2: approved + entity.status = 'approved'; + expect(entity.canAdvance).toBe(true); + expect(entity.nextStatus).toBe('generated'); + + // Step 3: generated + entity.status = 'generated'; + entity.outputDir = '/tmp/commands/analysis/complexity'; + entity.generatedFiles = ['ServerCommand.ts', 'Types.ts']; + expect(entity.nextStatus).toBe('validated'); + + // Step 4: validated + entity.status = 'validated'; + entity.validationResults = { + compiled: true, + testsRun: 5, + testsPassed: 5, + errors: [], + durationMs: 1200, + }; + + // Step 5: activated + entity.status = 'active'; + entity.activatedAt = Date.now(); + expect(entity.isActive).toBe(true); + expect(entity.validate().success).toBe(true); + }); + }); + + describe('failure paths', () => { + it('failure at generation stage', () => { + const entity = makeSkillEntity('proposed', 'personal'); + + entity.status = 'failed'; + entity.failureReason = 'CommandGenerator error: invalid spec'; + + expect(entity.canAdvance).toBe(false); + expect(entity.nextStatus).toBeUndefined(); + expect(entity.isActive).toBe(false); + expect(entity.failureReason).toContain('CommandGenerator'); + expect(entity.validate().success).toBe(true); + }); + + it('failure at validation β€” compilation error', () => { + const entity = makeSkillEntity('generated'); + entity.outputDir = '/tmp/skills/test'; + entity.generatedFiles = ['ServerCommand.ts']; + + entity.status = 'failed'; + entity.failureReason = 'Compilation failed: TS2345 - Argument type mismatch'; + entity.validationResults = { + compiled: false, + testsRun: 0, + testsPassed: 0, + errors: ['Compilation failed: TS2345 - Argument type mismatch'], + durationMs: 200, + }; + + expect(entity.canAdvance).toBe(false); + expect(entity.validationResults.compiled).toBe(false); + expect(entity.validationResults.errors).toHaveLength(1); + }); + + it('failure at validation β€” tests fail', () => { + const entity = makeSkillEntity('generated'); + entity.outputDir = '/tmp/skills/test'; + entity.generatedFiles = ['ServerCommand.ts']; + + entity.status = 'failed'; + entity.validationResults = { + compiled: true, + testsRun: 10, + testsPassed: 7, + errors: [ + 'Test "edge case" failed: expected 0, got -1', + 'Test "null input" failed: TypeError', + 'Test "large input" failed: timeout after 60000ms', + ], + durationMs: 60500, + }; + entity.failureReason = entity.validationResults.errors.join('; '); + + expect(entity.validationResults.compiled).toBe(true); + expect(entity.validationResults.testsPassed).toBe(7); + expect(entity.validationResults.testsRun).toBe(10); + expect(entity.validationResults.errors).toHaveLength(3); + }); + + it('failure at activation', () => { + const entity = makeSkillEntity('validated'); + entity.outputDir = '/tmp/skills/test'; + entity.generatedFiles = ['ServerCommand.ts']; + entity.validationResults = { + compiled: true, testsRun: 1, testsPassed: 1, errors: [], durationMs: 100, + }; + + entity.status = 'failed'; + entity.failureReason = 'Activation failed: dynamic import error'; + + expect(entity.canAdvance).toBe(false); + expect(entity.isActive).toBe(false); + }); + }); + + describe('deprecation', () => { + it('active skill can be deprecated', () => { + const entity = makeSkillEntity('active'); + entity.activatedAt = Date.now() - 86400000; // 1 day ago + + expect(entity.isActive).toBe(true); + + entity.status = 'deprecated'; + expect(entity.isActive).toBe(false); + expect(entity.canAdvance).toBe(false); + expect(entity.nextStatus).toBeUndefined(); + expect(entity.validate().success).toBe(true); + }); + }); + + describe('validation results tracking', () => { + it('tracks successful validation with full metrics', () => { + const results: SkillValidationResults = { + compiled: true, + testsRun: 10, + testsPassed: 10, + errors: [], + durationMs: 2500, + }; + + const entity = makeSkillEntity('generated'); + entity.validationResults = results; + entity.status = 'validated'; + + expect(entity.validationResults.compiled).toBe(true); + expect(entity.validationResults.testsRun).toBe(10); + expect(entity.validationResults.testsPassed).toBe(10); + expect(entity.validationResults.errors).toHaveLength(0); + expect(entity.validationResults.durationMs).toBe(2500); + }); + }); + + describe('scope and governance rules', () => { + it('personal skill does not require approval', () => { + const entity = makeSkillEntity('proposed', 'personal'); + expect(entity.requiresApproval).toBe(false); + expect(entity.canAdvance).toBe(true); + }); + + it('team skill requires approval and governance', () => { + const entity = makeSkillEntity('proposed', 'team'); + expect(entity.requiresApproval).toBe(true); + expect(entity.canAdvance).toBe(false); // No proposal yet + + entity.proposalId = PROPOSAL_ID; + expect(entity.canAdvance).toBe(true); + }); + + it('team skills go through approved state', () => { + const entity = makeSkillEntity('proposed', 'team'); + expect(entity.nextStatus).toBe('approved'); + }); + + it('personal skills skip approved state', () => { + const entity = makeSkillEntity('proposed', 'personal'); + expect(entity.nextStatus).toBe('generated'); + }); + }); + + describe('entity validation consistency across all stages', () => { + it('all lifecycle stages produce valid entities', () => { + const stages: Array<{ status: SkillStatus; extras?: Record }> = [ + { status: 'proposed' }, + { status: 'approved' }, + { status: 'generated', extras: { outputDir: '/tmp/out', generatedFiles: ['a.ts'] } }, + { status: 'validated', extras: { + outputDir: '/tmp/out', + generatedFiles: ['a.ts'], + validationResults: { compiled: true, testsRun: 1, testsPassed: 1, errors: [], durationMs: 100 }, + }}, + { status: 'active', extras: { + outputDir: '/tmp/out', + generatedFiles: ['a.ts'], + activatedAt: Date.now(), + }}, + { status: 'failed', extras: { failureReason: 'Something went wrong' } }, + { status: 'deprecated' }, + ]; + + for (const { status, extras } of stages) { + const entity = makeSkillEntity(status); + if (extras) { + for (const [key, value] of Object.entries(extras)) { + (entity as Record)[key] = value; + } + } + const result = entity.validate(); + expect(result.success).toBe(true); + } + }); + }); + + describe('multiple skills with different names', () => { + it('supports various command naming patterns', () => { + const names = [ + 'lint', + 'code/lint', + 'analysis/complexity', + 'code/analysis/deep-scan', + 'my-tool', + ]; + + for (const name of names) { + const entity = new SkillEntity(); + entity.name = name; + entity.description = `A skill called ${name}`; + entity.createdById = PERSONA_ID; + entity.spec = makeSpec(name); + entity.scope = 'personal'; + entity.status = 'proposed'; + + const result = entity.validate(); + expect(result.success).toBe(true); + } + }); + }); +}); diff --git a/src/debug/jtag/tests/unit/training/TrainingCircuit.test.ts b/src/debug/jtag/tests/unit/training/TrainingCircuit.test.ts new file mode 100644 index 000000000..dc7840add --- /dev/null +++ b/src/debug/jtag/tests/unit/training/TrainingCircuit.test.ts @@ -0,0 +1,432 @@ +/** + * Training Circuit Unit Tests + * + * Verifies the three training paths are wired end-to-end: + * 1. PersonaTrainingManager: accumulator β†’ convert β†’ JSONL β†’ GenomeJobCreate + * 2. TrainingDaemonServer: threshold β†’ Events.emit('training:dataset-ready') + * 3. GenomeBatchMicroTuneServerCommand: PersonaUser β†’ accumulator β†’ PersonaTrainingManager + * + * Also tests the type conversion from accumulator format (input/output) + * to fine-tuning format (messages[]). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PersonaTrainingManager } from '../../../system/user/server/modules/PersonaTrainingManager'; +import { TrainingDataAccumulator } from '../../../system/user/server/modules/TrainingDataAccumulator'; +import type { InteractionCapture } from '../../../system/user/server/modules/TrainingDataAccumulator'; +import { Events } from '../../../system/core/shared/Events'; +import { GenomeJobCreate } from '../../../commands/genome/job-create/shared/GenomeJobCreateTypes'; +import { TrainingDatasetBuilder } from '../../../system/genome/fine-tuning/server/TrainingDatasetBuilder'; + +// Mock dependencies that PersonaTrainingManager uses +vi.mock('../../../system/core/shared/Events', () => ({ + Events: { + emit: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + }, +})); + +vi.mock('../../../commands/genome/job-create/shared/GenomeJobCreateTypes', () => ({ + GenomeJobCreate: { + execute: vi.fn().mockResolvedValue({ + success: true, + job: { + jobId: 'test-job-123', + providerJobId: 'prov-job-456', + provider: 'peft', + status: 'queued', + baseModel: 'llama3.2', + trainingFileId: '/tmp/test.jsonl', + createdAt: Date.now(), + configurationSummary: { + method: 'lora', + epochs: 3, + batchSize: 4, + learningRate: 0.0001, + sequenceLength: 2048, + }, + }, + }), + }, +})); + +vi.mock('../../../system/genome/fine-tuning/server/TrainingDatasetBuilder', () => ({ + TrainingDatasetBuilder: { + validateDataset: vi.fn().mockReturnValue({ valid: true, warnings: [], errors: [] }), + exportToJSONL: vi.fn().mockReturnValue('{"messages":[{"role":"user","content":"hello"}]}\n'), + }, +})); + +vi.mock('../../../daemons/data-daemon/shared/entities/FineTuningTypes', () => ({ + TrainingMethod: { FULL: 'full', LORA: 'lora', QLORA: 'qlora' }, + TrainOnInputs: { AUTO: 'auto', ENABLED: 'enabled', DISABLED: 'disabled' }, + LRSchedulerType: { COSINE: 'cosine', LINEAR: 'linear', CONSTANT: 'constant' }, +})); + +vi.mock('fs', () => ({ + default: { + promises: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + }, + }, + promises: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Helpers ──────────────────────────────────────────────── + +const PERSONA_ID = 'test-persona-training'; +const PERSONA_NAME = 'Test Trainer'; + +/** + * MIN_BATCH_SIZE in TrainingDataAccumulator is 10, so thresholds below 10 + * get clamped. Use 10 as the minimum meaningful threshold for tests. + */ +const MIN_THRESHOLD = 10; + +function createAccumulator(batchThreshold = MIN_THRESHOLD): TrainingDataAccumulator { + const accumulator = new TrainingDataAccumulator(PERSONA_ID, PERSONA_NAME, () => {}); + accumulator.setBatchThreshold('conversation', batchThreshold); + return accumulator; +} + +function createManager(accumulator: TrainingDataAccumulator): PersonaTrainingManager { + const mockState = { + learningState: { isLearning: false }, + }; + return new PersonaTrainingManager( + PERSONA_ID, + PERSONA_NAME, + accumulator, + () => mockState as any, + async () => ({ success: true }), + () => {}, // silent logger + ); +} + +async function fillAccumulator( + accumulator: TrainingDataAccumulator, + domain: string, + count: number, +): Promise { + const ids: string[] = []; + for (let i = 0; i < count; i++) { + const capture: InteractionCapture = { + roleId: 'student', + domain, + input: `Question ${i}: What is concept ${i}?`, + output: `Answer ${i}: Concept ${i} is an important idea in the domain.`, + }; + ids.push(await accumulator.captureInteraction(capture)); + } + return ids; +} + +// ── Tests ────────────────────────────────────────────────── + +describe('Training Circuit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('PersonaTrainingManager: type conversion', () => { + it('converts accumulator examples (input/output) to fine-tuning format (messages[])', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + + // Trigger training (buffer at threshold) + await manager.checkTrainingReadiness(); + + // Verify GenomeJobCreate was called + expect(GenomeJobCreate.execute).toHaveBeenCalled(); + + // Verify TrainingDatasetBuilder.validateDataset was called with converted dataset + expect(TrainingDatasetBuilder.validateDataset).toHaveBeenCalled(); + expect(TrainingDatasetBuilder.exportToJSONL).toHaveBeenCalled(); + + // The dataset passed to validateDataset should have messages[] format + const validateCall = vi.mocked(TrainingDatasetBuilder.validateDataset).mock.calls[0][0]; + expect(validateCall.examples).toHaveLength(MIN_THRESHOLD); + expect(validateCall.examples[0].messages).toBeDefined(); + expect(validateCall.examples[0].messages).toHaveLength(2); + expect(validateCall.examples[0].messages[0].role).toBe('user'); + expect(validateCall.examples[0].messages[0].content).toContain('Question 0'); + expect(validateCall.examples[0].messages[1].role).toBe('assistant'); + expect(validateCall.examples[0].messages[1].content).toContain('Answer 0'); + }); + + it('preserves feedback rating as confidence in metadata', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + // Fill to threshold, then attach feedback to last one + const ids = await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + await accumulator.captureFeedback({ + interactionId: ids[0], + source: 'human', + rating: 0.95, + }); + + await manager.checkTrainingReadiness(); + + const validateCall = vi.mocked(TrainingDatasetBuilder.validateDataset).mock.calls[0][0]; + expect(validateCall.examples[0].metadata?.confidence).toBe(0.95); + }); + }); + + describe('PersonaTrainingManager: training trigger', () => { + it('does not trigger when buffer below threshold', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD - 3); + + await manager.checkTrainingReadiness(); + + expect(GenomeJobCreate.execute).not.toHaveBeenCalled(); + }); + + it('triggers when buffer reaches threshold', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + + await manager.checkTrainingReadiness(); + + expect(GenomeJobCreate.execute).toHaveBeenCalledTimes(1); + }); + + it('forceDomain bypasses threshold check', async () => { + const accumulator = createAccumulator(1000); // Clamps to MAX_BATCH_SIZE but well above fill count + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', 2); + + await manager.checkTrainingReadiness('conversation'); // Force this domain + + expect(GenomeJobCreate.execute).toHaveBeenCalledTimes(1); + }); + + it('forceDomain does not affect other domains', async () => { + const accumulator = createAccumulator(1000); + accumulator.setBatchThreshold('code', 1000); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', 2); + await fillAccumulator(accumulator, 'code', 2); + + // Force 'conversation' only + await manager.checkTrainingReadiness('conversation'); + + // Only conversation should trigger, not code + expect(GenomeJobCreate.execute).toHaveBeenCalledTimes(1); + }); + + it('consumes buffer after training (buffer is empty after)', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD + 3); + expect(accumulator.getBufferSize('conversation')).toBe(MIN_THRESHOLD + 3); + + await manager.checkTrainingReadiness(); + + expect(accumulator.getBufferSize('conversation')).toBe(0); + }); + + it('emits TRAINING_STARTED event', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + await manager.checkTrainingReadiness(); + + const emitCalls = vi.mocked(Events.emit).mock.calls; + const startedCall = emitCalls.find( + call => call[0] === 'ai:learning:training-started', + ); + expect(startedCall).toBeDefined(); + expect(startedCall![1]).toMatchObject({ + personaId: PERSONA_ID, + domain: 'conversation', + exampleCount: MIN_THRESHOLD, + }); + }); + + it('writes JSONL file to disk before training', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + await manager.checkTrainingReadiness(); + + const fs = await import('fs'); + expect(fs.promises.mkdir).toHaveBeenCalled(); + expect(fs.promises.writeFile).toHaveBeenCalled(); + + // Verify the file path includes personaId + const writeCalls = vi.mocked(fs.promises.writeFile).mock.calls; + const filePath = writeCalls[0][0] as string; + expect(filePath).toContain(PERSONA_ID); + expect(filePath).toContain('.jsonl'); + }); + + it('emits TRAINING_ERROR when validation fails', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + + // Make validation fail + vi.mocked(TrainingDatasetBuilder.validateDataset).mockReturnValueOnce({ + valid: false, + warnings: [], + errors: ['Too few examples'], + }); + + await manager.checkTrainingReadiness(); + + const emitCalls = vi.mocked(Events.emit).mock.calls; + const errorCall = emitCalls.find( + call => call[0] === 'ai:learning:training-error', + ); + expect(errorCall).toBeDefined(); + expect(errorCall![1]).toMatchObject({ + personaId: PERSONA_ID, + phase: 'preparation', + }); + + // GenomeJobCreate should NOT have been called + expect(GenomeJobCreate.execute).not.toHaveBeenCalled(); + }); + + it('emits TRAINING_ERROR when GenomeJobCreate fails', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + + vi.mocked(GenomeJobCreate.execute).mockResolvedValueOnce({ + success: false, + error: 'Provider unavailable', + } as any); + + await manager.checkTrainingReadiness(); + + const emitCalls = vi.mocked(Events.emit).mock.calls; + const errorCall = emitCalls.find( + call => call[0] === 'ai:learning:training-error', + ); + expect(errorCall).toBeDefined(); + expect((errorCall![1] as any).error).toContain('Provider unavailable'); + }); + }); + + describe('TrainingDataAccumulator: domain isolation', () => { + it('different domains accumulate independently', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + // 'code' uses default threshold (50) + + await fillAccumulator(accumulator, 'conversation', 5); + await fillAccumulator(accumulator, 'code', 12); + + expect(accumulator.getBufferSize('conversation')).toBe(5); + expect(accumulator.getBufferSize('code')).toBe(12); + expect(accumulator.shouldMicroTune('conversation')).toBe(false); // 5 < 10 + expect(accumulator.shouldMicroTune('code')).toBe(false); // 12 < 50 (default) + }); + + it('consuming one domain does not affect others', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + + await fillAccumulator(accumulator, 'conversation', 15); + await fillAccumulator(accumulator, 'code', 15); + + const consumed = await accumulator.consumeTrainingData('conversation'); + expect(consumed).toHaveLength(15); + expect(accumulator.getBufferSize('conversation')).toBe(0); + expect(accumulator.getBufferSize('code')).toBe(15); + }); + + it('getStats returns all domains with correct thresholds', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + // 'code' gets default threshold (50) + + await fillAccumulator(accumulator, 'conversation', 3); + await fillAccumulator(accumulator, 'code', 7); + + const stats = accumulator.getStats(); + expect(stats['conversation']).toEqual({ count: 3, threshold: MIN_THRESHOLD, ready: false }); + expect(stats['code']).toEqual({ count: 7, threshold: 50, ready: false }); // Default threshold + }); + + it('getDomains only returns non-empty domains', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + + await fillAccumulator(accumulator, 'conversation', 2); + await accumulator.consumeTrainingData('conversation'); + + const domains = accumulator.getDomains(); + expect(domains).not.toContain('conversation'); + }); + }); + + describe('PersonaTrainingManager: multi-domain training', () => { + it('trains all domains that are at threshold in single call', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + accumulator.setBatchThreshold('code', MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + await fillAccumulator(accumulator, 'code', MIN_THRESHOLD + 3); + + await manager.checkTrainingReadiness(); + + // Both domains should trigger + expect(GenomeJobCreate.execute).toHaveBeenCalledTimes(2); + }); + + it('skips domains below threshold while training ready ones', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + accumulator.setBatchThreshold('code', 100); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); // At threshold + await fillAccumulator(accumulator, 'code', 5); // Below code threshold (100) + + await manager.checkTrainingReadiness(); + + // Only conversation should trigger + expect(GenomeJobCreate.execute).toHaveBeenCalledTimes(1); + }); + }); + + describe('GenomeJobCreate integration', () => { + it('passes correct configuration to GenomeJobCreate', async () => { + const accumulator = createAccumulator(MIN_THRESHOLD); + const manager = createManager(accumulator); + + await fillAccumulator(accumulator, 'conversation', MIN_THRESHOLD); + await manager.checkTrainingReadiness(); + + const call = vi.mocked(GenomeJobCreate.execute).mock.calls[0][0]; + + expect(call.personaId).toBe(PERSONA_ID); + expect(call.provider).toBe('unsloth'); + expect(call.trainingFileId).toBeDefined(); + expect(call.configuration).toBeDefined(); + expect(call.configuration.model.baseModel).toBe('llama3.2'); + expect(call.configuration.method.type).toBe('lora'); + expect(call.configuration.method.loraConfig).toMatchObject({ rank: 16, alpha: 32 }); + expect(call.configuration.schedule.epochs).toBe(3); + expect(call.configuration.schedule.batchSize).toBe(4); + expect(call.configuration.optimizer.learningRate).toBe(0.0001); + }); + }); +}); diff --git a/src/debug/jtag/widgets/chat/adapters/AdapterRegistry.ts b/src/debug/jtag/widgets/chat/adapters/AdapterRegistry.ts index 97234cdbc..e88c6cefa 100644 --- a/src/debug/jtag/widgets/chat/adapters/AdapterRegistry.ts +++ b/src/debug/jtag/widgets/chat/adapters/AdapterRegistry.ts @@ -9,11 +9,12 @@ import type { ChatMessageEntity } from '../../../system/data/entities/ChatMessag import type { AbstractMessageAdapter } from './AbstractMessageAdapter'; import { TextMessageAdapter } from './TextMessageAdapter'; import { ImageMessageAdapter } from './ImageMessageAdapter'; +import { ToolOutputAdapter } from './ToolOutputAdapter'; // Future imports: // import { VideoMessageAdapter } from './VideoMessageAdapter'; // import { URLCardAdapter } from './URLCardAdapter'; -export type ContentType = 'text' | 'image' | 'video' | 'audio' | 'file' | 'document' | 'code_editor'; +export type ContentType = 'text' | 'image' | 'video' | 'audio' | 'file' | 'document' | 'code_editor' | 'tool_output'; export class AdapterRegistry { private adapters: Map>; @@ -24,6 +25,7 @@ export class AdapterRegistry { // Register available adapters this.adapters.set('text', new TextMessageAdapter()); this.adapters.set('image', new ImageMessageAdapter()); + this.adapters.set('tool_output', new ToolOutputAdapter()); // Future registrations: // this.adapters.set('video', new VideoMessageAdapter()); // this.adapters.set('url_card', new URLCardAdapter()); @@ -36,7 +38,12 @@ export class AdapterRegistry { selectAdapter(message: ChatMessageEntity): AbstractMessageAdapter | null { // Priority order for content type detection: - // 1. Check for media first (images, videos, files) + // 1. Check for tool result metadata (highest priority β€” rich tool output rendering) + if (message.metadata?.toolResult === true) { + return this.adapters.get('tool_output') ?? this.adapters.get('text') ?? null; + } + + // 2. Check for media first (images, videos, files) if (message.content?.media && message.content.media.length > 0) { const firstMedia = message.content.media[0]; @@ -59,7 +66,7 @@ export class AdapterRegistry { return this.adapters.get('file') ?? this.adapters.get('text') ?? null; } - // 2. Default to text adapter for markdown rendering + // 3. Default to text adapter for markdown rendering return this.adapters.get('text') ?? null; } diff --git a/src/debug/jtag/widgets/chat/adapters/ToolOutputAdapter.ts b/src/debug/jtag/widgets/chat/adapters/ToolOutputAdapter.ts new file mode 100644 index 000000000..ce6db4370 --- /dev/null +++ b/src/debug/jtag/widgets/chat/adapters/ToolOutputAdapter.ts @@ -0,0 +1,760 @@ +/** + * ToolOutputAdapter - Renders tool execution results as rich, expandable cards + * + * When AIs use code/* tools, the results are stored as ChatMessageEntity with + * metadata.toolResult = true. This adapter renders them as compact summaries + * with expandable detail views instead of flat text like "write: 10 lines". + * + * Uses native
element for expand/collapse (no JS needed). + * Per-tool rendering via strategy map (not switch statements). + */ + +import type { ChatMessageEntity, MediaItem } from '../../../system/data/entities/ChatMessageEntity'; +import { AbstractMessageAdapter } from './AbstractMessageAdapter'; +import { Events } from '../../../system/core/shared/Events'; + +// ──────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────── + +interface ToolOutputContentData { + readonly toolName: string; + readonly toolAction: string; + readonly toolCategory: string; + readonly success: boolean; + readonly summary: string; + readonly parameters: Record; + readonly fullData: unknown; + readonly error?: string; + readonly media?: readonly MediaItem[]; + readonly messageId: string; +} + +interface ToolRenderer { + renderCompact(data: ToolOutputContentData): string; + renderExpanded(data: ToolOutputContentData): string; +} + +// ──────────────────────────────────────────────────────────── +// Utility +// ──────────────────────────────────────────────────────────── + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function shortenPath(filePath: string): string { + if (!filePath) return ''; + const parts = String(filePath).split('/'); + return parts.length > 3 ? '.../' + parts.slice(-3).join('/') : String(filePath); +} + +function guessLanguage(filePath: string): string { + if (!filePath) return 'plaintext'; + const ext = String(filePath).split('.').pop()?.toLowerCase(); + const map: Record = { + ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', + rs: 'rust', py: 'python', json: 'json', html: 'html', css: 'css', + md: 'markdown', toml: 'toml', yaml: 'yaml', yml: 'yaml', sh: 'bash', + swift: 'swift', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c', + }; + return map[ext || ''] || 'plaintext'; +} + +function truncate(str: string, max: number): string { + return str.length > max ? str.slice(0, max) + '...' : str; +} + +function stringifyData(data: unknown, maxLines = 200): string { + const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + if (!raw) return ''; + const lines = raw.split('\n'); + if (lines.length <= maxLines) return escapeHtml(raw); + return escapeHtml(lines.slice(0, maxLines).join('\n')) + `\n\n... (${lines.length - maxLines} more lines)`; +} + +function extractField(data: unknown, ...keys: string[]): unknown { + if (!data || typeof data !== 'object') return undefined; + const obj = data as Record; + for (const key of keys) { + if (obj[key] !== undefined) return obj[key]; + } + return undefined; +} + +// ──────────────────────────────────────────────────────────── +// Per-Tool Renderers +// ──────────────────────────────────────────────────────────── + +class WriteToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + const bytes = extractField(data.fullData, 'bytesWritten', 'bytes_written', 'size'); + const suffix = typeof bytes === 'number' ? ` (${bytes} bytes)` : ''; + return `${escapeHtml(shortenPath(filePath))}${suffix}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + const content = data.parameters.content as string; + if (content) { + const lang = guessLanguage(filePath); + return `
${escapeHtml(content)}
`; + } + return `
${stringifyData(data.fullData)}
`; + } +} + +class ReadToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + const content = typeof data.fullData === 'string' ? data.fullData : ''; + const lineCount = content ? content.split('\n').length : 0; + const suffix = lineCount > 0 ? ` (${lineCount} lines)` : ''; + return `${escapeHtml(shortenPath(filePath))}${suffix}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + const content = typeof data.fullData === 'string' ? data.fullData : ''; + if (content) { + const lang = guessLanguage(filePath); + return `
${stringifyData(content)}
`; + } + return `
${stringifyData(data.fullData)}
`; + } +} + +class EditToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + const editMode = extractField(data.parameters, 'editMode', 'editType', 'mode') as string || ''; + const modeLabel = editMode ? ` (${editMode})` : ''; + return `${escapeHtml(shortenPath(filePath))}${escapeHtml(modeLabel)}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const raw = typeof data.fullData === 'string' ? data.fullData : JSON.stringify(data.fullData, null, 2) || ''; + // Check if the output looks like a diff + if (raw.includes('---') || raw.includes('@@') || raw.includes('+++')) { + return `
${this.renderDiffLines(raw)}
`; + } + return `
${stringifyData(data.fullData)}
`; + } + + private renderDiffLines(diff: string): string { + return diff.split('\n').map(line => { + if (line.startsWith('+') && !line.startsWith('+++')) { + return `${escapeHtml(line)}`; + } + if (line.startsWith('-') && !line.startsWith('---')) { + return `${escapeHtml(line)}`; + } + if (line.startsWith('@@')) { + return `${escapeHtml(line)}`; + } + return escapeHtml(line); + }).join('\n'); + } +} + +class VerifyToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + if (data.success) { + const duration = extractField(data.fullData, 'duration', 'durationMs', 'elapsed'); + const durationStr = typeof duration === 'number' ? ` (${(duration / 1000).toFixed(1)}s)` : ''; + return `Build succeeded${durationStr}`; + } + const errorCount = extractField(data.fullData, 'errorCount', 'failedCount', 'errors'); + return `${typeof errorCount === 'number' ? `${errorCount} error(s)` : 'Build failed'}`; + } + + renderExpanded(data: ToolOutputContentData): string { + if (data.error) { + return `
${escapeHtml(data.error)}
`; + } + return `
${stringifyData(data.fullData)}
`; + } +} + +class GitToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const operation = extractField(data.parameters, 'operation', 'subcommand', 'op') as string || 'status'; + + if (operation === 'commit') { + const hash = extractField(data.fullData, 'hash', 'commitHash', 'sha') as string || ''; + const message = data.parameters.message as string || ''; + const shortHash = hash ? hash.slice(0, 7) : ''; + return `commit ${shortHash ? `${escapeHtml(shortHash)} ` : ''}— "${escapeHtml(truncate(message, 50))}"`; + } + + return `${escapeHtml(operation)}`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +class SearchToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const matches = extractField(data.fullData, 'matches', 'count', 'total'); + const query = data.parameters.query || data.parameters.pattern || ''; + if (typeof matches === 'number') { + return `${matches} match${matches !== 1 ? 'es' : ''} for "${escapeHtml(truncate(String(query), 30))}"`; + } + return `${escapeHtml(data.summary)}`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +class TreeToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const content = typeof data.fullData === 'string' ? data.fullData : ''; + const entries = content ? content.split('\n').filter((l: string) => l.trim()).length : 0; + const dir = extractField(data.parameters, 'directory', 'dir', 'path') as string || '.'; + return `${escapeHtml(shortenPath(dir))} (${entries} entries)`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +class DiffToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const filePath = extractField(data.parameters, 'filePath', 'path', 'file_path') as string || ''; + return `${escapeHtml(shortenPath(filePath))}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const raw = typeof data.fullData === 'string' ? data.fullData : JSON.stringify(data.fullData, null, 2) || ''; + return `
${new EditToolRenderer().renderExpanded(data).replace(/<\/?pre[^>]*>/g, '')}
`; + } +} + +// ──────────────────────────────────────────────────────────── +// Shell Tool Renderers +// ──────────────────────────────────────────────────────────── + +class ShellExecuteToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const cmd = extractField(data.parameters, 'cmd', 'command') as string || ''; + const fd = data.fullData as Record | undefined; + const status = (fd?.status as string) || (data.success ? 'completed' : 'failed'); + const exitCode = fd?.exitCode as number | undefined; + const exitStr = typeof exitCode === 'number' ? ` exit ${exitCode}` : ''; + + const statusClass = status === 'completed' ? 'shell-status-ok' + : status === 'running' ? 'shell-status-running' + : 'shell-status-fail'; + + return `$ ${escapeHtml(truncate(cmd, 60))} ` + + `${escapeHtml(status)}${exitStr}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const fd = data.fullData as Record | undefined; + const stdout = (fd?.stdout as string) || ''; + const stderr = (fd?.stderr as string) || ''; + const cmd = extractField(data.parameters, 'cmd', 'command') as string || ''; + + let html = `
$ ${escapeHtml(cmd)}
`; + + if (stdout) { + html += `
${stringifyData(stdout)}
`; + } + if (stderr) { + html += `
${stringifyData(stderr)}
`; + } + if (!stdout && !stderr) { + const status = (fd?.status as string) || ''; + if (status === 'running') { + html += `
Running... use code/shell/watch to stream output
`; + } else if (data.error) { + html += `
${escapeHtml(data.error)}
`; + } else { + html += `
${stringifyData(data.fullData)}
`; + } + } + return html; + } +} + +class ShellWatchToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const fd = data.fullData as Record | undefined; + const lines = fd?.lines as Array<{ text: string; classification: string }> | undefined; + const finished = fd?.finished as boolean | undefined; + const lineCount = lines?.length ?? 0; + const suffix = finished ? ' (finished)' : ' (streaming)'; + return `${lineCount} line${lineCount !== 1 ? 's' : ''}${suffix}`; + } + + renderExpanded(data: ToolOutputContentData): string { + const fd = data.fullData as Record | undefined; + const lines = fd?.lines as Array<{ text: string; classification: string }> | undefined; + if (!lines || lines.length === 0) { + return '
No output yet
'; + } + const rendered = lines.map(line => { + const cls = line.classification || 'raw'; + return `${escapeHtml(line.text)}`; + }).join('\n'); + return `
${rendered}
`; + } +} + +class ShellStatusToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const fd = data.fullData as Record | undefined; + const cwd = fd?.cwd as string || ''; + const active = fd?.activeExecutions as number ?? 0; + return `${escapeHtml(shortenPath(cwd))} ` + + `${active} active`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +class ShellKillToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + const execId = extractField(data.parameters, 'executionId') as string || ''; + return `killed ${escapeHtml(truncate(execId, 12))}`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +class DefaultToolRenderer implements ToolRenderer { + renderCompact(data: ToolOutputContentData): string { + return `${escapeHtml(data.summary)}`; + } + + renderExpanded(data: ToolOutputContentData): string { + return `
${stringifyData(data.fullData)}
`; + } +} + +// ──────────────────────────────────────────────────────────── +// ToolOutputAdapter +// ──────────────────────────────────────────────────────────── + +export class ToolOutputAdapter extends AbstractMessageAdapter { + private renderers = new Map(); + private defaultRenderer = new DefaultToolRenderer(); + + constructor() { + super('text', { enableInteractions: true }); + + this.renderers.set('code/write', new WriteToolRenderer()); + this.renderers.set('code/read', new ReadToolRenderer()); + this.renderers.set('code/edit', new EditToolRenderer()); + this.renderers.set('code/verify', new VerifyToolRenderer()); + this.renderers.set('code/git', new GitToolRenderer()); + this.renderers.set('code/search', new SearchToolRenderer()); + this.renderers.set('code/tree', new TreeToolRenderer()); + this.renderers.set('code/diff', new DiffToolRenderer()); + this.renderers.set('code/undo', new DefaultToolRenderer()); + this.renderers.set('code/history', new DefaultToolRenderer()); + + // Shell command renderers + this.renderers.set('code/shell/execute', new ShellExecuteToolRenderer()); + this.renderers.set('code/shell/watch', new ShellWatchToolRenderer()); + this.renderers.set('code/shell/status', new ShellStatusToolRenderer()); + this.renderers.set('code/shell/kill', new ShellKillToolRenderer()); + this.renderers.set('code/shell/sentinel', new DefaultToolRenderer()); + } + + parseContent(message: ChatMessageEntity): ToolOutputContentData | null { + const meta = message.metadata; + if (!meta?.toolResult) return null; + + const toolName = (meta.toolName as string) || 'unknown'; + const segments = toolName.split('/'); + + return { + toolName, + toolAction: segments[segments.length - 1] || toolName, + toolCategory: segments[0] || 'unknown', + success: meta.success !== false, + summary: message.content?.text || '', + parameters: (meta.parameters as Record) || {}, + fullData: meta.fullData, + error: meta.error as string | undefined, + media: message.content?.media, + messageId: message.id, + }; + } + + renderContent(data: ToolOutputContentData, _currentUserId: string): string { + const renderer = this.renderers.get(data.toolName) ?? this.defaultRenderer; + const statusClass = data.success ? 'tool-success' : 'tool-failure'; + const statusIcon = data.success ? '' : ''; + const icon = this.getToolIcon(data.toolCategory); + + const compactHtml = renderer.renderCompact(data); + const expandedHtml = renderer.renderExpanded(data); + const mediaHtml = this.renderInlineMedia(data.media); + + return ` +
+ + ${icon} + ${escapeHtml(data.toolName)} + ${compactHtml} + ${statusIcon} + +
+ ${expandedHtml} + ${mediaHtml} +
+ + +
+
+
+ `; + } + + async handleContentLoading(_element: HTMLElement): Promise { + // Tool outputs are synchronous text β€” no async loading needed + } + + getContentClasses(): string[] { + return ['tool-output-adapter']; + } + + // ──────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────── + + private getToolIcon(category: string): string { + const icons: Record = { + code: '▶', // play/arrow for code operations + screenshot: '📷', // camera + ai: '🤖', // robot + collaboration: '👥', // people + data: '🗃', // file cabinet + }; + return icons[category] || '🔧'; // wrench default + } + + private renderInlineMedia(media?: readonly MediaItem[]): string { + if (!media || media.length === 0) return ''; + const images = media.filter(m => m.type === 'image'); + if (images.length === 0) return ''; + + return images.map((item, idx) => { + const url = item.url ?? (item.base64 ? `data:${item.mimeType ?? 'image/png'};base64,${item.base64}` : ''); + if (!url) return ''; + const alt = item.alt ?? item.description ?? `Tool output ${idx + 1}`; + return `
${escapeHtml(alt)}
`; + }).join(''); + } + + // ──────────────────────────────────────────────────────────── + // Static action handlers (used by MessageEventDelegator) + // ──────────────────────────────────────────────────────────── + + static handleCopy(target: HTMLElement): void { + const card = target.closest('.tool-output-card'); + const pre = card?.querySelector('.tool-output-pre'); + if (pre?.textContent && typeof navigator !== 'undefined' && navigator.clipboard) { + navigator.clipboard.writeText(pre.textContent).then(() => { + const original = target.textContent; + target.textContent = 'Copied!'; + setTimeout(() => { target.textContent = original; }, 1500); + }); + } + } + + static handleOpenInTab(target: HTMLElement): void { + const card = target.closest('.tool-output-card'); + const toolName = target.dataset.toolName || 'Tool Output'; + const messageId = target.dataset.messageId || ''; + + // Get the expanded content directly from the DOM (already rendered in
)
+    const pre = card?.querySelector('.tool-output-pre');
+    const content = pre?.textContent || '';
+
+    // Store content for LogViewerWidget to pick up
+    ToolOutputAdapter.storeInlineContent(messageId, content, toolName);
+
+    // Open a diagnostics-log tab β€” MainWidget routes to LogViewerWidget
+    Events.emit('content:opened', {
+      contentType: 'diagnostics-log',
+      entityId: `tool:${messageId}`,
+      uniqueId: `tool:${messageId}`,
+      title: toolName,
+      setAsCurrent: true
+    });
+  }
+
+  // ────────────────────────────────────────────────────────────
+  // Transient content store β€” LogViewerWidget reads from this
+  // ────────────────────────────────────────────────────────────
+
+  private static _contentStore = new Map();
+
+  static storeInlineContent(key: string, content: string, toolName: string): void {
+    ToolOutputAdapter._contentStore.set(key, { content, toolName });
+  }
+
+  static getInlineContent(key: string): { content: string; toolName: string } | undefined {
+    const entry = ToolOutputAdapter._contentStore.get(key);
+    if (entry) ToolOutputAdapter._contentStore.delete(key); // One-time read, prevent leak
+    return entry;
+  }
+
+  // ────────────────────────────────────────────────────────────
+  // CSS
+  // ────────────────────────────────────────────────────────────
+
+  getCSS(): string {
+    return `
+      /* ToolOutputAdapter β€” Rich tool result cards */
+      .tool-output-adapter {
+        margin: 2px 0;
+      }
+
+      .tool-output-card {
+        border: 1px solid rgba(175, 184, 193, 0.3);
+        border-radius: 6px;
+        margin: 4px 0;
+        font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
+        font-size: 13px;
+        overflow: hidden;
+      }
+
+      .tool-output-card.tool-success {
+        border-left: 3px solid #2ea043;
+        background: rgba(46, 160, 67, 0.04);
+      }
+
+      .tool-output-card.tool-failure {
+        border-left: 3px solid #d73a49;
+        background: rgba(215, 58, 73, 0.04);
+      }
+
+      .tool-output-summary {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        padding: 6px 10px;
+        cursor: pointer;
+        user-select: none;
+        font-weight: 500;
+        color: rgba(255, 255, 255, 0.85);
+        list-style: none;
+      }
+
+      .tool-output-summary::-webkit-details-marker {
+        display: none;
+      }
+
+      .tool-output-summary::before {
+        content: '\\25B6';
+        font-size: 10px;
+        transition: transform 0.15s;
+        opacity: 0.5;
+      }
+
+      .tool-output-card[open] > .tool-output-summary::before {
+        transform: rotate(90deg);
+      }
+
+      .tool-output-summary:hover {
+        background: rgba(175, 184, 193, 0.1);
+      }
+
+      .tool-output-card[open] .tool-output-summary {
+        border-bottom: 1px solid rgba(175, 184, 193, 0.2);
+      }
+
+      .tool-icon {
+        flex-shrink: 0;
+        opacity: 0.6;
+        font-size: 12px;
+      }
+
+      .tool-name {
+        color: #58a6ff;
+        font-weight: 600;
+        white-space: nowrap;
+        font-size: 12px;
+      }
+
+      .tool-compact-info {
+        flex: 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        color: rgba(255, 255, 255, 0.6);
+      }
+
+      .tool-status-icon {
+        flex-shrink: 0;
+      }
+
+      .tool-file-path {
+        color: #d2a8ff;
+      }
+
+      .tool-edit-mode {
+        color: rgba(255, 255, 255, 0.4);
+        font-style: italic;
+        margin-left: 4px;
+      }
+
+      .tool-search-query {
+        color: #ffa657;
+        font-style: italic;
+      }
+
+      .git-hash {
+        color: #d2a8ff;
+        font-family: 'SF Mono', Monaco, Consolas, monospace;
+      }
+
+      .verify-pass { color: #3fb950; font-weight: 600; }
+      .verify-fail { color: #f85149; font-weight: 600; }
+
+      .tool-summary-text {
+        color: rgba(255, 255, 255, 0.6);
+      }
+
+      /* Expanded detail view */
+      .tool-output-detail {
+        padding: 8px 10px;
+        max-height: 400px;
+        overflow-y: auto;
+      }
+
+      .tool-output-pre {
+        background: #161b22;
+        color: #c9d1d9;
+        padding: 10px;
+        border-radius: 4px;
+        overflow-x: auto;
+        font-size: 12px;
+        line-height: 1.5;
+        margin: 0;
+        white-space: pre-wrap;
+        word-break: break-all;
+      }
+
+      .tool-error-output {
+        border: 1px solid rgba(248, 81, 73, 0.3);
+        background: rgba(248, 81, 73, 0.06);
+      }
+
+      /* Diff highlighting */
+      .diff-add { color: #3fb950; }
+      .diff-remove { color: #f85149; }
+      .diff-hunk { color: #d2a8ff; font-weight: 600; }
+
+      /* Tree output */
+      .tool-tree-output {
+        color: #8b949e;
+      }
+
+      /* Action buttons */
+      .tool-output-actions {
+        display: flex;
+        justify-content: flex-end;
+        padding: 6px 0 0 0;
+        gap: 6px;
+      }
+
+      .tool-action-btn {
+        background: rgba(175, 184, 193, 0.1);
+        border: 1px solid rgba(175, 184, 193, 0.2);
+        border-radius: 4px;
+        padding: 2px 8px;
+        font-size: 11px;
+        cursor: pointer;
+        color: rgba(255, 255, 255, 0.5);
+        font-family: inherit;
+      }
+
+      .tool-action-btn:hover {
+        background: rgba(175, 184, 193, 0.2);
+        color: rgba(255, 255, 255, 0.8);
+      }
+
+      /* Shell execute output */
+      .shell-cmd {
+        color: #ffa657;
+        font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
+      }
+
+      .shell-status-ok { color: #3fb950; font-weight: 600; }
+      .shell-status-running { color: #58a6ff; font-weight: 600; }
+      .shell-status-fail { color: #f85149; font-weight: 600; }
+
+      .shell-prompt {
+        padding: 6px 10px;
+        background: #0d1117;
+        border-radius: 4px 4px 0 0;
+        color: #ffa657;
+        font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
+        font-size: 12px;
+        font-weight: 600;
+        border-bottom: 1px solid rgba(175, 184, 193, 0.15);
+      }
+
+      .shell-stdout {
+        border-radius: 0 0 4px 4px;
+        margin-top: 0;
+      }
+
+      .shell-stderr {
+        margin-top: 4px;
+      }
+
+      .shell-running-hint {
+        color: #58a6ff;
+        font-size: 12px;
+        font-style: italic;
+        padding: 8px 0;
+      }
+
+      /* Shell watch classified lines */
+      .shell-watch-output {
+        line-height: 1.6;
+      }
+
+      .shell-line-error { color: #f85149; }
+      .shell-line-warning { color: #d29922; }
+      .shell-line-success { color: #3fb950; }
+      .shell-line-info { color: #58a6ff; }
+      .shell-line-verbose { color: #8b949e; }
+      .shell-line-raw { color: #c9d1d9; }
+
+      /* Inline images from tool output */
+      .tool-output-image {
+        margin: 8px 0;
+      }
+
+      .tool-inline-image {
+        display: block;
+        max-width: 100%;
+        max-height: 300px;
+        border: 1px solid rgba(175, 184, 193, 0.2);
+        border-radius: 4px;
+      }
+    `;
+  }
+}
diff --git a/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts b/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
index c4eb97a64..c32281329 100644
--- a/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
+++ b/src/debug/jtag/widgets/chat/chat-widget/ChatWidget.ts
@@ -27,6 +27,7 @@ import { AbstractMessageAdapter } from '../adapters/AbstractMessageAdapter';
 import { MessageEventDelegator } from '../adapters/MessageEventDelegator';
 import { ImageMessageAdapter } from '../adapters/ImageMessageAdapter';
 import { URLCardAdapter } from '../adapters/URLCardAdapter';
+import { ToolOutputAdapter } from '../adapters/ToolOutputAdapter';
 import { MessageInputEnhancer } from '../message-input/MessageInputEnhancer';
 import { AIStatusIndicator } from './AIStatusIndicator';
 import { AI_DECISION_EVENTS } from '../../../system/events/shared/AIDecisionEvents';
@@ -967,6 +968,10 @@ export class ChatWidget extends EntityScrollerWidget {
       this.eventDelegator.onAction('url-ai-summarize', (target) => URLCardAdapter.handleAISummarize(target));
       this.eventDelegator.onAction('url-retry-preview', (target) => URLCardAdapter.handleRetryPreview(target));
 
+      // Register ToolOutputAdapter action handlers
+      this.eventDelegator.onAction('tool-copy', (target) => ToolOutputAdapter.handleCopy(target));
+      this.eventDelegator.onAction('tool-open-tab', (target) => ToolOutputAdapter.handleOpenInTab(target));
+
       verbose() && console.log('βœ… ChatWidget: Event delegator attached with action handlers');
     }
   }
diff --git a/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts b/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
index 0d4ee052a..1162c3f1b 100644
--- a/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
+++ b/src/debug/jtag/widgets/log-viewer/LogViewerWidget.ts
@@ -92,6 +92,21 @@ export class LogViewerWidget extends BasePanelWidget {
                     (this as any).entityId ||
                     '.continuum/personas/helper/logs/hippocampus.log'; // Default for testing
 
+    // Check for inline tool output content (opened from ToolOutputAdapter "Open" button)
+    if (logPath.startsWith('tool:')) {
+      const key = logPath.slice(5); // strip "tool:" prefix
+      try {
+        const { ToolOutputAdapter } = await import('../chat/adapters/ToolOutputAdapter');
+        const entry = ToolOutputAdapter.getInlineContent(key);
+        if (entry) {
+          this.renderInlineContent(entry.content, entry.toolName);
+          return;
+        }
+      } catch (err) {
+        console.warn('πŸ“œ LogViewer: Failed to load inline content, falling back to file-based loading', err);
+      }
+    }
+
     this.logData.logPath = logPath;
     this.logData.logName = logPath.split('/').pop() || 'log';
 
@@ -103,6 +118,41 @@ export class LogViewerWidget extends BasePanelWidget {
     this.startAutoRefresh();
   }
 
+  private renderInlineContent(content: string, toolName: string): void {
+    const lines: LogLine[] = content.split('\n').map((text, i) => ({
+      lineNumber: i + 1,
+      content: text,
+      level: this.detectLevel(text),
+      timestamp: undefined,
+      component: undefined
+    }));
+
+    this.logData = {
+      ...this.logData,
+      logPath: toolName,
+      logName: toolName,
+      lines,
+      totalLines: lines.length,
+      hasMore: false,
+      isLoading: false,
+      autoFollow: false, // Static content β€” no auto-refresh
+      error: undefined
+    };
+
+    this.panelConfig.panelTitle = toolName;
+    this.panelConfig.panelSubtitle = `${lines.length} lines`;
+
+    this.renderWidget();
+  }
+
+  private detectLevel(text: string): string | undefined {
+    if (/\berror\b|\bERR\b|\bfailed\b|\bFAIL\b/i.test(text)) return 'ERROR';
+    if (/\bwarn\b|\bWARN\b|\bwarning\b/i.test(text)) return 'WARN';
+    if (/\binfo\b|\bINFO\b/i.test(text)) return 'INFO';
+    if (/\bdebug\b|\bDEBUG\b/i.test(text)) return 'DEBUG';
+    return undefined;
+  }
+
   private async loadLog(): Promise {
     this.logData.isLoading = true;
     this.renderWidget();
diff --git a/src/debug/jtag/widgets/terminal/README.md b/src/debug/jtag/widgets/terminal/README.md
new file mode 100644
index 000000000..566d35d0b
--- /dev/null
+++ b/src/debug/jtag/widgets/terminal/README.md
@@ -0,0 +1,208 @@
+# TerminalWidget
+
+tmux-like terminal multiplexer showing AI shell sessions and live process output
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Structure](#structure)
+- [Usage](#usage)
+  - [Navigation](#navigation)
+  - [Recipe Configuration](#recipe-configuration)
+- [Positron Context](#positron-context)
+- [Styling](#styling)
+- [Customization](#customization)
+- [Testing](#testing)
+- [AI Experimentation](#ai-experimentation)
+
+## Overview
+
+**Display Name:** Terminal
+**Tag Name:** ``
+**Path Prefix:** `/terminal`
+**Requires Entity:** No
+
+## Structure
+
+```
+widgets/terminal/
+β”œβ”€β”€ TerminalWidget.ts        # Widget logic
+β”œβ”€β”€ public/
+β”‚   β”œβ”€β”€ terminal-widget.html  # Template
+β”‚   β”œβ”€β”€ terminal-widget.scss  # Styles (source)
+β”‚   β”œβ”€β”€ terminal-widget.css   # Compiled CSS
+β”‚   └── terminal-widget.styles.ts  # CSS as TS export
+└── README.md                        # This file
+```
+
+## Usage
+
+### Navigation
+
+**CLI:**
+```bash
+./jtag interface/navigate --path="/terminal"
+```
+
+**Programmatic:**
+```typescript
+import { Commands } from '@system/core/shared/Commands';
+
+await Commands.execute('interface/navigate', {
+  path: '/terminal'
+});
+```
+
+**Recipe:**
+```typescript
+await Commands.execute('workspace/recipe/load', {
+  recipeId: 'terminal'
+});
+```
+
+### Recipe Configuration
+
+This widget's behavior is configured via recipe at `system/recipes/terminal.json`:
+
+```json
+{
+  "uniqueId": "terminal",
+  "name": "Terminal",
+  "layout": {
+    "main": ["terminal-widget"],
+    "right": {
+        "widgets": [
+            "chat-widget"
+        ],
+        "config": {
+            "room": "help",
+            "compact": true
+        }
+    }
+  }
+}
+```
+
+## Positron Context
+
+This widget emits context to Positron for AI awareness:
+
+```typescript
+PositronWidgetState.emit({
+  widgetType: 'terminal',
+  title: 'Terminal',
+  metadata: { /* widget-specific data */ }
+}, {
+  action: 'viewing',
+  target: 'terminal'
+});
+```
+
+AIs in the right panel automatically receive this context, enabling contextual help.
+
+## Styling
+
+Styles use shared SCSS variables from `widgets/shared/styles/_variables.scss`:
+
+```scss
+@import '../../shared/styles/variables';
+@import '../../shared/styles/mixins';
+
+:host {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.terminal-container {
+  background: $bg-panel;
+  color: $color-text;
+}
+```
+
+**Compile SCSS:**
+```bash
+npx tsx scripts/compile-sass.ts
+```
+
+This generates both:
+- `.css` file (for HTTP fetch loading)
+- `.styles.ts` file (for TypeScript imports)
+
+## Customization
+
+### Adding Interactivity
+
+1. Add elements to `public/terminal-widget.html`
+2. Add event listeners in `setupEventListeners()`:
+
+```typescript
+private setupEventListeners(): void {
+  const button = this.shadowRoot?.querySelector('#my-button');
+  button?.addEventListener('click', () => this.handleClick());
+}
+```
+
+### Fetching Data
+
+```typescript
+protected async onWidgetInitialize(): Promise {
+  const result = await this.executeCommand('data/list', {
+    collection: 'my-collection'
+  });
+  // Use result.items
+}
+```
+
+### Emitting Events
+
+```typescript
+this.dispatchEvent(new CustomEvent('terminal-action', {
+  detail: { action: 'something' },
+  bubbles: true
+}));
+```
+
+## Testing
+
+### Visual Verification
+
+```bash
+npm start  # Deploy changes
+./jtag interface/navigate --path="/terminal"
+./jtag interface/screenshot
+```
+
+### AI QA Testing
+
+Ask the AI team to test your widget:
+
+```bash
+./jtag collaboration/chat/send --room="general" \
+  --message="I just updated Terminal. Can you navigate there and let me know if it looks right?"
+
+# Wait 30 seconds, then check responses
+./jtag collaboration/chat/export --room="general" --limit=20
+```
+
+## AI Experimentation
+
+This widget follows the modular pattern enabling safe AI experimentation:
+
+1. **Isolation**: Widget runs in shadow DOM, can't break other widgets
+2. **Hot reload**: Changes visible after `npm start` (90s)
+3. **Rollback**: Git revert if experiment fails
+4. **Graduation**: Successful experiments become permanent via commit
+
+**Experiment workflow:**
+```bash
+# 1. AI modifies widget files
+# 2. Deploy: npm start
+# 3. Test: ./jtag interface/screenshot
+# 4. If good: git add && git commit
+# 5. If bad: git checkout -- widgets/terminal/
+```
+
+---
+
+*Generated by WidgetGenerator*
diff --git a/src/debug/jtag/widgets/terminal/TerminalWidget.ts b/src/debug/jtag/widgets/terminal/TerminalWidget.ts
new file mode 100644
index 000000000..d7b0348f1
--- /dev/null
+++ b/src/debug/jtag/widgets/terminal/TerminalWidget.ts
@@ -0,0 +1,522 @@
+/**
+ * TerminalWidget - tmux-like terminal multiplexer for AI shell sessions
+ *
+ * Shows all AI persona shell sessions with live output streaming.
+ * Left panel: execution list (running/completed/failed)
+ * Right panel: selected execution's output stream
+ * Bottom bar: session info (persona, cwd, controls)
+ *
+ * Uses code/shell/status to discover sessions,
+ * code/shell/watch for live output streaming,
+ * code/shell/kill to abort executions.
+ */
+
+import { ReactiveWidget, html, css, reactive, type TemplateResult } from '../shared/ReactiveWidget';
+import { PositronWidgetState } from '../shared/services/state/PositronWidgetState';
+import type { CommandParams, CommandResult } from '@system/core/types/JTAGTypes';
+
+// ────────────────────────────────────────────────────────────
+// Types (mirror server-side shell types for browser use)
+// ────────────────────────────────────────────────────────────
+
+interface ShellExecution {
+  executionId: string;
+  cmd: string;
+  status: 'running' | 'completed' | 'failed' | 'timed_out' | 'killed';
+  personaName: string;
+  personaId: string;
+  startedAt: number;
+  lines: ClassifiedLine[];
+  exitCode?: number;
+}
+
+interface ClassifiedLine {
+  text: string;
+  classification: 'error' | 'warning' | 'info' | 'success' | 'verbose' | 'raw';
+  timestamp: number;
+}
+
+interface ShellStatusResult extends CommandResult {
+  success: boolean;
+  shellSessionId: string;
+  personaId: string;
+  cwd: string;
+  workspaceRoot: string;
+  activeExecutions: number;
+  totalExecutions: number;
+}
+
+interface ShellWatchResult extends CommandResult {
+  success: boolean;
+  executionId: string;
+  finished: boolean;
+  exitCode?: number;
+  lines: Array<{ text: string; classification: string }>;
+}
+
+// ────────────────────────────────────────────────────────────
+// Widget
+// ────────────────────────────────────────────────────────────
+
+export class TerminalWidget extends ReactiveWidget {
+
+  // ── Reactive State ──────────────────────────────────────
+  @reactive() private executions: ShellExecution[] = [];
+  @reactive() private selectedId: string | null = null;
+  @reactive() private statusInfo: string = 'No active sessions';
+  @reactive() private autoScroll = true;
+
+  // ── Polling ─────────────────────────────────────────────
+  private _statusPollTimer: ReturnType | null = null;
+  private _watchAbort: AbortController | null = null;
+
+  // ── Styles ──────────────────────────────────────────────
+  static override styles = css`
+    :host {
+      display: block;
+      height: 100%;
+      font-family: var(--font-mono, 'SF Mono', 'Fira Code', 'Cascadia Code', monospace);
+      font-size: 13px;
+    }
+
+    .terminal-layout {
+      display: grid;
+      grid-template-columns: 240px 1fr;
+      grid-template-rows: 1fr auto;
+      height: 100%;
+      background: var(--background-color, #0a0e1a);
+      color: var(--text-primary, #e0e0e0);
+    }
+
+    /* ── Left Panel: Execution List ──────────────────── */
+    .exec-panel {
+      grid-row: 1 / 3;
+      border-right: 1px solid var(--border-color, #1e2a3a);
+      overflow-y: auto;
+      padding: 0;
+    }
+
+    .exec-panel-header {
+      padding: 12px 16px;
+      font-size: 11px;
+      font-weight: 600;
+      text-transform: uppercase;
+      letter-spacing: 0.08em;
+      color: var(--text-tertiary, #666);
+      border-bottom: 1px solid var(--border-color, #1e2a3a);
+      position: sticky;
+      top: 0;
+      background: var(--background-color, #0a0e1a);
+      z-index: 1;
+    }
+
+    .exec-item {
+      padding: 10px 16px;
+      cursor: pointer;
+      border-left: 3px solid transparent;
+      border-bottom: 1px solid var(--border-color, #1e2a3a);
+      transition: background-color 0.12s ease;
+    }
+
+    .exec-item:hover {
+      background: var(--hover-background, rgba(255, 255, 255, 0.03));
+    }
+
+    .exec-item.selected {
+      border-left-color: var(--accent-color, #00c8ff);
+      background: var(--active-background, rgba(0, 200, 255, 0.06));
+    }
+
+    .exec-cmd {
+      font-size: 12px;
+      color: var(--text-primary, #e0e0e0);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      margin-bottom: 4px;
+    }
+
+    .exec-meta {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 10px;
+      color: var(--text-tertiary, #666);
+    }
+
+    .exec-badge {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      padding: 1px 6px;
+      border-radius: 3px;
+      font-size: 10px;
+      font-weight: 500;
+    }
+
+    .exec-badge.running {
+      background: rgba(0, 200, 255, 0.15);
+      color: #00c8ff;
+    }
+
+    .exec-badge.completed {
+      background: rgba(0, 200, 100, 0.15);
+      color: #00c864;
+    }
+
+    .exec-badge.failed, .exec-badge.killed, .exec-badge.timed_out {
+      background: rgba(255, 80, 80, 0.15);
+      color: #ff5050;
+    }
+
+    .exec-persona {
+      color: var(--text-tertiary, #666);
+    }
+
+    .exec-empty {
+      padding: 24px 16px;
+      text-align: center;
+      color: var(--text-tertiary, #555);
+      font-size: 12px;
+      line-height: 1.6;
+    }
+
+    /* ── Right Panel: Output Stream ──────────────────── */
+    .output-panel {
+      overflow-y: auto;
+      padding: 12px 16px;
+      scroll-behavior: smooth;
+    }
+
+    .output-line {
+      white-space: pre-wrap;
+      word-break: break-all;
+      line-height: 1.5;
+      padding: 0 4px;
+    }
+
+    .output-line.error { color: #ff5050; }
+    .output-line.warning { color: #ffaa00; }
+    .output-line.success { color: #00c864; }
+    .output-line.info { color: #00c8ff; }
+    .output-line.verbose { color: #666; }
+    .output-line.raw { color: var(--text-secondary, #aaa); }
+
+    .output-empty {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+      color: var(--text-tertiary, #555);
+      font-size: 13px;
+    }
+
+    /* ── Bottom Bar ──────────────────────────────────── */
+    .bottom-bar {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      padding: 8px 16px;
+      border-top: 1px solid var(--border-color, #1e2a3a);
+      font-size: 11px;
+      color: var(--text-tertiary, #666);
+      background: var(--surface-color, #0d1220);
+    }
+
+    .bottom-bar .status-text {
+      flex: 1;
+    }
+
+    .bottom-bar button {
+      background: transparent;
+      border: 1px solid var(--border-color, #1e2a3a);
+      color: var(--text-secondary, #aaa);
+      padding: 4px 12px;
+      border-radius: 4px;
+      font-size: 11px;
+      font-family: inherit;
+      cursor: pointer;
+      transition: background-color 0.12s ease, color 0.12s ease;
+    }
+
+    .bottom-bar button:hover {
+      background: var(--hover-background, rgba(255, 255, 255, 0.05));
+      color: var(--text-primary, #e0e0e0);
+    }
+
+    .bottom-bar button.danger:hover {
+      background: rgba(255, 80, 80, 0.15);
+      color: #ff5050;
+    }
+
+    .pulse {
+      display: inline-block;
+      width: 6px;
+      height: 6px;
+      border-radius: 50%;
+      background: #00c8ff;
+      animation: pulse-anim 1.5s infinite;
+    }
+
+    @keyframes pulse-anim {
+      0%, 100% { opacity: 1; }
+      50% { opacity: 0.3; }
+    }
+  `;
+
+  // ── Lifecycle ───────────────────────────────────────────
+
+  protected override onConnect(): void {
+    super.onConnect();
+    this.emitPositronContext();
+    this.startStatusPolling();
+  }
+
+  protected override onDisconnect(): void {
+    super.onDisconnect();
+    this.stopStatusPolling();
+    this.stopWatching();
+  }
+
+  // ── Positron Context ────────────────────────────────────
+
+  private emitPositronContext(): void {
+    PositronWidgetState.emit(
+      {
+        widgetType: 'terminal',
+        title: 'Terminal',
+        metadata: {
+          activeExecutions: this.executions.filter(e => e.status === 'running').length,
+          totalExecutions: this.executions.length,
+        }
+      },
+      { action: 'viewing', target: 'terminal' }
+    );
+  }
+
+  // ── Status Polling ──────────────────────────────────────
+
+  private startStatusPolling(): void {
+    this.pollShellStatus();
+    this._statusPollTimer = setInterval(() => this.pollShellStatus(), 5000);
+  }
+
+  private stopStatusPolling(): void {
+    if (this._statusPollTimer) {
+      clearInterval(this._statusPollTimer);
+      this._statusPollTimer = null;
+    }
+  }
+
+  private async pollShellStatus(): Promise {
+    try {
+      // Get list of all users (personas) that may have shell sessions
+      const usersResult = await this.executeCommand('data/list', {
+        collection: 'users',
+        filter: { type: 'ai' },
+        limit: 50,
+      } as any);
+
+      if (!usersResult?.items?.length) {
+        this.statusInfo = 'No AI personas active';
+        return;
+      }
+
+      const activePersonas: string[] = [];
+
+      // Check shell status for each persona
+      for (const user of usersResult.items) {
+        try {
+          const status = await this.executeCommand(
+            'code/shell/status',
+            { userId: user.id } as any,
+          );
+          if (status?.success) {
+            activePersonas.push(user.displayName || user.uniqueId);
+            this.statusInfo = `${activePersonas.length} active session${activePersonas.length > 1 ? 's' : ''} | ${status.cwd}`;
+          }
+        } catch {
+          // No shell session for this persona β€” skip
+        }
+      }
+
+      if (activePersonas.length === 0) {
+        this.statusInfo = 'No active shell sessions';
+      }
+    } catch (err) {
+      this.statusInfo = 'Status poll failed';
+    }
+  }
+
+  // ── Watch Loop ──────────────────────────────────────────
+
+  private async startWatching(executionId: string, personaId: string): Promise {
+    this.stopWatching();
+    this._watchAbort = new AbortController();
+
+    const exec = this.executions.find(e => e.executionId === executionId);
+    if (!exec || exec.status !== 'running') return;
+
+    try {
+      while (!this._watchAbort.signal.aborted) {
+        const result = await this.executeCommand(
+          'code/shell/watch',
+          { executionId, userId: personaId } as any,
+        );
+
+        if (!result?.success) break;
+
+        // Append new lines
+        if (result.lines?.length) {
+          const newLines: ClassifiedLine[] = result.lines.map(l => ({
+            text: l.text,
+            classification: l.classification as ClassifiedLine['classification'],
+            timestamp: Date.now(),
+          }));
+          exec.lines = [...exec.lines, ...newLines];
+          this.requestUpdate();
+          this.scrollToBottom();
+        }
+
+        if (result.finished) {
+          exec.status = result.exitCode === 0 ? 'completed' : 'failed';
+          exec.exitCode = result.exitCode;
+          this.requestUpdate();
+          break;
+        }
+      }
+    } catch {
+      // Watch ended (connection lost, abort, etc.)
+    }
+  }
+
+  private stopWatching(): void {
+    if (this._watchAbort) {
+      this._watchAbort.abort();
+      this._watchAbort = null;
+    }
+  }
+
+  // ── Actions ─────────────────────────────────────────────
+
+  private selectExecution(executionId: string): void {
+    this.selectedId = executionId;
+    const exec = this.executions.find(e => e.executionId === executionId);
+    if (exec?.status === 'running') {
+      this.startWatching(executionId, exec.personaId);
+    }
+    this.scrollToBottom();
+  }
+
+  private async killExecution(): Promise {
+    const exec = this.selectedExecution;
+    if (!exec || exec.status !== 'running') return;
+
+    try {
+      await this.executeCommand(
+        'code/shell/kill',
+        { executionId: exec.executionId, userId: exec.personaId } as any,
+      );
+      exec.status = 'killed';
+      this.stopWatching();
+      this.requestUpdate();
+    } catch (err) {
+      console.error('Kill failed:', err);
+    }
+  }
+
+  private clearCompleted(): void {
+    this.executions = this.executions.filter(e => e.status === 'running');
+    if (this.selectedId && !this.executions.find(e => e.executionId === this.selectedId)) {
+      this.selectedId = null;
+    }
+  }
+
+  // ── Helpers ─────────────────────────────────────────────
+
+  private get selectedExecution(): ShellExecution | undefined {
+    return this.executions.find(e => e.executionId === this.selectedId);
+  }
+
+  private scrollToBottom(): void {
+    if (!this.autoScroll) return;
+    requestAnimationFrame(() => {
+      const output = this.shadowRoot?.querySelector('.output-panel');
+      if (output) {
+        output.scrollTop = output.scrollHeight;
+      }
+    });
+  }
+
+  private formatElapsed(startedAt: number): string {
+    const seconds = Math.floor((Date.now() - startedAt) / 1000);
+    if (seconds < 60) return `${seconds}s`;
+    const minutes = Math.floor(seconds / 60);
+    return `${minutes}m ${seconds % 60}s`;
+  }
+
+  // ── Render ──────────────────────────────────────────────
+
+  override render(): TemplateResult {
+    const selected = this.selectedExecution;
+    const runningCount = this.executions.filter(e => e.status === 'running').length;
+
+    return html`
+      
+ +
+
+ Executions ${runningCount > 0 ? html`` : ''} +
+ ${this.executions.length === 0 + ? html` +
+ No shell executions yet.
+ AI personas will appear here when they run commands + via code/shell/execute. +
` + : this.executions.map(exec => html` +
this.selectExecution(exec.executionId)} + > +
$ ${exec.cmd}
+
+ + ${exec.status === 'running' ? html`` : ''} + ${exec.status} + + ${exec.personaName} + ${this.formatElapsed(exec.startedAt)} +
+
+ `) + } +
+ + +
+ ${selected + ? selected.lines.length === 0 + ? html`
Waiting for output...
` + : selected.lines.map(line => html` +
${line.text}
+ `) + : html`
Select an execution to view output
` + } +
+ + +
+ ${this.statusInfo} + ${selected?.status === 'running' + ? html`` + : ''} + +
+
+ `; + } +} + +// Registration handled by centralized BROWSER_WIDGETS registry diff --git a/src/debug/jtag/widgets/terminal/public/terminal-widget.css b/src/debug/jtag/widgets/terminal/public/terminal-widget.css new file mode 100644 index 000000000..e6839b8f1 --- /dev/null +++ b/src/debug/jtag/widgets/terminal/public/terminal-widget.css @@ -0,0 +1,6 @@ +/** + * Auto-generated by compile-sass.ts + * Source: terminal-widget.scss + * DO NOT EDIT DIRECTLY - edit the .scss file instead + */ +:host{display:block} diff --git a/src/debug/jtag/widgets/terminal/public/terminal-widget.html b/src/debug/jtag/widgets/terminal/public/terminal-widget.html new file mode 100644 index 000000000..4ea5cf16a --- /dev/null +++ b/src/debug/jtag/widgets/terminal/public/terminal-widget.html @@ -0,0 +1,12 @@ +
+
+

Terminal

+
tmux-like terminal multiplexer showing AI shell sessions and live process output
+
+
+
+

AI Shell Sessions

+

Monitor live AI process output and shell sessions.

+
+
+
diff --git a/src/debug/jtag/widgets/terminal/public/terminal-widget.scss b/src/debug/jtag/widgets/terminal/public/terminal-widget.scss new file mode 100644 index 000000000..0b839ec49 --- /dev/null +++ b/src/debug/jtag/widgets/terminal/public/terminal-widget.scss @@ -0,0 +1,10 @@ +/** + * TerminalWidget Styles (stub) + * + * All styles are defined inline via ReactiveWidget's static `styles` property. + * This file exists for SCSS compilation compatibility but is not loaded at runtime. + */ + +:host { + display: block; +} diff --git a/src/debug/jtag/widgets/terminal/public/terminal-widget.styles.ts b/src/debug/jtag/widgets/terminal/public/terminal-widget.styles.ts new file mode 100644 index 000000000..751cc4ad4 --- /dev/null +++ b/src/debug/jtag/widgets/terminal/public/terminal-widget.styles.ts @@ -0,0 +1,9 @@ +/** + * Auto-generated by compile-sass.ts + * Source: terminal-widget.scss + * DO NOT EDIT DIRECTLY - edit the .scss file instead + */ + +export const styles = ` +:host{display:block} +`; diff --git a/src/debug/jtag/workers/continuum-core/Cargo.toml b/src/debug/jtag/workers/continuum-core/Cargo.toml index 849a93390..509bab1cf 100644 --- a/src/debug/jtag/workers/continuum-core/Cargo.toml +++ b/src/debug/jtag/workers/continuum-core/Cargo.toml @@ -53,5 +53,11 @@ ts-rs.workspace = true # TypeScript type generation # Memory/Hippocampus β€” pure compute engine (data from TS ORM via IPC) fastembed.workspace = true # Inline ONNX embedding (~5ms per embed, no IPC hop) +# Code module β€” file operations, change tracking, code intelligence +similar = "2.6" # Unified diff computation +ignore = "0.4" # .gitignore-aware file walking (from ripgrep) +regex = "1" # Regex search for code search + [dev-dependencies] tokio-test = "0.4" +tempfile = "3" # Temp directories for code module tests diff --git a/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts b/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts index 1e3220bf8..7ac9f143d 100644 --- a/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts +++ b/src/debug/jtag/workers/continuum-core/bindings/RustCoreIPC.ts @@ -26,6 +26,24 @@ import type { ChannelRegistryStatus, ChannelEnqueueRequest, ServiceCycleResult, + // Code module types + EditMode, + ReadResult, + WriteResult, + SearchMatch, + SearchResult, + TreeNode, + TreeResult, + UndoResult, + ChangeNode, + HistoryResult, + GitStatusInfo, + // Shell session types + ShellExecuteResponse, + ShellPollResponse, + ShellSessionInfo, + ShellWatchResponse, + SentinelRule, } from '../../../shared/generated'; // Memory subsystem types (Hippocampus in Rust β€” corpus-based, no SQL) @@ -57,6 +75,11 @@ export interface UtteranceEvent { timestamp: number; } +// ============================================================================ +// Code Module Types β€” imported from ts-rs generated (Rust is source of truth) +// All code types imported at top level from shared/generated +// ============================================================================ + interface Response { success: boolean; result?: any; @@ -81,6 +104,11 @@ export class RustCoreIPCClient extends EventEmitter { private nextRequestId = 1; private connected = false; + /** Rate-limit slow IPC warnings globally: command -> last warning timestamp */ + private static slowWarningTimestamps: Map = new Map(); + private static readonly SLOW_IPC_THRESHOLD_MS = 500; + private static readonly SLOW_WARNING_COOLDOWN_MS = 10_000; + constructor(private socketPath: string) { super(); } @@ -194,8 +222,13 @@ export class RustCoreIPCClient extends EventEmitter { this.pendingRequests.set(requestId, (result) => { const duration = performance.now() - start; - if (duration > 10) { - console.warn(`⚠️ Slow IPC call: ${command.command} took ${duration.toFixed(2)}ms`); + if (duration > RustCoreIPCClient.SLOW_IPC_THRESHOLD_MS) { + const now = Date.now(); + const lastWarned = RustCoreIPCClient.slowWarningTimestamps.get(command.command) ?? 0; + if (now - lastWarned > RustCoreIPCClient.SLOW_WARNING_COOLDOWN_MS) { + RustCoreIPCClient.slowWarningTimestamps.set(command.command, now); + console.warn(`⚠️ Slow IPC call: ${command.command} took ${duration.toFixed(0)}ms`); + } } resolve(result); }); @@ -723,6 +756,574 @@ export class RustCoreIPCClient extends EventEmitter { return response.result as ConsciousnessContextResponse; } + // ======================================================================== + // Code Module Methods (file operations, change tracking, code intelligence) + // ======================================================================== + + /** + * Initialize a per-persona workspace with file engine and change graph. + * Must be called before any other code/* operations for this persona. + * + * @param personaId - The persona's UUID + * @param workspaceRoot - Absolute path to the persona's workspace directory + * @param readRoots - Optional read-only root directories (e.g., main codebase for discovery) + */ + async codeCreateWorkspace( + personaId: string, + workspaceRoot: string, + readRoots?: string[] + ): Promise { + const response = await this.request({ + command: 'code/create-workspace', + persona_id: personaId, + workspace_root: workspaceRoot, + read_roots: readRoots ?? [], + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to create workspace'); + } + } + + /** + * Read a file or line range from the persona's workspace. + */ + async codeRead( + personaId: string, + filePath: string, + startLine?: number, + endLine?: number + ): Promise { + const response = await this.request({ + command: 'code/read', + persona_id: personaId, + file_path: filePath, + start_line: startLine ?? null, + end_line: endLine ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to read file'); + } + + return response.result as ReadResult; + } + + /** + * Write or create a file in the persona's workspace. + * Creates a ChangeNode in the change graph for undo support. + */ + async codeWrite( + personaId: string, + filePath: string, + content: string, + description?: string + ): Promise { + const response = await this.request({ + command: 'code/write', + persona_id: personaId, + file_path: filePath, + content, + description: description ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to write file'); + } + + return response.result as WriteResult; + } + + /** + * Edit a file using one of four edit modes: + * - line_range: Replace content between line numbers + * - search_replace: Find and replace text + * - insert_at: Insert content at a specific line + * - append: Add content to end of file + */ + async codeEdit( + personaId: string, + filePath: string, + editMode: EditMode, + description?: string + ): Promise { + const response = await this.request({ + command: 'code/edit', + persona_id: personaId, + file_path: filePath, + edit_mode: editMode, + description: description ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to edit file'); + } + + return response.result as WriteResult; + } + + /** + * Delete a file from the persona's workspace. + * Full content is preserved in the change graph for undo. + */ + async codeDelete( + personaId: string, + filePath: string, + description?: string + ): Promise { + const response = await this.request({ + command: 'code/delete', + persona_id: personaId, + file_path: filePath, + description: description ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to delete file'); + } + + return response.result as WriteResult; + } + + /** + * Preview an edit as a unified diff without applying it. + */ + async codeDiff( + personaId: string, + filePath: string, + editMode: EditMode + ): Promise<{ success: boolean; unified: string }> { + const response = await this.request({ + command: 'code/diff', + persona_id: personaId, + file_path: filePath, + edit_mode: editMode, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to compute diff'); + } + + return response.result as { success: boolean; unified: string }; + } + + /** + * Undo a specific change or the last N changes. + * Pass changeId to undo a specific operation, or count to undo last N. + */ + async codeUndo( + personaId: string, + changeId?: string, + count?: number + ): Promise { + const response = await this.request({ + command: 'code/undo', + persona_id: personaId, + change_id: changeId ?? null, + count: count ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to undo'); + } + + return response.result as UndoResult; + } + + /** + * Get change history for a file or entire workspace. + */ + async codeHistory( + personaId: string, + filePath?: string, + limit?: number + ): Promise { + const response = await this.request({ + command: 'code/history', + persona_id: personaId, + file_path: filePath ?? null, + limit: limit ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get history'); + } + + return response.result as HistoryResult; + } + + /** + * Search for a regex pattern across workspace files. + * Respects .gitignore, supports glob filtering. + */ + async codeSearch( + personaId: string, + pattern: string, + fileGlob?: string, + maxResults?: number + ): Promise { + const response = await this.request({ + command: 'code/search', + persona_id: personaId, + pattern, + file_glob: fileGlob ?? null, + max_results: maxResults ?? null, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to search'); + } + + return response.result as SearchResult; + } + + /** + * Generate a directory tree for the workspace. + */ + async codeTree( + personaId: string, + path?: string, + maxDepth?: number, + includeHidden?: boolean + ): Promise { + const response = await this.request({ + command: 'code/tree', + persona_id: personaId, + path: path ?? null, + max_depth: maxDepth ?? null, + include_hidden: includeHidden ?? false, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to generate tree'); + } + + return response.result as TreeResult; + } + + /** + * Get git status for the workspace. + */ + async codeGitStatus(personaId: string): Promise { + const response = await this.request({ + command: 'code/git-status', + persona_id: personaId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get git status'); + } + + return response.result as GitStatusInfo; + } + + /** + * Get git diff for the workspace. + */ + async codeGitDiff(personaId: string, staged?: boolean): Promise<{ success: boolean; diff: string }> { + const response = await this.request({ + command: 'code/git-diff', + persona_id: personaId, + staged: staged ?? false, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get git diff'); + } + + return response.result as { success: boolean; diff: string }; + } + + /** + * Get git log for the workspace. + */ + async codeGitLog(personaId: string, count?: number): Promise<{ success: boolean; log: string }> { + const response = await this.request({ + command: 'code/git-log', + persona_id: personaId, + count: count ?? 10, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get git log'); + } + + return response.result as { success: boolean; log: string }; + } + + /** + * Stage files for commit. + */ + async codeGitAdd(personaId: string, paths: string[]): Promise<{ staged: string[] }> { + const response = await this.request({ + command: 'code/git-add', + persona_id: personaId, + paths, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to stage files'); + } + + return response.result as { staged: string[] }; + } + + /** + * Create a git commit. + */ + async codeGitCommit(personaId: string, message: string): Promise<{ hash: string }> { + const response = await this.request({ + command: 'code/git-commit', + persona_id: personaId, + message, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to create commit'); + } + + return response.result as { hash: string }; + } + + /** + * Push to remote. + */ + async codeGitPush(personaId: string, remote?: string, branch?: string): Promise<{ output: string }> { + const response = await this.request({ + command: 'code/git-push', + persona_id: personaId, + remote: remote ?? '', + branch: branch ?? '', + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to push'); + } + + return response.result as { output: string }; + } + + // ── Shell Session Methods ────────────────────────────────────── + + /** + * Create a shell session for a workspace. + */ + async shellCreate(personaId: string, workspaceRoot: string): Promise { + const response = await this.request({ + command: 'code/shell-create', + persona_id: personaId, + workspace_root: workspaceRoot, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to create shell session'); + } + + return response.result as ShellSessionInfo; + } + + /** + * Execute a command in a shell session. + * + * Two modes: + * - `wait: false` (default) β€” returns immediately with execution handle. Poll for output. + * - `wait: true` β€” blocks until completion, returns full stdout/stderr. + */ + async shellExecute( + personaId: string, + cmd: string, + options?: { timeoutMs?: number; wait?: boolean }, + ): Promise { + const response = await this.request({ + command: 'code/shell-execute', + persona_id: personaId, + cmd, + timeout_ms: options?.timeoutMs ?? null, + wait: options?.wait ?? false, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to execute command'); + } + + return response.result as ShellExecuteResponse; + } + + /** + * Poll an execution for new output since last poll. + * Call repeatedly until `finished` is true. + */ + async shellPoll(personaId: string, executionId: string): Promise { + const response = await this.request({ + command: 'code/shell-poll', + persona_id: personaId, + execution_id: executionId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to poll execution'); + } + + return response.result as ShellPollResponse; + } + + /** + * Kill a running execution. + */ + async shellKill(personaId: string, executionId: string): Promise { + const response = await this.request({ + command: 'code/shell-kill', + persona_id: personaId, + execution_id: executionId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to kill execution'); + } + } + + /** + * Change shell session working directory. + */ + async shellCd(personaId: string, path: string): Promise<{ cwd: string }> { + const response = await this.request({ + command: 'code/shell-cd', + persona_id: personaId, + path, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to change directory'); + } + + return response.result as { cwd: string }; + } + + /** + * Get shell session status/info. + */ + async shellStatus(personaId: string): Promise { + const response = await this.request({ + command: 'code/shell-status', + persona_id: personaId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to get shell status'); + } + + return response.result as ShellSessionInfo; + } + + /** + * Destroy a shell session (kills all running executions). + */ + async shellDestroy(personaId: string): Promise { + const response = await this.request({ + command: 'code/shell-destroy', + persona_id: personaId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to destroy shell session'); + } + } + + /** + * Watch a shell execution for new output. + * Blocks until output is available β€” no timeout, no polling. + * Returns classified output lines filtered through sentinel rules. + */ + async shellWatch(personaId: string, executionId: string): Promise { + const response = await this.request({ + command: 'code/shell-watch', + persona_id: personaId, + execution_id: executionId, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to watch execution'); + } + + return response.result as ShellWatchResponse; + } + + /** + * Configure sentinel filter rules on a shell execution. + * Rules classify output lines and control which are emitted or suppressed during watch. + */ + async shellSentinel(personaId: string, executionId: string, rules: SentinelRule[]): Promise<{ applied: boolean; ruleCount: number }> { + const response = await this.request({ + command: 'code/shell-sentinel', + persona_id: personaId, + execution_id: executionId, + rules, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to set sentinel rules'); + } + + return response.result as { applied: boolean; ruleCount: number }; + } + + // ======================================================================== + // Model Discovery Methods + // ======================================================================== + + /** + * Discover model metadata from provider APIs. + * ALL HTTP I/O runs in Rust (off Node.js main thread). + * Returns discovered models for ModelRegistry population. + */ + async modelsDiscover(providers: Array<{ + provider_id: string; + api_key: string; + base_url: string; + static_models?: Array<{ + id: string; + context_window: number; + max_output_tokens?: number; + capabilities?: string[]; + cost_per_1k_tokens?: { input: number; output: number }; + }>; + }>): Promise<{ + models: Array<{ + modelId: string; + contextWindow: number; + maxOutputTokens?: number; + provider: string; + capabilities?: string[]; + costPer1kTokens?: { input: number; output: number }; + discoveredAt: number; + }>; + count: number; + providers: number; + }> { + const response = await this.request({ + command: 'models/discover', + providers, + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to discover models'); + } + + return response.result as { + models: Array<{ + modelId: string; + contextWindow: number; + maxOutputTokens?: number; + provider: string; + capabilities?: string[]; + costPer1kTokens?: { input: number; output: number }; + discoveredAt: number; + }>; + count: number; + providers: number; + }; + } + /** * Disconnect from server */ diff --git a/src/debug/jtag/workers/continuum-core/src/code/change_graph.rs b/src/debug/jtag/workers/continuum-core/src/code/change_graph.rs new file mode 100644 index 000000000..c164de5d8 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/change_graph.rs @@ -0,0 +1,427 @@ +//! Change Graph β€” DAG of file operations with undo/redo capability. +//! +//! Every file operation (create, write, edit, delete) creates a ChangeNode +//! in the graph. Each node stores forward and reverse diffs, enabling +//! point-in-time undo of any operation. +//! +//! Uses DashMap for lock-free concurrent access β€” multiple personas can +//! operate on different workspaces simultaneously with zero contention. + +use dashmap::DashMap; +use parking_lot::RwLock; +use uuid::Uuid; + +use super::types::{ChangeNode, FileOperation, FileDiff}; + +/// Per-workspace DAG of change operations. +/// +/// Thread-safe: DashMap for node storage, RwLock for ordered indices. +/// Each workspace gets its own ChangeGraph instance. +pub struct ChangeGraph { + workspace_id: String, + /// Primary storage: node ID β†’ ChangeNode + nodes: DashMap, + /// File index: file_path β†’ ordered list of node IDs (append-only) + file_index: DashMap>, + /// Chronological order of all node IDs (most recent last) + chronological: RwLock>, +} + +impl ChangeGraph { + /// Create a new empty change graph for a workspace. + pub fn new(workspace_id: &str) -> Self { + Self { + workspace_id: workspace_id.to_string(), + nodes: DashMap::new(), + file_index: DashMap::new(), + chronological: RwLock::new(Vec::new()), + } + } + + /// Record a new change node in the graph. + /// + /// Adds the node to primary storage, file index, and chronological order. + /// The caller is responsible for constructing the ChangeNode with correct + /// parent_ids, diffs, etc. + pub fn record(&self, node: ChangeNode) { + let id = node.id; + let file_path = node.file_path.clone(); + + // Insert into primary storage + self.nodes.insert(id, node); + + // Update file index + self.file_index + .entry(file_path) + .or_default() + .push(id); + + // Append to chronological order + self.chronological.write().push(id); + } + + /// Get a specific change node by ID. + pub fn get(&self, id: &Uuid) -> Option { + self.nodes.get(id).map(|r| r.clone()) + } + + /// Get the reverse diff for a specific change (for undo). + /// + /// Returns `(reverse_diff, file_path)` so the caller can apply the + /// reverse diff to restore the file. Returns None if node not found. + pub fn reverse_diff_for(&self, id: &Uuid) -> Option<(FileDiff, String)> { + self.nodes + .get(id) + .map(|node| (node.reverse_diff.clone(), node.file_path.clone())) + } + + /// Record an undo operation. + /// + /// Creates a new ChangeNode that reverses the target node. + /// The reverse node's forward_diff is the target's reverse_diff (and vice versa). + pub fn record_undo(&self, target_id: Uuid, author_id: &str) -> Option { + let target = self.nodes.get(&target_id)?; + + let undo_node = ChangeNode { + id: Uuid::new_v4(), + parent_ids: vec![target_id], + author_id: author_id.to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + file_path: target.file_path.clone(), + operation: FileOperation::Undo { + reverted_id: target_id, + }, + // Swap forward/reverse: undo's forward is the original's reverse + forward_diff: target.reverse_diff.clone(), + reverse_diff: target.forward_diff.clone(), + description: Some(format!("Undo: {}", target.description.as_deref().unwrap_or("previous change"))), + workspace_id: self.workspace_id.clone(), + }; + + drop(target); // Release DashMap ref before mutating + let result = undo_node.clone(); + self.record(undo_node); + Some(result) + } + + /// Get the last N change node IDs in reverse chronological order (for undo_last). + /// + /// Skips nodes that are already undo operations to avoid undo-of-undo chains. + pub fn last_n_undoable(&self, count: usize) -> Vec { + let chrono = self.chronological.read(); + chrono + .iter() + .rev() + .filter(|id| { + self.nodes + .get(id) + .map(|n| !matches!(n.operation, FileOperation::Undo { .. })) + .unwrap_or(false) + }) + .take(count) + .copied() + .collect() + } + + /// Get change history for a specific file, most recent first. + pub fn file_history(&self, file_path: &str, limit: usize) -> Vec { + let ids = match self.file_index.get(file_path) { + Some(ids) => ids.clone(), + None => return Vec::new(), + }; + + ids.iter() + .rev() + .take(limit) + .filter_map(|id| self.nodes.get(id).map(|r| r.clone())) + .collect() + } + + /// Get the most recent change node for a file. + pub fn latest_for_file(&self, file_path: &str) -> Option { + let ids = self.file_index.get(file_path)?; + let last_id = ids.last()?; + self.nodes.get(last_id).map(|r| r.clone()) + } + + /// Get all change history for the workspace, most recent first. + pub fn workspace_history(&self, limit: usize) -> Vec { + let chrono = self.chronological.read(); + chrono + .iter() + .rev() + .take(limit) + .filter_map(|id| self.nodes.get(id).map(|r| r.clone())) + .collect() + } + + /// Walk the DAG backwards from a node, collecting all ancestors. + /// + /// Uses BFS to handle the DAG structure (nodes can have multiple parents). + /// Returns ancestors in breadth-first order (immediate parents first). + pub fn ancestors(&self, node_id: &Uuid) -> Vec { + let mut result = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut queue = std::collections::VecDeque::new(); + + // Seed with the starting node's parents + if let Some(node) = self.nodes.get(node_id) { + for parent_id in &node.parent_ids { + if visited.insert(*parent_id) { + queue.push_back(*parent_id); + } + } + } + + while let Some(current_id) = queue.pop_front() { + if let Some(node) = self.nodes.get(¤t_id) { + for parent_id in &node.parent_ids { + if visited.insert(*parent_id) { + queue.push_back(*parent_id); + } + } + result.push(node.clone()); + } + } + + result + } + + /// Total number of change nodes in the graph. + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Whether the graph is empty. + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// List all files that have been modified in this workspace. + pub fn modified_files(&self) -> Vec { + self.file_index + .iter() + .map(|entry| entry.key().clone()) + .collect() + } + + /// Get the workspace ID this graph belongs to. + pub fn workspace_id(&self) -> &str { + &self.workspace_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::code::types::{FileDiff, DiffHunk}; + + fn make_diff(content: &str) -> FileDiff { + FileDiff { + unified: content.to_string(), + hunks: vec![DiffHunk { + old_start: 1, + old_count: 1, + new_start: 1, + new_count: 1, + content: content.to_string(), + }], + } + } + + fn make_node(file_path: &str, description: &str) -> ChangeNode { + ChangeNode { + id: Uuid::new_v4(), + parent_ids: Vec::new(), + author_id: "test-persona".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + file_path: file_path.to_string(), + operation: FileOperation::Write, + forward_diff: make_diff("+new content"), + reverse_diff: make_diff("-new content"), + description: Some(description.to_string()), + workspace_id: "test-workspace".to_string(), + } + } + + #[test] + fn test_record_and_get() { + let graph = ChangeGraph::new("test-workspace"); + let node = make_node("src/main.ts", "Initial write"); + let id = node.id; + + graph.record(node); + + let retrieved = graph.get(&id).unwrap(); + assert_eq!(retrieved.id, id); + assert_eq!(retrieved.file_path, "src/main.ts"); + assert_eq!(graph.len(), 1); + } + + #[test] + fn test_file_history() { + let graph = ChangeGraph::new("test-workspace"); + + let node1 = make_node("src/main.ts", "First edit"); + let node2 = make_node("src/main.ts", "Second edit"); + let node3 = make_node("src/other.ts", "Other file edit"); + + let id1 = node1.id; + let id2 = node2.id; + + graph.record(node1); + graph.record(node2); + graph.record(node3); + + let history = graph.file_history("src/main.ts", 10); + assert_eq!(history.len(), 2); + // Most recent first + assert_eq!(history[0].id, id2); + assert_eq!(history[1].id, id1); + } + + #[test] + fn test_workspace_history() { + let graph = ChangeGraph::new("test-workspace"); + + let node1 = make_node("src/a.ts", "Edit a"); + let node2 = make_node("src/b.ts", "Edit b"); + let node3 = make_node("src/c.ts", "Edit c"); + + let id1 = node1.id; + let id3 = node3.id; + + graph.record(node1); + graph.record(node2); + graph.record(node3); + + let history = graph.workspace_history(2); + assert_eq!(history.len(), 2); + assert_eq!(history[0].id, id3); // Most recent + assert_eq!(history[1].description.as_deref(), Some("Edit b")); + + let all = graph.workspace_history(100); + assert_eq!(all.len(), 3); + assert_eq!(all[2].id, id1); // Oldest + } + + #[test] + fn test_undo_creates_reverse_node() { + let graph = ChangeGraph::new("test-workspace"); + + let original = make_node("src/main.ts", "Original write"); + let original_id = original.id; + let original_forward = original.forward_diff.unified.clone(); + let original_reverse = original.reverse_diff.unified.clone(); + + graph.record(original); + + let undo_node = graph.record_undo(original_id, "undo-persona").unwrap(); + + // Undo node's forward diff should be original's reverse diff + assert_eq!(undo_node.forward_diff.unified, original_reverse); + // Undo node's reverse diff should be original's forward diff + assert_eq!(undo_node.reverse_diff.unified, original_forward); + assert!(matches!(undo_node.operation, FileOperation::Undo { reverted_id } if reverted_id == original_id)); + assert_eq!(graph.len(), 2); + } + + #[test] + fn test_last_n_undoable_skips_undo_nodes() { + let graph = ChangeGraph::new("test-workspace"); + + let node1 = make_node("src/a.ts", "Edit 1"); + let node2 = make_node("src/b.ts", "Edit 2"); + let id1 = node1.id; + let id2 = node2.id; + + graph.record(node1); + graph.record(node2); + + // Create an undo (which adds a 3rd node) + graph.record_undo(id1, "persona"); + + // last_n_undoable should skip the undo node + let undoable = graph.last_n_undoable(5); + assert_eq!(undoable.len(), 2); + assert_eq!(undoable[0], id2); // Most recent non-undo + assert_eq!(undoable[1], id1); // Older non-undo + } + + #[test] + fn test_ancestors_walks_dag() { + let graph = ChangeGraph::new("test-workspace"); + + // Create a chain: root β†’ child β†’ grandchild + let root = make_node("src/main.ts", "Root"); + let root_id = root.id; + graph.record(root); + + let mut child = make_node("src/main.ts", "Child"); + child.parent_ids = vec![root_id]; + let child_id = child.id; + graph.record(child); + + let mut grandchild = make_node("src/main.ts", "Grandchild"); + grandchild.parent_ids = vec![child_id]; + let grandchild_id = grandchild.id; + graph.record(grandchild); + + let ancestors = graph.ancestors(&grandchild_id); + assert_eq!(ancestors.len(), 2); + // BFS: child first, then root + assert_eq!(ancestors[0].id, child_id); + assert_eq!(ancestors[1].id, root_id); + } + + #[test] + fn test_latest_for_file() { + let graph = ChangeGraph::new("test-workspace"); + + let node1 = make_node("src/main.ts", "First"); + let node2 = make_node("src/main.ts", "Second"); + let id2 = node2.id; + + graph.record(node1); + graph.record(node2); + + let latest = graph.latest_for_file("src/main.ts").unwrap(); + assert_eq!(latest.id, id2); + } + + #[test] + fn test_modified_files() { + let graph = ChangeGraph::new("test-workspace"); + + graph.record(make_node("src/a.ts", "Edit a")); + graph.record(make_node("src/b.ts", "Edit b")); + graph.record(make_node("src/a.ts", "Edit a again")); + + let mut files = graph.modified_files(); + files.sort(); + assert_eq!(files, vec!["src/a.ts", "src/b.ts"]); + } + + #[test] + fn test_empty_graph() { + let graph = ChangeGraph::new("test-workspace"); + assert!(graph.is_empty()); + assert_eq!(graph.len(), 0); + assert!(graph.file_history("any.ts", 10).is_empty()); + assert!(graph.workspace_history(10).is_empty()); + assert!(graph.latest_for_file("any.ts").is_none()); + assert!(graph.last_n_undoable(5).is_empty()); + } + + #[test] + fn test_undo_nonexistent_returns_none() { + let graph = ChangeGraph::new("test-workspace"); + assert!(graph.record_undo(Uuid::new_v4(), "persona").is_none()); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/diff_engine.rs b/src/debug/jtag/workers/continuum-core/src/code/diff_engine.rs new file mode 100644 index 000000000..189cc7515 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/diff_engine.rs @@ -0,0 +1,175 @@ +//! Diff engine β€” unified diff computation using the `similar` crate. +//! +//! Generates forward and reverse diffs for file operations, +//! enabling the change graph's undo/redo capability. + +use similar::{ChangeTag, DiffTag, TextDiff}; + +use super::types::{DiffHunk, FileDiff}; + +/// Compute a FileDiff between two strings. +/// Returns both the unified text representation and structured hunks. +pub fn compute_diff(old: &str, new: &str, file_path: &str) -> FileDiff { + let diff = TextDiff::from_lines(old, new); + + // Generate unified diff text + let unified = diff + .unified_diff() + .context_radius(3) + .header(&format!("a/{}", file_path), &format!("b/{}", file_path)) + .to_string(); + + // Build structured hunks + let hunks = build_hunks(&diff); + + FileDiff { unified, hunks } +} + +/// Compute forward (oldβ†’new) and reverse (newβ†’old) diffs simultaneously. +pub fn compute_bidirectional_diff( + old: &str, + new: &str, + file_path: &str, +) -> (FileDiff, FileDiff) { + let forward = compute_diff(old, new, file_path); + let reverse = compute_diff(new, old, file_path); + (forward, reverse) +} + +/// Build structured DiffHunks from a TextDiff. +fn build_hunks(diff: &TextDiff<'_, '_, '_, str>) -> Vec { + let mut hunks = Vec::new(); + + for group in diff.grouped_ops(3) { + let mut old_start = 0u32; + let mut new_start = 0u32; + let mut old_count = 0u32; + let mut new_count = 0u32; + let mut content = String::new(); + + for op in &group { + if old_start == 0 && new_start == 0 { + old_start = op.old_range().start as u32 + 1; + new_start = op.new_range().start as u32 + 1; + } + + match op.tag() { + DiffTag::Equal => { + let count = op.old_range().len() as u32; + old_count += count; + new_count += count; + for value in diff.iter_changes(op) { + content.push(' '); + content.push_str(value.as_str().unwrap_or("")); + } + } + DiffTag::Delete => { + let count = op.old_range().len() as u32; + old_count += count; + for value in diff.iter_changes(op) { + content.push('-'); + content.push_str(value.as_str().unwrap_or("")); + } + } + DiffTag::Insert => { + let count = op.new_range().len() as u32; + new_count += count; + for value in diff.iter_changes(op) { + content.push('+'); + content.push_str(value.as_str().unwrap_or("")); + } + } + DiffTag::Replace => { + // Replace = Delete + Insert combined + old_count += op.old_range().len() as u32; + new_count += op.new_range().len() as u32; + for value in diff.iter_changes(op) { + match value.tag() { + ChangeTag::Delete => { + content.push('-'); + content.push_str(value.as_str().unwrap_or("")); + } + ChangeTag::Insert => { + content.push('+'); + content.push_str(value.as_str().unwrap_or("")); + } + ChangeTag::Equal => { + content.push(' '); + content.push_str(value.as_str().unwrap_or("")); + } + } + } + } + } + } + + if !content.is_empty() { + hunks.push(DiffHunk { + old_start, + old_count, + new_start, + new_count, + content, + }); + } + } + + hunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_diff_no_changes() { + let text = "line 1\nline 2\nline 3\n"; + let diff = compute_diff(text, text, "test.ts"); + assert!(diff.unified.is_empty() || !diff.unified.contains('-')); + assert!(diff.hunks.is_empty()); + } + + #[test] + fn test_compute_diff_simple_edit() { + let old = "line 1\nline 2\nline 3\n"; + let new = "line 1\nline 2 modified\nline 3\n"; + let diff = compute_diff(old, new, "test.ts"); + assert!(diff.unified.contains("-line 2")); + assert!(diff.unified.contains("+line 2 modified")); + assert!(!diff.hunks.is_empty()); + } + + #[test] + fn test_compute_diff_add_lines() { + let old = "line 1\nline 3\n"; + let new = "line 1\nline 2\nline 3\n"; + let diff = compute_diff(old, new, "test.ts"); + assert!(diff.unified.contains("+line 2")); + } + + #[test] + fn test_compute_diff_delete_lines() { + let old = "line 1\nline 2\nline 3\n"; + let new = "line 1\nline 3\n"; + let diff = compute_diff(old, new, "test.ts"); + assert!(diff.unified.contains("-line 2")); + } + + #[test] + fn test_bidirectional_diff() { + let old = "hello\nworld\n"; + let new = "hello\nrust\n"; + let (forward, reverse) = compute_bidirectional_diff(old, new, "test.ts"); + assert!(forward.unified.contains("-world")); + assert!(forward.unified.contains("+rust")); + assert!(reverse.unified.contains("-rust")); + assert!(reverse.unified.contains("+world")); + } + + #[test] + fn test_create_file_diff() { + let (forward, reverse) = compute_bidirectional_diff("", "new content\n", "test.ts"); + assert!(forward.unified.contains("+new content")); + assert!(reverse.unified.contains("-new content")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/file_engine.rs b/src/debug/jtag/workers/continuum-core/src/code/file_engine.rs new file mode 100644 index 000000000..5fe51c618 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/file_engine.rs @@ -0,0 +1,905 @@ +//! File Engine β€” per-persona file operations with change tracking. +//! +//! Every write/edit/delete operation creates a ChangeNode in the change graph, +//! enabling undo at any point. Reads are side-effect-free. +//! +//! Thread safety: Each persona gets their own FileEngine instance. +//! The underlying ChangeGraph and PathSecurity handle concurrency. + +use std::fs; +use std::path::PathBuf; + +use uuid::Uuid; + +use super::change_graph::ChangeGraph; +use super::diff_engine::compute_bidirectional_diff; +use super::path_security::{PathSecurity, PathSecurityError}; +use super::types::*; + +/// Per-persona file engine with workspace scoping and change tracking. +pub struct FileEngine { + persona_id: String, + security: PathSecurity, + graph: ChangeGraph, +} + +/// Errors from file engine operations. +#[derive(Debug)] +pub enum FileEngineError { + Security(PathSecurityError), + Io(std::io::Error), + NotFound(String), + EditFailed(String), +} + +impl std::fmt::Display for FileEngineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Security(e) => write!(f, "Security: {}", e), + Self::Io(e) => write!(f, "I/O: {}", e), + Self::NotFound(path) => write!(f, "File not found: {}", path), + Self::EditFailed(msg) => write!(f, "Edit failed: {}", msg), + } + } +} + +impl std::error::Error for FileEngineError {} + +impl From for FileEngineError { + fn from(e: PathSecurityError) -> Self { + Self::Security(e) + } +} + +impl From for FileEngineError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl FileEngine { + /// Create a new FileEngine for a persona. + pub fn new(persona_id: &str, security: PathSecurity) -> Self { + let workspace_id = format!("workspace-{}", persona_id); + Self { + persona_id: persona_id.to_string(), + security, + graph: ChangeGraph::new(&workspace_id), + } + } + + /// Read a file, optionally a range of lines (1-indexed, inclusive). + pub fn read( + &self, + relative_path: &str, + start_line: Option, + end_line: Option, + ) -> Result { + let abs_path = self.security.validate_read(relative_path)?; + + if !abs_path.exists() { + return Err(FileEngineError::NotFound(relative_path.to_string())); + } + + let content = fs::read_to_string(&abs_path)?; + let total_lines = content.lines().count() as u32; + let size_bytes = content.len() as u64; + + let start = start_line.unwrap_or(1).max(1); + let end = end_line.unwrap_or(total_lines).min(total_lines); + + let selected: String = content + .lines() + .enumerate() + .filter(|(i, _)| { + let line_num = *i as u32 + 1; + line_num >= start && line_num <= end + }) + .map(|(_, line)| line) + .collect::>() + .join("\n"); + + let lines_returned = if end >= start { end - start + 1 } else { 0 }; + + Ok(ReadResult { + success: true, + content: Some(if selected.is_empty() && total_lines > 0 { + // If the file has content but selection is empty, return empty + String::new() + } else { + selected + }), + file_path: relative_path.to_string(), + total_lines, + lines_returned, + start_line: start, + end_line: end, + size_bytes, + error: None, + }) + } + + /// Write (create or overwrite) a file. Records a ChangeNode. + pub fn write( + &self, + relative_path: &str, + content: &str, + description: Option<&str>, + ) -> Result { + let abs_path = self.security.validate_write(relative_path)?; + self.security.validate_size(relative_path, content.len() as u64)?; + + // Read old content (empty string for new files) + let old_content = if abs_path.exists() { + fs::read_to_string(&abs_path).unwrap_or_default() + } else { + String::new() + }; + + let operation = if abs_path.exists() { + FileOperation::Write + } else { + FileOperation::Create + }; + + // Compute diffs + let (forward_diff, reverse_diff) = + compute_bidirectional_diff(&old_content, content, relative_path); + + // Create parent directories if needed + if let Some(parent) = abs_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + // Write the file + fs::write(&abs_path, content)?; + + // Record in change graph + let node = ChangeNode { + id: Uuid::new_v4(), + parent_ids: self.latest_parent(relative_path), + author_id: self.persona_id.clone(), + timestamp: now_millis(), + file_path: relative_path.to_string(), + operation, + forward_diff, + reverse_diff, + description: description.map(String::from), + workspace_id: self.graph.workspace_id().to_string(), + }; + + let change_id = node.id.to_string(); + self.graph.record(node); + + Ok(WriteResult { + success: true, + change_id: Some(change_id), + file_path: relative_path.to_string(), + bytes_written: content.len() as u64, + error: None, + }) + } + + /// Edit a file using an EditMode. Records a ChangeNode. + pub fn edit( + &self, + relative_path: &str, + edit_mode: &EditMode, + description: Option<&str>, + ) -> Result { + let abs_path = self.security.validate_write(relative_path)?; + + if !abs_path.exists() { + return Err(FileEngineError::NotFound(relative_path.to_string())); + } + + let old_content = fs::read_to_string(&abs_path)?; + let new_content = apply_edit(&old_content, edit_mode)?; + + self.security.validate_size(relative_path, new_content.len() as u64)?; + + // Compute diffs + let (forward_diff, reverse_diff) = + compute_bidirectional_diff(&old_content, &new_content, relative_path); + + // Write the modified file + fs::write(&abs_path, &new_content)?; + + // Record in change graph + let node = ChangeNode { + id: Uuid::new_v4(), + parent_ids: self.latest_parent(relative_path), + author_id: self.persona_id.clone(), + timestamp: now_millis(), + file_path: relative_path.to_string(), + operation: FileOperation::Edit, + forward_diff, + reverse_diff, + description: description.map(String::from), + workspace_id: self.graph.workspace_id().to_string(), + }; + + let change_id = node.id.to_string(); + let bytes_written = new_content.len() as u64; + self.graph.record(node); + + Ok(WriteResult { + success: true, + change_id: Some(change_id), + file_path: relative_path.to_string(), + bytes_written, + error: None, + }) + } + + /// Delete a file. Records a ChangeNode with the full content as reverse diff. + pub fn delete( + &self, + relative_path: &str, + description: Option<&str>, + ) -> Result { + let abs_path = self.security.validate_write(relative_path)?; + + if !abs_path.exists() { + return Err(FileEngineError::NotFound(relative_path.to_string())); + } + + let old_content = fs::read_to_string(&abs_path)?; + + // Compute diffs (new content is empty for delete) + let (forward_diff, reverse_diff) = + compute_bidirectional_diff(&old_content, "", relative_path); + + // Delete the file + fs::remove_file(&abs_path)?; + + // Record in change graph + let node = ChangeNode { + id: Uuid::new_v4(), + parent_ids: self.latest_parent(relative_path), + author_id: self.persona_id.clone(), + timestamp: now_millis(), + file_path: relative_path.to_string(), + operation: FileOperation::Delete, + forward_diff, + reverse_diff, + description: description.map(String::from), + workspace_id: self.graph.workspace_id().to_string(), + }; + + let change_id = node.id.to_string(); + self.graph.record(node); + + Ok(WriteResult { + success: true, + change_id: Some(change_id), + file_path: relative_path.to_string(), + bytes_written: 0, + error: None, + }) + } + + /// Preview what an edit would produce (unified diff) without applying it. + pub fn preview_diff( + &self, + relative_path: &str, + edit_mode: &EditMode, + ) -> Result { + let abs_path = self.security.validate_read(relative_path)?; + + if !abs_path.exists() { + return Err(FileEngineError::NotFound(relative_path.to_string())); + } + + let old_content = fs::read_to_string(&abs_path)?; + let new_content = apply_edit(&old_content, edit_mode)?; + + let (forward_diff, _) = + compute_bidirectional_diff(&old_content, &new_content, relative_path); + + Ok(forward_diff) + } + + /// Undo a specific change by applying its reverse diff. + pub fn undo(&self, change_id: &Uuid) -> Result { + let (reverse_diff, file_path) = self + .graph + .reverse_diff_for(change_id) + .ok_or_else(|| FileEngineError::EditFailed(format!("Change {} not found", change_id)))?; + + // Read current file content + let abs_path = self.security.validate_write(&file_path)?; + let current_content = if abs_path.exists() { + fs::read_to_string(&abs_path)? + } else { + String::new() + }; + + // The reverse diff's unified text tells us what to apply. + // For a proper undo, we use the stored old content from the original node. + let original_node = self + .graph + .get(change_id) + .ok_or_else(|| FileEngineError::EditFailed(format!("Change {} not found", change_id)))?; + + // Reconstruct: the original node's reverse_diff goes oldβ†’new when applied backward. + // We apply the reverse_diff to the current content. Since we stored the complete + // forward and reverse diffs, we can reconstruct by computing what the content + // should be by using the reverse operation's forward diff. + // + // For simple cases (createβ†’undo = delete, writeβ†’undo = restore old): + // The undo node created by ChangeGraph has the correct forward_diff. + let undo_node = self + .graph + .record_undo(*change_id, &self.persona_id) + .ok_or_else(|| FileEngineError::EditFailed(format!("Change {} not found for undo", change_id)))?; + + // For the undo, we need to apply the reverse diff to the file. + // The simplest correct approach: re-read the original diff to determine + // what the file should look like after undo. + // + // Since the reverse diff might not apply cleanly if other changes happened, + // we do a best-effort: if the change was the latest for this file, apply the + // reverse content directly; otherwise, warn about conflicts. + let latest = self.graph.latest_for_file(&file_path); + let is_latest = latest + .as_ref() + .map(|n| n.id == undo_node.id) + .unwrap_or(false); + + // Apply the reverse diff content β€” use the unified diff text + // For now, use a simple heuristic: if we can identify the old content, + // reconstruct it from the diff hunks. + let _restored_content = if !reverse_diff.unified.is_empty() { + // The reverse diff exists, attempt to apply + apply_reverse_simple(¤t_content, &reverse_diff) + .unwrap_or_else(|| current_content.clone()) + } else { + current_content.clone() + }; + + // Write the restored content + if original_node.operation == FileOperation::Create { + // Undoing a create = delete the file + if abs_path.exists() { + fs::remove_file(&abs_path)?; + } + } else if matches!(original_node.operation, FileOperation::Delete) { + // Undoing a delete = recreate the file with reverse diff content + // The reverse_diff for a delete contains the original content + let content = extract_added_content(&reverse_diff); + if let Some(parent) = abs_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + fs::write(&abs_path, content)?; + } else { + // Undoing a write/edit = apply reverse diff + let restored = apply_reverse_simple(¤t_content, &reverse_diff) + .unwrap_or_else(|| current_content.clone()); + fs::write(&abs_path, &restored)?; + } + + Ok(WriteResult { + success: true, + change_id: Some(undo_node.id.to_string()), + file_path, + bytes_written: 0, + error: if !is_latest { + Some("Warning: undone change was not the latest; result may have conflicts".to_string()) + } else { + None + }, + }) + } + + /// Undo the last N non-undo operations. + pub fn undo_last(&self, count: usize) -> Result { + let ids = self.graph.last_n_undoable(count); + let mut changes_undone = Vec::new(); + + for id in ids { + match self.undo(&id) { + Ok(result) => changes_undone.push(result), + Err(e) => { + return Ok(UndoResult { + success: false, + changes_undone, + error: Some(format!("Failed to undo {}: {}", id, e)), + }); + } + } + } + + Ok(UndoResult { + success: true, + changes_undone, + error: None, + }) + } + + /// Get change history for a specific file. + pub fn file_history(&self, file_path: &str, limit: usize) -> HistoryResult { + let nodes = self.graph.file_history(file_path, limit); + let total_count = nodes.len() as u32; + HistoryResult { + success: true, + nodes, + total_count, + error: None, + } + } + + /// Get all change history for the workspace. + pub fn workspace_history(&self, limit: usize) -> HistoryResult { + let nodes = self.graph.workspace_history(limit); + let total_count = nodes.len() as u32; + HistoryResult { + success: true, + nodes, + total_count, + error: None, + } + } + + /// Get the underlying PathSecurity (for search/tree operations that need it). + pub fn security(&self) -> &PathSecurity { + &self.security + } + + /// Get the workspace root path. + pub fn workspace_root(&self) -> PathBuf { + self.security.workspace_root().to_path_buf() + } + + /// Get the latest parent ID for a file (for DAG edges). + fn latest_parent(&self, file_path: &str) -> Vec { + self.graph + .latest_for_file(file_path) + .map(|n| vec![n.id]) + .unwrap_or_default() + } +} + +/// Apply an EditMode to file content, producing the new content. +fn apply_edit(content: &str, edit_mode: &EditMode) -> Result { + match edit_mode { + EditMode::LineRange { + start_line, + end_line, + new_content, + } => { + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len() as u32; + + if *start_line == 0 || *start_line > total + 1 { + return Err(FileEngineError::EditFailed(format!( + "start_line {} out of range (1-{})", + start_line, + total + 1 + ))); + } + if *end_line < *start_line || *end_line > total { + return Err(FileEngineError::EditFailed(format!( + "end_line {} out of range ({}-{})", + end_line, start_line, total + ))); + } + + let start_idx = (*start_line - 1) as usize; + let end_idx = *end_line as usize; + + let mut result = String::new(); + + // Lines before the range + for line in &lines[..start_idx] { + result.push_str(line); + result.push('\n'); + } + + // Insert new content + if !new_content.is_empty() { + result.push_str(new_content); + if !new_content.ends_with('\n') { + result.push('\n'); + } + } + + // Lines after the range + for line in &lines[end_idx..] { + result.push_str(line); + result.push('\n'); + } + + // Preserve trailing newline behavior + if !content.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } + + EditMode::SearchReplace { + search, + replace, + all, + } => { + if !content.contains(search.as_str()) { + return Err(FileEngineError::EditFailed(format!( + "Search text not found: '{}'", + if search.len() > 50 { + format!("{}...", &search[..50]) + } else { + search.clone() + } + ))); + } + + let result = if *all { + content.replace(search.as_str(), replace.as_str()) + } else { + content.replacen(search.as_str(), replace.as_str(), 1) + }; + + Ok(result) + } + + EditMode::InsertAt { line, content: new_content } => { + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len() as u32; + + if *line == 0 || *line > total + 1 { + return Err(FileEngineError::EditFailed(format!( + "Insert line {} out of range (1-{})", + line, + total + 1 + ))); + } + + let insert_idx = (*line - 1) as usize; + let mut result = String::new(); + + for line_str in &lines[..insert_idx] { + result.push_str(line_str); + result.push('\n'); + } + + result.push_str(new_content); + if !new_content.ends_with('\n') { + result.push('\n'); + } + + for line_str in &lines[insert_idx..] { + result.push_str(line_str); + result.push('\n'); + } + + if !content.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } + + EditMode::Append { content: new_content } => { + let mut result = content.to_string(); + if !result.ends_with('\n') && !result.is_empty() { + result.push('\n'); + } + result.push_str(new_content); + Ok(result) + } + } +} + +/// Simple reverse diff application. +/// +/// Extracts removed lines from the diff and added lines from the original, +/// reconstructing the previous content. This handles the common case where +/// the undo target was the most recent change. +fn apply_reverse_simple(current: &str, reverse_diff: &FileDiff) -> Option { + if reverse_diff.hunks.is_empty() { + return None; + } + + // Simple approach: use the unified diff lines. + // Lines starting with '-' in the reverse diff are what to remove from current. + // Lines starting with '+' in the reverse diff are what to add. + // Lines starting with ' ' are context (unchanged). + let mut result_lines: Vec = Vec::new(); + let current_lines: Vec<&str> = current.lines().collect(); + let mut current_idx = 0; + + for hunk in &reverse_diff.hunks { + let hunk_start = (hunk.old_start as usize).saturating_sub(1); + + // Copy lines before this hunk + while current_idx < hunk_start && current_idx < current_lines.len() { + result_lines.push(current_lines[current_idx].to_string()); + current_idx += 1; + } + + // Process hunk content + for line in hunk.content.lines() { + if let Some(stripped) = line.strip_prefix('+') { + // Add this line (it's being added by the reverse) + result_lines.push(stripped.to_string()); + } else if let Some(_stripped) = line.strip_prefix('-') { + // Skip this line (it's being removed by the reverse) + current_idx += 1; + } else if let Some(stripped) = line.strip_prefix(' ') { + // Context line + result_lines.push(stripped.to_string()); + current_idx += 1; + } + } + } + + // Copy remaining lines + while current_idx < current_lines.len() { + result_lines.push(current_lines[current_idx].to_string()); + current_idx += 1; + } + + let mut result = result_lines.join("\n"); + if current.ends_with('\n') && !result.ends_with('\n') { + result.push('\n'); + } + + Some(result) +} + +/// Extract added content from a diff (lines starting with '+'). +/// Used for reconstructing files on undo of delete. +fn extract_added_content(diff: &FileDiff) -> String { + let mut lines = Vec::new(); + for hunk in &diff.hunks { + for line in hunk.content.lines() { + if let Some(stripped) = line.strip_prefix('+') { + lines.push(stripped); + } + } + } + let mut result = lines.join("\n"); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result +} + +/// Get current time in milliseconds since epoch. +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_engine() -> (tempfile::TempDir, FileEngine) { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/main.ts"), + "line 1\nline 2\nline 3\n", + ) + .unwrap(); + + let security = PathSecurity::new(dir.path()).unwrap(); + let engine = FileEngine::new("test-persona", security); + (dir, engine) + } + + #[test] + fn test_read_full_file() { + let (_dir, engine) = setup_engine(); + let result = engine.read("src/main.ts", None, None).unwrap(); + assert!(result.success); + assert_eq!(result.total_lines, 3); + assert!(result.content.unwrap().contains("line 1")); + } + + #[test] + fn test_read_line_range() { + let (_dir, engine) = setup_engine(); + let result = engine.read("src/main.ts", Some(2), Some(2)).unwrap(); + assert!(result.success); + assert_eq!(result.lines_returned, 1); + assert_eq!(result.content.unwrap(), "line 2"); + } + + #[test] + fn test_read_nonexistent() { + let (_dir, engine) = setup_engine(); + let result = engine.read("src/nonexistent.ts", None, None); + assert!(result.is_err()); + } + + #[test] + fn test_write_new_file() { + let (_dir, engine) = setup_engine(); + let result = engine + .write("src/new.ts", "export const x = 1;\n", Some("Create new file")) + .unwrap(); + assert!(result.success); + assert!(result.change_id.is_some()); + assert_eq!(result.bytes_written, 20); + + // Verify content + let read = engine.read("src/new.ts", None, None).unwrap(); + assert!(read.content.unwrap().contains("export const x = 1;")); + } + + #[test] + fn test_write_overwrite_creates_diff() { + let (_dir, engine) = setup_engine(); + + // Overwrite existing file + let result = engine + .write("src/main.ts", "modified\n", Some("Overwrite")) + .unwrap(); + assert!(result.success); + + // Check history + let history = engine.file_history("src/main.ts", 10); + assert_eq!(history.nodes.len(), 1); + assert!(history.nodes[0].forward_diff.unified.contains("-line 1")); + assert!(history.nodes[0].forward_diff.unified.contains("+modified")); + } + + #[test] + fn test_edit_search_replace() { + let (_dir, engine) = setup_engine(); + + let result = engine + .edit( + "src/main.ts", + &EditMode::SearchReplace { + search: "line 2".to_string(), + replace: "line 2 modified".to_string(), + all: false, + }, + Some("Modify line 2"), + ) + .unwrap(); + assert!(result.success); + + let read = engine.read("src/main.ts", None, None).unwrap(); + assert!(read.content.unwrap().contains("line 2 modified")); + } + + #[test] + fn test_edit_line_range() { + let (_dir, engine) = setup_engine(); + + let result = engine + .edit( + "src/main.ts", + &EditMode::LineRange { + start_line: 2, + end_line: 2, + new_content: "replaced line".to_string(), + }, + Some("Replace line 2"), + ) + .unwrap(); + assert!(result.success); + + let read = engine.read("src/main.ts", None, None).unwrap(); + let content = read.content.unwrap(); + assert!(content.contains("line 1")); + assert!(content.contains("replaced line")); + assert!(content.contains("line 3")); + assert!(!content.contains("line 2\n")); + } + + #[test] + fn test_edit_insert_at() { + let (_dir, engine) = setup_engine(); + + let result = engine + .edit( + "src/main.ts", + &EditMode::InsertAt { + line: 2, + content: "inserted line".to_string(), + }, + Some("Insert before line 2"), + ) + .unwrap(); + assert!(result.success); + + let read = engine.read("src/main.ts", None, None).unwrap(); + let content = read.content.unwrap(); + assert!(content.contains("line 1\ninserted line\nline 2")); + } + + #[test] + fn test_edit_append() { + let (_dir, engine) = setup_engine(); + + let result = engine + .edit( + "src/main.ts", + &EditMode::Append { + content: "line 4".to_string(), + }, + Some("Append line 4"), + ) + .unwrap(); + assert!(result.success); + + let read = engine.read("src/main.ts", None, None).unwrap(); + assert!(read.content.unwrap().contains("line 4")); + } + + #[test] + fn test_delete_file() { + let (_dir, engine) = setup_engine(); + + let result = engine.delete("src/main.ts", Some("Remove main")).unwrap(); + assert!(result.success); + + let read = engine.read("src/main.ts", None, None); + assert!(read.is_err()); // File should not exist + } + + #[test] + fn test_write_blocked_extension() { + let (_dir, engine) = setup_engine(); + let result = engine.write("src/malware.exe", "bad", None); + assert!(result.is_err()); + } + + #[test] + fn test_preview_diff() { + let (_dir, engine) = setup_engine(); + let diff = engine + .preview_diff( + "src/main.ts", + &EditMode::SearchReplace { + search: "line 2".to_string(), + replace: "LINE TWO".to_string(), + all: false, + }, + ) + .unwrap(); + assert!(diff.unified.contains("-line 2")); + assert!(diff.unified.contains("+LINE TWO")); + } + + #[test] + fn test_workspace_history() { + let (_dir, engine) = setup_engine(); + + engine.write("src/a.ts", "a", Some("Write a")).unwrap(); + engine.write("src/b.ts", "b", Some("Write b")).unwrap(); + + let history = engine.workspace_history(10); + assert_eq!(history.nodes.len(), 2); + assert_eq!(history.nodes[0].description.as_deref(), Some("Write b")); + assert_eq!(history.nodes[1].description.as_deref(), Some("Write a")); + } + + #[test] + fn test_edit_search_not_found() { + let (_dir, engine) = setup_engine(); + let result = engine.edit( + "src/main.ts", + &EditMode::SearchReplace { + search: "nonexistent text".to_string(), + replace: "replacement".to_string(), + all: false, + }, + None, + ); + assert!(result.is_err()); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/git_bridge.rs b/src/debug/jtag/workers/continuum-core/src/code/git_bridge.rs new file mode 100644 index 000000000..770206641 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/git_bridge.rs @@ -0,0 +1,286 @@ +//! Git Bridge β€” git status, diff, and branch operations. +//! +//! Shells out to `git` CLI for operations. This keeps the implementation +//! simple and avoids pulling in libgit2 as a dependency. + +use std::path::Path; +use std::process::Command; + +use super::types::GitStatusInfo; + +/// Get git status for a workspace directory. +/// +/// Returns branch name, modified/added/deleted/untracked files. +pub fn git_status(workspace_root: &Path) -> GitStatusInfo { + // Get current branch + let branch = run_git(workspace_root, &["rev-parse", "--abbrev-ref", "HEAD"]) + .ok() + .map(|s| s.trim().to_string()); + + // Get porcelain status (machine-readable) + let status_output = match run_git(workspace_root, &["status", "--porcelain=v1"]) { + Ok(output) => output, + Err(e) => { + return GitStatusInfo { + success: false, + branch, + modified: Vec::new(), + added: Vec::new(), + deleted: Vec::new(), + untracked: Vec::new(), + error: Some(format!("git status failed: {}", e)), + }; + } + }; + + let mut modified = Vec::new(); + let mut added = Vec::new(); + let mut deleted = Vec::new(); + let mut untracked = Vec::new(); + + for line in status_output.lines() { + if line.len() < 3 { + continue; + } + + let status_code = &line[..2]; + let file_path = line[3..].trim().to_string(); + + // Parse porcelain v1 status codes + // First char = index status, second char = work tree status + match status_code { + "??" => untracked.push(file_path), + s if s.starts_with('A') || s.ends_with('A') => added.push(file_path), + s if s.starts_with('D') || s.ends_with('D') => deleted.push(file_path), + s if s.starts_with('M') || s.ends_with('M') + || s.starts_with('R') || s.ends_with('R') + || s.starts_with('C') || s.ends_with('C') => modified.push(file_path), + _ => { + // Catch-all: treat as modified + if !file_path.is_empty() { + modified.push(file_path); + } + } + } + } + + GitStatusInfo { + success: true, + branch, + modified, + added, + deleted, + untracked, + error: None, + } +} + +/// Get git diff output for the workspace. +/// +/// If `staged` is true, shows staged changes (--cached). +/// Otherwise shows unstaged working tree changes. +pub fn git_diff(workspace_root: &Path, staged: bool) -> Result { + let mut args = vec!["diff"]; + if staged { + args.push("--cached"); + } + run_git(workspace_root, &args) +} + +/// Get git diff against a specific ref (branch, commit, etc.). +pub fn git_diff_ref(workspace_root: &Path, reference: &str) -> Result { + run_git(workspace_root, &["diff", reference]) +} + +/// Get git log (last N commits, one-line format). +pub fn git_log(workspace_root: &Path, count: u32) -> Result { + run_git( + workspace_root, + &["log", &format!("-{}", count), "--oneline", "--no-decorate"], + ) +} + +/// Stage files for commit. +/// +/// Pass specific file paths, or `&["--all"]` / `&["."]` to stage everything. +pub fn git_add(workspace_root: &Path, paths: &[&str]) -> Result { + let mut args = vec!["add"]; + args.extend_from_slice(paths); + run_git(workspace_root, &args) +} + +/// Create a commit with the given message. +/// +/// Returns the full commit hash on success. +pub fn git_commit(workspace_root: &Path, message: &str) -> Result { + // Commit (skip hooks β€” AI-authored commits are verified separately) + run_git(workspace_root, &["commit", "--no-verify", "-m", message])?; + + // Return the commit hash + run_git(workspace_root, &["rev-parse", "HEAD"]) + .map(|s| s.trim().to_string()) +} + +/// Push the current branch to a remote. +/// +/// Defaults to `origin` if remote is empty. +pub fn git_push(workspace_root: &Path, remote: &str, branch: &str) -> Result { + let remote = if remote.is_empty() { "origin" } else { remote }; + let mut args = vec!["push", remote]; + if !branch.is_empty() { + args.push(branch); + } + run_git(workspace_root, &args) +} + +/// Run a git command in the workspace directory. +fn run_git(workspace_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(workspace_root) + .output() + .map_err(|e| format!("Failed to run git: {}", e))?; + + if output.status.success() { + String::from_utf8(output.stdout) + .map_err(|e| format!("Invalid UTF-8 in git output: {}", e)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("git {} failed: {}", args.join(" "), stderr.trim())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_git_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + + // Initialize a git repo + run_git(dir.path(), &["init"]).expect("git init should work"); + run_git( + dir.path(), + &["config", "user.email", "test@test.com"], + ) + .expect("git config email"); + run_git( + dir.path(), + &["config", "user.name", "Test"], + ) + .expect("git config name"); + + // Create an initial commit + fs::write(dir.path().join("initial.txt"), "hello\n").unwrap(); + run_git(dir.path(), &["add", "."]).expect("git add"); + run_git(dir.path(), &["commit", "-m", "Initial"]).expect("git commit"); + + dir + } + + #[test] + fn test_git_status_clean() { + let dir = setup_git_repo(); + let status = git_status(dir.path()); + assert!(status.success); + assert!(status.branch.is_some()); + assert!(status.modified.is_empty()); + assert!(status.untracked.is_empty()); + } + + #[test] + fn test_git_status_modified() { + let dir = setup_git_repo(); + fs::write(dir.path().join("initial.txt"), "modified\n").unwrap(); + + let status = git_status(dir.path()); + assert!(status.success); + assert!(status.modified.contains(&"initial.txt".to_string())); + } + + #[test] + fn test_git_status_untracked() { + let dir = setup_git_repo(); + fs::write(dir.path().join("new_file.txt"), "new\n").unwrap(); + + let status = git_status(dir.path()); + assert!(status.success); + assert!(status.untracked.contains(&"new_file.txt".to_string())); + } + + #[test] + fn test_git_diff() { + let dir = setup_git_repo(); + fs::write(dir.path().join("initial.txt"), "modified\n").unwrap(); + + let diff = git_diff(dir.path(), false).unwrap(); + assert!(diff.contains("-hello")); + assert!(diff.contains("+modified")); + } + + #[test] + fn test_git_log() { + let dir = setup_git_repo(); + let log = git_log(dir.path(), 5).unwrap(); + assert!(log.contains("Initial")); + } + + #[test] + fn test_git_status_not_a_repo() { + let dir = tempfile::tempdir().unwrap(); + let status = git_status(dir.path()); + // Should still return a result (possibly with error) + // git status in non-repo returns error + assert!(!status.success || status.branch.is_none()); + } + + #[test] + fn test_git_add_and_commit() { + let dir = setup_git_repo(); + + // Create a new file + fs::write(dir.path().join("feature.txt"), "new feature\n").unwrap(); + + // Stage it + git_add(dir.path(), &["feature.txt"]).expect("git add should work"); + + // Status should show it as added + let status = git_status(dir.path()); + assert!(status.added.contains(&"feature.txt".to_string())); + + // Commit it + let hash = git_commit(dir.path(), "Add feature").expect("git commit should work"); + assert!(!hash.is_empty()); + assert!(hash.len() >= 7); // At least a short hash + + // Status should be clean now + let status_after = git_status(dir.path()); + assert!(status_after.modified.is_empty()); + assert!(status_after.added.is_empty()); + assert!(status_after.untracked.is_empty()); + } + + #[test] + fn test_git_commit_empty_fails() { + let dir = setup_git_repo(); + // Nothing staged β€” commit should fail + let result = git_commit(dir.path(), "Empty commit"); + assert!(result.is_err()); + } + + #[test] + fn test_git_add_all() { + let dir = setup_git_repo(); + + fs::write(dir.path().join("a.txt"), "aaa\n").unwrap(); + fs::write(dir.path().join("b.txt"), "bbb\n").unwrap(); + + git_add(dir.path(), &["."]).expect("git add . should work"); + + let status = git_status(dir.path()); + // Both files should be staged (added) + assert!(status.added.contains(&"a.txt".to_string())); + assert!(status.added.contains(&"b.txt".to_string())); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/mod.rs b/src/debug/jtag/workers/continuum-core/src/code/mod.rs new file mode 100644 index 000000000..0f1dc2194 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/mod.rs @@ -0,0 +1,32 @@ +//! Code module β€” file operations, change tracking, shell sessions, and code intelligence. +//! +//! Provides the Rust foundation for the coding agent system: +//! - `types` β€” Shared wire types for IPC (ChangeNode, FileDiff, EditMode, etc.) +//! - `shell_types` β€” Wire types for shell session IPC +//! - `shell_session` β€” Persistent shell sessions per workspace (handle + poll) +//! - `diff_engine` β€” Unified diff computation using the `similar` crate +//! - `change_graph` β€” Per-workspace DAG of file operations with undo/redo +//! - `path_security` β€” Workspace-scoped path validation and traversal guard +//! - `file_engine` β€” Per-persona file operations (read/write/edit/delete) +//! - `search` β€” Regex + glob code search with .gitignore awareness +//! - `tree` β€” Directory tree generation +//! - `git_bridge` β€” Git status, diff, and branch operations + +pub mod types; +pub mod shell_types; +pub mod shell_session; +pub mod diff_engine; +pub mod change_graph; +pub mod path_security; +pub mod file_engine; +pub mod search; +pub mod tree; +pub mod git_bridge; + +// Re-export key types for convenience +pub use types::*; +pub use change_graph::ChangeGraph; +pub use diff_engine::{compute_diff, compute_bidirectional_diff}; +pub use path_security::PathSecurity; +pub use file_engine::FileEngine; +pub use shell_session::{ShellSession, watch_execution}; diff --git a/src/debug/jtag/workers/continuum-core/src/code/path_security.rs b/src/debug/jtag/workers/continuum-core/src/code/path_security.rs new file mode 100644 index 000000000..54af47ebc --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/path_security.rs @@ -0,0 +1,447 @@ +//! Path Security β€” workspace-scoped path validation and traversal guard. +//! +//! Each persona gets a workspace root directory they cannot escape. +//! All file paths are canonicalized and validated before any I/O operation. +//! +//! Security guarantees: +//! - No directory traversal (../ sequences resolved and blocked) +//! - Extension allowlist enforced on write operations +//! - File size limits enforced on writes +//! - Symlinks resolved before validation (no symlink-based escapes) + +use std::path::{Path, PathBuf}; + +use super::types::{ALLOWED_EXTENSIONS, MAX_WRITE_SIZE}; + +/// Workspace-scoped path security validator. +/// +/// Validates that all file operations stay within the workspace boundary. +/// Created per-persona with their assigned workspace root. +#[derive(Debug, Clone)] +pub struct PathSecurity { + /// The absolute, canonicalized workspace root. + workspace_root: PathBuf, + /// Optional read-only roots (e.g., the main codebase for discovery). + read_roots: Vec, +} + +/// Errors that can occur during path validation. +#[derive(Debug, Clone, PartialEq)] +pub enum PathSecurityError { + /// Path escapes the workspace boundary. + TraversalBlocked { path: String, workspace: String }, + /// File extension not in allowlist. + ExtensionBlocked { path: String, extension: String }, + /// File exceeds maximum write size. + FileTooLarge { path: String, size: u64, max: u64 }, + /// Path is not valid UTF-8. + InvalidPath { path: String }, + /// Workspace root does not exist or is not a directory. + InvalidWorkspace { path: String }, +} + +impl std::fmt::Display for PathSecurityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TraversalBlocked { path, workspace } => { + write!(f, "Path '{}' escapes workspace '{}'", path, workspace) + } + Self::ExtensionBlocked { path, extension } => { + write!(f, "Extension '.{}' not allowed for '{}'", extension, path) + } + Self::FileTooLarge { path, size, max } => { + write!(f, "File '{}' is {} bytes (max: {})", path, size, max) + } + Self::InvalidPath { path } => { + write!(f, "Invalid path: '{}'", path) + } + Self::InvalidWorkspace { path } => { + write!(f, "Invalid workspace root: '{}'", path) + } + } + } +} + +impl std::error::Error for PathSecurityError {} + +impl PathSecurity { + /// Create a new PathSecurity validator for a workspace. + /// + /// The workspace_root must exist and be a directory. + /// It will be canonicalized (resolving symlinks). + pub fn new(workspace_root: &Path) -> Result { + let canonical = workspace_root.canonicalize().map_err(|_| { + PathSecurityError::InvalidWorkspace { + path: workspace_root.display().to_string(), + } + })?; + + if !canonical.is_dir() { + return Err(PathSecurityError::InvalidWorkspace { + path: canonical.display().to_string(), + }); + } + + Ok(Self { + workspace_root: canonical, + read_roots: Vec::new(), + }) + } + + /// Add a read-only root (e.g., the main codebase for code discovery). + /// + /// Paths within read_roots can be read but not written. + pub fn add_read_root(&mut self, root: &Path) -> Result<(), PathSecurityError> { + let canonical = root.canonicalize().map_err(|_| { + PathSecurityError::InvalidWorkspace { + path: root.display().to_string(), + } + })?; + self.read_roots.push(canonical); + Ok(()) + } + + /// Validate and resolve a path for read operations. + /// + /// The path must be within the workspace root OR any read-only root. + /// Returns the absolute, canonicalized path. + pub fn validate_read(&self, relative_path: &str) -> Result { + // Try workspace root first + if let Ok(path) = self.resolve_within(&self.workspace_root, relative_path) { + return Ok(path); + } + + // Try read-only roots + for root in &self.read_roots { + if let Ok(path) = self.resolve_within(root, relative_path) { + return Ok(path); + } + } + + Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: self.workspace_root.display().to_string(), + }) + } + + /// Validate and resolve a path for write operations. + /// + /// The path must be within the workspace root (not read-only roots). + /// Also validates the file extension against the allowlist. + /// Returns the absolute path (parent dir must exist). + pub fn validate_write(&self, relative_path: &str) -> Result { + let resolved = self.resolve_for_write(relative_path)?; + self.check_extension(relative_path)?; + Ok(resolved) + } + + /// Validate file size for a write operation. + pub fn validate_size(&self, path: &str, size: u64) -> Result<(), PathSecurityError> { + if size > MAX_WRITE_SIZE { + return Err(PathSecurityError::FileTooLarge { + path: path.to_string(), + size, + max: MAX_WRITE_SIZE, + }); + } + Ok(()) + } + + /// Resolve a relative path within a root, ensuring it doesn't escape. + /// + /// For existing files, uses canonicalize() to resolve symlinks. + /// For new files, manually resolves the path and checks the prefix. + fn resolve_within(&self, root: &Path, relative_path: &str) -> Result { + let joined = root.join(relative_path); + + // For existing paths, canonicalize resolves symlinks + if joined.exists() { + let canonical = joined.canonicalize().map_err(|_| { + PathSecurityError::InvalidPath { + path: relative_path.to_string(), + } + })?; + + if canonical.starts_with(root) { + return Ok(canonical); + } + + return Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: root.display().to_string(), + }); + } + + // For non-existing paths, resolve parent and check + Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: root.display().to_string(), + }) + } + + /// Resolve a relative path for write operations (file may not exist yet). + /// + /// The parent directory must exist and be within the workspace root. + fn resolve_for_write(&self, relative_path: &str) -> Result { + // Check for obvious traversal attempts before any I/O + let normalized = self.normalize_path(relative_path); + if normalized.starts_with("..") || normalized.contains("/../") { + return Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: self.workspace_root.display().to_string(), + }); + } + + let joined = self.workspace_root.join(&normalized); + + // If the file exists, canonicalize it + if joined.exists() { + let canonical = joined.canonicalize().map_err(|_| { + PathSecurityError::InvalidPath { + path: relative_path.to_string(), + } + })?; + + if !canonical.starts_with(&self.workspace_root) { + return Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: self.workspace_root.display().to_string(), + }); + } + + return Ok(canonical); + } + + // For new files: walk up the parent chain to find the nearest existing + // ancestor, canonicalize it, and verify it's within the workspace. + // This handles creating files in not-yet-existing subdirectories + // (e.g., "shared/format-utils.ts" when "shared/" doesn't exist yet). + { + let mut ancestor = joined.clone(); + // Walk up until we find an existing directory + while let Some(parent) = ancestor.parent() { + if parent.exists() { + let canonical_ancestor = parent.canonicalize().map_err(|_| { + PathSecurityError::InvalidPath { + path: relative_path.to_string(), + } + })?; + + if !canonical_ancestor.starts_with(&self.workspace_root) { + return Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: self.workspace_root.display().to_string(), + }); + } + + // Reconstruct: canonical ancestor + remaining relative components + let remaining = joined.strip_prefix(parent).map_err(|_| { + PathSecurityError::InvalidPath { + path: relative_path.to_string(), + } + })?; + return Ok(canonical_ancestor.join(remaining)); + } + ancestor = parent.to_path_buf(); + } + } + + Err(PathSecurityError::TraversalBlocked { + path: relative_path.to_string(), + workspace: self.workspace_root.display().to_string(), + }) + } + + /// Check that a file's extension is in the allowlist. + fn check_extension(&self, path: &str) -> Result<(), PathSecurityError> { + let path = Path::new(path); + let extension = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + if extension.is_empty() || !ALLOWED_EXTENSIONS.contains(&extension) { + return Err(PathSecurityError::ExtensionBlocked { + path: path.display().to_string(), + extension: extension.to_string(), + }); + } + + Ok(()) + } + + /// Normalize a path by collapsing `.` and `..` components without I/O. + /// + /// This is a pre-check before any filesystem operations. + /// Returns the normalized path. If `..` underflows (tries to go above root), + /// the result will start with `..` to signal a traversal attempt. + fn normalize_path(&self, path: &str) -> String { + let mut components = Vec::new(); + let mut depth: i32 = 0; // Track depth relative to root + + for part in path.split('/') { + match part { + "" | "." => continue, + ".." => { + if depth > 0 { + components.pop(); + depth -= 1; + } else { + // Underflow: trying to go above workspace root + components.push(".."); + } + } + other => { + components.push(other); + depth += 1; + } + } + } + + components.join("/") + } + + /// Get the workspace root path. + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_workspace() -> (tempfile::TempDir, PathSecurity) { + let dir = tempfile::tempdir().unwrap(); + // Create some subdirectories and files + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/main.ts"), "console.log('hello');").unwrap(); + fs::write(dir.path().join("readme.md"), "# Hello").unwrap(); + + let security = PathSecurity::new(dir.path()).unwrap(); + (dir, security) + } + + #[test] + fn test_valid_read() { + let (_dir, security) = setup_workspace(); + let result = security.validate_read("src/main.ts"); + assert!(result.is_ok()); + } + + #[test] + fn test_traversal_blocked() { + let (_dir, security) = setup_workspace(); + let result = security.validate_read("../../etc/passwd"); + assert!(matches!(result, Err(PathSecurityError::TraversalBlocked { .. }))); + } + + #[test] + fn test_dot_dot_traversal() { + let (_dir, security) = setup_workspace(); + let result = security.validate_write("src/../../etc/passwd.ts"); + assert!(result.is_err()); + } + + #[test] + fn test_valid_write_existing() { + let (_dir, security) = setup_workspace(); + let result = security.validate_write("src/main.ts"); + assert!(result.is_ok()); + } + + #[test] + fn test_valid_write_new_file() { + let (_dir, security) = setup_workspace(); + // New file in existing directory + let result = security.validate_write("src/new_file.ts"); + assert!(result.is_ok()); + } + + #[test] + fn test_extension_blocked() { + let (_dir, security) = setup_workspace(); + let result = security.validate_write("src/malware.exe"); + assert!(matches!(result, Err(PathSecurityError::ExtensionBlocked { .. }))); + } + + #[test] + fn test_allowed_extensions() { + let (_dir, security) = setup_workspace(); + // All these should pass extension check + for ext in &["ts", "tsx", "js", "jsx", "json", "md", "css", "html", "rs", "toml", "yaml", "yml", "txt", "sh", "py"] { + let path = format!("src/test.{}", ext); + let result = security.check_extension(&path); + assert!(result.is_ok(), "Extension '{}' should be allowed", ext); + } + } + + #[test] + fn test_file_too_large() { + let (_dir, security) = setup_workspace(); + let result = security.validate_size("test.ts", MAX_WRITE_SIZE + 1); + assert!(matches!(result, Err(PathSecurityError::FileTooLarge { .. }))); + } + + #[test] + fn test_file_within_limit() { + let (_dir, security) = setup_workspace(); + let result = security.validate_size("test.ts", MAX_WRITE_SIZE); + assert!(result.is_ok()); + } + + #[test] + fn test_read_root() { + let dir = tempfile::tempdir().unwrap(); + let read_dir = tempfile::tempdir().unwrap(); + fs::write(read_dir.path().join("lib.ts"), "export {};").unwrap(); + + let mut security = PathSecurity::new(dir.path()).unwrap(); + security.add_read_root(read_dir.path()).unwrap(); + + // Can read from read root + let result = security.validate_read("lib.ts"); + assert!(result.is_ok()); + } + + #[test] + fn test_write_creates_in_workspace_not_read_root() { + let dir = tempfile::tempdir().unwrap(); + let read_dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::create_dir_all(read_dir.path().join("libs")).unwrap(); + fs::write(read_dir.path().join("libs/external.ts"), "export {};").unwrap(); + + let mut security = PathSecurity::new(dir.path()).unwrap(); + security.add_read_root(read_dir.path()).unwrap(); + + // Can read from read root via relative path + let read_result = security.validate_read("libs/external.ts"); + assert!(read_result.is_ok()); + + // Writing "libs/external.ts" creates it in the WORKSPACE (writable), + // not in the read root. This is valid β€” the file will be at + // workspace/libs/external.ts. The read root is untouched. + let write_result = security.validate_write("libs/external.ts"); + assert!(write_result.is_ok(), "Should be able to write new file in workspace subdirectory"); + let resolved = write_result.unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + assert!(resolved.starts_with(&canonical_dir), "Write should resolve within workspace, not read root"); + } + + #[test] + fn test_normalize_path() { + let (_dir, security) = setup_workspace(); + assert_eq!(security.normalize_path("src/../src/main.ts"), "src/main.ts"); + assert_eq!(security.normalize_path("./src/main.ts"), "src/main.ts"); + assert_eq!(security.normalize_path("src/./main.ts"), "src/main.ts"); + assert_eq!(security.normalize_path("a/b/c/../../d"), "a/d"); + } + + #[test] + fn test_invalid_workspace() { + let result = PathSecurity::new(Path::new("/nonexistent/path/that/does/not/exist")); + assert!(matches!(result, Err(PathSecurityError::InvalidWorkspace { .. }))); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/search.rs b/src/debug/jtag/workers/continuum-core/src/code/search.rs new file mode 100644 index 000000000..cefb894bf --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/search.rs @@ -0,0 +1,221 @@ +//! Code Search β€” regex + glob file search with .gitignore awareness. +//! +//! Uses the `ignore` crate (from ripgrep) for .gitignore-aware file walking, +//! and standard regex for content matching. + +use std::path::Path; + +use super::types::{SearchMatch, SearchResult}; + +/// Search for a regex pattern across files in a directory. +/// +/// Respects .gitignore rules and supports file glob filtering. +/// Returns matches up to `max_results`. +pub fn search_files( + root: &Path, + pattern: &str, + file_glob: Option<&str>, + max_results: u32, +) -> SearchResult { + let regex = match regex::Regex::new(pattern) { + Ok(r) => r, + Err(e) => { + return SearchResult { + success: false, + matches: Vec::new(), + total_matches: 0, + files_searched: 0, + error: Some(format!("Invalid regex: {}", e)), + }; + } + }; + + // Build the file walker with .gitignore awareness + let mut builder = ignore::WalkBuilder::new(root); + builder + .hidden(true) // Skip hidden files + .git_ignore(true) // Respect .gitignore + .git_global(true) // Respect global gitignore + .git_exclude(true); // Respect .git/info/exclude + + // Apply file glob filter if provided + if let Some(glob) = file_glob { + // The ignore crate uses overrides for glob filtering + let mut overrides = ignore::overrides::OverrideBuilder::new(root); + if let Err(e) = overrides.add(glob) { + return SearchResult { + success: false, + matches: Vec::new(), + total_matches: 0, + files_searched: 0, + error: Some(format!("Invalid glob pattern '{}': {}", glob, e)), + }; + } + match overrides.build() { + Ok(ov) => { builder.overrides(ov); } + Err(e) => { + return SearchResult { + success: false, + matches: Vec::new(), + total_matches: 0, + files_searched: 0, + error: Some(format!("Invalid glob pattern: {}", e)), + }; + } + } + } + + let mut matches = Vec::new(); + let mut files_searched = 0u32; + let mut total_matches = 0u32; + let max = max_results as usize; + + for entry in builder.build().flatten() { + let path = entry.path(); + + // Skip directories + if path.is_dir() { + continue; + } + + // Skip binary files (simple heuristic: try reading as UTF-8) + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, // Skip files we can't read as text + }; + + files_searched += 1; + + // Search for matches in each line + for (line_idx, line) in content.lines().enumerate() { + for mat in regex.find_iter(line) { + total_matches += 1; + + if matches.len() < max { + let relative_path = path + .strip_prefix(root) + .unwrap_or(path) + .display() + .to_string(); + + matches.push(SearchMatch { + file_path: relative_path, + line_number: (line_idx + 1) as u32, + line_content: line.to_string(), + match_start: mat.start() as u32, + match_end: mat.end() as u32, + }); + } + } + } + + // Early exit if we have enough results + if matches.len() >= max { + break; + } + } + + SearchResult { + success: true, + matches, + total_matches, + files_searched, + error: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_search_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/main.ts"), + "function hello() {\n console.log('world');\n}\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/utils.ts"), + "export function greet(name: string) {\n return `Hello ${name}`;\n}\n", + ) + .unwrap(); + fs::write( + dir.path().join("src/style.css"), + "body { color: red; }\n", + ) + .unwrap(); + fs::write(dir.path().join("readme.md"), "# Hello World\n").unwrap(); + dir + } + + #[test] + fn test_search_basic() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), "function", None, 100); + assert!(result.success); + assert_eq!(result.total_matches, 2); // hello() and greet() + assert!(result.files_searched >= 2); + } + + #[test] + fn test_search_with_glob() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), "function", Some("*.ts"), 100); + assert!(result.success); + assert_eq!(result.total_matches, 2); + // All matches should be .ts files + for m in &result.matches { + assert!(m.file_path.ends_with(".ts")); + } + } + + #[test] + fn test_search_max_results() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), ".", None, 3); + assert!(result.success); + assert!(result.matches.len() <= 3); + } + + #[test] + fn test_search_no_matches() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), "zzz_nonexistent_zzz", None, 100); + assert!(result.success); + assert_eq!(result.total_matches, 0); + assert!(result.matches.is_empty()); + } + + #[test] + fn test_search_regex() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), r"function\s+\w+", None, 100); + assert!(result.success); + assert_eq!(result.total_matches, 2); + } + + #[test] + fn test_search_invalid_regex() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), "[invalid", None, 100); + assert!(!result.success); + assert!(result.error.is_some()); + } + + #[test] + fn test_search_match_positions() { + let dir = setup_search_dir(); + let result = search_files(dir.path(), "hello", None, 100); + assert!(result.success); + for m in &result.matches { + assert!(m.match_start < m.match_end); + assert_eq!( + &m.line_content[m.match_start as usize..m.match_end as usize], + "hello" + ); + } + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/shell_session.rs b/src/debug/jtag/workers/continuum-core/src/code/shell_session.rs new file mode 100644 index 000000000..47410cb36 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/shell_session.rs @@ -0,0 +1,1082 @@ +//! ShellSession β€” Persistent shell session per workspace. +//! +//! Provides a handle-based shell execution model: +//! 1. Create session (bound to workspace directory) +//! 2. Execute command β†’ get execution handle immediately +//! 3. Poll execution handle β†’ get new stdout/stderr chunks +//! 4. Or: execute with wait=true β†’ block until complete +//! 5. Kill execution if needed +//! 6. Destroy session on cleanup +//! +//! Supports BOTH quick commands (wait=true β†’ immediate result) and +//! long-running commands (poll repeatedly β†’ streaming output). +//! +//! Each command runs in its own process for isolation. The session +//! maintains working directory and environment across executions. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use regex::Regex; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command as TokioCommand; +use tokio::sync::Notify; +use uuid::Uuid; + +use super::shell_types::{ + ClassifiedLine, OutputClassification, SentinelAction, SentinelRule, + ShellExecuteResponse, ShellExecutionStatus, ShellHistoryEntry, ShellPollResponse, + ShellSessionInfo, ShellWatchResponse, +}; +use crate::log_info; + +// ============================================================================ +// Execution State (shared between tokio task and IPC handler) +// ============================================================================ + +/// Mutable state for a running or completed execution. +/// +/// Written by the background tokio task (stdout/stderr lines, status). +/// Read by the IPC poll handler (cursor-based output retrieval) and watch handler. +pub struct ExecutionState { + pub id: String, + pub command: String, + pub status: ShellExecutionStatus, + pub stdout_lines: Vec, + pub stderr_lines: Vec, + pub exit_code: Option, + pub pid: Option, + pub started_at: u64, + pub finished_at: Option, + /// Cursor: index of next stdout line to return on poll/watch. + stdout_cursor: usize, + /// Cursor: index of next stderr line to return on poll/watch. + stderr_cursor: usize, + /// Notified whenever new output lines arrive or execution finishes. + /// Used by `watch()` to block without polling. + pub output_notify: Arc, + /// Compiled sentinel filter rules (empty = pass all lines through as Info). + pub sentinel: CompiledSentinel, +} + +impl std::fmt::Debug for ExecutionState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExecutionState") + .field("id", &self.id) + .field("command", &self.command) + .field("status", &self.status) + .field("stdout_lines", &self.stdout_lines.len()) + .field("stderr_lines", &self.stderr_lines.len()) + .field("exit_code", &self.exit_code) + .field("pid", &self.pid) + .field("sentinel_rules", &self.sentinel.len()) + .finish() + } +} + +// ============================================================================ +// Compiled Sentinel β€” pre-compiled regex rules for output classification +// ============================================================================ + +/// Pre-compiled sentinel rules for efficient per-line classification. +/// +/// Regex patterns are compiled once when `set_sentinel()` is called, +/// then applied to every output line without re-compilation. +pub struct CompiledSentinel { + rules: Vec<(Regex, OutputClassification, SentinelAction)>, +} + +impl CompiledSentinel { + /// Create an empty sentinel (passes all lines through as Info). + pub fn empty() -> Self { + Self { rules: Vec::new() } + } + + /// Compile sentinel rules from wire format. Fails on invalid regex. + pub fn compile(rules: &[SentinelRule]) -> Result { + let mut compiled = Vec::with_capacity(rules.len()); + for rule in rules { + let regex = Regex::new(&rule.pattern) + .map_err(|e| format!("Invalid regex '{}': {}", rule.pattern, e))?; + compiled.push((regex, rule.classification.clone(), rule.action.clone())); + } + Ok(Self { rules: compiled }) + } + + /// Number of active rules. + pub fn len(&self) -> usize { + self.rules.len() + } + + /// Classify a single output line. Returns None if the line should be suppressed. + pub fn classify(&self, text: &str, stream: &str, line_num: u64) -> Option { + let ts = now(); + + if self.rules.is_empty() { + // No sentinel configured β€” pass everything through as Info + return Some(ClassifiedLine { + text: text.to_string(), + classification: OutputClassification::Info, + line_number: line_num, + stream: stream.to_string(), + timestamp: ts, + }); + } + + // First matching rule wins + for (regex, classification, action) in &self.rules { + if regex.is_match(text) { + return match action { + SentinelAction::Emit => Some(ClassifiedLine { + text: text.to_string(), + classification: classification.clone(), + line_number: line_num, + stream: stream.to_string(), + timestamp: ts, + }), + SentinelAction::Suppress => None, + }; + } + } + + // No rule matched β€” emit as Verbose + Some(ClassifiedLine { + text: text.to_string(), + classification: OutputClassification::Verbose, + line_number: line_num, + stream: stream.to_string(), + timestamp: ts, + }) + } +} + +// ============================================================================ +// Shell Session +// ============================================================================ + +/// A persistent shell session bound to a workspace. +/// +/// Maintains working directory and environment across command executions. +/// Each command runs in its own isolated process (bash -c "..."). +pub struct ShellSession { + id: String, + persona_id: String, + workspace_root: PathBuf, + cwd: PathBuf, + env: HashMap, + executions: HashMap>>, + history: Vec, + total_executions: u32, +} + +impl ShellSession { + /// Create a new shell session bound to a workspace directory. + /// + /// The workspace_root is canonicalized to resolve symlinks (required + /// for reliable path containment checks on macOS where /var β†’ /private/var). + pub fn new(session_id: &str, persona_id: &str, workspace_root: &Path) -> Result { + let canonical_root = workspace_root.canonicalize().map_err(|e| { + format!( + "Invalid workspace root '{}': {}", + workspace_root.display(), + e + ) + })?; + + let cwd = canonical_root.clone(); + Ok(Self { + id: session_id.to_string(), + persona_id: persona_id.to_string(), + workspace_root: canonical_root, + cwd, + env: HashMap::new(), + executions: HashMap::new(), + history: Vec::new(), + total_executions: 0, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + pub fn persona_id(&self) -> &str { + &self.persona_id + } + pub fn cwd(&self) -> &Path { + &self.cwd + } + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + /// Set an environment variable for future commands. + pub fn set_env(&mut self, key: String, value: String) { + self.env.insert(key, value); + } + + /// Change working directory. Validates the path stays within workspace. + pub fn cd(&mut self, path: &str) -> Result { + let new_cwd = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + self.cwd.join(path) + }; + + let canonical = new_cwd + .canonicalize() + .map_err(|e| format!("Cannot cd to '{}': {}", path, e))?; + + if !canonical.starts_with(&self.workspace_root) { + return Err(format!( + "Cannot cd to '{}': outside workspace boundary '{}'", + path, + self.workspace_root.display() + )); + } + + if !canonical.is_dir() { + return Err(format!("Cannot cd to '{}': not a directory", path)); + } + + self.cwd = canonical.clone(); + Ok(canonical.display().to_string()) + } + + /// Get session info snapshot. + pub fn info(&self) -> ShellSessionInfo { + let active = self + .executions + .values() + .filter(|e| { + e.lock() + .map(|s| s.status == ShellExecutionStatus::Running) + .unwrap_or(false) + }) + .count() as u32; + + ShellSessionInfo { + session_id: self.id.clone(), + persona_id: self.persona_id.clone(), + cwd: self.cwd.display().to_string(), + workspace_root: self.workspace_root.display().to_string(), + active_executions: active, + total_executions: self.total_executions, + } + } + + /// Start a command execution. Returns the execution ID immediately. + /// + /// The command runs asynchronously in a tokio task. Use `poll()` to + /// retrieve output, or pass `wait=true` to `execute_and_wait()`. + pub fn execute( + &mut self, + command: &str, + timeout_ms: Option, + rt_handle: &tokio::runtime::Handle, + ) -> Result { + let execution_id = Uuid::new_v4().to_string(); + let now_ms = now(); + + let notify = Arc::new(Notify::new()); + let state = Arc::new(Mutex::new(ExecutionState { + id: execution_id.clone(), + command: command.to_string(), + status: ShellExecutionStatus::Running, + stdout_lines: Vec::new(), + stderr_lines: Vec::new(), + exit_code: None, + pid: None, + started_at: now_ms, + finished_at: None, + stdout_cursor: 0, + stderr_cursor: 0, + output_notify: notify, + sentinel: CompiledSentinel::empty(), + })); + + self.executions + .insert(execution_id.clone(), state.clone()); + self.total_executions += 1; + + // Spawn the process in a tokio task + let cwd = self.cwd.clone(); + let env = self.env.clone(); + let cmd_str = command.to_string(); + + rt_handle.spawn(async move { + run_shell_command(state, &cmd_str, &cwd, &env, timeout_ms).await; + }); + + log_info!( + "code", + "shell", + "Execution {} started: {}", + &execution_id[..8], + command + ); + Ok(execution_id) + } + + /// Execute a command and block until completion. Returns the full result. + /// + /// For quick commands (git status, ls, etc.) where you want the result + /// immediately rather than polling. + pub fn execute_and_wait( + &mut self, + command: &str, + timeout_ms: Option, + rt_handle: &tokio::runtime::Handle, + ) -> Result { + let execution_id = self.execute(command, timeout_ms, rt_handle)?; + + // Block this thread until the execution finishes + let state_arc = self + .executions + .get(&execution_id) + .ok_or_else(|| "Execution vanished".to_string())? + .clone(); + + // Poll until complete (on the current IPC thread) + loop { + { + let s = state_arc + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))?; + if s.status != ShellExecutionStatus::Running { + return Ok(ShellExecuteResponse { + execution_id: s.id.clone(), + status: s.status.clone(), + stdout: Some(s.stdout_lines.join("\n")), + stderr: Some(s.stderr_lines.join("\n")), + exit_code: s.exit_code, + }); + } + } + // Yield briefly to let the tokio task progress + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// Poll an execution for new output since the last poll. + /// + /// Returns new stdout/stderr lines and current status. Call repeatedly + /// until `finished` is true. Cursor advances automatically β€” each line + /// is returned exactly once across polls. + pub fn poll(&self, execution_id: &str) -> Result { + let state_arc = self + .executions + .get(execution_id) + .ok_or_else(|| format!("No execution '{}'", execution_id))?; + + let mut state = state_arc + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + let new_stdout: Vec = state.stdout_lines[state.stdout_cursor..].to_vec(); + let new_stderr: Vec = state.stderr_lines[state.stderr_cursor..].to_vec(); + state.stdout_cursor = state.stdout_lines.len(); + state.stderr_cursor = state.stderr_lines.len(); + + let finished = state.status != ShellExecutionStatus::Running; + + Ok(ShellPollResponse { + execution_id: execution_id.to_string(), + status: state.status.clone(), + new_stdout, + new_stderr, + exit_code: state.exit_code, + finished, + }) + } + + /// Kill a running execution. + /// + /// Sets the kill flag; the background task detects it and terminates + /// the child process. No-op if already finished. + pub fn kill(&self, execution_id: &str) -> Result<(), String> { + let state_arc = self + .executions + .get(execution_id) + .ok_or_else(|| format!("No execution '{}'", execution_id))?; + + let mut state = state_arc + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + if state.status != ShellExecutionStatus::Running { + return Ok(()); // Already done + } + + // Signal kill β€” the tokio task will detect this and kill the child + state.status = ShellExecutionStatus::Killed; + state.finished_at = Some(now()); + + // Also send SIGKILL via the stored PID for immediate effect + if let Some(pid) = state.pid { + kill_process(pid); + } + + log_info!( + "code", + "shell", + "Killed execution {}: {}", + &execution_id[..8.min(execution_id.len())], + state.command + ); + Ok(()) + } + + /// Get history of completed executions. + pub fn history(&self) -> &[ShellHistoryEntry] { + &self.history + } + + /// Garbage-collect completed executions, moving them to history. + /// Call periodically to prevent unbounded memory growth. + pub fn gc(&mut self) { + let completed_ids: Vec = self + .executions + .iter() + .filter_map(|(id, state)| { + let s = state.lock().ok()?; + if s.status != ShellExecutionStatus::Running { + Some(id.clone()) + } else { + None + } + }) + .collect(); + + for id in completed_ids { + if let Some(state_arc) = self.executions.remove(&id) { + if let Ok(state) = state_arc.lock() { + self.history.push(ShellHistoryEntry { + execution_id: state.id.clone(), + command: state.command.clone(), + exit_code: state.exit_code, + started_at: state.started_at, + finished_at: state.finished_at, + }); + } + } + } + } + + /// Kill all running executions and clear state. + pub fn destroy(&mut self) { + for (_, state_arc) in self.executions.iter() { + if let Ok(mut state) = state_arc.lock() { + if state.status == ShellExecutionStatus::Running { + state.status = ShellExecutionStatus::Killed; + state.finished_at = Some(now()); + if let Some(pid) = state.pid { + kill_process(pid); + } + } + } + } + self.executions.clear(); + } + + // ════════════════════════════════════════════════════════════ + // Watch + Sentinel + // ════════════════════════════════════════════════════════════ + + /// Get execution state arc and notify handle for async watch. + /// + /// Returns clones that can be used after the DashMap lock is released. + /// The caller MUST release any DashMap locks before awaiting on the Notify. + pub fn get_watch_handles( + &self, + execution_id: &str, + ) -> Result<(Arc>, Arc), String> { + let exec_state = self + .executions + .get(execution_id) + .ok_or_else(|| format!("No execution '{}'", execution_id))? + .clone(); + let notify = exec_state + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))? + .output_notify + .clone(); + Ok((exec_state, notify)) + } + + /// Configure sentinel filter rules on an execution. + /// + /// Rules are compiled to regexes immediately. Returns the count of rules applied. + /// Pass an empty slice to clear sentinel (reverts to pass-all-as-Info). + pub fn set_sentinel( + &self, + execution_id: &str, + rules: &[SentinelRule], + ) -> Result { + let exec_state = self + .executions + .get(execution_id) + .ok_or_else(|| format!("No execution '{}'", execution_id))?; + + let compiled = CompiledSentinel::compile(rules)?; + let count = compiled.len(); + + let mut state = exec_state + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))?; + state.sentinel = compiled; + Ok(count) + } +} + +/// Watch an execution for new output β€” blocks until output is available. +/// +/// This is a free async function (not a method on ShellSession) because it must +/// be called AFTER releasing the DashMap lock. The caller extracts the handles +/// via `get_watch_handles()`, drops the DashMap ref, then calls this. +/// +/// Uses `tokio::sync::Notify` β€” blocks without polling or timeouts. +/// Like `read()` on a Unix pipe: returns when data arrives. +pub async fn watch_execution( + execution_id: &str, + exec_state: Arc>, + notify: Arc, +) -> Result { + loop { + // Check for new data under the lock + { + let mut state = exec_state + .lock() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + let has_new_stdout = state.stdout_cursor < state.stdout_lines.len(); + let has_new_stderr = state.stderr_cursor < state.stderr_lines.len(); + let is_finished = state.status != ShellExecutionStatus::Running; + + if has_new_stdout || has_new_stderr || is_finished { + let lines = collect_and_classify(&mut state); + return Ok(ShellWatchResponse { + execution_id: execution_id.to_string(), + lines, + finished: is_finished, + exit_code: state.exit_code, + }); + } + } + // Lock released β€” safe to await + // notify_one() stores a permit if nobody is waiting, so we won't + // miss notifications between the lock release and this await. + notify.notified().await; + } +} + +/// Collect new output lines since the cursors and classify them through sentinel rules. +fn collect_and_classify(state: &mut ExecutionState) -> Vec { + let mut lines = Vec::new(); + + // Collect stdout since cursor + for i in state.stdout_cursor..state.stdout_lines.len() { + if let Some(classified) = state.sentinel.classify(&state.stdout_lines[i], "stdout", i as u64) { + lines.push(classified); + } + } + state.stdout_cursor = state.stdout_lines.len(); + + // Collect stderr since cursor + for i in state.stderr_cursor..state.stderr_lines.len() { + if let Some(classified) = state.sentinel.classify(&state.stderr_lines[i], "stderr", i as u64) { + lines.push(classified); + } + } + state.stderr_cursor = state.stderr_lines.len(); + + lines +} + +// ============================================================================ +// Background Command Execution +// ============================================================================ + +/// Run a shell command asynchronously, streaming output into shared state. +/// +/// This function runs in a tokio task. It: +/// 1. Spawns `bash -c "command"` with the session's cwd and env +/// 2. Reads stdout/stderr line-by-line into the shared ExecutionState +/// 3. Handles timeouts by killing the process +/// 4. Detects kill requests by checking the status flag +async fn run_shell_command( + state: Arc>, + command: &str, + cwd: &Path, + env: &HashMap, + timeout_ms: Option, +) { + // Build the command + let mut cmd = TokioCommand::new("bash"); + cmd.arg("-c") + .arg(command) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // Don't inherit stdin β€” non-interactive + .stdin(std::process::Stdio::null()); + + // Apply session environment variables + for (k, v) in env { + cmd.env(k, v); + } + + // Spawn the child process + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + if let Ok(mut s) = state.lock() { + s.status = ShellExecutionStatus::Failed; + s.stderr_lines + .push(format!("Failed to spawn bash: {}", e)); + s.finished_at = Some(now()); + s.output_notify.notify_one(); + } + return; + } + }; + + // Store PID for external kill capability + if let Some(pid) = child.id() { + if let Ok(mut s) = state.lock() { + s.pid = Some(pid); + } + } + + // Take stdout/stderr handles + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + // Spawn line readers (notify watchers on each new line) + let state_out = state.clone(); + let stdout_task = tokio::spawn(async move { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(mut s) = state_out.lock() { + // If killed, stop reading + if s.status == ShellExecutionStatus::Killed { + break; + } + s.stdout_lines.push(line); + s.output_notify.notify_one(); + } + } + }); + + let state_err = state.clone(); + let stderr_task = tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(mut s) = state_err.lock() { + if s.status == ShellExecutionStatus::Killed { + break; + } + s.stderr_lines.push(line); + s.output_notify.notify_one(); + } + } + }); + + // Wait for process completion (with optional timeout and kill detection) + let state_wait = state.clone(); + let exit_status = if let Some(timeout) = timeout_ms { + tokio::select! { + // Branch 1: Process completes + result = child.wait() => { + match result { + Ok(status) => Some(status), + Err(e) => { + if let Ok(mut s) = state_wait.lock() { + s.stderr_lines.push(format!("Process wait error: {}", e)); + } + None + } + } + } + // Branch 2: Timeout fires + _ = tokio::time::sleep(Duration::from_millis(timeout)) => { + // Check if already killed + let already_done = state_wait.lock() + .map(|s| s.status != ShellExecutionStatus::Running) + .unwrap_or(false); + + if !already_done { + let _ = child.kill().await; + if let Ok(mut s) = state_wait.lock() { + if s.status == ShellExecutionStatus::Running { + s.status = ShellExecutionStatus::TimedOut; + s.stderr_lines.push(format!("Timed out after {}ms", timeout)); + s.finished_at = Some(now()); + s.output_notify.notify_one(); + } + } + } + None + } + } + } else { + // No timeout β€” wait indefinitely, but check for kill + let state_for_error = state.clone(); + let state_for_kill = state.clone(); + tokio::select! { + result = child.wait() => { + match result { + Ok(status) => Some(status), + Err(e) => { + if let Ok(mut s) = state_for_error.lock() { + s.stderr_lines.push(format!("Process wait error: {}", e)); + } + None + } + } + } + // Check kill flag periodically + _ = poll_kill_flag(state_for_kill) => { + let _ = child.kill().await; + None + } + } + }; + + // Wait for output readers to drain + let _ = stdout_task.await; + let _ = stderr_task.await; + + // Update final state (if not already set by timeout/kill) + if let Some(status) = exit_status { + if let Ok(mut s) = state.lock() { + if s.status == ShellExecutionStatus::Running { + s.exit_code = status.code(); + s.status = if status.success() { + ShellExecutionStatus::Completed + } else { + ShellExecutionStatus::Failed + }; + s.finished_at = Some(now()); + // Wake any blocked watch() calls to deliver final status + s.output_notify.notify_one(); + + log_info!( + "code", + "shell", + "Execution {} finished: exit={} cmd={}", + &s.id[..8], + s.exit_code.unwrap_or(-1), + &s.command + ); + } + } + } +} + +/// Poll the kill flag on the execution state. Returns when kill is requested. +async fn poll_kill_flag(state: Arc>) { + loop { + { + if let Ok(s) = state.lock() { + if s.status != ShellExecutionStatus::Running { + return; + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Kill a process by PID (best-effort, Unix only). +fn kill_process(pid: u32) { + // Use kill command β€” works on macOS and Linux, no extra deps + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_workspace() -> (tempfile::TempDir, tokio::runtime::Runtime) { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/main.ts"), "console.log('hello');").unwrap(); + let rt = tokio::runtime::Runtime::new().unwrap(); + (dir, rt) + } + + #[test] + fn test_session_creation() { + let (dir, _rt) = setup_workspace(); + let session = ShellSession::new("test-session", "persona-1", dir.path()).unwrap(); + + assert_eq!(session.id(), "test-session"); + assert_eq!(session.persona_id(), "persona-1"); + // cwd and workspace_root are canonicalized (macOS: /var β†’ /private/var) + let canonical = dir.path().canonicalize().unwrap(); + assert_eq!(session.cwd(), canonical); + assert_eq!(session.workspace_root(), canonical); + } + + #[test] + fn test_cd_within_workspace() { + let (dir, _rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session.cd("src"); + assert!(result.is_ok()); + assert!(session.cwd().ends_with("src")); + } + + #[test] + fn test_cd_outside_workspace_blocked() { + let (dir, _rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session.cd(".."); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("outside workspace")); + } + + #[test] + fn test_cd_nonexistent_blocked() { + let (dir, _rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session.cd("nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn test_execute_quick_command() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session.execute_and_wait("echo hello", Some(5000), rt.handle()); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.status, ShellExecutionStatus::Completed); + assert_eq!(response.exit_code, Some(0)); + assert!(response.stdout.unwrap().contains("hello")); + } + + #[test] + fn test_execute_failing_command() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = + session.execute_and_wait("exit 42", Some(5000), rt.handle()); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.status, ShellExecutionStatus::Failed); + assert_eq!(response.exit_code, Some(42)); + } + + #[test] + fn test_execute_with_cwd() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // cd into src, then run pwd + session.cd("src").unwrap(); + let result = session.execute_and_wait("pwd", Some(5000), rt.handle()); + assert!(result.is_ok()); + + let response = result.unwrap(); + let stdout = response.stdout.unwrap(); + assert!(stdout.contains("src"), "pwd should show src dir: {}", stdout); + } + + #[test] + fn test_execute_with_env() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + session.set_env("MY_VAR".to_string(), "hello_world".to_string()); + let result = session.execute_and_wait("echo $MY_VAR", Some(5000), rt.handle()); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert!(response.stdout.unwrap().contains("hello_world")); + } + + #[test] + fn test_poll_pattern() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Execute asynchronously + let exec_id = session + .execute("echo line1; echo line2; echo line3", Some(5000), rt.handle()) + .unwrap(); + + // Poll until finished + let mut all_stdout = Vec::new(); + loop { + std::thread::sleep(Duration::from_millis(50)); + let poll = session.poll(&exec_id).unwrap(); + all_stdout.extend(poll.new_stdout); + if poll.finished { + assert_eq!(poll.exit_code, Some(0)); + break; + } + } + + assert_eq!(all_stdout, vec!["line1", "line2", "line3"]); + } + + #[test] + fn test_timeout() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Command that sleeps longer than timeout + let result = + session.execute_and_wait("sleep 30", Some(500), rt.handle()); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.status, ShellExecutionStatus::TimedOut); + } + + #[test] + fn test_kill_execution() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Start a long-running command + let exec_id = session + .execute("sleep 60", None, rt.handle()) + .unwrap(); + + // Give it a moment to start + std::thread::sleep(Duration::from_millis(200)); + + // Kill it + session.kill(&exec_id).unwrap(); + + // Poll should show killed + std::thread::sleep(Duration::from_millis(200)); + let poll = session.poll(&exec_id).unwrap(); + assert!(poll.finished); + assert_eq!(poll.status, ShellExecutionStatus::Killed); + } + + #[test] + fn test_session_info() { + let (dir, _rt) = setup_workspace(); + let session = ShellSession::new("test-session", "persona-1", dir.path()).unwrap(); + + let info = session.info(); + assert_eq!(info.session_id, "test-session"); + assert_eq!(info.persona_id, "persona-1"); + assert_eq!(info.active_executions, 0); + assert_eq!(info.total_executions, 0); + } + + #[test] + fn test_gc_moves_to_history() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Run a command to completion + let _result = session.execute_and_wait("echo done", Some(5000), rt.handle()); + + assert!(session.history().is_empty()); + + // GC should move it to history + session.gc(); + + assert_eq!(session.history().len(), 1); + assert_eq!(session.history()[0].command, "echo done"); + assert_eq!(session.history()[0].exit_code, Some(0)); + } + + #[test] + fn test_destroy_kills_running() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Start long-running command + let _exec_id = session + .execute("sleep 60", None, rt.handle()) + .unwrap(); + + std::thread::sleep(Duration::from_millis(200)); + + // Destroy should kill it + session.destroy(); + assert!(session.executions.is_empty()); + } + + #[test] + fn test_multiple_executions() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + // Run multiple sequential commands + let r1 = session + .execute_and_wait("echo first", Some(5000), rt.handle()) + .unwrap(); + let r2 = session + .execute_and_wait("echo second", Some(5000), rt.handle()) + .unwrap(); + let r3 = session + .execute_and_wait("echo third", Some(5000), rt.handle()) + .unwrap(); + + assert_eq!(r1.status, ShellExecutionStatus::Completed); + assert_eq!(r2.status, ShellExecutionStatus::Completed); + assert_eq!(r3.status, ShellExecutionStatus::Completed); + assert!(r1.stdout.unwrap().contains("first")); + assert!(r2.stdout.unwrap().contains("second")); + assert!(r3.stdout.unwrap().contains("third")); + } + + #[test] + fn test_command_reads_workspace_files() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session + .execute_and_wait("cat src/main.ts", Some(5000), rt.handle()) + .unwrap(); + + assert_eq!(result.status, ShellExecutionStatus::Completed); + assert!(result.stdout.unwrap().contains("console.log")); + } + + #[test] + fn test_stderr_capture() { + let (dir, rt) = setup_workspace(); + let mut session = ShellSession::new("test", "p1", dir.path()).unwrap(); + + let result = session + .execute_and_wait("echo error_msg >&2", Some(5000), rt.handle()) + .unwrap(); + + assert_eq!(result.status, ShellExecutionStatus::Completed); + assert!(result.stderr.unwrap().contains("error_msg")); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/shell_types.rs b/src/debug/jtag/workers/continuum-core/src/code/shell_types.rs new file mode 100644 index 000000000..9f68a3c6a --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/shell_types.rs @@ -0,0 +1,161 @@ +//! Shell wire types β€” IPC protocol for shell session management. +//! +//! TypeScript types generated via ts-rs. +//! Re-generate: `cargo test --package continuum-core export_bindings` + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Status of a shell command execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "../../../shared/generated/code/ShellExecutionStatus.ts")] +pub enum ShellExecutionStatus { + Running, + Completed, + Failed, + TimedOut, + Killed, +} + +/// Response from `code/shell-execute`. +/// +/// Always returns immediately with the execution handle. +/// If `wait: true` was specified, also includes the completed result. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ShellExecuteResponse.ts")] +pub struct ShellExecuteResponse { + pub execution_id: String, + pub status: ShellExecutionStatus, + /// Full stdout (only present when `wait: true` and execution completed). + #[ts(optional)] + pub stdout: Option, + /// Full stderr (only present when `wait: true` and execution completed). + #[ts(optional)] + pub stderr: Option, + /// Exit code (only present when execution completed). + #[ts(optional)] + pub exit_code: Option, +} + +/// Response from `code/shell-poll`. +/// +/// Returns new output since the last poll (cursor-based). +/// Call repeatedly until `finished` is true. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ShellPollResponse.ts")] +pub struct ShellPollResponse { + pub execution_id: String, + pub status: ShellExecutionStatus, + /// New stdout lines since last poll. + pub new_stdout: Vec, + /// New stderr lines since last poll. + pub new_stderr: Vec, + /// Exit code (present when finished). + #[ts(optional)] + pub exit_code: Option, + /// True when the execution is no longer running. + pub finished: bool, +} + +/// Response from `code/shell-status` β€” session metadata. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ShellSessionInfo.ts")] +pub struct ShellSessionInfo { + pub session_id: String, + pub persona_id: String, + pub cwd: String, + pub workspace_root: String, + pub active_executions: u32, + pub total_executions: u32, +} + +/// A history entry for a completed execution. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ShellHistoryEntry.ts")] +pub struct ShellHistoryEntry { + pub execution_id: String, + pub command: String, + #[ts(optional)] + pub exit_code: Option, + #[ts(type = "number")] + pub started_at: u64, + #[ts(optional, type = "number")] + pub finished_at: Option, +} + +// ============================================================================ +// Sentinel Types β€” Output classification and filtering +// ============================================================================ + +/// Classification level for a line of shell output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/OutputClassification.ts")] +pub enum OutputClassification { + Error, + Warning, + Info, + Success, + Verbose, +} + +/// What to do with a line that matches a sentinel rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/SentinelAction.ts")] +pub enum SentinelAction { + /// Include the line in watch results. + Emit, + /// Filter the line out silently. + Suppress, +} + +/// A sentinel filter rule: regex pattern β†’ classification + action. +/// +/// Wire type for IPC. Patterns are compiled to `regex::Regex` on the Rust side +/// when `set_sentinel()` is called. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/SentinelRule.ts")] +pub struct SentinelRule { + /// Regex pattern to match against each output line. + pub pattern: String, + /// Classification to assign when this rule matches. + pub classification: OutputClassification, + /// Whether to include or suppress the matched line. + pub action: SentinelAction, +} + +/// A single line of classified shell output. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ClassifiedLine.ts")] +pub struct ClassifiedLine { + /// The raw text content of the line. + pub text: String, + /// Classification assigned by sentinel rules. + pub classification: OutputClassification, + /// Line number within the stream (0-indexed from execution start). + #[ts(type = "number")] + pub line_number: u64, + /// Which stream this line came from: "stdout" or "stderr". + pub stream: String, + /// Unix timestamp in milliseconds when the line was classified. + #[ts(type = "number")] + pub timestamp: u64, +} + +/// Response from `code/shell-watch`. +/// +/// Returns classified output lines since the last watch call. +/// Blocks until output is available (no timeout, no polling). +/// Call in a loop until `finished` is true. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ShellWatchResponse.ts")] +pub struct ShellWatchResponse { + pub execution_id: String, + /// Classified output lines (filtered through sentinel rules). + pub lines: Vec, + /// True when the execution is no longer running. + pub finished: bool, + /// Exit code (present when finished). + #[ts(optional)] + pub exit_code: Option, +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/tree.rs b/src/debug/jtag/workers/continuum-core/src/code/tree.rs new file mode 100644 index 000000000..6b4ec8e7b --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/tree.rs @@ -0,0 +1,305 @@ +//! Directory Tree β€” recursive directory structure generation. +//! +//! Generates a tree representation of a directory, respecting .gitignore +//! rules and supporting depth limits. + +use std::fs; +use std::path::Path; + +use super::types::{TreeNode, TreeResult}; + +/// Generate a directory tree starting from `root`. +/// +/// Respects .gitignore, skips hidden files by default, +/// and limits depth to prevent runaway recursion. +pub fn generate_tree( + root: &Path, + max_depth: u32, + include_hidden: bool, +) -> TreeResult { + if !root.exists() || !root.is_dir() { + return TreeResult { + success: false, + root: None, + total_files: 0, + total_directories: 0, + error: Some(format!("Not a directory: {}", root.display())), + }; + } + + let mut total_files = 0u32; + let mut total_directories = 0u32; + + let tree = build_tree_node( + root, + root, + 0, + max_depth, + include_hidden, + &mut total_files, + &mut total_directories, + ); + + TreeResult { + success: true, + root: tree, + total_files, + total_directories, + error: None, + } +} + +/// Recursively build a TreeNode for a directory entry. +fn build_tree_node( + entry_path: &Path, + root: &Path, + current_depth: u32, + max_depth: u32, + include_hidden: bool, + total_files: &mut u32, + total_directories: &mut u32, +) -> Option { + let name = entry_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| entry_path.display().to_string()); + + // Skip hidden files/directories unless requested + if !include_hidden && name.starts_with('.') && entry_path != root { + return None; + } + + let relative_path = entry_path + .strip_prefix(root) + .unwrap_or(entry_path) + .display() + .to_string(); + + if entry_path.is_dir() { + *total_directories += 1; + + let mut children = Vec::new(); + + if current_depth < max_depth { + // Read directory entries + if let Ok(entries) = fs::read_dir(entry_path) { + let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + // Sort entries: directories first, then alphabetically + entries.sort_by(|a, b| { + let a_is_dir = a.path().is_dir(); + let b_is_dir = b.path().is_dir(); + match (a_is_dir, b_is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + } + }); + + for entry in entries { + let entry_name = entry.file_name().to_string_lossy().to_string(); + + // Skip common ignored directories + if entry.path().is_dir() && is_ignored_dir(&entry_name) { + continue; + } + + if let Some(child) = build_tree_node( + &entry.path(), + root, + current_depth + 1, + max_depth, + include_hidden, + total_files, + total_directories, + ) { + children.push(child); + } + } + } + } + + Some(TreeNode { + name, + path: if relative_path.is_empty() { + ".".to_string() + } else { + relative_path + }, + is_directory: true, + size_bytes: None, + children, + }) + } else { + *total_files += 1; + + let size_bytes = fs::metadata(entry_path).map(|m| m.len()).ok(); + + Some(TreeNode { + name, + path: relative_path, + is_directory: false, + size_bytes, + children: Vec::new(), + }) + } +} + +/// Common directories to skip in tree generation. +fn is_ignored_dir(name: &str) -> bool { + matches!( + name, + "node_modules" + | ".git" + | "target" + | "dist" + | "build" + | ".next" + | ".nuxt" + | ".cache" + | "__pycache__" + | ".tsbuildinfo" + | "coverage" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_tree_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src/components")).unwrap(); + fs::create_dir_all(dir.path().join("src/utils")).unwrap(); + fs::create_dir_all(dir.path().join("tests")).unwrap(); + fs::write(dir.path().join("src/main.ts"), "main").unwrap(); + fs::write(dir.path().join("src/components/App.tsx"), "app").unwrap(); + fs::write(dir.path().join("src/utils/helpers.ts"), "helpers").unwrap(); + fs::write(dir.path().join("tests/main.test.ts"), "test").unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + dir + } + + #[test] + fn test_generate_tree() { + let dir = setup_tree_dir(); + let result = generate_tree(dir.path(), 10, false); + assert!(result.success); + assert!(result.root.is_some()); + assert!(result.total_files > 0); + assert!(result.total_directories > 0); + } + + #[test] + fn test_tree_depth_limit() { + let dir = setup_tree_dir(); + + // Depth 0: only root, no children explored + let shallow = generate_tree(dir.path(), 0, false); + assert!(shallow.success); + let root = shallow.root.unwrap(); + assert!(root.children.is_empty()); + + // Depth 1: root's immediate children + let one_deep = generate_tree(dir.path(), 1, false); + assert!(one_deep.success); + let root = one_deep.root.unwrap(); + assert!(!root.children.is_empty()); + // Subdirectories at depth 1 should have no children + for child in &root.children { + if child.is_directory { + assert!(child.children.is_empty()); + } + } + } + + #[test] + fn test_tree_sorted() { + let dir = setup_tree_dir(); + let result = generate_tree(dir.path(), 10, false); + let root = result.root.unwrap(); + + // Directories should come before files + let mut saw_file = false; + for child in &root.children { + if child.is_directory { + assert!(!saw_file, "Directory after file β€” sorting broken"); + } else { + saw_file = true; + } + } + } + + #[test] + fn test_tree_skips_node_modules() { + let dir = setup_tree_dir(); + fs::create_dir_all(dir.path().join("node_modules/foo")).unwrap(); + fs::write(dir.path().join("node_modules/foo/index.js"), "x").unwrap(); + + let result = generate_tree(dir.path(), 10, false); + let root = result.root.unwrap(); + + // node_modules should not appear + for child in &root.children { + assert_ne!(child.name, "node_modules"); + } + } + + #[test] + fn test_tree_skips_hidden() { + let dir = setup_tree_dir(); + fs::create_dir_all(dir.path().join(".hidden")).unwrap(); + fs::write(dir.path().join(".hidden/secret"), "s").unwrap(); + + let result = generate_tree(dir.path(), 10, false); + let root = result.root.unwrap(); + + for child in &root.children { + assert!(!child.name.starts_with('.')); + } + } + + #[test] + fn test_tree_includes_hidden() { + let dir = setup_tree_dir(); + fs::create_dir_all(dir.path().join(".config")).unwrap(); + fs::write(dir.path().join(".config/settings.json"), "{}").unwrap(); + + let result = generate_tree(dir.path(), 10, true); + let root = result.root.unwrap(); + + let has_hidden = root.children.iter().any(|c| c.name == ".config"); + assert!(has_hidden, "Hidden directory should be included"); + } + + #[test] + fn test_tree_nonexistent() { + let result = generate_tree(Path::new("/nonexistent/path"), 10, false); + assert!(!result.success); + assert!(result.error.is_some()); + } + + #[test] + fn test_tree_file_sizes() { + let dir = setup_tree_dir(); + let result = generate_tree(dir.path(), 10, false); + let root = result.root.unwrap(); + + // Find a file and check it has size + fn find_file(node: &TreeNode) -> Option<&TreeNode> { + if !node.is_directory { + return Some(node); + } + for child in &node.children { + if let Some(f) = find_file(child) { + return Some(f); + } + } + None + } + + let file = find_file(&root).expect("Should have at least one file"); + assert!(file.size_bytes.is_some()); + assert!(file.size_bytes.unwrap() > 0); + } +} diff --git a/src/debug/jtag/workers/continuum-core/src/code/types.rs b/src/debug/jtag/workers/continuum-core/src/code/types.rs new file mode 100644 index 000000000..6d3ef3625 --- /dev/null +++ b/src/debug/jtag/workers/continuum-core/src/code/types.rs @@ -0,0 +1,239 @@ +//! Shared types for the code module. +//! +//! **Single source of truth** β€” TypeScript types are generated via `ts-rs`. +//! These are the wire types for IPC communication between TS and Rust. +//! +//! Re-generate TypeScript bindings: +//! cargo test --package continuum-core export_bindings +//! +//! Output: shared/generated/code/*.ts + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/// Every file operation creates a ChangeNode in the DAG. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ChangeNode.ts")] +pub struct ChangeNode { + #[ts(type = "string")] + pub id: Uuid, + /// Parent node IDs. Empty for root operations. Multiple for merges. + #[ts(type = "Array")] + pub parent_ids: Vec, + /// Who performed this operation (persona UUID string). + pub author_id: String, + /// When the operation occurred (unix millis). + #[ts(type = "number")] + pub timestamp: u64, + /// The file affected (relative to workspace root). + pub file_path: String, + /// The operation type. + pub operation: FileOperation, + /// Forward diff (apply to go forward in time). + pub forward_diff: FileDiff, + /// Reverse diff (apply to go backward in time β€” undo). + pub reverse_diff: FileDiff, + /// Optional description from the AI about what this change does. + #[ts(optional)] + pub description: Option, + /// Workspace ID this change belongs to. + pub workspace_id: String, +} + +/// File operation types. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "../../../shared/generated/code/FileOperation.ts")] +pub enum FileOperation { + Create, + Write, + Edit, + Delete, + Rename { + from: String, + to: String, + }, + /// An undo operation that reversed a previous change. + Undo { + #[ts(type = "string")] + reverted_id: Uuid, + }, +} + +/// A file diff consisting of hunks. +#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] +#[ts(export, export_to = "../../../shared/generated/code/FileDiff.ts")] +pub struct FileDiff { + /// Unified diff text (compatible with standard tooling). + pub unified: String, + /// Structured hunks for programmatic application. + pub hunks: Vec, +} + +/// A single hunk in a unified diff. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/DiffHunk.ts")] +pub struct DiffHunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// The hunk content (with +/- prefixes on each line). + pub content: String, +} + +/// How to edit a file (four modes). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export, export_to = "../../../shared/generated/code/EditMode.ts")] +pub enum EditMode { + /// Replace content between line numbers (1-indexed, inclusive). + LineRange { + start_line: u32, + end_line: u32, + new_content: String, + }, + /// Find text and replace it. + SearchReplace { + search: String, + replace: String, + #[serde(default)] + all: bool, + }, + /// Insert content at a specific line (pushes existing lines down). + InsertAt { + line: u32, + content: String, + }, + /// Append content to end of file. + Append { + content: String, + }, +} + +/// Result of a file write/edit/delete operation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/WriteResult.ts")] +pub struct WriteResult { + pub success: bool, + /// UUID of the ChangeNode created. + #[ts(optional)] + pub change_id: Option, + pub file_path: String, + #[ts(type = "number")] + pub bytes_written: u64, + #[ts(optional)] + pub error: Option, +} + +/// Result of a file read operation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/ReadResult.ts")] +pub struct ReadResult { + pub success: bool, + #[ts(optional)] + pub content: Option, + pub file_path: String, + pub total_lines: u32, + pub lines_returned: u32, + pub start_line: u32, + pub end_line: u32, + #[ts(type = "number")] + pub size_bytes: u64, + #[ts(optional)] + pub error: Option, +} + +/// A single search match. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/SearchMatch.ts")] +pub struct SearchMatch { + pub file_path: String, + pub line_number: u32, + pub line_content: String, + pub match_start: u32, + pub match_end: u32, +} + +/// Result of a code search operation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/SearchResult.ts")] +pub struct SearchResult { + pub success: bool, + pub matches: Vec, + pub total_matches: u32, + pub files_searched: u32, + #[ts(optional)] + pub error: Option, +} + +/// A node in a directory tree. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/TreeNode.ts")] +pub struct TreeNode { + pub name: String, + pub path: String, + pub is_directory: bool, + #[ts(optional, type = "number")] + pub size_bytes: Option, + pub children: Vec, +} + +/// Result of a tree operation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/TreeResult.ts")] +pub struct TreeResult { + pub success: bool, + #[ts(optional)] + pub root: Option, + pub total_files: u32, + pub total_directories: u32, + #[ts(optional)] + pub error: Option, +} + +/// Result of an undo operation. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/UndoResult.ts")] +pub struct UndoResult { + pub success: bool, + pub changes_undone: Vec, + #[ts(optional)] + pub error: Option, +} + +/// History query result. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/HistoryResult.ts")] +pub struct HistoryResult { + pub success: bool, + pub nodes: Vec, + pub total_count: u32, + #[ts(optional)] + pub error: Option, +} + +/// Git status information. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/code/GitStatusInfo.ts")] +pub struct GitStatusInfo { + pub success: bool, + #[ts(optional)] + pub branch: Option, + pub modified: Vec, + pub added: Vec, + pub deleted: Vec, + pub untracked: Vec, + #[ts(optional)] + pub error: Option, +} + +/// Allowed file extensions for write operations. +pub const ALLOWED_EXTENSIONS: &[&str] = &[ + "ts", "tsx", "js", "jsx", "json", "md", "css", "html", + "rs", "toml", "yaml", "yml", "txt", "sh", "py", +]; + +/// Maximum file size for write operations (1MB). +pub const MAX_WRITE_SIZE: u64 = 1_048_576; diff --git a/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs b/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs index 6c03a187d..a4090cc0b 100644 --- a/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs +++ b/src/debug/jtag/workers/continuum-core/src/ipc/mod.rs @@ -12,6 +12,7 @@ use crate::voice::{UtteranceEvent, VoiceParticipant}; use crate::persona::{PersonaInbox, PersonaCognitionEngine, InboxMessage, SenderType, Modality, ChannelRegistry, ChannelEnqueueRequest, ActivityDomain, PersonaState}; use crate::rag::RagEngine; use crate::logging::TimingGuard; +use crate::code::{self, FileEngine, PathSecurity, ShellSession}; use ts_rs::TS; use crate::{log_debug, log_info, log_error}; use serde::{Deserialize, Serialize}; @@ -284,6 +285,229 @@ enum Request { event: crate::memory::CorpusTimelineEvent, }, + // ======================================================================== + // Code Module Commands + // ======================================================================== + + /// Create a per-persona file engine (workspace). + #[serde(rename = "code/create-workspace")] + CodeCreateWorkspace { + persona_id: String, + workspace_root: String, + #[serde(default)] + read_roots: Vec, + }, + + /// Read a file (or line range). + #[serde(rename = "code/read")] + CodeRead { + persona_id: String, + file_path: String, + start_line: Option, + end_line: Option, + }, + + /// Write/create a file. + #[serde(rename = "code/write")] + CodeWrite { + persona_id: String, + file_path: String, + content: String, + description: Option, + }, + + /// Edit a file using an EditMode. + #[serde(rename = "code/edit")] + CodeEdit { + persona_id: String, + file_path: String, + edit_mode: code::EditMode, + description: Option, + }, + + /// Delete a file. + #[serde(rename = "code/delete")] + CodeDelete { + persona_id: String, + file_path: String, + description: Option, + }, + + /// Preview an edit as a unified diff (read-only). + #[serde(rename = "code/diff")] + CodeDiff { + persona_id: String, + file_path: String, + edit_mode: code::EditMode, + }, + + /// Undo a specific change or the last N changes. + #[serde(rename = "code/undo")] + CodeUndo { + persona_id: String, + change_id: Option, + count: Option, + }, + + /// Get change history for a file or workspace. + #[serde(rename = "code/history")] + CodeHistory { + persona_id: String, + file_path: Option, + limit: Option, + }, + + /// Search files with regex + optional glob filter. + #[serde(rename = "code/search")] + CodeSearch { + persona_id: String, + pattern: String, + file_glob: Option, + max_results: Option, + }, + + /// Generate a directory tree. + #[serde(rename = "code/tree")] + CodeTree { + persona_id: String, + path: Option, + max_depth: Option, + #[serde(default)] + include_hidden: bool, + }, + + /// Get git status for the workspace. + #[serde(rename = "code/git-status")] + CodeGitStatus { + persona_id: String, + }, + + /// Get git diff (staged or unstaged). + #[serde(rename = "code/git-diff")] + CodeGitDiff { + persona_id: String, + #[serde(default)] + staged: bool, + }, + + /// Get git log (last N commits). + #[serde(rename = "code/git-log")] + CodeGitLog { + persona_id: String, + count: Option, + }, + + /// Stage files for commit. + #[serde(rename = "code/git-add")] + CodeGitAdd { + persona_id: String, + paths: Vec, + }, + + /// Create a git commit. + #[serde(rename = "code/git-commit")] + CodeGitCommit { + persona_id: String, + message: String, + }, + + /// Push to remote. + #[serde(rename = "code/git-push")] + CodeGitPush { + persona_id: String, + #[serde(default)] + remote: String, + #[serde(default)] + branch: String, + }, + + // ── Shell Session Commands ────────────────────────────────────── + + /// Create a shell session for a workspace. + #[serde(rename = "code/shell-create")] + CodeShellCreate { + persona_id: String, + /// Workspace root directory (must match file engine workspace). + workspace_root: String, + }, + + /// Execute a command in a shell session. + /// Returns immediately with execution_id (handle). + /// If `wait` is true, blocks until completion and returns full result. + #[serde(rename = "code/shell-execute")] + CodeShellExecute { + persona_id: String, + /// The shell command to execute (named `cmd` to avoid serde tag conflict with `command`). + cmd: String, + #[serde(default)] + timeout_ms: Option, + /// If true, block until completion and return full result. + #[serde(default)] + wait: bool, + }, + + /// Poll an execution for new output since last poll. + #[serde(rename = "code/shell-poll")] + CodeShellPoll { + persona_id: String, + execution_id: String, + }, + + /// Kill a running execution. + #[serde(rename = "code/shell-kill")] + CodeShellKill { + persona_id: String, + execution_id: String, + }, + + /// Change the shell session's working directory. + #[serde(rename = "code/shell-cd")] + CodeShellCd { + persona_id: String, + path: String, + }, + + /// Get shell session status/info. + #[serde(rename = "code/shell-status")] + CodeShellStatus { + persona_id: String, + }, + + /// Watch an execution for new output. Blocks until output is available + /// (no timeout, no polling). Returns classified lines via sentinel rules. + #[serde(rename = "code/shell-watch")] + CodeShellWatch { + persona_id: String, + execution_id: String, + }, + + /// Configure sentinel filter rules on an execution. + /// Rules classify output lines and control which are emitted or suppressed. + #[serde(rename = "code/shell-sentinel")] + CodeShellSentinel { + persona_id: String, + execution_id: String, + rules: Vec, + }, + + /// Destroy a shell session (kills all running executions). + #[serde(rename = "code/shell-destroy")] + CodeShellDestroy { + persona_id: String, + }, + + // ======================================================================== + // Model Discovery Commands + // ======================================================================== + + /// Discover model metadata from provider APIs. + /// ALL HTTP I/O runs here in Rust (off Node.js main thread). + /// Returns discovered models for TypeScript to populate ModelRegistry. + #[serde(rename = "models/discover")] + ModelsDiscover { + providers: Vec, + }, + #[serde(rename = "health-check")] HealthCheck, @@ -353,6 +577,10 @@ struct ServerState { /// Per-persona memory manager β€” pure compute on in-memory MemoryCorpus. /// Data comes from the TS ORM via IPC. Zero SQL access. memory_manager: Arc, + /// Per-persona file engines β€” workspace-scoped file operations with change tracking. + file_engines: Arc>, + /// Per-persona shell sessions β€” persistent bash per workspace with handle+poll. + shell_sessions: Arc>, } impl ServerState { @@ -371,6 +599,8 @@ impl ServerState { audio_pool: Arc::new(crate::voice::audio_buffer::AudioBufferPool::new()), rt_handle, memory_manager, + file_engines: Arc::new(DashMap::new()), + shell_sessions: Arc::new(DashMap::new()), } } @@ -1168,6 +1398,566 @@ impl ServerState { }) } + // ================================================================ + // Code Module Handlers + // ================================================================ + + Request::CodeCreateWorkspace { persona_id, workspace_root, read_roots } => { + let _timer = TimingGuard::new("ipc", "code_create_workspace"); + + let root = std::path::Path::new(&workspace_root); + let security = match PathSecurity::new(root) { + Ok(mut s) => { + for rr in &read_roots { + if let Err(e) = s.add_read_root(std::path::Path::new(rr)) { + return HandleResult::Json(Response::error( + format!("Invalid read root '{}': {}", rr, e) + )); + } + } + s + } + Err(e) => { + return HandleResult::Json(Response::error(format!("Invalid workspace: {}", e))); + } + }; + + let engine = FileEngine::new(&persona_id, security); + self.file_engines.insert(persona_id.clone(), engine); + + log_info!("ipc", "code", "Created workspace for {} at {}", persona_id, workspace_root); + HandleResult::Json(Response::success(serde_json::json!({ "created": true }))) + } + + Request::CodeRead { persona_id, file_path, start_line, end_line } => { + let _timer = TimingGuard::new("ipc", "code_read"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match engine.read(&file_path, start_line, end_line) { + Ok(result) => HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + + Request::CodeWrite { persona_id, file_path, content, description } => { + let _timer = TimingGuard::new("ipc", "code_write"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match engine.write(&file_path, &content, description.as_deref()) { + Ok(result) => { + log_info!("ipc", "code", "Write {} ({} bytes) by {}", + file_path, result.bytes_written, persona_id); + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + + Request::CodeEdit { persona_id, file_path, edit_mode, description } => { + let _timer = TimingGuard::new("ipc", "code_edit"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match engine.edit(&file_path, &edit_mode, description.as_deref()) { + Ok(result) => { + log_info!("ipc", "code", "Edit {} by {}", file_path, persona_id); + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + + Request::CodeDelete { persona_id, file_path, description } => { + let _timer = TimingGuard::new("ipc", "code_delete"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match engine.delete(&file_path, description.as_deref()) { + Ok(result) => { + log_info!("ipc", "code", "Delete {} by {}", file_path, persona_id); + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + + Request::CodeDiff { persona_id, file_path, edit_mode } => { + let _timer = TimingGuard::new("ipc", "code_diff"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match engine.preview_diff(&file_path, &edit_mode) { + Ok(diff) => HandleResult::Json(Response::success( + serde_json::to_value(&diff).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + + Request::CodeUndo { persona_id, change_id, count } => { + let _timer = TimingGuard::new("ipc", "code_undo"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + if let Some(id_str) = change_id { + // Undo specific change + let change_uuid = match Uuid::parse_str(&id_str) { + Ok(u) => u, + Err(e) => return HandleResult::Json(Response::error( + format!("Invalid change_id: {}", e) + )), + }; + match engine.undo(&change_uuid) { + Ok(result) => { + log_info!("ipc", "code", "Undo {} by {}", id_str, persona_id); + HandleResult::Json(Response::success(serde_json::json!({ + "success": true, + "changes_undone": [serde_json::to_value(&result).unwrap_or_default()], + "error": null + }))) + } + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } else { + // Undo last N + let n = count.unwrap_or(1); + match engine.undo_last(n) { + Ok(result) => HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(format!("{}", e))), + } + } + } + + Request::CodeHistory { persona_id, file_path, limit } => { + let _timer = TimingGuard::new("ipc", "code_history"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + let lim = limit.unwrap_or(50); + let result = if let Some(fp) = file_path { + engine.file_history(&fp, lim) + } else { + engine.workspace_history(lim) + }; + + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + + Request::CodeSearch { persona_id, pattern, file_glob, max_results } => { + let _timer = TimingGuard::new("ipc", "code_search"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + let max = max_results.unwrap_or(100); + let result = code::search::search_files( + &engine.workspace_root(), + &pattern, + file_glob.as_deref(), + max, + ); + + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + + Request::CodeTree { persona_id, path, max_depth, include_hidden } => { + let _timer = TimingGuard::new("ipc", "code_tree"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + let root = match &path { + Some(p) => engine.workspace_root().join(p), + None => engine.workspace_root(), + }; + let depth = max_depth.unwrap_or(5); + let result = code::tree::generate_tree(&root, depth, include_hidden); + + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + + Request::CodeGitStatus { persona_id } => { + let _timer = TimingGuard::new("ipc", "code_git_status"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + let result = code::git_bridge::git_status(&engine.workspace_root()); + HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )) + } + + Request::CodeGitDiff { persona_id, staged } => { + let _timer = TimingGuard::new("ipc", "code_git_diff"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match code::git_bridge::git_diff(&engine.workspace_root(), staged) { + Ok(diff) => HandleResult::Json(Response::success(serde_json::json!({ + "diff": diff + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeGitLog { persona_id, count } => { + let _timer = TimingGuard::new("ipc", "code_git_log"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match code::git_bridge::git_log(&engine.workspace_root(), count.unwrap_or(10)) { + Ok(log) => HandleResult::Json(Response::success(serde_json::json!({ + "log": log + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeGitAdd { persona_id, paths } => { + let _timer = TimingGuard::new("ipc", "code_git_add"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + match code::git_bridge::git_add(&engine.workspace_root(), &path_refs) { + Ok(_) => HandleResult::Json(Response::success(serde_json::json!({ + "staged": paths + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeGitCommit { persona_id, message } => { + let _timer = TimingGuard::new("ipc", "code_git_commit"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match code::git_bridge::git_commit(&engine.workspace_root(), &message) { + Ok(hash) => HandleResult::Json(Response::success(serde_json::json!({ + "hash": hash + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeGitPush { persona_id, remote, branch } => { + let _timer = TimingGuard::new("ipc", "code_git_push"); + + let engine = match self.file_engines.get(&persona_id) { + Some(e) => e, + None => return HandleResult::Json(Response::error( + format!("No workspace for persona {}", persona_id) + )), + }; + + match code::git_bridge::git_push(&engine.workspace_root(), &remote, &branch) { + Ok(output) => HandleResult::Json(Response::success(serde_json::json!({ + "output": output + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + // ── Shell Session Handlers ────────────────────────────────── + + Request::CodeShellCreate { persona_id, workspace_root } => { + let _timer = TimingGuard::new("ipc", "code_shell_create"); + + let root = std::path::Path::new(&workspace_root); + match ShellSession::new(&persona_id, &persona_id, root) { + Ok(session) => { + let info = session.info(); + self.shell_sessions.insert(persona_id.clone(), session); + log_info!("ipc", "shell", "Created shell session for {} at {}", persona_id, workspace_root); + HandleResult::Json(Response::success( + serde_json::to_value(&info).unwrap_or_default() + )) + } + Err(e) => HandleResult::Json(Response::error( + format!("Failed to create shell session: {}", e) + )), + } + } + + Request::CodeShellExecute { persona_id, cmd, timeout_ms, wait } => { + let _timer = TimingGuard::new("ipc", "code_shell_execute"); + + let mut session = match self.shell_sessions.get_mut(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + if wait { + // Blocking mode: wait for completion, return full result + match session.execute_and_wait(&cmd, timeout_ms, &self.rt_handle) { + Ok(result) => HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(e)), + } + } else { + // Handle mode: return immediately with execution_id + match session.execute(&cmd, timeout_ms, &self.rt_handle) { + Ok(execution_id) => { + let response = code::shell_types::ShellExecuteResponse { + execution_id, + status: code::shell_types::ShellExecutionStatus::Running, + stdout: None, + stderr: None, + exit_code: None, + }; + HandleResult::Json(Response::success( + serde_json::to_value(&response).unwrap_or_default() + )) + } + Err(e) => HandleResult::Json(Response::error(e)), + } + } + } + + Request::CodeShellPoll { persona_id, execution_id } => { + let _timer = TimingGuard::new("ipc", "code_shell_poll"); + + let session = match self.shell_sessions.get(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + match session.poll(&execution_id) { + Ok(result) => HandleResult::Json(Response::success( + serde_json::to_value(&result).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeShellKill { persona_id, execution_id } => { + let _timer = TimingGuard::new("ipc", "code_shell_kill"); + + let session = match self.shell_sessions.get(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + match session.kill(&execution_id) { + Ok(()) => HandleResult::Json(Response::success(serde_json::json!({ + "killed": true + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeShellCd { persona_id, path } => { + let _timer = TimingGuard::new("ipc", "code_shell_cd"); + + let mut session = match self.shell_sessions.get_mut(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + match session.cd(&path) { + Ok(new_cwd) => HandleResult::Json(Response::success(serde_json::json!({ + "cwd": new_cwd + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeShellStatus { persona_id } => { + let _timer = TimingGuard::new("ipc", "code_shell_status"); + + let session = match self.shell_sessions.get(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + let info = session.info(); + HandleResult::Json(Response::success( + serde_json::to_value(&info).unwrap_or_default() + )) + } + + Request::CodeShellWatch { persona_id, execution_id } => { + let _timer = TimingGuard::new("ipc", "code_shell_watch"); + + // Extract watch handles THEN release the DashMap lock before blocking. + let handles = { + let session = match self.shell_sessions.get(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + session.get_watch_handles(&execution_id) + // DashMap Ref dropped here + }; + + match handles { + Err(e) => HandleResult::Json(Response::error(e)), + Ok((exec_state, notify)) => { + // Block this IPC thread until output is available. + // Safe: IPC runs on std threads, not inside the tokio runtime. + match self.rt_handle.block_on( + code::shell_session::watch_execution(&execution_id, exec_state, notify) + ) { + Ok(response) => HandleResult::Json(Response::success( + serde_json::to_value(&response).unwrap_or_default() + )), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + } + } + + Request::CodeShellSentinel { persona_id, execution_id, rules } => { + let _timer = TimingGuard::new("ipc", "code_shell_sentinel"); + + let session = match self.shell_sessions.get(&persona_id) { + Some(s) => s, + None => return HandleResult::Json(Response::error( + format!("No shell session for persona {}", persona_id) + )), + }; + + match session.set_sentinel(&execution_id, &rules) { + Ok(count) => HandleResult::Json(Response::success(serde_json::json!({ + "applied": true, + "ruleCount": count + }))), + Err(e) => HandleResult::Json(Response::error(e)), + } + } + + Request::CodeShellDestroy { persona_id } => { + let _timer = TimingGuard::new("ipc", "code_shell_destroy"); + + if let Some(mut session) = self.shell_sessions.get_mut(&persona_id) { + session.destroy(); + } + self.shell_sessions.remove(&persona_id); + + log_info!("ipc", "shell", "Destroyed shell session for {}", persona_id); + HandleResult::Json(Response::success(serde_json::json!({ + "destroyed": true + }))) + } + + Request::ModelsDiscover { providers } => { + let _timer = TimingGuard::new("ipc", "models_discover"); + let provider_count = providers.len(); + + // Run async discovery on the tokio runtime (all HTTP I/O off main thread) + let models = self.rt_handle.block_on(async { + crate::models::discover_all(providers).await + }); + + let model_count = models.len(); + log_info!("ipc", "models", + "Discovered {} models from {} providers", model_count, provider_count); + + HandleResult::Json(Response::success(serde_json::json!({ + "models": models, + "count": model_count, + "providers": provider_count + }))) + } + Request::HealthCheck => { HandleResult::Json(Response::success(serde_json::json!({ "healthy": true }))) } @@ -1241,14 +2031,50 @@ fn send_binary_frame(stream: &mut UnixStream, response: &Response, binary_data: stream.flush() } -fn handle_client(mut stream: UnixStream, state: Arc) -> std::io::Result<()> { +/// Handle a single IPC client connection with concurrent request processing. +/// +/// Architecture: +/// - Reader thread (this function): reads newline-delimited JSON requests from the socket +/// - Writer thread: serializes responses back to the socket in arrival order +/// - Rayon pool: processes each request concurrently on worker threads +/// +/// The TS client multiplexes via requestId β€” responses can arrive in any order. +/// This eliminates the sequential bottleneck where 6 concurrent requests from +/// RAGComposer (global-awareness, semantic-memory, etc.) were serialized per-connection. +fn handle_client(stream: UnixStream, state: Arc) -> std::io::Result<()> { let peer_addr = stream.peer_addr()?; log_debug!("ipc", "server", "Client connected: {:?}", peer_addr); - // Requests still arrive as newline-delimited JSON (small control messages). - // Responses use length-prefixed binary framing (supports large audio payloads). let reader = BufReader::new(stream.try_clone()?); + // Response channel β€” rayon tasks send completed results, writer thread serializes to socket. + // Unbounded: request rate is limited by socket read speed, not processing speed. + let (tx, rx) = std::sync::mpsc::channel::<(Option, HandleResult)>(); + + // Writer thread β€” owns the write half of the socket, serializes response frames. + // Multiple rayon tasks complete concurrently; this thread ensures atomic frame writes. + let mut writer_stream = stream.try_clone()?; + let writer_handle = std::thread::spawn(move || { + for (request_id, result) in rx { + let write_result = match result { + HandleResult::Json(response) => { + let response = response.with_request_id(request_id); + send_json_frame(&mut writer_stream, &response) + } + HandleResult::Binary { json_header, binary_data } => { + let json_header = json_header.with_request_id(request_id); + send_binary_frame(&mut writer_stream, &json_header, &binary_data) + } + }; + if let Err(e) = write_result { + log_error!("ipc", "server", "Write error: {}", e); + break; + } + } + }); + + // Reader loop β€” parse requests and dispatch to rayon for concurrent processing. + // No longer blocks waiting for handle_request() to complete before reading next request. for line in reader.lines() { let line = line?; if line.is_empty() { @@ -1259,41 +2085,35 @@ fn handle_client(mut stream: UnixStream, state: Arc) -> std::io::Re let json_value: serde_json::Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { - let response = Response::error(format!("Invalid JSON: {e}")); - send_json_frame(&mut stream, &response)?; + let _ = tx.send((None, HandleResult::Json(Response::error(format!("Invalid JSON: {e}"))))); continue; } }; - // Extract requestId if present let request_id = json_value.get("requestId").and_then(|v| v.as_u64()); - // Parse request let request: Request = match serde_json::from_value(json_value) { Ok(r) => r, Err(e) => { - let response = Response::error(format!("Invalid request: {e}")).with_request_id(request_id); - send_json_frame(&mut stream, &response)?; + let _ = tx.send((request_id, HandleResult::Json(Response::error(format!("Invalid request: {e}"))))); continue; } }; - // Handle request - let result = state.handle_request(request); - - // Send response using appropriate framing - match result { - HandleResult::Json(response) => { - let response = response.with_request_id(request_id); - send_json_frame(&mut stream, &response)?; - } - HandleResult::Binary { json_header, binary_data } => { - let json_header = json_header.with_request_id(request_id); - send_binary_frame(&mut stream, &json_header, &binary_data)?; - } - } + // Dispatch to rayon thread pool β€” each request runs concurrently. + // handle_request(&self) is safe for concurrent calls (DashMap per-key locking). + let state = state.clone(); + let tx = tx.clone(); + rayon::spawn(move || { + let result = state.handle_request(request); + let _ = tx.send((request_id, result)); + }); } + // Drop sender to signal writer thread to exit, then wait for it + drop(tx); + let _ = writer_handle.join(); + log_debug!("ipc", "server", "Client disconnected: {:?}", peer_addr); Ok(()) } diff --git a/src/debug/jtag/workers/continuum-core/src/lib.rs b/src/debug/jtag/workers/continuum-core/src/lib.rs index 9bcf967f7..5124cef59 100644 --- a/src/debug/jtag/workers/continuum-core/src/lib.rs +++ b/src/debug/jtag/workers/continuum-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod ffi; pub mod utils; pub mod rag; pub mod memory; +pub mod code; +pub mod models; pub use audio_constants::*; diff --git a/src/debug/jtag/workers/continuum-core/src/persona/channel_items.rs b/src/debug/jtag/workers/continuum-core/src/persona/channel_items.rs index e439e238a..1b86aefb4 100644 --- a/src/debug/jtag/workers/continuum-core/src/persona/channel_items.rs +++ b/src/debug/jtag/workers/continuum-core/src/persona/channel_items.rs @@ -338,6 +338,71 @@ impl TaskQueueItem { } } +//============================================================================= +// CODE QUEUE ITEM +//============================================================================= + +/// Code: workspace-scoped coding tasks. Not urgent, never kicked, slow aging. +/// Consolidates multiple requests for the same workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeQueueItem { + pub id: Uuid, + pub room_id: Uuid, + pub persona_id: Uuid, + pub task_description: String, + pub workspace_handle: String, + pub priority: f32, + pub is_review: bool, + pub timestamp: u64, + pub enqueued_at: u64, +} + +impl QueueItemBehavior for CodeQueueItem { + fn item_type(&self) -> &'static str { "code" } + fn domain(&self) -> ActivityDomain { ActivityDomain::Code } + fn id(&self) -> Uuid { self.id } + fn timestamp(&self) -> u64 { self.timestamp } + fn base_priority(&self) -> f32 { self.priority } + + // Slow aging β€” coding tasks are long-lived, 60s to reach max boost + fn aging_boost_ms(&self) -> f32 { 60_000.0 } + + // Not urgent β€” coding is not real-time + fn is_urgent(&self) -> bool { false } + + // Never kicked β€” don't drop active coding work + fn can_be_kicked(&self) -> bool { false } + fn kick_resistance(&self, _now_ms: u64, _enqueued_at_ms: u64) -> f32 { f32::INFINITY } + + // Consolidate multiple requests for the same workspace + fn should_consolidate_with(&self, other: &dyn QueueItemBehavior) -> bool { + if other.item_type() != "code" { + return false; + } + if let Some(other_code) = other.as_any().downcast_ref::() { + other_code.workspace_handle == self.workspace_handle + } else { + false + } + } + + fn as_any(&self) -> &dyn Any { self } + + fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "type": "code", + "id": self.id.to_string(), + "roomId": self.room_id.to_string(), + "personaId": self.persona_id.to_string(), + "taskDescription": self.task_description, + "workspaceHandle": self.workspace_handle, + "priority": self.priority, + "isReview": self.is_review, + "timestamp": self.timestamp, + }) + } +} + //============================================================================= // IPC REQUEST TYPES β€” For receiving items from TypeScript //============================================================================= @@ -392,6 +457,18 @@ pub enum ChannelEnqueueRequest { depends_on: Vec, blocked_by: Vec, }, + #[serde(rename = "code")] + Code { + id: String, + room_id: String, + persona_id: String, + task_description: String, + workspace_handle: String, + priority: f32, + is_review: bool, + #[ts(type = "number")] + timestamp: u64, + }, } impl ChannelEnqueueRequest { @@ -435,6 +512,22 @@ impl ChannelEnqueueRequest { consolidated_context: Vec::new(), })) } + ChannelEnqueueRequest::Code { + id, room_id, persona_id, task_description, + workspace_handle, priority, is_review, timestamp, + } => { + Ok(Box::new(CodeQueueItem { + id: parse_uuid(id, "id")?, + room_id: parse_uuid(room_id, "room_id")?, + persona_id: parse_uuid(persona_id, "persona_id")?, + task_description: task_description.clone(), + workspace_handle: workspace_handle.clone(), + priority: *priority, + is_review: *is_review, + timestamp: *timestamp, + enqueued_at: now, + })) + } ChannelEnqueueRequest::Task { id, task_id, assignee_id, created_by, task_domain, task_type, context_id, description, priority, status, diff --git a/src/debug/jtag/workers/continuum-core/src/persona/channel_registry.rs b/src/debug/jtag/workers/continuum-core/src/persona/channel_registry.rs index 131e06b24..ee02ceba1 100644 --- a/src/debug/jtag/workers/continuum-core/src/persona/channel_registry.rs +++ b/src/debug/jtag/workers/continuum-core/src/persona/channel_registry.rs @@ -39,6 +39,11 @@ impl ChannelRegistry { max_size: 500, name: "CHAT".into(), })); + registry.register(ChannelQueue::new(ChannelQueueConfig { + domain: ActivityDomain::Code, + max_size: 100, + name: "CODE".into(), + })); registry.register(ChannelQueue::new(ChannelQueueConfig { domain: ActivityDomain::Background, max_size: 200, @@ -229,6 +234,7 @@ fn domain_name(domain: ActivityDomain) -> &'static str { match domain { ActivityDomain::Audio => "AUDIO", ActivityDomain::Chat => "CHAT", + ActivityDomain::Code => "CODE", ActivityDomain::Background => "BACKGROUND", } } @@ -287,6 +293,7 @@ mod tests { let registry = ChannelRegistry::new(); assert!(registry.get(ActivityDomain::Audio).is_some()); assert!(registry.get(ActivityDomain::Chat).is_some()); + assert!(registry.get(ActivityDomain::Code).is_some()); assert!(registry.get(ActivityDomain::Background).is_some()); } @@ -343,7 +350,7 @@ mod tests { assert_eq!(status.total_size, 2); assert!(status.has_urgent_work); assert!(status.has_work); - assert_eq!(status.channels.len(), 3); // All domains reported + assert_eq!(status.channels.len(), 4); // All domains reported } #[test] diff --git a/src/debug/jtag/workers/continuum-core/src/persona/channel_types.rs b/src/debug/jtag/workers/continuum-core/src/persona/channel_types.rs index ccdf0157c..80ec5ca08 100644 --- a/src/debug/jtag/workers/continuum-core/src/persona/channel_types.rs +++ b/src/debug/jtag/workers/continuum-core/src/persona/channel_types.rs @@ -26,13 +26,10 @@ pub enum ActivityDomain { Audio, /// Chat messages: per-room consolidation, mention urgency, RTOS aging Chat, + /// Code: workspace-scoped coding tasks, not urgent, never kicked, slow aging + Code, /// Background tasks: dependency-aware, overdue urgency Background, - // Future domains: - // RealtimeGame, - // Code, - // Music, - // RobotControl, } /// All currently registered domains in priority order (highest first). @@ -40,6 +37,7 @@ pub enum ActivityDomain { pub const DOMAIN_PRIORITY_ORDER: &[ActivityDomain] = &[ ActivityDomain::Audio, ActivityDomain::Chat, + ActivityDomain::Code, ActivityDomain::Background, ]; @@ -229,6 +227,7 @@ mod tests { fn test_domain_priority_order() { assert_eq!(DOMAIN_PRIORITY_ORDER[0], ActivityDomain::Audio); assert_eq!(DOMAIN_PRIORITY_ORDER[1], ActivityDomain::Chat); - assert_eq!(DOMAIN_PRIORITY_ORDER[2], ActivityDomain::Background); + assert_eq!(DOMAIN_PRIORITY_ORDER[2], ActivityDomain::Code); + assert_eq!(DOMAIN_PRIORITY_ORDER[3], ActivityDomain::Background); } } diff --git a/src/debug/jtag/workers/logger/src/file_manager.rs b/src/debug/jtag/workers/logger/src/file_manager.rs index 9f0ba66be..41c18022f 100644 --- a/src/debug/jtag/workers/logger/src/file_manager.rs +++ b/src/debug/jtag/workers/logger/src/file_manager.rs @@ -104,6 +104,29 @@ pub fn active_category_count(file_cache: &FileCache) -> usize { file_cache.lock().unwrap().len() } +/// Flush all open file handles to disk. +/// +/// Called periodically by the writer thread (every 250ms or after a batch). +/// This is the ONLY place flush() should be called β€” individual writes do NOT flush. +/// +/// PERFORMANCE: Acquires global cache lock briefly to snapshot handles, +/// then flushes each file with per-file locks (no global contention during I/O). +pub fn flush_all(file_cache: &FileCache) { + // Snapshot all file handles (brief global lock) + let handles: Vec = { + let cache = file_cache.lock().unwrap(); + cache.values().cloned().collect() + }; // Global lock released + + // Flush each file independently (per-file locks) + for locked_file in handles { + let mut file = locked_file.lock().unwrap(); + if let Err(e) = file.flush() { + eprintln!("❌ Logger flush error: {e}"); + } + } +} + // ============================================================================ // Internal Implementation // ============================================================================ @@ -195,10 +218,10 @@ fn write_header( }; // Global lock released here // Write header using per-file lock (no global contention) + // NOTE: No flush() here β€” batched flushing via flush_all() { let mut file = locked_file.lock().unwrap(); file.write_all(header.as_bytes())?; - file.flush()?; } // Per-file lock released here // Mark header as written @@ -208,10 +231,11 @@ fn write_header( Ok(bytes) } -/// Write log entry to file. +/// Write log entry to file (NO flush β€” caller is responsible for periodic flushing). /// /// PERFORMANCE: Global cache lock held ONLY during lookup. /// File write uses per-file lock (no contention). +/// Flush is deferred to `flush_all()` which runs on a periodic timer. fn write_entry(category: &str, log_entry: &str, file_cache: &FileCache) -> WriteResult { // Get locked file handle from cache (brief global lock) let locked_file = { @@ -220,10 +244,10 @@ fn write_entry(category: &str, log_entry: &str, file_cache: &FileCache) -> Write }; // Global lock released here // Write entry using per-file lock (no global contention) + // NOTE: No flush() here β€” batched flushing via flush_all() is ~100x faster { let mut file = locked_file.lock().unwrap(); file.write_all(log_entry.as_bytes())?; - file.flush()?; } // Per-file lock released here Ok(log_entry.len()) diff --git a/src/debug/jtag/workers/logger/src/main.rs b/src/debug/jtag/workers/logger/src/main.rs index d467b13d9..9ba224a44 100644 --- a/src/debug/jtag/workers/logger/src/main.rs +++ b/src/debug/jtag/workers/logger/src/main.rs @@ -19,11 +19,13 @@ mod connection_handler; mod file_manager; mod health; mod messages; +mod rate_limiter; use std::os::unix::net::UnixListener; use std::path::Path; use std::sync::mpsc; use std::thread; +use std::time::Duration; // ============================================================================ // Main Entry Point @@ -62,19 +64,117 @@ fn main() -> std::io::Result<()> { // Create log queue channel (unbounded for max throughput) let (log_tx, log_rx) = mpsc::channel::(); - // Spawn dedicated writer thread (drains queue and writes to files) + // Spawn dedicated writer thread with BATCHED flushing + rate limiting + // + // Instead of flushing to disk after every message (which was causing 55%+ of + // main-thread time in IPC latency), we now: + // 1. Rate-limit per category (100 msg/sec default β€” drops excess, logs warning) + // 2. Write messages to OS buffers (fast, no disk I/O) + // 3. Drain the channel in batches (non-blocking try_recv after first message) + // 4. Flush all dirty files every 250ms OR after 200 messages (whichever first) + // + // This reduces disk flushes from ~700/sec (peak) to ~4/sec + // and prevents any single category from flooding disk I/O. let writer_file_cache = file_cache.clone(); let writer_headers = headers_written.clone(); let writer_log_dir = log_dir.clone(); thread::spawn(move || { - for payload in log_rx.iter() { - if let Err(e) = file_manager::write_log_message( - &payload, - &writer_log_dir, - &writer_file_cache, - &writer_headers, - ) { - eprintln!("❌ Logger write error: {e}"); + const FLUSH_INTERVAL: Duration = Duration::from_millis(250); + const MAX_BATCH_BEFORE_FLUSH: usize = 200; + + let mut pending_writes: usize = 0; + + // Rate limiter: 100 messages/sec per category (prevents spam flooding) + let mut limiter = rate_limiter::RateLimiter::new(100); + + // Process a single payload with rate limiting + let process_payload = |payload: &messages::WriteLogPayload, + limiter: &mut rate_limiter::RateLimiter, + pending: &mut usize| { + match limiter.check(&payload.category) { + rate_limiter::RateDecision::Allow => { + if let Err(e) = file_manager::write_log_message( + payload, + &writer_log_dir, + &writer_file_cache, + &writer_headers, + ) { + eprintln!("❌ Logger write error: {e}"); + } + *pending += 1; + } + rate_limiter::RateDecision::Drop => { + // Silently dropped β€” warning logged when burst ends + } + rate_limiter::RateDecision::BurstEnded(dropped) => { + // Log that we dropped messages from previous burst + let warning = messages::WriteLogPayload { + category: payload.category.clone(), + level: messages::LogLevel::Warn, + component: "RateLimiter".to_string(), + message: format!( + "Rate limit: dropped {} messages from '{}' (>100/sec)", + dropped, payload.category + ), + args: None, + }; + let _ = file_manager::write_log_message( + &warning, + &writer_log_dir, + &writer_file_cache, + &writer_headers, + ); + // Also write the current message + if let Err(e) = file_manager::write_log_message( + payload, + &writer_log_dir, + &writer_file_cache, + &writer_headers, + ) { + eprintln!("❌ Logger write error: {e}"); + } + *pending += 2; + } + } + }; + + // Simple loop: block up to FLUSH_INTERVAL, process batch, flush. + // CRITICAL: Always use FLUSH_INTERVAL as timeout to avoid busy-spin. + // (Previous version used Duration::ZERO which caused 100% CPU) + loop { + match log_rx.recv_timeout(FLUSH_INTERVAL) { + Ok(payload) => { + process_payload(&payload, &mut limiter, &mut pending_writes); + + // Drain remaining messages non-blocking (batch) + while pending_writes < MAX_BATCH_BEFORE_FLUSH { + match log_rx.try_recv() { + Ok(payload) => { + process_payload(&payload, &mut limiter, &mut pending_writes); + } + Err(_) => break, + } + } + + // Flush if batch limit reached + if pending_writes >= MAX_BATCH_BEFORE_FLUSH { + file_manager::flush_all(&writer_file_cache); + pending_writes = 0; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Periodic flush β€” fires every FLUSH_INTERVAL when idle + if pending_writes > 0 { + file_manager::flush_all(&writer_file_cache); + pending_writes = 0; + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + if pending_writes > 0 { + file_manager::flush_all(&writer_file_cache); + } + break; + } } } }); diff --git a/src/debug/jtag/workers/logger/src/rate_limiter.rs b/src/debug/jtag/workers/logger/src/rate_limiter.rs new file mode 100644 index 000000000..5a2c11747 --- /dev/null +++ b/src/debug/jtag/workers/logger/src/rate_limiter.rs @@ -0,0 +1,159 @@ +/// Rate Limiter Module β€” Per-category spam control for the logger worker +/// +/// Prevents any single category from flooding disk I/O. +/// When a category exceeds its rate limit, messages are dropped +/// and a single summary warning is logged when the burst ends. +/// +/// Default: 100 messages/sec per category (configurable per-category). +/// Rate limits reset every second. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Per-category rate state +struct CategoryRate { + /// Messages written in current window + count: u32, + /// Messages dropped in current window + dropped: u32, + /// Window start time + window_start: Instant, + /// Max messages per second for this category (0 = unlimited) + limit: u32, +} + +/// Rate limiter for log categories +pub struct RateLimiter { + categories: HashMap, + default_limit: u32, + window_duration: Duration, +} + +/// Result of checking rate limit +pub enum RateDecision { + /// Message is allowed + Allow, + /// Message is rate-limited (dropped) + Drop, + /// Previous burst ended β€” returns count of dropped messages to log as warning + BurstEnded(u32), +} + +impl RateLimiter { + /// Create a new rate limiter with the given default limit per second + pub fn new(default_limit: u32) -> Self { + Self { + categories: HashMap::new(), + default_limit, + window_duration: Duration::from_secs(1), + } + } + + /// Check if a message for the given category should be allowed. + /// Returns the decision (Allow, Drop, or BurstEnded with dropped count). + pub fn check(&mut self, category: &str) -> RateDecision { + let now = Instant::now(); + let default_limit = self.default_limit; + let window = self.window_duration; + + let state = self.categories.entry(category.to_string()).or_insert_with(|| { + CategoryRate { + count: 0, + dropped: 0, + window_start: now, + limit: default_limit, + } + }); + + // Check if window has elapsed + if now.duration_since(state.window_start) >= window { + let prev_dropped = state.dropped; + state.count = 1; // Count this message + state.dropped = 0; + state.window_start = now; + + if prev_dropped > 0 { + return RateDecision::BurstEnded(prev_dropped); + } + return RateDecision::Allow; + } + + // Unlimited + if state.limit == 0 { + state.count += 1; + return RateDecision::Allow; + } + + // Within window β€” check limit + if state.count < state.limit { + state.count += 1; + RateDecision::Allow + } else { + state.dropped += 1; + RateDecision::Drop + } + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn test_allows_within_limit() { + let mut rl = RateLimiter::new(5); + for _ in 0..5 { + assert!(matches!(rl.check("test"), RateDecision::Allow)); + } + } + + #[test] + fn test_drops_over_limit() { + let mut rl = RateLimiter::new(3); + assert!(matches!(rl.check("test"), RateDecision::Allow)); + assert!(matches!(rl.check("test"), RateDecision::Allow)); + assert!(matches!(rl.check("test"), RateDecision::Allow)); + assert!(matches!(rl.check("test"), RateDecision::Drop)); + assert!(matches!(rl.check("test"), RateDecision::Drop)); + } + + #[test] + fn test_window_reset() { + let mut rl = RateLimiter::new(2); + assert!(matches!(rl.check("test"), RateDecision::Allow)); + assert!(matches!(rl.check("test"), RateDecision::Allow)); + assert!(matches!(rl.check("test"), RateDecision::Drop)); + + // Wait for window to expire + thread::sleep(Duration::from_millis(1100)); + + // Should report burst ended with 1 dropped, then allow + match rl.check("test") { + RateDecision::BurstEnded(dropped) => assert_eq!(dropped, 1), + _ => panic!("Expected BurstEnded"), + } + } + + #[test] + fn test_independent_categories() { + let mut rl = RateLimiter::new(2); + assert!(matches!(rl.check("cat_a"), RateDecision::Allow)); + assert!(matches!(rl.check("cat_a"), RateDecision::Allow)); + assert!(matches!(rl.check("cat_a"), RateDecision::Drop)); + // Different category is still allowed + assert!(matches!(rl.check("cat_b"), RateDecision::Allow)); + } + + #[test] + fn test_high_limit_category() { + // With a high limit, many messages pass through + let mut rl = RateLimiter::new(500); + for _ in 0..500 { + assert!(matches!(rl.check("high"), RateDecision::Allow)); + } + // 501st should be dropped + assert!(matches!(rl.check("high"), RateDecision::Drop)); + } +}