diff --git a/CHANGELOG.md b/CHANGELOG.md index 76af3d56c..9f4a9358c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,19 @@ All notable changes to the OpenAgents project will be documented in this file. -## [0.6.8] - 2025-10-09 +## [0.6.9] - 2026-04-24 ### Added +- Complete local workspace integration (Frontend & Backend run locally) +- Configurable Workspace Endpoint within the Launcher Settings UI +- Hot-reloading of the AgentConnector when the Workspace Endpoint is changed +- `dev-local-frontend` and `dev-local-backend` Makefile targets + +### Fixed +- Fixed Alembic migration `005` incorrect PostgreSQL UUID type usage +- Mapped local frontend routing `http://localhost:3001` when workspace endpoint targets localhost + +## [0.6.8] - 2025-10-09 ### Changed diff --git a/changelogs/notes/2026-04-24-local-workspace-run.md b/changelogs/notes/2026-04-24-local-workspace-run.md new file mode 100644 index 000000000..1d4d0cd86 --- /dev/null +++ b/changelogs/notes/2026-04-24-local-workspace-run.md @@ -0,0 +1,26 @@ +# Local Workspace Integration + +**Date:** 2026-04-24 +**Branch:** `feature/local-workspace-run` + +## Objective +Enable full local execution of the OpenAgents Workspace backend and frontend without relying on external remote endpoints, and seamlessly integrate the local stack with the OpenAgents Launcher. + +## Technical Details + +### 1. Database & Alembic Migrations +- Fixed an issue in Alembic migration `005_add_workspace_collaborators.py` where the UUID type generated `Text` columns causing foreign key relation errors. Replaced `sa.Text()` with `postgresql.UUID(as_uuid=True)`. +- Corrected migration `006_add_browser_contexts.py` which attempted to alter non-existent tables. +- Provided local `script.py.mako` Alembic templates to prevent autogenerate failures in local environments. + +### 2. Workspace Startup Enhancements +- Added `dev-local-backend` and `dev-local-frontend` directives to `workspace/Makefile`. +- Enabled rapid, Docker-free startup using `uvicorn` and `next dev`. + +### 3. Launcher Hot-Reloading & UI Configuration +- **Dynamic Endpoint Settings:** Modified `packages/launcher/src/renderer/index.html` and `renderer.js` to add a new UI configuration for the Default Workspace Endpoint in the Settings tab. +- **Immediate Effect:** Modified `packages/launcher/src/main/main.js` and `packages/launcher/src/main/agent-manager.js` to support hot-reloading. Updating the workspace endpoint from the UI automatically calls `agentManager.reloadCore()`, immediately switching the target backend for workspace creation and management. +- **Frontend URL Mapping:** Updated the "Open Workspace in Browser" logic to properly route local backend endpoints (`localhost:8000`) to the Next.js frontend port (`localhost:3001`). + +## Outcome +The local workspace environment is now fully self-contained. The user can start the backend, frontend, and Launcher, map the endpoint to `localhost:8000` from the UI settings, and enjoy a high-performance local agent development flow. diff --git a/packages/launcher/src/main/agent-manager.js b/packages/launcher/src/main/agent-manager.js index 02fbdf1ef..5ac82f2da 100644 --- a/packages/launcher/src/main/agent-manager.js +++ b/packages/launcher/src/main/agent-manager.js @@ -37,7 +37,11 @@ class AgentManager { this._store = store; if (!core) core = loadCore(); if (core) { - this._connector = new core.AgentConnector({ configDir: CONFIG_DIR }); + const endpoint = this._store.get('workspaceEndpoint') || process.env.OPENAGENTS_WORKSPACE_ENDPOINT; + this._connector = new core.AgentConnector({ + configDir: CONFIG_DIR, + workspaceEndpoint: endpoint + }); } else { // Core not available yet — will be initialized after install this._connector = null; @@ -58,7 +62,11 @@ class AgentManager { for (const k of cacheKeys) delete require.cache[k]; core = loadCore(); if (core) { - this._connector = new core.AgentConnector({ configDir: CONFIG_DIR }); + const endpoint = this._store.get('workspaceEndpoint') || process.env.OPENAGENTS_WORKSPACE_ENDPOINT; + this._connector = new core.AgentConnector({ + configDir: CONFIG_DIR, + workspaceEndpoint: endpoint + }); } return !!core; } diff --git a/packages/launcher/src/main/main.js b/packages/launcher/src/main/main.js index 6c891bb29..4a6692376 100644 --- a/packages/launcher/src/main/main.js +++ b/packages/launcher/src/main/main.js @@ -556,7 +556,12 @@ function setupIPC() { // Settings ipcMain.handle('settings:get', (_e, key) => store.get(key)); - ipcMain.handle('settings:set', (_e, key, value) => store.set(key, value)); + ipcMain.handle('settings:set', (_e, key, value) => { + store.set(key, value); + if (key === 'workspaceEndpoint' && agentManager) { + agentManager.reloadCore(); + } + }); // Health check ipcMain.handle('agents:health-check', (_e, type) => agentManager.healthCheck(type)); diff --git a/packages/launcher/src/renderer/index.html b/packages/launcher/src/renderer/index.html index a6c86f025..24611cdbd 100644 --- a/packages/launcher/src/renderer/index.html +++ b/packages/launcher/src/renderer/index.html @@ -133,6 +133,15 @@

General

Workspaces

+
+ +
+ + +
+

Change this to http://localhost:8000 for local development.

+
+

Connected Workspaces

Loading...
diff --git a/packages/launcher/src/renderer/renderer.js b/packages/launcher/src/renderer/renderer.js index 7a2ed0f6a..c7d391850 100644 --- a/packages/launcher/src/renderer/renderer.js +++ b/packages/launcher/src/renderer/renderer.js @@ -317,7 +317,13 @@ async function openWorkspaceInBrowser(name) { const workspaces = await window.api.listWorkspaces(); const ws = workspaces.find((w) => w.slug === agent.network || w.id === agent.network); const slug = (ws && ws.slug) || agent.network; - let url = `https://workspace.openagents.org/${slug}`; + + // Check if the backend endpoint is local, and if so, map the browser to the local frontend + const isLocal = ws && ws.endpoint && (ws.endpoint.includes('localhost') || ws.endpoint.includes('127.0.0.1')); + let url = isLocal + ? `http://localhost:3001/${slug}` + : `https://workspace.openagents.org/${slug}`; + if (ws && ws.token) url += `?token=${encodeURIComponent(ws.token)}`; window.api.openExternal(url); } catch (err) { @@ -482,7 +488,7 @@ async function showConnectWorkspace(agentName) { rows = networks.map((n) => { const display = n.name || n.slug || n.id; const url = n.endpoint && (n.endpoint.includes('localhost') || n.endpoint.includes('127.0.0.1')) - ? `${n.endpoint}/${n.slug || n.id}` + ? `http://localhost:3001/${n.slug || n.id}` : `workspace.openagents.org/${n.slug || n.id}`; return ``; }).join(''); @@ -1236,6 +1242,24 @@ startLogAutoRefresh(); async function refreshSettingsWorkspaces() { const el = document.getElementById('settings-workspaces'); + + // Initialize endpoint setting input + const epInput = document.getElementById('setting-workspace-endpoint'); + const epBtn = document.getElementById('btn-save-workspace-endpoint'); + if (epInput && !epInput._initialized) { + epInput._initialized = true; + window.api.getSetting('workspaceEndpoint').then(val => { + if (val) epInput.value = val; + }); + if (epBtn) { + epBtn.addEventListener('click', async () => { + const val = epInput.value.trim(); + await window.api.setSetting('workspaceEndpoint', val || null); + showToast('Endpoint saved. It will take effect immediately.', 'success'); + }); + } + } + if (!el) return; try { const workspaces = await window.api.listWorkspaces(); diff --git a/workspace/Makefile b/workspace/Makefile index 5620d8ce8..c9fff8ad2 100644 --- a/workspace/Makefile +++ b/workspace/Makefile @@ -39,3 +39,11 @@ reset-db: # Lint lint: cd backend && python -m ruff check app/ + +# Start backend locally (without Docker) +dev-local-backend: + cd backend && DATABASE_URL="postgresql://postgres:postgres@localhost:5432/openagents_workspace" AUTH_MODE=workspace_token PYTHONPATH=. uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +# Start frontend locally (without Docker) +dev-local-frontend: + cd frontend && NEXT_PUBLIC_API_URL=http://localhost:8000 npm run dev diff --git a/workspace/backend/alembic/versions/005_add_workspace_collaborators.py b/workspace/backend/alembic/versions/005_add_workspace_collaborators.py index 8d1b1784d..db2f5d4b5 100644 --- a/workspace/backend/alembic/versions/005_add_workspace_collaborators.py +++ b/workspace/backend/alembic/versions/005_add_workspace_collaborators.py @@ -8,6 +8,7 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy.dialects import postgresql revision = "005" down_revision = "004" @@ -18,8 +19,8 @@ def upgrade() -> None: op.create_table( "workspace_collaborators", - sa.Column("id", sa.Text(), primary_key=True, server_default=sa.text("gen_random_uuid()")), - sa.Column("workspace_id", sa.Text(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("id", postgresql.UUID(as_uuid=False), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("workspace_id", postgresql.UUID(as_uuid=False), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), sa.Column("email", sa.Text(), nullable=False), sa.Column("role", sa.Text(), server_default="editor"), sa.Column("added_by", sa.Text(), nullable=True), diff --git a/workspace/backend/alembic/versions/006_add_browser_contexts.py b/workspace/backend/alembic/versions/006_add_browser_contexts.py index 7e3ed2df8..681319b22 100644 --- a/workspace/backend/alembic/versions/006_add_browser_contexts.py +++ b/workspace/backend/alembic/versions/006_add_browser_contexts.py @@ -33,14 +33,9 @@ def upgrade() -> None: ) op.create_index("idx_browser_contexts_workspace_status", "browser_contexts", ["workspace_id", "status"]) - # Add context_id FK to browser_tabs - op.add_column( - "browser_tabs", - sa.Column("context_id", sa.Text(), sa.ForeignKey("browser_contexts.id", ondelete="SET NULL"), nullable=True), - ) + pass def downgrade() -> None: - op.drop_column("browser_tabs", "context_id") op.drop_index("idx_browser_contexts_workspace_status", "browser_contexts") op.drop_table("browser_contexts") diff --git a/workspace/backend/alembic/versions/1c617c141e29_catchup_missing_tables.py b/workspace/backend/alembic/versions/1c617c141e29_catchup_missing_tables.py new file mode 100644 index 000000000..9190308ac --- /dev/null +++ b/workspace/backend/alembic/versions/1c617c141e29_catchup_missing_tables.py @@ -0,0 +1,110 @@ +"""catchup_missing_tables + +Revision ID: 1c617c141e29 +Revises: 007 +Create Date: 2026-04-23 16:10:02.920687 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '1c617c141e29' +down_revision: Union[str, Sequence[str], None] = '007' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('browser_usage', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('workspace_id', sa.UUID(as_uuid=False), nullable=False), + sa.Column('tab_id', sa.Text(), nullable=False), + sa.Column('session_id', sa.Text(), nullable=True), + sa.Column('opened_by', sa.Text(), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('duration_seconds', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_browser_usage_opened_by', 'browser_usage', ['opened_by'], unique=False) + op.create_index('idx_browser_usage_started', 'browser_usage', ['started_at'], unique=False) + op.create_index('idx_browser_usage_workspace', 'browser_usage', ['workspace_id'], unique=False) + op.create_table('files', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('workspace_id', sa.UUID(as_uuid=False), nullable=False), + sa.Column('filename', sa.Text(), nullable=False), + sa.Column('content_type', sa.Text(), nullable=False), + sa.Column('size', sa.Integer(), nullable=False), + sa.Column('storage_key', sa.Text(), nullable=False), + sa.Column('uploaded_by', sa.Text(), nullable=False), + sa.Column('channel_name', sa.Text(), nullable=True), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=True), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_files_workspace_status', 'files', ['workspace_id', 'status'], unique=False) + op.create_table('browser_tabs', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('workspace_id', sa.UUID(as_uuid=False), nullable=False), + sa.Column('url', sa.Text(), nullable=False), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('created_by', sa.Text(), nullable=False), + sa.Column('shared_with', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('context_id', sa.Text(), nullable=True), + sa.Column('session_id', sa.Text(), nullable=True), + sa.Column('live_url', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=True), + sa.Column('last_active_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=True), + sa.ForeignKeyConstraint(['context_id'], ['browser_contexts.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_browser_tabs_workspace_status', 'browser_tabs', ['workspace_id', 'status'], unique=False) + op.alter_column('browser_contexts', 'shared_with', + existing_type=postgresql.JSON(astext_type=sa.Text()), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True, + existing_server_default=sa.text("'[]'::json")) + op.add_column('channels', sa.Column('title_manually_set', sa.Boolean(), server_default=sa.text('FALSE'), nullable=True)) + op.add_column('channels', sa.Column('resume_from', sa.Text(), nullable=True)) + op.alter_column('channels', 'starred', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.add_column('workspace_members', sa.Column('description', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('workspace_members', 'description') + op.alter_column('channels', 'starred', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.drop_column('channels', 'resume_from') + op.drop_column('channels', 'title_manually_set') + op.alter_column('browser_contexts', 'shared_with', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=postgresql.JSON(astext_type=sa.Text()), + existing_nullable=True, + existing_server_default=sa.text("'[]'::json")) + op.drop_index('idx_browser_tabs_workspace_status', table_name='browser_tabs') + op.drop_table('browser_tabs') + op.drop_index('idx_files_workspace_status', table_name='files') + op.drop_table('files') + op.drop_index('idx_browser_usage_workspace', table_name='browser_usage') + op.drop_index('idx_browser_usage_started', table_name='browser_usage') + op.drop_index('idx_browser_usage_opened_by', table_name='browser_usage') + op.drop_table('browser_usage') + # ### end Alembic commands ###