Migrates go.mod + 22 Go imports + README + comments + generated config templates off the dead github.com/Molecule-AI/ identity onto the vanity host go.moleculesai.app, owned by us. Surfaces touched: - go.mod module declaration: github.com/Molecule-AI/molecule-cli -> go.moleculesai.app/cli - Every Go import statement under cmd/ + internal/ - README install section: rewritten to lead with the vanity install command (the previous text was migration-in-progress hedging) - Comment URLs in internal/backends/backend.go + internal/cmd/connect.go (https://github.com/Molecule-AI/molecule-cli/issues/10) -> point at git.moleculesai.app/molecule-ai/molecule-cli - Generated config templates in internal/cmd/init.go + internal/cmd/config.go: header URL updated so new users land on the live SCM - Adds internal/lint/import_path_lint_test.go — structural test that walks every *.go / *.mod / Dockerfile / *.md / *.sh / *.yml in the module and rejects future references to github.com/Molecule-AI/ or Molecule-AI/molecule-monorepo. Mutation-tested before commit. Test plan - go build ./... clean - go test ./... green (cmd/molecule + 5 internal packages + new lint gate, all pass) - TestNoLegacyGitHubImportPaths fails on injected canary, passes on clean tree (no tautology) Open dependency - go.moleculesai.app responder must be deployed before 'go install go.moleculesai.app/cli/cmd/molecule@latest' works externally. Internal builds + 'go build ./cmd/molecule' from a fresh clone work today (self-referential module path). - Responder code prepared (worker.js, vendor-portable for CF Workers / Vercel Edge); deploy tracked separately under internal#71 phase 1. Pairs with parallel migrations of plugin-gh-identity (#3) + molecule-controlplane + molecule-core under the same internal#71 sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
5.9 KiB
Go
172 lines
5.9 KiB
Go
// Package backends defines the pluggable handler interface that
|
|
// `molecule connect` dispatches inbound A2A messages to.
|
|
//
|
|
// Each backend impl is a sub-package (claude_code/, exec/, mock/, etc.)
|
|
// that registers itself via `Register()` from an `init()` block.
|
|
// Runtime selection is done via the --backend flag.
|
|
//
|
|
// See RFC: https://git.moleculesai.app/molecule-ai/molecule-cli/issues/10
|
|
package backends
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// Request is the inbound A2A message handed to a Backend. Mirrors the
|
|
// JSON-RPC `params` shape that workspace-server's /workspaces/:id/a2a
|
|
// endpoint consumes — kept lossless so backends can re-issue the request
|
|
// to a downstream system without re-parsing.
|
|
//
|
|
// The fields here are the stable contract; new optional fields can be
|
|
// added but must be additive.
|
|
type Request struct {
|
|
// WorkspaceID is the ID of the receiving workspace (this side).
|
|
WorkspaceID string `json:"workspace_id"`
|
|
// CallerID is the workspace ID of the sender, when known. Empty for
|
|
// canvas-originated messages.
|
|
CallerID string `json:"caller_id,omitempty"`
|
|
// MessageID is the per-message UUID. Unique per send; backends use
|
|
// this for idempotency dedupe.
|
|
MessageID string `json:"message_id,omitempty"`
|
|
// IdempotencyKey is the caller-supplied dedupe key. If set, prefer
|
|
// it over MessageID for de-dup.
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
// TaskID is the long-running task this message belongs to (when the
|
|
// caller is in a delegation flow).
|
|
TaskID string `json:"task_id,omitempty"`
|
|
// Parts carries the message content (text/file/data parts per A2A
|
|
// v0.3). Backends that only handle text concatenate the text parts.
|
|
Parts []Part `json:"parts"`
|
|
// Method is the JSON-RPC method ("message/send", "message/stream",
|
|
// etc.) — backends that can stream may branch on this.
|
|
Method string `json:"method,omitempty"`
|
|
// Raw is the unparsed JSON-RPC envelope, kept for backends that need
|
|
// to forward the full request shape (mcp, openai-passthrough).
|
|
Raw []byte `json:"-"`
|
|
}
|
|
|
|
// Part is one A2A message part. Type is "text", "file", "data".
|
|
type Part struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
MimeType string `json:"mime_type,omitempty"`
|
|
URI string `json:"uri,omitempty"`
|
|
Data map[string]interface{} `json:"data,omitempty"`
|
|
}
|
|
|
|
// Response is the backend's reply to an A2A request.
|
|
type Response struct {
|
|
// Parts is the response content. At least one part is required;
|
|
// most backends produce a single text part.
|
|
Parts []Part `json:"parts"`
|
|
// Final indicates this is the terminal response for the request
|
|
// (vs an intermediate streaming chunk). Single-shot backends
|
|
// always set true.
|
|
Final bool `json:"final"`
|
|
}
|
|
|
|
// TextResponse is a convenience constructor for the common case: a
|
|
// single text part, terminal response.
|
|
func TextResponse(text string) Response {
|
|
return Response{
|
|
Parts: []Part{{Type: "text", Text: text}},
|
|
Final: true,
|
|
}
|
|
}
|
|
|
|
// Backend is the seam every concrete handler implements. Two methods,
|
|
// no inheritance, no surprise side effects: HandleA2A is called once
|
|
// per inbound message, Close once at shutdown.
|
|
//
|
|
// Backends MUST be safe for concurrent HandleA2A calls — `molecule
|
|
// connect` may dispatch poll-batch messages in parallel.
|
|
type Backend interface {
|
|
// HandleA2A processes one inbound message and returns the reply.
|
|
// Implementations should respect ctx cancellation; the caller may
|
|
// cancel on shutdown.
|
|
HandleA2A(ctx context.Context, req Request) (Response, error)
|
|
// Close releases backend resources (subprocess, network conn, etc.).
|
|
// Called exactly once during graceful shutdown. Must be idempotent.
|
|
Close() error
|
|
}
|
|
|
|
// Factory builds a Backend from per-backend config. Returned by
|
|
// each backend impl's `init()`-time registration.
|
|
type Factory func(cfg Config) (Backend, error)
|
|
|
|
// Config is the loosely-typed bag of per-backend options. Each backend
|
|
// documents the keys it consumes in its package-level doc. Unknown
|
|
// keys are ignored so adding a key doesn't break existing setups.
|
|
type Config map[string]string
|
|
|
|
// Get returns cfg[key], or fallback if unset.
|
|
func (c Config) Get(key, fallback string) string {
|
|
if v, ok := c[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// Require returns cfg[key], or an error if unset/empty. Use for keys
|
|
// the backend cannot start without.
|
|
func (c Config) Require(key string) (string, error) {
|
|
v := c[key]
|
|
if v == "" {
|
|
return "", fmt.Errorf("backend config: %q is required", key)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
var (
|
|
registryMu sync.RWMutex
|
|
registry = map[string]Factory{}
|
|
)
|
|
|
|
// Register adds a backend Factory under name. Called from each backend
|
|
// impl's init() block. Panics on duplicate name — registration drift
|
|
// is a programming error and should fail loudly at startup.
|
|
func Register(name string, factory Factory) {
|
|
if name == "" {
|
|
panic("backends.Register: name must be non-empty")
|
|
}
|
|
if factory == nil {
|
|
panic("backends.Register: factory must be non-nil")
|
|
}
|
|
registryMu.Lock()
|
|
defer registryMu.Unlock()
|
|
if _, dup := registry[name]; dup {
|
|
panic("backends.Register: duplicate backend name " + name)
|
|
}
|
|
registry[name] = factory
|
|
}
|
|
|
|
// Build instantiates the named backend with cfg. Returns an error if
|
|
// no backend is registered under that name (typo, missing build tag,
|
|
// etc.) — callers should surface the error with a clear message that
|
|
// includes the list from `Names()`.
|
|
func Build(name string, cfg Config) (Backend, error) {
|
|
registryMu.RLock()
|
|
factory, ok := registry[name]
|
|
registryMu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("backends.Build: unknown backend %q (registered: %v)", name, Names())
|
|
}
|
|
return factory(cfg)
|
|
}
|
|
|
|
// Names returns the sorted list of registered backend names. Used in
|
|
// `--help` rendering and error messages.
|
|
func Names() []string {
|
|
registryMu.RLock()
|
|
out := make([]string, 0, len(registry))
|
|
for k := range registry {
|
|
out = append(out, k)
|
|
}
|
|
registryMu.RUnlock()
|
|
sort.Strings(out)
|
|
return out
|
|
}
|