Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions changelogs/notes/2026-04-24-local-workspace-run.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions packages/launcher/src/main/agent-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/launcher/src/main/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 9 additions & 0 deletions packages/launcher/src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ <h3>General</h3>
</div>
<div class="card">
<h3>Workspaces</h3>
<div class="form-group" style="margin-bottom: 12px;">
<label>Default Workspace Endpoint (for creating new workspaces)</label>
<div style="display:flex; gap:8px;">
<input type="text" id="setting-workspace-endpoint" class="input" placeholder="https://workspace-endpoint.openagents.org" style="flex: 1;">
<button class="btn btn-primary" id="btn-save-workspace-endpoint">Save</button>
</div>
<p class="hint">Change this to http://localhost:8000 for local development.</p>
</div>
<h4 style="margin-bottom: 8px;">Connected Workspaces</h4>
<div id="settings-workspaces" class="hint">Loading...</div>
</div>
<div class="card">
Expand Down
28 changes: 26 additions & 2 deletions packages/launcher/src/renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 `<button class="btn modal-action-btn" data-action="do-connect-workspace" data-name="${esc(agentName)}" data-slug="${esc(n.slug || n.id)}">${esc(display)} β€” ${esc(url)}</button>`;
}).join('');
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions workspace/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = "005"
down_revision = "004"
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -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 ###
Loading