mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
* feat(runtimes): add Qoder Cloud Agent managed remote execution * fix(qoder): satisfy migration and localization CI contracts * fix(qoder): bound execution and scope managed credentials * fix(qoder): avoid upstream migration number collisions * fix(qoder): gate issue creation and correlate tool results * fix(qoder): synchronize guarded issue lifecycle transitions * test(agent): publish Hermes holder identity atomically * feat(agents): runtime-first create flow and cloud runtimes section for Qoder Cloud Agent - Three-step create-agent flow (runtime -> configure -> done) with local, remote and cloud sections; Qoder connect form inline in the runtime step - Runtimes page regrouped into remote and cloud sections with Qoder card - Generalize Qoder storage to cloud_agent_connection (migrations 586/587) - Locales for en, zh-Hans, ja, ko, fr; desktop and web routes for done step - Fix status-picker fixture owner_id and diagnostics route coverage after main merge Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(runtimes): unify runtimes page into machines and cloud services - One Add menu replaces Add remote runtime, the duplicated Add a computer and the web-only Cloud Runtime header button - Group by machines / cloud services instead of local / remote / cloud, so web and desktop share one structure; this device is pinned with a badge - Cloud services use the machine row anatomy in a single ListGrid table; unconnected Qoder is a row with Connect, coming-soon providers are rows - Empty groups show the next step inline; search and online/offline filter - Rename computer to machine across runtimes copy in all locales Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(runtimes): cover unified runtimes page; pin status and action tracks Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(runtimes): trim redundant descriptive copy on runtimes page Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(runtimes): keep row actions visible at narrow widths Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(runtimes): align Qoder add-menu item with other cloud services Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(runtimes): close the Qoder dialog after a first-time connection Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(agents): pick the runtime from one grouped list The runtime step showed four kinds of cards, a comparison panel and per-section descriptions. It is now one list in the runtimes-page layout: machines (this one open, others folded to a row) and cloud services. An unconnected Qoder opens the runtimes-page connect dialog in place and is selected once it comes online. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): show owners as 'you' and bare version numbers in the runtime list Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(qoder): disconnect action and dedicated Qoder runtime page Disconnect removes the encrypted connection and the managed runtime, unbinding its agents after the admin confirms them. The Qoder row now opens its own page (status, agents, usage, configuration, pause/resume, disconnect) instead of the machine layout; generic runtime delete and visibility refuse a managed runtime. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): land on the done page after creating from step 2 on desktop Desktop's location is the tab URL, which moves to /agents/new/done/<id> while the configure page is still mounted. Its missing ?runtime= tripped the skipped-step-1 redirect and replaced the tab back to /agents/new, so Create looked like it did nothing. Only redirect on the manual route. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): show only settings a Qoder agent can run with The Qoder bridge fails every task whose agent carries a model, thinking or speed override, env, custom args, custom skills, MCP config or connected apps, and it runs issues only. The agent detail page still offered all of those editors, so one save could break every later run. For a qoder_cloud agent the page now keeps Instructions under Capabilities, General / Access / QCA Agent under Settings, drops the model rows and the header model chip, shows the QCA agent in the summary, and locks the runtime. Other agents no longer see the Qoder runtime in their picker, since moving onto it needs a QCA agent the server would otherwise reject. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): hide conversation starters for Qoder agents Starters only appear in a chat, and the Qoder bridge runs issues only. The create flow already leaves them out for this runtime. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(runtimes): show the Qoder connection as cards with per-section editing The Qoder runtime page rendered the whole connection as one always-editable form. It now summarizes the connection, repositories and limits as read-only cards; each section opens its own dialog and saves only that section. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(qoder): reuse a follow-up session only with the same repository and credential Resolve repository credentials before deciding to continue a prior session, and stamp each session with a fingerprint of its repositories, branches and tokens. A follow-up continues only when the fingerprint still matches, so a changed repository, branch or rotated/revoked token never rides into an old session. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(qoder): keep a paused connection paused when its settings are saved Saving repositories, limits or the connection no longer re-enables a paused runtime. Resume goes through a new POST /qoder/resume. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): drop settings a Qoder agent cannot run when creating one A draft started on another runtime could carry hidden skills, model overrides and starters into a Qoder agent that then failed every task. The create request now drops them for Qoder, and the API rejects skills and execution overrides on Qoder agents at save time. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * chore(db): regenerate sqlc models for cloud_agent_connection Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(runtimes): never list the Qoder runtime under Machines When the server could not run Qoder (no MULTICA_VCS_SECRET_KEY / MULTICA_QODER_STATE_DIR), the managed Qoder runtime fell back into the Machines group as if it were a computer. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(qoder): drop MULTICA_QODER_STATE_DIR The managed bridge keeps its run journal encrypted in cloud_agent_connection and a database advisory lock already elects one supervisor, so the state directory only held a redundant file lock and acted as a feature switch. Qoder is now available whenever MULTICA_VCS_SECRET_KEY is set. The standalone qoder-runtime command still uses its on-disk state_dir. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(qoder): keep the branch in the session fingerprint after a journal restore Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(agents): validate Qoder agents' effective settings on every write Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: 安辰 <[email protected]> Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
628 lines
24 KiB
Go
628 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/qoderruntime"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
type qoderSettings struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
QoderToken string `json:"qoder_token,omitempty"`
|
|
GitHubTokens map[string]string `json:"github_tokens,omitempty"`
|
|
MulticaToken string `json:"multica_token,omitempty"`
|
|
MaxConcurrency int `json:"max_concurrency,omitempty"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes,omitempty"`
|
|
}
|
|
|
|
const (
|
|
defaultQoderConcurrency = 3
|
|
defaultQoderRunTimeout = 60
|
|
maxQoderRunTimeout = 240
|
|
)
|
|
|
|
// withDefaults fills limits for connections saved before they were configurable.
|
|
func (s qoderSettings) withDefaults() qoderSettings {
|
|
if s.MaxConcurrency == 0 {
|
|
s.MaxConcurrency = defaultQoderConcurrency
|
|
}
|
|
if s.RunTimeoutMinutes == 0 {
|
|
s.RunTimeoutMinutes = defaultQoderRunTimeout
|
|
}
|
|
return s
|
|
}
|
|
|
|
type qoderSettingsView struct {
|
|
Configured bool `json:"configured"`
|
|
Available bool `json:"available"`
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
HasToken bool `json:"has_token"`
|
|
Repositories []string `json:"repositories"`
|
|
Enabled bool `json:"enabled"`
|
|
Status string `json:"status"`
|
|
LastError string `json:"last_error"`
|
|
MaxConcurrency int `json:"max_concurrency"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes"`
|
|
ActiveRuns int `json:"active_runs"`
|
|
QueuedRuns int `json:"queued_runs"`
|
|
}
|
|
|
|
func (h *Handler) readQoderSettings(ctx context.Context, ws string) (qoderSettings, bool, string, string, error) {
|
|
var encrypted, status, lastError string
|
|
var enabled bool
|
|
err := h.DB.QueryRow(ctx, `SELECT config_encrypted, enabled, status, last_error FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&encrypted, &enabled, &status, &lastError)
|
|
if err != nil {
|
|
return qoderSettings{}, false, "", "", err
|
|
}
|
|
raw, err := h.openVCSSecret(encrypted)
|
|
if err != nil {
|
|
return qoderSettings{}, false, "", "", fmt.Errorf("cannot decrypt QCA configuration")
|
|
}
|
|
var cfg qoderSettings
|
|
err = json.Unmarshal([]byte(raw), &cfg)
|
|
return cfg.withDefaults(), enabled, status, lastError, err
|
|
}
|
|
|
|
func (h *Handler) GetQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
view := qoderSettingsView{Available: h.VCSSecretBox != nil, Repositories: []string{}}
|
|
if !view.Available {
|
|
writeJSON(w, 200, view)
|
|
return
|
|
}
|
|
cfg, enabled, status, lastError, err := h.readQoderSettings(r.Context(), uuidToString(ws))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeJSON(w, 200, view)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, 500, "cannot read QCA configuration")
|
|
return
|
|
}
|
|
var online bool
|
|
_ = h.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_runtime WHERE workspace_id=$1 AND provider='qoder_cloud' AND last_seen_at>now()-interval '45 seconds' AND status='online')`, ws).Scan(&online)
|
|
if status == "online" && enabled && !online {
|
|
status = "offline"
|
|
}
|
|
view.Configured = true
|
|
view.Name = cfg.Name
|
|
view.BaseURL = cfg.BaseURL
|
|
view.EnvironmentID = cfg.EnvironmentID
|
|
view.HasToken = cfg.QoderToken != ""
|
|
view.Enabled = enabled
|
|
view.Status = status
|
|
view.LastError = lastError
|
|
view.MaxConcurrency = cfg.MaxConcurrency
|
|
view.RunTimeoutMinutes = cfg.RunTimeoutMinutes
|
|
_ = h.DB.QueryRow(r.Context(), `SELECT count(*) FILTER (WHERE t.status IN ('dispatched','running')), count(*) FILTER (WHERE t.status='queued') FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('queued','dispatched','running')`, ws).Scan(&view.ActiveRuns, &view.QueuedRuns)
|
|
for repo := range cfg.GitHubTokens {
|
|
view.Repositories = append(view.Repositories, repo)
|
|
}
|
|
sort.Strings(view.Repositories)
|
|
writeJSON(w, 200, view)
|
|
}
|
|
|
|
func (h *Handler) qoderInput(w http.ResponseWriter, r *http.Request, ws string) (qoderSettings, bool) {
|
|
var cfg qoderSettings
|
|
if h.VCSSecretBox == nil {
|
|
writeError(w, 503, "QCA requires MULTICA_VCS_SECRET_KEY on the server")
|
|
return cfg, false
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
QoderToken string `json:"qoder_token"`
|
|
GitHubTokens map[string]string `json:"github_tokens"`
|
|
MaxConcurrency int `json:"max_concurrency"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes"`
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
writeError(w, 400, "invalid QCA configuration")
|
|
return cfg, false
|
|
}
|
|
var extra any
|
|
if decoder.Decode(&extra) != io.EOF {
|
|
writeError(w, 400, "configuration must contain one JSON object")
|
|
return cfg, false
|
|
}
|
|
cfg, _, _, _, err := h.readQoderSettings(r.Context(), ws)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, 500, "cannot read QCA configuration")
|
|
return cfg, false
|
|
}
|
|
cfg.Name = strings.TrimSpace(req.Name)
|
|
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
|
cfg.EnvironmentID = strings.TrimSpace(req.EnvironmentID)
|
|
if !qoderAPIURLs[cfg.BaseURL] || cfg.EnvironmentID == "" || cfg.Name == "" {
|
|
writeError(w, 400, "name, environment and official API URL are required")
|
|
return cfg, false
|
|
}
|
|
if req.MaxConcurrency != 0 {
|
|
cfg.MaxConcurrency = req.MaxConcurrency
|
|
}
|
|
if req.RunTimeoutMinutes != 0 {
|
|
cfg.RunTimeoutMinutes = req.RunTimeoutMinutes
|
|
}
|
|
cfg = cfg.withDefaults()
|
|
if cfg.MaxConcurrency < 1 || cfg.MaxConcurrency > qoderruntime.MaxConcurrencyLimit {
|
|
writeError(w, 400, fmt.Sprintf("concurrency must be between 1 and %d", qoderruntime.MaxConcurrencyLimit))
|
|
return cfg, false
|
|
}
|
|
if cfg.RunTimeoutMinutes < 5 || cfg.RunTimeoutMinutes > maxQoderRunTimeout {
|
|
writeError(w, 400, fmt.Sprintf("run timeout must be between 5 and %d minutes", maxQoderRunTimeout))
|
|
return cfg, false
|
|
}
|
|
if req.QoderToken != "" {
|
|
cfg.QoderToken = strings.TrimSpace(req.QoderToken)
|
|
}
|
|
if cfg.QoderToken == "" || strings.ContainsAny(cfg.QoderToken, "\r\n\t ") {
|
|
writeError(w, 400, "QCA token is required")
|
|
return cfg, false
|
|
}
|
|
if req.GitHubTokens != nil {
|
|
next := map[string]string{}
|
|
for raw, token := range req.GitHubTokens {
|
|
key, err := qoderruntime.RepositoryKey(raw)
|
|
if err != nil {
|
|
writeError(w, 400, "invalid GitHub repository URL")
|
|
return cfg, false
|
|
}
|
|
if _, exists := next[key]; exists {
|
|
writeError(w, 400, "duplicate repository binding")
|
|
return cfg, false
|
|
}
|
|
if token == "" {
|
|
token = cfg.GitHubTokens[key]
|
|
}
|
|
if strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), "\r\n\t ") {
|
|
writeError(w, 400, "GitHub token is required for each repository")
|
|
return cfg, false
|
|
}
|
|
next[key] = strings.TrimSpace(token)
|
|
}
|
|
cfg.GitHubTokens = next
|
|
}
|
|
return cfg, true
|
|
}
|
|
|
|
func (h *Handler) CheckQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
cfg, ok := h.qoderInput(w, r, uuidToString(ws))
|
|
if !ok {
|
|
return
|
|
}
|
|
client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
for path, expectedID := range map[string]string{"/environments/" + url.PathEscape(cfg.EnvironmentID): cfg.EnvironmentID} {
|
|
req, err := http.NewRequestWithContext(r.Context(), "GET", cfg.BaseURL+path, nil)
|
|
if err != nil {
|
|
writeError(w, 400, "invalid QCA resource")
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+cfg.QoderToken)
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
writeError(w, 502, "QCA connection failed")
|
|
return
|
|
}
|
|
if res.StatusCode != 200 {
|
|
res.Body.Close()
|
|
writeError(w, 400, fmt.Sprintf("QCA access check returned HTTP %d", res.StatusCode))
|
|
return
|
|
}
|
|
var resource struct {
|
|
ID string `json:"id"`
|
|
}
|
|
err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&resource)
|
|
res.Body.Close()
|
|
if err != nil || resource.ID != expectedID {
|
|
writeError(w, 502, "invalid QCA resource response")
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// SaveQoderConnection keeps a paused connection paused; only
|
|
// ResumeQoderConnection (or the first connect) enables execution.
|
|
func (h *Handler) SaveQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
h.saveQoderConnection(w, r, false)
|
|
}
|
|
|
|
func (h *Handler) ResumeQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
h.saveQoderConnection(w, r, true)
|
|
}
|
|
|
|
func (h *Handler) saveQoderConnection(w http.ResponseWriter, r *http.Request, resume bool) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
user, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
cfg, ok := h.qoderInput(w, r, uuidToString(ws))
|
|
if !ok {
|
|
return
|
|
}
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, 500, "cannot save QCA configuration")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
// Serialize writes even before a connection exists.
|
|
if _, err = tx.Exec(r.Context(), `SELECT pg_advisory_xact_lock(hashtextextended($1, 19))`, uuidToString(ws)); err != nil {
|
|
writeError(w, 500, "cannot lock QCA configuration")
|
|
return
|
|
}
|
|
var busy bool
|
|
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1 AND busy_until>now())`, ws).Scan(&busy); err != nil {
|
|
writeError(w, 500, "cannot check QCA connection")
|
|
return
|
|
}
|
|
if busy {
|
|
writeError(w, 409, "QCA is synchronizing; retry shortly")
|
|
return
|
|
}
|
|
|
|
var journal string
|
|
if err := tx.QueryRow(r.Context(), `SELECT journal_encrypted FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&journal); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
if journal != "" {
|
|
raw, err := h.openVCSSecret(journal)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
var state struct {
|
|
Run json.RawMessage `json:"run"`
|
|
Runs []json.RawMessage `json:"runs"`
|
|
Cleanup []string `json:"cleanup"`
|
|
}
|
|
if json.Unmarshal([]byte(raw), &state) != nil {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
if len(state.Cleanup) > 0 || len(state.Runs) > 0 || (len(state.Run) > 0 && string(state.Run) != "null") {
|
|
writeError(w, 409, "wait for QCA execution cleanup before changing configuration")
|
|
return
|
|
}
|
|
}
|
|
var revokedHash string
|
|
_ = tx.QueryRow(r.Context(), `SELECT d.token_hash FROM daemon_token d JOIN cloud_agent_connection c ON c.token_id=d.id AND c.provider='qoder_cloud' WHERE c.workspace_id=$1`, ws).Scan(&revokedHash)
|
|
var active bool
|
|
err = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('running','dispatched'))`, ws).Scan(&active)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot check active QCA tasks")
|
|
return
|
|
}
|
|
if active {
|
|
writeError(w, 409, "wait for active QCA tasks to finish before changing configuration")
|
|
return
|
|
}
|
|
rawToken, err := auth.GenerateDaemonToken()
|
|
if err != nil {
|
|
writeError(w, 500, "cannot generate service credential")
|
|
return
|
|
}
|
|
q := h.Queries.WithTx(tx)
|
|
pat, err := q.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{WorkspaceID: ws, DaemonID: "qoder-managed-" + uuidToString(ws), TokenHash: auth.HashToken(rawToken), ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}})
|
|
if err != nil {
|
|
writeError(w, 500, "cannot create service credential")
|
|
return
|
|
}
|
|
cfg.MulticaToken = rawToken
|
|
raw, _ := json.Marshal(cfg)
|
|
encrypted, err := h.sealVCSSecret(string(raw))
|
|
if err != nil {
|
|
writeError(w, 500, "cannot encrypt QCA configuration")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `DELETE FROM daemon_token WHERE id IN (SELECT token_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1)`, ws)
|
|
if err == nil {
|
|
_, err = tx.Exec(r.Context(), `INSERT INTO cloud_agent_connection(workspace_id,provider,owner_id,token_id,config_encrypted) VALUES($1,'qoder_cloud',$2,$3,$4) ON CONFLICT(workspace_id,provider) DO UPDATE SET owner_id=excluded.owner_id,token_id=excluded.token_id,config_encrypted=excluded.config_encrypted,enabled=($5 OR cloud_agent_connection.enabled),journal_encrypted='',revision=cloud_agent_connection.revision+1,status=CASE WHEN $5 OR cloud_agent_connection.enabled THEN 'starting' ELSE cloud_agent_connection.status END,last_error='',updated_at=now()`, ws, parseUUID(user), pat.ID, encrypted, resume)
|
|
}
|
|
if err != nil || tx.Commit(r.Context()) != nil {
|
|
writeError(w, 500, "cannot save QCA configuration")
|
|
return
|
|
}
|
|
h.DaemonTokenCache.Invalidate(r.Context(), revokedHash)
|
|
h.GetQoderConnection(w, r)
|
|
}
|
|
|
|
// RunQoderConnections owns managed bridges for this server. A database advisory
|
|
// lock elects one supervisor, and each bridge journal lives encrypted in
|
|
// cloud_agent_connection, so a restart or another replica resumes it.
|
|
func (h *Handler) RunQoderConnections(ctx context.Context, pool *pgxpool.Pool, serverURL string) {
|
|
if h.VCSSecretBox == nil {
|
|
return
|
|
}
|
|
for ctx.Err() == nil {
|
|
conn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
slog.WarnContext(ctx, "Qoder supervisor database connection unavailable", "error", err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
continue
|
|
}
|
|
}
|
|
var locked bool
|
|
err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(71243, 8191)`).Scan(&locked)
|
|
if err == nil && locked {
|
|
h.runQoderSupervisor(ctx, conn, serverURL)
|
|
}
|
|
if locked {
|
|
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(71243,8191)`)
|
|
}
|
|
conn.Release()
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
type managedQoderRun struct {
|
|
revision int64
|
|
cancel context.CancelFunc
|
|
done chan error
|
|
}
|
|
|
|
func (h *Handler) runQoderSupervisor(ctx context.Context, conn *pgxpool.Conn, serverURL string) {
|
|
runs := map[string]managedQoderRun{}
|
|
retryAt := map[string]time.Time{}
|
|
failures := map[string]int{}
|
|
defer func() {
|
|
for _, run := range runs {
|
|
run.cancel()
|
|
<-run.done
|
|
}
|
|
}()
|
|
for ctx.Err() == nil {
|
|
_, _ = conn.Exec(ctx, `UPDATE daemon_token d SET expires_at=now()+interval '1 hour' FROM cloud_agent_connection c JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND d.id=c.token_id AND c.enabled=true AND m.role IN ('owner','admin') AND d.expires_at<now()+interval '30 minutes'`)
|
|
revoked, revokeErr := conn.Query(ctx, `DELETE FROM daemon_token d USING cloud_agent_connection c WHERE c.provider='qoder_cloud' AND d.id=c.token_id AND (NOT c.enabled OR NOT EXISTS(SELECT 1 FROM member m WHERE m.workspace_id=c.workspace_id AND m.user_id=c.owner_id AND m.role IN ('owner','admin'))) RETURNING d.token_hash`)
|
|
if revokeErr != nil {
|
|
return
|
|
}
|
|
for revoked.Next() {
|
|
var hash string
|
|
if revoked.Scan(&hash) == nil {
|
|
h.DaemonTokenCache.Invalidate(ctx, hash)
|
|
}
|
|
}
|
|
revoked.Close()
|
|
rows, err := conn.Query(ctx, `SELECT c.workspace_id::text,c.revision FROM cloud_agent_connection c JOIN workspace w ON w.id=c.workspace_id JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.enabled=true AND m.role IN ('owner','admin')`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
desired := map[string]int64{}
|
|
for rows.Next() {
|
|
var ws string
|
|
var rev int64
|
|
if rows.Scan(&ws, &rev) != nil {
|
|
rows.Close()
|
|
return
|
|
}
|
|
desired[ws] = rev
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
retryLater := map[string]bool{}
|
|
for ws, run := range runs {
|
|
if desired[ws] != run.revision {
|
|
run.cancel()
|
|
<-run.done
|
|
delete(runs, ws)
|
|
continue
|
|
}
|
|
select {
|
|
case err := <-run.done:
|
|
message := "execution service stopped; retrying"
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Qoder bridge stopped", "workspace_id", ws, "error", err)
|
|
message = "remote execution unavailable; retrying"
|
|
}
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='error',last_error=$2 WHERE provider='qoder_cloud' AND workspace_id=$1`, ws, message)
|
|
delete(runs, ws)
|
|
retryLater[ws] = true
|
|
failures[ws]++
|
|
delay := 10 * time.Second << min(failures[ws]-1, 5)
|
|
retryAt[ws] = time.Now().Add(delay)
|
|
default:
|
|
}
|
|
}
|
|
h.cleanupInactiveQoder(ctx)
|
|
for ws, rev := range desired {
|
|
if retryLater[ws] || time.Now().Before(retryAt[ws]) {
|
|
continue
|
|
}
|
|
if _, ok := runs[ws]; ok {
|
|
continue
|
|
}
|
|
cfg, _, _, _, err := h.readQoderSettings(ctx, ws)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bridge, err := qoderruntime.New(qoderruntime.Config{DaemonID: "qoder-managed-" + ws, MulticaURL: serverURL, MulticaToken: cfg.MulticaToken, WorkspaceID: ws, QoderURL: cfg.BaseURL, QoderToken: cfg.QoderToken, EnvironmentID: cfg.EnvironmentID, Name: cfg.Name, MaxConcurrency: cfg.MaxConcurrency, RunTimeout: time.Duration(cfg.RunTimeoutMinutes) * time.Minute, GitHubTokens: cfg.GitHubTokens,
|
|
LoadState: func(loadCtx context.Context) ([]byte, error) {
|
|
var encrypted string
|
|
if err := h.DB.QueryRow(loadCtx, `SELECT journal_encrypted FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&encrypted); err != nil {
|
|
return nil, err
|
|
}
|
|
if encrypted == "" {
|
|
return nil, nil
|
|
}
|
|
raw, err := h.openVCSSecret(encrypted)
|
|
return []byte(raw), err
|
|
},
|
|
SaveState: func(saveCtx context.Context, raw []byte) error {
|
|
encrypted, err := h.sealVCSSecret(string(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = h.DB.Exec(saveCtx, `UPDATE cloud_agent_connection SET journal_encrypted=$2 WHERE provider='qoder_cloud' AND workspace_id=$1`, ws, encrypted)
|
|
return err
|
|
},
|
|
OnReady: func(readyCtx context.Context, runtimeID string) error {
|
|
return h.qoderReady(readyCtx, ws, rev, runtimeID)
|
|
},
|
|
BeforeStep: func(stepCtx context.Context) (func(), error) {
|
|
return h.acquireQoderStep(stepCtx, ws, rev)
|
|
}}, nil)
|
|
if err != nil {
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='error',last_error='invalid execution configuration' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
continue
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
runs[ws] = managedQoderRun{rev, cancel, done}
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='starting',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
go func() { done <- bridge.Run(runCtx) }()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
// StopQoderConnection pauses an idle connection without discarding its secrets.
|
|
// Active work must be cancelled through the task API before stopping service.
|
|
func (h *Handler) StopQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, 500, "cannot stop QCA runtime")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
_, err = tx.Exec(r.Context(), `SELECT pg_advisory_xact_lock(hashtextextended($1,19))`, uuidToString(ws))
|
|
if err != nil {
|
|
writeError(w, 500, "cannot lock QCA configuration")
|
|
return
|
|
}
|
|
var busy bool
|
|
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1 AND busy_until>now())`, ws).Scan(&busy); err != nil {
|
|
writeError(w, 500, "cannot check QCA connection")
|
|
return
|
|
}
|
|
if busy {
|
|
writeError(w, 409, "QCA is synchronizing; retry shortly")
|
|
return
|
|
}
|
|
var revokedHash string
|
|
_ = tx.QueryRow(r.Context(), `SELECT d.token_hash FROM daemon_token d JOIN cloud_agent_connection c ON c.token_id=d.id AND c.provider='qoder_cloud' WHERE c.workspace_id=$1`, ws).Scan(&revokedHash)
|
|
var active bool
|
|
err = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('running','dispatched'))`, ws).Scan(&active)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot check active QCA tasks")
|
|
return
|
|
}
|
|
if active {
|
|
writeError(w, 409, "wait for or cancel active QCA tasks before stopping")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `DELETE FROM daemon_token WHERE id IN (SELECT token_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1)`, ws)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot revoke runtime credential")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `UPDATE cloud_agent_connection SET enabled=false,revision=revision+1,status='stopped',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
if err != nil || tx.Commit(r.Context()) != nil {
|
|
writeError(w, 500, "cannot stop QCA runtime")
|
|
return
|
|
}
|
|
h.DaemonTokenCache.Invalidate(r.Context(), revokedHash)
|
|
h.GetQoderConnection(w, r)
|
|
}
|
|
|
|
func (h *Handler) qoderReady(ctx context.Context, ws string, revision int64, runtimeID string) error {
|
|
if _, err := h.DB.Exec(ctx, `UPDATE agent_runtime SET visibility='public',owner_id=(SELECT owner_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$2) WHERE id=$1 AND workspace_id=$2`, runtimeID, ws); err != nil {
|
|
return fmt.Errorf("cannot publish managed runtime")
|
|
}
|
|
_, err := h.DB.Exec(ctx, `UPDATE cloud_agent_connection SET status='online',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, revision)
|
|
return err
|
|
}
|
|
|
|
func (h *Handler) qoderCredentialAllowed(r *http.Request, ws, daemonID string) (bool, error) {
|
|
var allowed bool
|
|
err := h.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection c JOIN daemon_token d ON d.id=c.token_id JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.workspace_id=$1 AND c.enabled AND d.daemon_id=$2 AND d.token_hash=$3 AND d.expires_at>now() AND m.role IN ('owner','admin'))`, ws, daemonID, auth.HashToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))).Scan(&allowed)
|
|
return allowed, err
|
|
}
|
|
|
|
func (h *Handler) acquireQoderStep(stepCtx context.Context, ws string, rev int64) (func(), error) {
|
|
tx, err := h.TxStarter.Begin(stepCtx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(stepCtx)
|
|
release := func() {
|
|
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_, _ = h.DB.Exec(releaseCtx, `UPDATE cloud_agent_connection SET busy_until=NULL WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, rev)
|
|
}
|
|
if _, err = tx.Exec(stepCtx, `SELECT pg_advisory_xact_lock(hashtextextended($1,19))`, ws); err != nil {
|
|
return nil, err
|
|
}
|
|
var valid bool
|
|
err = tx.QueryRow(stepCtx, `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection c JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.workspace_id=$1 AND c.revision=$2 AND c.enabled=true AND m.role IN ('owner','admin') AND (c.busy_until IS NULL OR c.busy_until<now()))`, ws, rev).Scan(&valid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !valid {
|
|
return nil, context.Canceled
|
|
}
|
|
if _, err := tx.Exec(stepCtx, `UPDATE cloud_agent_connection SET busy_until=now()+interval '45 seconds' WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, rev); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(stepCtx); err != nil {
|
|
return nil, err
|
|
}
|
|
return release, nil
|
|
}
|