Files
7838d00f57 feat: connector workbench (#641)
* wip: connector workbench

* wip: improve workbench

* feat(workbench): improve UI with sidebar, routing, and syntax highlighting

- Replace connector dropdown with Slack-style sidebar for connector selection
- Add URL-based routing with react-router-dom for browser navigation support
- Add JSON syntax highlighting with react-syntax-highlighter
- Rework State tab layout to list/detail view with filtering
- Update terminology from "create" to "instantiate" for connectors
- Fix duplicate selection bug in lists (ID generation collision)
- Unify data table row heights across tabs
- Increase code block font sizes for readability
- Hide navigation tabs on homepage when no connector selected
- Make CW logo clickable to return to home view

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use textarea for PEM/certificate config fields

Fields containing 'privateKey', 'certificate', or 'pem' in their name
now use a textarea instead of single-line input, preserving newlines
in PEM content that would otherwise be stripped.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review issues

- Fix data race on idCounter using atomic.Int64
- Fix path traversal vulnerability in introspect.go
- Fix pointer comparison for DefaultAsset in baseline.go

Co-authored-by: Cursor <[email protected]>

* feat(workbench): add generic connector server for integration testing

Expose the generic connector REST API from the workbench so staging
services can install a "generic connector" pointing to the local
workbench, enabling end-to-end integration testing.

- Add /generic/* routes (accounts, balances, beneficiaries, transactions)
- Return cached data from storage instead of making fresh API calls
- Add connector selection and optional API key authentication
- Add UI panel in Debug tab for configuration

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use internal registry to avoid pkg dependency

Change imports from pkg/registry to internal/connectors/plugins/registry
so the workbench can be merged before the pkg refactoring. The internal
registry will automatically delegate to pkg/registry once that refactor
lands.

Also adds light/dark mode toggle to the UI with system preference detection.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review feedback

- Add ctx.Done() checks in fetch loops to exit early on client disconnect
- Refactor generic server to get connector directly instead of via context,
  providing clearer data flow as suggested in review

Co-authored-by: Cursor <[email protected]>

* fix(workbench): include built UI files for embed directive

The //go:embed ui/dist/* directive requires files to exist at build time.
Add exception to .gitignore and commit the built UI distribution files.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address golangci-lint errors

- Remove unused globalDebugTransport variable
- Remove unused parseIntParam function
- Fix printf format for models.TaskType (use %v instead of %s)
- Add error return value handling for various function calls

Co-authored-by: Cursor <[email protected]>

* fix: remove .vite cache folder from git

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix remaining errcheck issue

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix all remaining lint issues

- Add error handling for Body.Close() calls
- Remove unnecessary fmt.Sprintf wrapper
- Simplify hex character check condition

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix errcheck in cmd/workbench.go

Co-authored-by: Cursor <[email protected]>

* chore: replace ui dist build artifacts with placeholder

The //go:embed directive needs at least one file to exist.
Instead of committing the entire built UI, commit only a
.gitkeep placeholder. The server has fallback code that
displays instructions when the full UI isn't available.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-02-12 18:29:17 +00:00

332 lines
8.4 KiB
Go

package workbench
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
)
// DebugEntryType represents the type of debug entry.
type DebugEntryType string
const (
DebugEntryTypeLog DebugEntryType = "log"
DebugEntryTypeRequest DebugEntryType = "request"
DebugEntryTypeResponse DebugEntryType = "response"
DebugEntryTypePluginCall DebugEntryType = "plugin_call"
DebugEntryTypePluginResult DebugEntryType = "plugin_result"
DebugEntryTypeError DebugEntryType = "error"
DebugEntryTypeStateChange DebugEntryType = "state_change"
)
// DebugEntry represents a single debug entry.
type DebugEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Type DebugEntryType `json:"type"`
Operation string `json:"operation"`
Message string `json:"message,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
Error string `json:"error,omitempty"`
}
// DebugStore stores debug information for inspection.
type DebugStore struct {
mu sync.RWMutex
entries []DebugEntry
maxEntries int
idCounter atomic.Int64
// HTTP request/response tracking
httpRequests []HTTPRequestEntry
// Plugin call tracking (for detailed introspection)
pluginCalls []PluginCallEntry
}
// HTTPRequestEntry tracks an HTTP request made by the connector.
type HTTPRequestEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
URL string `json:"url"`
RequestHeaders map[string]string `json:"request_headers,omitempty"`
RequestBody string `json:"request_body,omitempty"`
ResponseStatus int `json:"response_status,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
Duration time.Duration `json:"duration"`
Error string `json:"error,omitempty"`
}
// PluginCallEntry tracks a plugin method call.
type PluginCallEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Input json.RawMessage `json:"input"`
Output json.RawMessage `json:"output,omitempty"`
Duration time.Duration `json:"duration"`
Error string `json:"error,omitempty"`
}
// NewDebugStore creates a new debug store.
func NewDebugStore(maxEntries int) *DebugStore {
if maxEntries <= 0 {
maxEntries = 1000
}
return &DebugStore{
entries: make([]DebugEntry, 0, maxEntries),
httpRequests: make([]HTTPRequestEntry, 0, maxEntries),
pluginCalls: make([]PluginCallEntry, 0, maxEntries),
maxEntries: maxEntries,
}
}
// nextID generates the next unique ID.
func (d *DebugStore) nextID() string {
id := d.idCounter.Add(1)
return fmt.Sprintf("%s-%d", time.Now().Format("20060102-150405"), id)
}
// Log adds a log entry.
func (d *DebugStore) Log(operation, message string) {
d.addEntry(DebugEntry{
Type: DebugEntryTypeLog,
Operation: operation,
Message: message,
})
}
// LogError adds an error entry.
func (d *DebugStore) LogError(operation string, err error) {
d.addEntry(DebugEntry{
Type: DebugEntryTypeError,
Operation: operation,
Error: err.Error(),
})
}
// LogPluginCall logs a plugin method call start.
func (d *DebugStore) LogPluginCall(method string, input interface{}) string {
inputJSON, _ := json.Marshal(input)
entry := PluginCallEntry{
ID: d.nextID(),
Timestamp: time.Now(),
Method: method,
Input: inputJSON,
}
d.mu.Lock()
d.pluginCalls = append(d.pluginCalls, entry)
if len(d.pluginCalls) > d.maxEntries {
d.pluginCalls = d.pluginCalls[1:]
}
d.mu.Unlock()
d.addEntry(DebugEntry{
Type: DebugEntryTypePluginCall,
Operation: method,
Data: inputJSON,
})
return entry.ID
}
// LogPluginResult logs a plugin method call result.
func (d *DebugStore) LogPluginResult(callID string, output interface{}, duration time.Duration, err error) {
outputJSON, _ := json.Marshal(output)
d.mu.Lock()
for i := range d.pluginCalls {
if d.pluginCalls[i].ID == callID {
d.pluginCalls[i].Output = outputJSON
d.pluginCalls[i].Duration = duration
if err != nil {
d.pluginCalls[i].Error = err.Error()
}
break
}
}
d.mu.Unlock()
entry := DebugEntry{
Type: DebugEntryTypePluginResult,
Data: outputJSON,
Duration: duration,
}
if err != nil {
entry.Error = err.Error()
}
d.addEntry(entry)
}
// LogStateChange logs a state change.
func (d *DebugStore) LogStateChange(key string, oldState, newState json.RawMessage) {
data := map[string]interface{}{
"key": key,
"old_state": oldState,
"new_state": newState,
}
dataJSON, _ := json.Marshal(data)
d.addEntry(DebugEntry{
Type: DebugEntryTypeStateChange,
Operation: "state_change",
Message: key,
Data: dataJSON,
})
}
// LogHTTPRequest logs an HTTP request.
func (d *DebugStore) LogHTTPRequest(entry HTTPRequestEntry) {
entry.ID = d.nextID()
entry.Timestamp = time.Now()
d.mu.Lock()
d.httpRequests = append(d.httpRequests, entry)
if len(d.httpRequests) > d.maxEntries {
d.httpRequests = d.httpRequests[1:]
}
d.mu.Unlock()
// Also add to general entries
data, _ := json.Marshal(entry)
d.addEntry(DebugEntry{
Type: DebugEntryTypeRequest,
Operation: entry.Method + " " + entry.URL,
Duration: entry.Duration,
Data: data,
})
}
func (d *DebugStore) addEntry(entry DebugEntry) {
entry.ID = d.nextID()
entry.Timestamp = time.Now()
d.mu.Lock()
defer d.mu.Unlock()
d.entries = append(d.entries, entry)
if len(d.entries) > d.maxEntries {
d.entries = d.entries[1:]
}
}
// GetEntries returns all entries, optionally filtered by type.
func (d *DebugStore) GetEntries(entryType *DebugEntryType, limit int) []DebugEntry {
d.mu.RLock()
defer d.mu.RUnlock()
if limit <= 0 || limit > len(d.entries) {
limit = len(d.entries)
}
if entryType == nil {
// Return last N entries (most recent first)
result := make([]DebugEntry, limit)
for i := 0; i < limit; i++ {
result[i] = d.entries[len(d.entries)-1-i]
}
return result
}
// Filter by type
var filtered []DebugEntry
for i := len(d.entries) - 1; i >= 0 && len(filtered) < limit; i-- {
if d.entries[i].Type == *entryType {
filtered = append(filtered, d.entries[i])
}
}
return filtered
}
// GetHTTPRequests returns HTTP request entries.
func (d *DebugStore) GetHTTPRequests(limit int) []HTTPRequestEntry {
d.mu.RLock()
defer d.mu.RUnlock()
if limit <= 0 || limit > len(d.httpRequests) {
limit = len(d.httpRequests)
}
// Return most recent first
result := make([]HTTPRequestEntry, limit)
for i := 0; i < limit; i++ {
result[i] = d.httpRequests[len(d.httpRequests)-1-i]
}
return result
}
// GetHTTPRequestByID returns a specific HTTP request by ID.
func (d *DebugStore) GetHTTPRequestByID(id string) *HTTPRequestEntry {
d.mu.RLock()
defer d.mu.RUnlock()
for i := range d.httpRequests {
if d.httpRequests[i].ID == id {
entry := d.httpRequests[i]
return &entry
}
}
return nil
}
// GetPluginCalls returns plugin call entries.
func (d *DebugStore) GetPluginCalls(limit int) []PluginCallEntry {
d.mu.RLock()
defer d.mu.RUnlock()
if limit <= 0 || limit > len(d.pluginCalls) {
limit = len(d.pluginCalls)
}
// Return most recent first
result := make([]PluginCallEntry, limit)
for i := 0; i < limit; i++ {
result[i] = d.pluginCalls[len(d.pluginCalls)-1-i]
}
return result
}
// Clear clears all debug entries.
func (d *DebugStore) Clear() {
d.mu.Lock()
defer d.mu.Unlock()
d.entries = make([]DebugEntry, 0, d.maxEntries)
d.httpRequests = make([]HTTPRequestEntry, 0, d.maxEntries)
d.pluginCalls = make([]PluginCallEntry, 0, d.maxEntries)
}
// Stats returns debug store statistics.
type DebugStats struct {
TotalEntries int `json:"total_entries"`
TotalHTTPReqs int `json:"total_http_requests"`
TotalPluginCalls int `json:"total_plugin_calls"`
ErrorCount int `json:"error_count"`
}
func (d *DebugStore) Stats() DebugStats {
d.mu.RLock()
defer d.mu.RUnlock()
errorCount := 0
for _, e := range d.entries {
if e.Type == DebugEntryTypeError || e.Error != "" {
errorCount++
}
}
return DebugStats{
TotalEntries: len(d.entries),
TotalHTTPReqs: len(d.httpRequests),
TotalPluginCalls: len(d.pluginCalls),
ErrorCount: errorCount,
}
}