From 9077f8f846667d99886fac584ffe22fdb98d26e0 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sat, 21 Feb 2026 12:55:10 -0800 Subject: [PATCH 01/18] Agent sandboxes --- .env.example | 10 +- README.md | 3 +- Tiltfile | 28 +- docs/schema/yaml/1.0.0.yaml | 23 +- helm/environments/local/lifecycle.yaml | 17 +- helm/web-app/templates/deployments.yaml | 12 + .../web-app/templates/keycloak-configmap.yaml | 23 +- .../templates/keycloak-deployment.yaml | 8 +- helm/web-app/templates/secret.yaml | 3 + .../feedback/[id]/conversation/route.test.ts | 8 +- .../api/v2/ai/admin/feedback/route.test.ts | 8 +- .../api/v2/ai/agent/api-keys/route.test.ts | 193 +++ src/app/api/v2/ai/agent/api-keys/route.ts | 239 ++++ src/app/api/v2/ai/agent/models/route.ts | 139 +++ .../launches/[launchId]/route.ts | 133 ++ .../api/v2/ai/agent/sandbox-sessions/route.ts | 323 +++++ .../v2/ai/agent/session-candidates/route.ts | 133 ++ .../v2/ai/agent/sessions/[sessionId]/route.ts | 222 ++++ src/app/api/v2/ai/agent/sessions/route.ts | 469 +++++++ .../chat/[buildUuid]/feedback/route.test.ts | 4 +- .../[messageId]/feedback/route.test.ts | 4 +- src/app/api/v2/ai/chat/[buildUuid]/route.ts | 13 +- src/server/database.ts | 16 +- .../015_add_agent_sessions_and_api_keys.ts | 138 +++ .../__tests__/agentSessionCleanup.test.ts | 80 ++ src/server/jobs/agentSandboxSessionLaunch.ts | 115 ++ src/server/jobs/agentSessionCleanup.ts | 44 + src/server/jobs/index.ts | 31 + src/server/lib/__tests__/encryption.test.ts | 84 ++ src/server/lib/__tests__/get-user.test.ts | 112 ++ .../__tests__/configSeeder.test.ts | 133 ++ .../__tests__/devModeManager.test.ts | 692 +++++++++++ .../__tests__/editorServiceFactory.test.ts | 83 ++ .../agentSession/__tests__/execProxy.test.ts | 40 + .../__tests__/forwardedEnv.test.ts | 284 +++++ .../__tests__/githubToken.test.ts | 143 +++ .../__tests__/gvisorCheck.test.ts | 76 ++ .../__tests__/jsonlParser.test.ts | 232 ++++ .../agentSession/__tests__/podFactory.test.ts | 503 ++++++++ .../agentSession/__tests__/pvcFactory.test.ts | 130 ++ .../__tests__/runtimeConfig.test.ts | 161 +++ .../lib/agentSession/apiKeySecretFactory.ts | 80 ++ src/server/lib/agentSession/configSeeder.ts | 149 +++ src/server/lib/agentSession/devModeManager.ts | 738 +++++++++++ .../lib/agentSession/editorServiceFactory.ts | 85 ++ src/server/lib/agentSession/execProxy.ts | 182 +++ src/server/lib/agentSession/forwardedEnv.ts | 197 +++ src/server/lib/agentSession/githubToken.ts | 105 ++ src/server/lib/agentSession/gvisorCheck.ts | 56 + src/server/lib/agentSession/jsonlParser.ts | 341 ++++++ src/server/lib/agentSession/podFactory.ts | 577 +++++++++ src/server/lib/agentSession/pvcFactory.ts | 92 ++ src/server/lib/agentSession/runtimeConfig.ts | 150 +++ .../lib/agentSession/sandboxLaunchState.ts | 107 ++ src/server/lib/auth.ts | 56 +- src/server/lib/codefresh/index.ts | 2 +- src/server/lib/encryption.ts | 60 + src/server/lib/envVariables.ts | 32 +- src/server/lib/fastly.ts | 2 +- src/server/lib/get-user.ts | 91 ++ src/server/lib/github/index.ts | 7 +- src/server/lib/github/types.ts | 1 + src/server/lib/jsonschema/schemas/1.0.0.json | 37 + src/server/lib/kubernetes.ts | 71 +- src/server/lib/kubernetes/JobMonitor.ts | 12 +- .../lib/kubernetes/__tests__/labels.test.ts | 87 ++ src/server/lib/kubernetes/externalSecret.ts | 3 +- src/server/lib/kubernetes/jobFactory.ts | 6 +- src/server/lib/kubernetes/labels.ts | 28 + .../lib/kubernetes/networkPolicyFactory.ts | 59 + .../lib/kubernetes/webhookJobFactory.ts | 3 +- .../lib/kubernetesApply/applyManifest.ts | 26 +- src/server/lib/logger/types.ts | 8 + src/server/lib/metrics/index.ts | 4 +- src/server/lib/nativeHelm/helm.ts | 42 +- src/server/lib/nativeHelm/utils.ts | 3 + src/server/lib/queueManager.ts | 67 +- src/server/lib/redisClient.ts | 3 +- src/server/lib/response.ts | 2 +- src/server/lib/tests/envVariables.test.ts | 50 +- .../yamlSchemas/schema_1_0_0/schema_1_0_0.ts | 14 + src/server/models/AgentSession.ts | 83 ++ src/server/models/Build.ts | 17 +- src/server/models/Deploy.ts | 73 +- src/server/models/Service.ts | 4 +- src/server/models/UserApiKey.ts | 39 + src/server/models/WebhookInvocations.ts | 6 +- src/server/models/_Model.ts | 20 +- src/server/models/index.ts | 6 + src/server/models/yaml/YamlService.ts | 27 + .../models/yaml/tests/YamlService.test.ts | 91 ++ .../__tests__/agentSandboxSession.test.ts | 124 ++ .../services/__tests__/agentSession.test.ts | 1083 +++++++++++++++++ .../__tests__/agentSessionCandidates.test.ts | 123 ++ .../services/__tests__/globalConfig.test.ts | 52 +- .../services/__tests__/userApiKey.test.ts | 316 +++++ src/server/services/activityStream.ts | 9 +- src/server/services/agentSandboxSession.ts | 700 +++++++++++ src/server/services/agentSession.ts | 603 +++++++++ src/server/services/agentSessionCandidates.ts | 112 ++ .../ai/orchestration/__tests__/safety.test.ts | 4 +- .../services/ai/orchestration/orchestrator.ts | 20 +- .../services/ai/orchestration/safety.ts | 11 +- src/server/services/ai/service.ts | 5 +- src/server/services/ai/tools/baseTool.ts | 1 + src/server/services/ai/types/tool.ts | 2 + src/server/services/build.ts | 77 +- src/server/services/deploy.ts | 45 +- src/server/services/deployable.ts | 4 +- src/server/services/github.ts | 9 +- src/server/services/globalConfig.ts | 51 +- src/server/services/ingress.ts | 3 +- src/server/services/types/github.ts | 19 +- src/server/services/types/globalConfig.ts | 24 + src/server/services/userApiKey.ts | 116 ++ src/shared/config.ts | 2 + src/shared/constants.ts | 6 + src/shared/openApiSpec.ts | 20 +- src/shared/types/agentSession.ts | 75 ++ sysops/dockerfiles/agent.Dockerfile | 37 + tsconfig.server.json | 3 +- ws-server.ts | 872 ++++++++++++- 122 files changed, 13337 insertions(+), 304 deletions(-) create mode 100644 src/app/api/v2/ai/agent/api-keys/route.test.ts create mode 100644 src/app/api/v2/ai/agent/api-keys/route.ts create mode 100644 src/app/api/v2/ai/agent/models/route.ts create mode 100644 src/app/api/v2/ai/agent/sandbox-sessions/launches/[launchId]/route.ts create mode 100644 src/app/api/v2/ai/agent/sandbox-sessions/route.ts create mode 100644 src/app/api/v2/ai/agent/session-candidates/route.ts create mode 100644 src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts create mode 100644 src/app/api/v2/ai/agent/sessions/route.ts create mode 100644 src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts create mode 100644 src/server/jobs/__tests__/agentSessionCleanup.test.ts create mode 100644 src/server/jobs/agentSandboxSessionLaunch.ts create mode 100644 src/server/jobs/agentSessionCleanup.ts create mode 100644 src/server/lib/__tests__/encryption.test.ts create mode 100644 src/server/lib/__tests__/get-user.test.ts create mode 100644 src/server/lib/agentSession/__tests__/configSeeder.test.ts create mode 100644 src/server/lib/agentSession/__tests__/devModeManager.test.ts create mode 100644 src/server/lib/agentSession/__tests__/editorServiceFactory.test.ts create mode 100644 src/server/lib/agentSession/__tests__/execProxy.test.ts create mode 100644 src/server/lib/agentSession/__tests__/forwardedEnv.test.ts create mode 100644 src/server/lib/agentSession/__tests__/githubToken.test.ts create mode 100644 src/server/lib/agentSession/__tests__/gvisorCheck.test.ts create mode 100644 src/server/lib/agentSession/__tests__/jsonlParser.test.ts create mode 100644 src/server/lib/agentSession/__tests__/podFactory.test.ts create mode 100644 src/server/lib/agentSession/__tests__/pvcFactory.test.ts create mode 100644 src/server/lib/agentSession/__tests__/runtimeConfig.test.ts create mode 100644 src/server/lib/agentSession/apiKeySecretFactory.ts create mode 100644 src/server/lib/agentSession/configSeeder.ts create mode 100644 src/server/lib/agentSession/devModeManager.ts create mode 100644 src/server/lib/agentSession/editorServiceFactory.ts create mode 100644 src/server/lib/agentSession/execProxy.ts create mode 100644 src/server/lib/agentSession/forwardedEnv.ts create mode 100644 src/server/lib/agentSession/githubToken.ts create mode 100644 src/server/lib/agentSession/gvisorCheck.ts create mode 100644 src/server/lib/agentSession/jsonlParser.ts create mode 100644 src/server/lib/agentSession/podFactory.ts create mode 100644 src/server/lib/agentSession/pvcFactory.ts create mode 100644 src/server/lib/agentSession/runtimeConfig.ts create mode 100644 src/server/lib/agentSession/sandboxLaunchState.ts create mode 100644 src/server/lib/encryption.ts create mode 100644 src/server/lib/kubernetes/__tests__/labels.test.ts create mode 100644 src/server/lib/kubernetes/labels.ts create mode 100644 src/server/lib/kubernetes/networkPolicyFactory.ts create mode 100644 src/server/models/AgentSession.ts create mode 100644 src/server/models/UserApiKey.ts create mode 100644 src/server/services/__tests__/agentSandboxSession.test.ts create mode 100644 src/server/services/__tests__/agentSession.test.ts create mode 100644 src/server/services/__tests__/agentSessionCandidates.test.ts create mode 100644 src/server/services/__tests__/userApiKey.test.ts create mode 100644 src/server/services/agentSandboxSession.ts create mode 100644 src/server/services/agentSession.ts create mode 100644 src/server/services/agentSessionCandidates.ts create mode 100644 src/server/services/userApiKey.ts create mode 100644 src/shared/types/agentSession.ts create mode 100644 sysops/dockerfiles/agent.Dockerfile diff --git a/.env.example b/.env.example index 92bc00c..30762af 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,7 @@ CODEFRESH_API_KEY= # NGROK NGROK_AUTHTOKEN= NGROK_LIFECYCLE_DOMAIN= +NGROK_KEYCLOAK_DOMAIN= ENVIRONMENT='dev' APP_ENV='development' @@ -65,9 +66,16 @@ DD_ENV='development' DD_SERVICE='lifecycle-job' # KEYCLOAK +# Public issuer used to validate browser-issued tokens. KEYCLOAK_ISSUER= +# Internal issuer used by in-cluster services for broker token and JWKS lookups. +KEYCLOAK_ISSUER_INTERNAL= KEYCLOAK_CLIENT_ID= KEYCLOAK_JWKS_URL= +# LOCAL DEVELOPMENT +LOCAL_DEV_USER_ID= +LOCAL_DEV_GITHUB_TOKEN= + # CORS -ALLOWED_ORIGINS='https://example.com,https://staging.example.com' \ No newline at end of file +ALLOWED_ORIGINS='https://example.com,https://staging.example.com' diff --git a/README.md b/README.md index 9c1ed6c..debb585 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ brew install --cask docker githubAppId: "" githubClientId: "" githubInstallationId: "" + encryptionKey: "<64-char hex string, e.g. output of openssl rand -hex 32>" ``` > [!NOTE] > You can create the GitHub app with the app creation setup flow and then copy the secrets created for local development. @@ -88,4 +89,4 @@ brew install --cask docker - Start the development environment using `tilt`: ```shell tilt up - ``` \ No newline at end of file + ``` diff --git a/Tiltfile b/Tiltfile index 150f9ac..8c819cf 100644 --- a/Tiltfile +++ b/Tiltfile @@ -42,6 +42,14 @@ ngrok_authtoken = os.getenv("NGROK_AUTHTOKEN", "") ngrok_domain = os.getenv("NGROK_LIFECYCLE_DOMAIN", "") ngrok_keycloak_domain = os.getenv("NGROK_KEYCLOAK_DOMAIN", "") ngrok_ui_domain = os.getenv("NGROK_LIFECYCLE_UI_DOMAIN", "") +keycloak_scheme = "https" if ngrok_keycloak_domain else "http" +app_scheme = "https" if ngrok_domain else "http" +ui_scheme = "https" if ngrok_ui_domain else "http" +keycloak_host = ngrok_keycloak_domain or "localhost:8081" +app_host = ngrok_domain or "localhost:5001" +ui_host = ngrok_ui_domain or "localhost:3000" +company_idp_origin = "{}://{}".format(keycloak_scheme, keycloak_host) +internal_keycloak_origin = "http://lifecycle-keycloak.{}.svc.cluster.local:8080".format(app_namespace) ################################## @@ -164,15 +172,19 @@ helm_set_args = [ 'namespace={}'.format(app_namespace), 'image.repository={}'.format(lifecycle_app), 'image.tag=dev', - 'keycloak.url={}'.format(ngrok_keycloak_domain or 'localhost'), - 'keycloak.appUrl={}'.format(ngrok_domain or 'localhost:5001'), - 'keycloak.uiUrl={}'.format(ngrok_ui_domain or 'localhost:3000'), + 'keycloak.scheme={}'.format(keycloak_scheme), + 'keycloak.url={}'.format(keycloak_host), + 'keycloak.appUrl={}'.format(app_host), + 'keycloak.uiScheme={}'.format(ui_scheme), + 'keycloak.uiUrl={}'.format(ui_host), + 'secrets.keycloakIssuerPublic={}/realms/lifecycle'.format(company_idp_origin), + 'secrets.keycloakIssuerInternal={}/realms/lifecycle'.format(internal_keycloak_origin), # Update IDP URLs to use ngrok domain or localhost - 'keycloak.companyIdp.tokenUrl=https://{}/realms/company/protocol/openid-connect/token'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.tokenUrl=http://localhost:8080/realms/company/protocol/openid-connect/token', - 'keycloak.companyIdp.authorizationUrl=https://{}/realms/company/protocol/openid-connect/auth'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.authorizationUrl=http://localhost:8080/realms/company/protocol/openid-connect/auth', - 'keycloak.companyIdp.userInfoUrl=https://{}/realms/company/protocol/openid-connect/userinfo'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.userInfoUrl=http://localhost:8080/realms/company/protocol/openid-connect/userinfo', - 'keycloak.companyIdp.jwksUrl=https://{}/realms/company/protocol/openid-connect/certs'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.jwksUrl=http://localhost:8080/realms/company/protocol/openid-connect/certs', - 'keycloak.companyIdp.issuer=https://{}/realms/company'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.issuer=http://localhost:8080/realms/company', + 'keycloak.companyIdp.tokenUrl={}/realms/company/protocol/openid-connect/token'.format(internal_keycloak_origin), + 'keycloak.companyIdp.authorizationUrl={}/realms/company/protocol/openid-connect/auth'.format(company_idp_origin), + 'keycloak.companyIdp.userInfoUrl={}/realms/company/protocol/openid-connect/userinfo'.format(internal_keycloak_origin), + 'keycloak.companyIdp.jwksUrl={}/realms/company/protocol/openid-connect/certs'.format(internal_keycloak_origin), + 'keycloak.companyIdp.issuer={}/realms/company'.format(company_idp_origin), 'secrets.aiApiKey={}'.format(os.getenv("AI_API_KEY", "")), 'secrets.geminiApiKey={}'.format(os.getenv("GEMINI_API_KEY", "")), ] diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index 13e04b5..f3589a8 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -704,4 +704,25 @@ services: # @param services.configuration.defaultTag (required) defaultTag: '' # @param services.configuration.branchName (required) - branchName: '' \ No newline at end of file + branchName: '' + # @param services.dev + dev: + # @param services.dev.image (required) + image: '' + # @param services.dev.command (required) + command: '' + # @param services.dev.installCommand + installCommand: '' + # @param services.dev.workDir + workDir: '' + # @param services.dev.ports + ports: + # @param services.dev.ports[] + - 0 + # @param services.dev.env + env: + + # @param services.dev.forwardEnvVarsToAgent + forwardEnvVarsToAgent: + # @param services.dev.forwardEnvVarsToAgent[] + - '' \ No newline at end of file diff --git a/helm/environments/local/lifecycle.yaml b/helm/environments/local/lifecycle.yaml index ca04909..7474a13 100644 --- a/helm/environments/local/lifecycle.yaml +++ b/helm/environments/local/lifecycle.yaml @@ -30,6 +30,8 @@ global: # Add any global env vars here if needed - name: PORT value: '80' + - name: ENABLE_AUTH + value: 'false' - name: OBJECT_STORE_TYPE value: 'minio' - name: OBJECT_STORE_ENDPOINT @@ -190,8 +192,11 @@ rbac: keycloak: enabled: true realm: lifecycle - url: localhost + scheme: http + url: localhost:8081 appUrl: localhost:5001 + uiScheme: http + uiUrl: localhost:3000 adminUsername: admin adminPassword: admin @@ -207,11 +212,11 @@ keycloak: enabled: true clientId: app-broker clientSecret: changeme - tokenUrl: http://localhost:8080/realms/company/protocol/openid-connect/token - authorizationUrl: http://localhost:8080/realms/company/protocol/openid-connect/auth - userInfoUrl: http://localhost:8080/realms/company/protocol/openid-connect/userinfo - jwksUrl: http://localhost:8080/realms/company/protocol/openid-connect/certs - issuer: http://localhost:8080/realms/company + tokenUrl: http://lifecycle-keycloak.lifecycle-app.svc.cluster.local:8080/realms/company/protocol/openid-connect/token + authorizationUrl: http://localhost:8081/realms/company/protocol/openid-connect/auth + userInfoUrl: http://lifecycle-keycloak.lifecycle-app.svc.cluster.local:8080/realms/company/protocol/openid-connect/userinfo + jwksUrl: http://lifecycle-keycloak.lifecycle-app.svc.cluster.local:8080/realms/company/protocol/openid-connect/certs + issuer: http://localhost:8081/realms/company githubIdp: enabled: true diff --git a/helm/web-app/templates/deployments.yaml b/helm/web-app/templates/deployments.yaml index 2949fb9..5109d93 100644 --- a/helm/web-app/templates/deployments.yaml +++ b/helm/web-app/templates/deployments.yaml @@ -62,6 +62,18 @@ spec: {{- toYaml .valueFrom | nindent 16 }} {{- end }} {{- end }} + {{- if $.Values.secrets.keycloakIssuerPublic }} + - name: KEYCLOAK_ISSUER + value: {{ $.Values.secrets.keycloakIssuerPublic | quote }} + {{- end }} + {{- if $.Values.secrets.keycloakIssuerInternal }} + - name: KEYCLOAK_ISSUER_INTERNAL + value: {{ $.Values.secrets.keycloakIssuerInternal | quote }} + - name: KEYCLOAK_JWKS_URL + value: {{ printf "%s/protocol/openid-connect/certs" $.Values.secrets.keycloakIssuerInternal | quote }} + {{- end }} + - name: KEYCLOAK_CLIENT_ID + value: {{ $.Values.secrets.keycloakCoreClientId | default "lifecycle-core" | quote }} {{- range $component.deployment.extraEnv }} - name: {{ .name }} {{- if hasKey . "value" }} diff --git a/helm/web-app/templates/keycloak-configmap.yaml b/helm/web-app/templates/keycloak-configmap.yaml index c10efbb..5d8cc40 100644 --- a/helm/web-app/templates/keycloak-configmap.yaml +++ b/helm/web-app/templates/keycloak-configmap.yaml @@ -15,6 +15,10 @@ limitations under the License. */}} {{- if .Values.keycloak.enabled }} +{{- $keycloakScheme := .Values.keycloak.scheme | default "https" -}} +{{- $uiScheme := .Values.keycloak.uiScheme | default "https" -}} +{{- $keycloakOrigin := printf "%s://%s" $keycloakScheme .Values.keycloak.url -}} +{{- $uiOrigin := printf "%s://%s" $uiScheme .Values.keycloak.uiUrl -}} --- apiVersion: v1 kind: ConfigMap @@ -52,6 +56,17 @@ data: "groups": [] } ], + "roles": { + "client": { + "broker": [ + { + "name": "read-token", + "description": "Read broker tokens from linked identity providers", + "clientRole": true + } + ] + } + }, "clients": [ { "clientId": "broker", @@ -173,7 +188,7 @@ data: "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "secret": "changeme", - "redirectUris": ["https://{{ .Values.keycloak.uiUrl }}/*"], + "redirectUris": ["{{ $uiOrigin }}", "{{ $uiOrigin }}/*"], "webOrigins": ["+"], "notBefore": 0, "bearerOnly": false, @@ -328,7 +343,7 @@ data: "updateProfileFirstLoginMode": "on", "trustEmail": false, "storeToken": true, - "addReadTokenRoleOnCreate": false, + "addReadTokenRoleOnCreate": true, "authenticateByDefault": false, "linkOnly": true, "hideOnLogin": false, @@ -672,7 +687,7 @@ data: "publicClient": false, "secret": "changeme-company-realm-broker-secret", "redirectUris": [ - "https://{{ .Values.keycloak.url }}/realms/lifecycle/broker/company-sso/endpoint/*" + "{{ $keycloakOrigin }}/realms/lifecycle/broker/company-sso/endpoint/*" ], "webOrigins": ["+"], "standardFlowEnabled": true, @@ -700,4 +715,4 @@ data: } ] } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/helm/web-app/templates/keycloak-deployment.yaml b/helm/web-app/templates/keycloak-deployment.yaml index f848902..e97ae8a 100644 --- a/helm/web-app/templates/keycloak-deployment.yaml +++ b/helm/web-app/templates/keycloak-deployment.yaml @@ -15,6 +15,8 @@ limitations under the License. */}} {{- if .Values.keycloak.enabled }} +{{- $keycloakScheme := .Values.keycloak.scheme | default "https" -}} +{{- $keycloakOrigin := printf "%s://%s" $keycloakScheme .Values.keycloak.url -}} --- apiVersion: apps/v1 kind: Deployment @@ -55,8 +57,8 @@ spec: - '--db-url-host={{ .Release.Name }}-keycloak-postgresql' - '--db-username=$(KC_DB_USER)' - '--db-password=$(KC_DB_PASSWORD)' - - '--hostname=https://{{ .Values.keycloak.url }}' - - '--hostname-admin=https://{{ .Values.keycloak.url }}' + - '--hostname={{ $keycloakOrigin }}' + - '--hostname-admin={{ $keycloakOrigin }}' - '--hostname-strict=false' - '--health-enabled=true' - '--import-realm' @@ -113,4 +115,4 @@ spec: - name: keycloak-config configMap: name: {{ .Release.Name }}-keycloak-config -{{- end }} \ No newline at end of file +{{- end }} diff --git a/helm/web-app/templates/secret.yaml b/helm/web-app/templates/secret.yaml index 2ea557f..e892e23 100644 --- a/helm/web-app/templates/secret.yaml +++ b/helm/web-app/templates/secret.yaml @@ -86,4 +86,7 @@ data: {{- if .Values.secrets.geminiApiKey }} GEMINI_API_KEY: {{ .Values.secrets.geminiApiKey | b64enc | quote }} {{- end }} + {{- if .Values.secrets.encryptionKey }} + ENCRYPTION_KEY: {{ .Values.secrets.encryptionKey | b64enc | quote }} + {{- end }} {{- end }} diff --git a/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.test.ts b/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.test.ts index bed0144..e6a1bff 100644 --- a/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.test.ts +++ b/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.test.ts @@ -68,7 +68,7 @@ describe('GET /api/v2/ai/admin/feedback/[id]/conversation', () => { repo: 'org/repo', rating: 'up', text: 'Very helpful', - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', createdAt: '2026-02-27T10:00:00.000Z', }); MockMessageFeedback.query.mockReturnValue({ findById: findByIdMessageFeedback }); @@ -106,7 +106,7 @@ describe('GET /api/v2/ai/admin/feedback/[id]/conversation', () => { expect(response.status).toBe(200); expect(body.data.feedbackText).toBe('Very helpful'); - expect(body.data.feedbackUserIdentifier).toBe('vmelikyan'); + expect(body.data.feedbackUserIdentifier).toBe('sample-user'); expect(body.data.feedbackType).toBe('message'); expect(body.data.ratedMessageId).toBe(42); expect(body.data.conversation.messages).toHaveLength(2); @@ -121,7 +121,7 @@ describe('GET /api/v2/ai/admin/feedback/[id]/conversation', () => { repo: 'org/repo', rating: 'down', text: 'Session feedback comment', - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', createdAt: '2026-02-27T10:05:00.000Z', }); MockConversationFeedback.query.mockReturnValue({ findById: findByIdConversationFeedback }); @@ -153,7 +153,7 @@ describe('GET /api/v2/ai/admin/feedback/[id]/conversation', () => { expect(response.status).toBe(200); expect(body.data.feedbackType).toBe('conversation'); expect(body.data.feedbackText).toBe('Session feedback comment'); - expect(body.data.feedbackUserIdentifier).toBe('vmelikyan'); + expect(body.data.feedbackUserIdentifier).toBe('sample-user'); expect(body.data.ratedMessageId).toBeNull(); }); }); diff --git a/src/app/api/v2/ai/admin/feedback/route.test.ts b/src/app/api/v2/ai/admin/feedback/route.test.ts index 7705a61..e545064 100644 --- a/src/app/api/v2/ai/admin/feedback/route.test.ts +++ b/src/app/api/v2/ai/admin/feedback/route.test.ts @@ -99,7 +99,7 @@ describe('GET /api/v2/ai/admin/feedback', () => { buildUuid: 'uuid-1', rating: 'up', text: null, - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', repo: 'org/repo', prNumber: null, messageId: 11, @@ -129,7 +129,7 @@ describe('GET /api/v2/ai/admin/feedback', () => { expect(body.data).toHaveLength(1); expect(preview.endsWith('…')).toBe(true); expect(/[\uD800-\uDBFF]$/.test(previewWithoutEllipsis)).toBe(false); - expect(body.data[0].userIdentifier).toBe('vmelikyan'); + expect(body.data[0].userIdentifier).toBe('sample-user'); expect(body.data[0].costUsd).toBeCloseTo(4, 6); expect(rowsBuilder.orderBy).toHaveBeenCalledWith('createdAt', 'desc'); }); @@ -190,7 +190,7 @@ describe('GET /api/v2/ai/admin/feedback', () => { buildUuid: 'uuid-2', rating: 'up', text: 'Great session', - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', repo: 'org/repo', prNumber: null, messageId: null, @@ -211,7 +211,7 @@ describe('GET /api/v2/ai/admin/feedback', () => { expect(response.status).toBe(200); expect(body.data).toHaveLength(1); - expect(body.data[0].userIdentifier).toBe('vmelikyan'); + expect(body.data[0].userIdentifier).toBe('sample-user'); expect(body.data[0].costUsd).toBeCloseTo(4, 6); expect(conversationMessagesBuilder.whereIn).toHaveBeenCalledWith('cm.buildUuid', ['uuid-2']); }); diff --git a/src/app/api/v2/ai/agent/api-keys/route.test.ts b/src/app/api/v2/ai/agent/api-keys/route.test.ts new file mode 100644 index 0000000..0554f01 --- /dev/null +++ b/src/app/api/v2/ai/agent/api-keys/route.test.ts @@ -0,0 +1,193 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +jest.mock('server/lib/dependencies', () => ({ + defaultDb: {}, + defaultRedis: {}, +})); + +jest.mock('server/services/userApiKey', () => ({ + __esModule: true, + default: { + getMaskedKey: jest.fn(), + storeKey: jest.fn(), + deleteKey: jest.fn(), + }, +})); + +const mockFetch = jest.fn(); +(global as any).fetch = mockFetch; + +import { GET, POST, DELETE } from './route'; +import UserApiKeyService from 'server/services/userApiKey'; + +const mockGetMaskedKey = UserApiKeyService.getMaskedKey as jest.Mock; +const mockStoreKey = UserApiKeyService.storeKey as jest.Mock; +const mockDeleteKey = UserApiKeyService.deleteKey as jest.Mock; + +function makeRequest(body?: unknown, userClaims?: Record): NextRequest { + const headers = new Headers([['x-request-id', 'req-test']]); + if (userClaims) { + headers.set('x-user', Buffer.from(JSON.stringify(userClaims), 'utf8').toString('base64url')); + } + return { + headers, + json: jest.fn().mockResolvedValue(body || {}), + } as unknown as NextRequest; +} + +describe('API /api/v2/ai/agent/api-keys', () => { + const originalEnableAuth = process.env.ENABLE_AUTH; + const originalLocalDevUserId = process.env.LOCAL_DEV_USER_ID; + + const restoreEnv = () => { + if (originalEnableAuth === undefined) { + delete process.env.ENABLE_AUTH; + } else { + process.env.ENABLE_AUTH = originalEnableAuth; + } + + if (originalLocalDevUserId === undefined) { + delete process.env.LOCAL_DEV_USER_ID; + } else { + process.env.LOCAL_DEV_USER_ID = originalLocalDevUserId; + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + restoreEnv(); + }); + + afterAll(() => { + restoreEnv(); + }); + + describe('GET', () => { + it('returns 401 when no user', async () => { + process.env.ENABLE_AUTH = 'true'; + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + }); + + it('returns hasKey false when no key exists', async () => { + mockGetMaskedKey.mockResolvedValue(null); + const res = await GET(makeRequest(undefined, { sub: 'user-1' })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.hasKey).toBe(false); + }); + + it('uses the local dev user when auth is disabled', async () => { + process.env.ENABLE_AUTH = 'false'; + process.env.LOCAL_DEV_USER_ID = 'vm-local'; + mockGetMaskedKey.mockResolvedValue(null); + + const res = await GET(makeRequest()); + + expect(res.status).toBe(200); + expect(mockGetMaskedKey).toHaveBeenCalledWith('vm-local', 'anthropic', null); + }); + + it('returns masked key info when key exists', async () => { + mockGetMaskedKey.mockResolvedValue({ + provider: 'anthropic', + maskedKey: 'sk-ant...xyz9', + updatedAt: '2026-01-01T00:00:00Z', + }); + const res = await GET(makeRequest(undefined, { sub: 'user-1' })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.hasKey).toBe(true); + expect(json.data.maskedKey).toBe('sk-ant...xyz9'); + }); + }); + + describe('POST', () => { + it('returns 401 when no user', async () => { + process.env.ENABLE_AUTH = 'true'; + const res = await POST(makeRequest({ apiKey: 'sk-test' })); + expect(res.status).toBe(401); + }); + + it('returns 400 when apiKey is missing', async () => { + const res = await POST(makeRequest({}, { sub: 'user-1' })); + expect(res.status).toBe(400); + }); + + it('returns 400 when Anthropic validation fails', async () => { + mockFetch.mockResolvedValue({ status: 401 }); + const res = await POST(makeRequest({ apiKey: 'bad-key' }, { sub: 'user-1' })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.message).toContain('Invalid API key'); + }); + + it('stores key and returns 201 on success', async () => { + mockFetch.mockResolvedValue({ status: 200 }); + mockStoreKey.mockResolvedValue(undefined); + mockGetMaskedKey.mockResolvedValue({ + provider: 'anthropic', + maskedKey: 'sk-ant...abcd', + updatedAt: '2026-01-01T00:00:00Z', + }); + const res = await POST(makeRequest({ apiKey: 'sk-ant-valid' }, { sub: 'user-1' })); + expect(res.status).toBe(201); + expect(mockStoreKey).toHaveBeenCalledWith('user-1', 'anthropic', 'sk-ant-valid', null); + }); + + it('stores the key for the local dev user when auth is disabled', async () => { + process.env.ENABLE_AUTH = 'false'; + process.env.LOCAL_DEV_USER_ID = 'vm-local'; + mockFetch.mockResolvedValue({ status: 200 }); + mockStoreKey.mockResolvedValue(undefined); + mockGetMaskedKey.mockResolvedValue({ + provider: 'anthropic', + maskedKey: 'sk-ant...abcd', + updatedAt: '2026-01-01T00:00:00Z', + }); + + const res = await POST(makeRequest({ apiKey: 'sk-ant-valid' })); + + expect(res.status).toBe(201); + expect(mockStoreKey).toHaveBeenCalledWith('vm-local', 'anthropic', 'sk-ant-valid', null); + }); + }); + + describe('DELETE', () => { + it('returns 401 when no user', async () => { + process.env.ENABLE_AUTH = 'true'; + const res = await DELETE(makeRequest()); + expect(res.status).toBe(401); + }); + + it('returns 404 when no key exists', async () => { + mockDeleteKey.mockResolvedValue(false); + const res = await DELETE(makeRequest(undefined, { sub: 'user-1' })); + expect(res.status).toBe(404); + }); + + it('returns 200 on successful deletion', async () => { + mockDeleteKey.mockResolvedValue(true); + const res = await DELETE(makeRequest(undefined, { sub: 'user-1' })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.deleted).toBe(true); + }); + }); +}); diff --git a/src/app/api/v2/ai/agent/api-keys/route.ts b/src/app/api/v2/ai/agent/api-keys/route.ts new file mode 100644 index 0000000..1035cd0 --- /dev/null +++ b/src/app/api/v2/ai/agent/api-keys/route.ts @@ -0,0 +1,239 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, errorResponse } from 'server/lib/response'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import UserApiKeyService from 'server/services/userApiKey'; + +const PROVIDER = 'anthropic'; + +async function validateAnthropicKey(apiKey: string): Promise { + try { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + return res.status !== 401 && res.status !== 403; + } catch { + return false; + } +} + +/** + * @openapi + * /api/v2/ai/agent/api-keys: + * get: + * summary: Get the authenticated user's Anthropic API key status + * tags: + * - Agent Sessions + * operationId: getAgentApiKey + * responses: + * '200': + * description: API key state + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - hasKey + * properties: + * hasKey: + * type: boolean + * provider: + * type: string + * maskedKey: + * type: string + * updatedAt: + * type: string + * format: date-time + * error: + * nullable: true + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * post: + * summary: Save or replace the authenticated user's Anthropic API key + * tags: + * - Agent Sessions + * operationId: upsertAgentApiKey + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - apiKey + * properties: + * apiKey: + * type: string + * responses: + * '201': + * description: API key stored + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - hasKey + * - provider + * - maskedKey + * - updatedAt + * properties: + * hasKey: + * type: boolean + * provider: + * type: string + * maskedKey: + * type: string + * updatedAt: + * type: string + * format: date-time + * error: + * nullable: true + * '400': + * description: Invalid API key payload + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * delete: + * summary: Delete the authenticated user's Anthropic API key + * tags: + * - Agent Sessions + * operationId: deleteAgentApiKey + * responses: + * '200': + * description: API key deleted + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - deleted + * properties: + * deleted: + * type: boolean + * error: + * nullable: true + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: API key not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const masked = await UserApiKeyService.getMaskedKey(userIdentity.userId, PROVIDER, userIdentity.githubUsername); + if (!masked) { + return successResponse({ hasKey: false }, { status: 200 }, req); + } + + return successResponse( + { hasKey: true, provider: masked.provider, maskedKey: masked.maskedKey, updatedAt: masked.updatedAt }, + { status: 200 }, + req + ); +}; + +const postHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const body = await req.json(); + const { apiKey } = body; + + if (!apiKey || typeof apiKey !== 'string') { + return errorResponse(new Error('apiKey is required and must be a string'), { status: 400 }, req); + } + + const valid = await validateAnthropicKey(apiKey); + if (!valid) { + return errorResponse(new Error('Invalid API key: authentication failed with Anthropic'), { status: 400 }, req); + } + + await UserApiKeyService.storeKey(userIdentity.userId, PROVIDER, apiKey, userIdentity.githubUsername); + const masked = await UserApiKeyService.getMaskedKey(userIdentity.userId, PROVIDER, userIdentity.githubUsername); + + return successResponse( + { hasKey: true, provider: masked!.provider, maskedKey: masked!.maskedKey, updatedAt: masked!.updatedAt }, + { status: 201 }, + req + ); +}; + +const deleteHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const deleted = await UserApiKeyService.deleteKey(userIdentity.userId, PROVIDER, userIdentity.githubUsername); + if (!deleted) { + return errorResponse(new Error('No API key found'), { status: 404 }, req); + } + + return successResponse({ deleted: true }, { status: 200 }, req); +}; + +export const GET = createApiHandler(getHandler); +export const POST = createApiHandler(postHandler); +export const DELETE = createApiHandler(deleteHandler); diff --git a/src/app/api/v2/ai/agent/models/route.ts b/src/app/api/v2/ai/agent/models/route.ts new file mode 100644 index 0000000..3e4bd89 --- /dev/null +++ b/src/app/api/v2/ai/agent/models/route.ts @@ -0,0 +1,139 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, errorResponse } from 'server/lib/response'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import UserApiKeyService from 'server/services/userApiKey'; +import RedisClient from 'server/lib/redisClient'; + +const CACHE_TTL_SECONDS = 300; + +/** + * @openapi + * /api/v2/ai/agent/models: + * get: + * summary: List Anthropic models available to the authenticated user's stored API key + * tags: + * - Agent Sessions + * operationId: getAgentModels + * responses: + * '200': + * description: Available models + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - data + * - has_more + * - first_id + * - last_id + * properties: + * data: + * type: array + * items: + * type: object + * required: + * - id + * - display_name + * - created_at + * - type + * properties: + * id: + * type: string + * display_name: + * type: string + * created_at: + * type: string + * format: date-time + * type: + * type: string + * has_more: + * type: boolean + * first_id: + * type: string + * nullable: true + * last_id: + * type: string + * nullable: true + * error: + * nullable: true + * '400': + * description: API key missing + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '502': + * description: Anthropic models request failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const apiKey = await UserApiKeyService.getDecryptedKey(userIdentity.userId, 'anthropic', userIdentity.githubUsername); + if (!apiKey) { + return errorResponse( + new Error('An Anthropic API key is required. Please add one in settings.'), + { status: 400 }, + req + ); + } + + const redis = RedisClient.getInstance().getRedis(); + const cacheOwner = userIdentity.githubUsername || userIdentity.userId; + const cacheKey = `lifecycle:agent:models:${cacheOwner}`; + const cached = await redis.get(cacheKey); + if (cached) { + return successResponse(JSON.parse(cached), { status: 200 }, req); + } + + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }); + + if (!res.ok) { + return errorResponse(new Error(`Failed to fetch models: ${res.status} ${res.statusText}`), { status: 502 }, req); + } + + const data = await res.json(); + await redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify(data)); + + return successResponse(data, { status: 200 }, req); +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/launches/[launchId]/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/launches/[launchId]/route.ts new file mode 100644 index 0000000..1f47282 --- /dev/null +++ b/src/app/api/v2/ai/agent/sandbox-sessions/launches/[launchId]/route.ts @@ -0,0 +1,133 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +import { redisClient } from 'server/lib/dependencies'; +import { getRequestUserSub } from 'server/lib/get-user'; +import { getSandboxLaunchState, toPublicSandboxLaunchState } from 'server/lib/agentSession/sandboxLaunchState'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; + +/** + * @openapi + * /api/v2/ai/agent/sandbox-sessions/launches/{launchId}: + * get: + * summary: Get sandbox launch progress + * tags: + * - Agent Sessions + * operationId: getSandboxAgentSessionLaunch + * parameters: + * - in: path + * name: launchId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Current sandbox launch status + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - launchId + * - status + * - stage + * - message + * - createdAt + * - updatedAt + * - baseBuildUuid + * - service + * - buildUuid + * - namespace + * - sessionId + * - focusUrl + * - error + * properties: + * launchId: + * type: string + * status: + * type: string + * enum: [queued, running, created, error] + * stage: + * type: string + * enum: + * - queued + * - resolving_base_build + * - resolving_services + * - creating_sandbox_build + * - resolving_environment + * - deploying_resources + * - creating_agent_session + * - ready + * - error + * message: + * type: string + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * baseBuildUuid: + * type: string + * service: + * type: string + * buildUuid: + * type: string + * nullable: true + * namespace: + * type: string + * nullable: true + * sessionId: + * type: string + * nullable: true + * focusUrl: + * type: string + * nullable: true + * error: + * type: string + * nullable: true + * error: + * nullable: true + * '401': + * description: Unauthorized + * '404': + * description: Launch not found + */ +const getHandler = async (req: NextRequest, { params }: { params: Promise<{ launchId: string }> }) => { + const userId = getRequestUserSub(req); + if (!userId) { + return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + } + + const { launchId } = await params; + const state = await getSandboxLaunchState(redisClient.getRedis(), launchId); + if (!state || state.userId !== userId) { + return errorResponse(new Error('Sandbox launch not found'), { status: 404 }, req); + } + + return successResponse(toPublicSandboxLaunchState(state), { status: 200 }, req); +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts new file mode 100644 index 0000000..ae59da1 --- /dev/null +++ b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts @@ -0,0 +1,323 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { v4 as uuid } from 'uuid'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import { resolveRequestGitHubToken } from 'server/lib/agentSession/githubToken'; +import { + AgentSessionRuntimeConfigError, + resolveAgentSessionRuntimeConfig, +} from 'server/lib/agentSession/runtimeConfig'; +import { encrypt } from 'server/lib/encryption'; +import { redisClient } from 'server/lib/dependencies'; +import QueueManager from 'server/lib/queueManager'; +import { QUEUE_NAMES } from 'shared/config'; +import { setSandboxLaunchState, toPublicSandboxLaunchState } from 'server/lib/agentSession/sandboxLaunchState'; +import AgentSandboxSessionService from 'server/services/agentSandboxSession'; +import type { SandboxSessionLaunchJob } from 'server/jobs/agentSandboxSessionLaunch'; + +interface CreateSandboxSessionBody { + baseBuildUuid?: string; + service?: string; + model?: string; +} + +const sandboxLaunchQueue = QueueManager.getInstance().registerQueue(QUEUE_NAMES.AGENT_SANDBOX_SESSION_LAUNCH, { + connection: redisClient.getConnection(), + defaultJobOptions: { + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, +}); + +/** + * @openapi + * /api/v2/ai/agent/sandbox-sessions: + * get: + * summary: List sandboxable services for a base environment build + * tags: + * - Agent Sessions + * operationId: getSandboxServiceCandidates + * parameters: + * - in: query + * name: baseBuildUuid + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Sandboxable services returned + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - status + * - services + * properties: + * status: + * type: string + * enum: [needs_service_selection] + * services: + * type: array + * items: + * type: object + * required: + * - name + * - type + * properties: + * name: + * type: string + * type: + * type: string + * error: + * nullable: true + * '400': + * description: Invalid request + * '401': + * description: Unauthorized + * '404': + * description: Base build or lifecycle config not found + * post: + * summary: Launch an isolated sandbox-backed agent session from an existing build snapshot + * tags: + * - Agent Sessions + * operationId: createSandboxAgentSession + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - baseBuildUuid + * properties: + * baseBuildUuid: + * type: string + * service: + * type: string + * model: + * type: string + * responses: + * '200': + * description: Service selection required or sandbox session launch queued + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - launchId + * - status + * - stage + * - message + * - createdAt + * - updatedAt + * - baseBuildUuid + * - service + * - buildUuid + * - namespace + * - sessionId + * - focusUrl + * - error + * properties: + * launchId: + * type: string + * status: + * type: string + * enum: [queued, running, created, error] + * stage: + * type: string + * enum: + * - queued + * - resolving_base_build + * - resolving_services + * - creating_sandbox_build + * - resolving_environment + * - deploying_resources + * - creating_agent_session + * - ready + * - error + * message: + * type: string + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * baseBuildUuid: + * type: string + * service: + * type: string + * buildUuid: + * type: string + * nullable: true + * namespace: + * type: string + * nullable: true + * sessionId: + * type: string + * nullable: true + * focusUrl: + * type: string + * nullable: true + * error: + * type: string + * nullable: true + * error: + * nullable: true + * '400': + * description: Invalid request + * '401': + * description: Unauthorized + * '404': + * description: Base build not found + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const { searchParams } = new URL(req.url); + const baseBuildUuid = searchParams.get('baseBuildUuid'); + + if (!baseBuildUuid) { + return errorResponse(new Error('baseBuildUuid is required'), { status: 400 }, req); + } + + try { + const services = await new AgentSandboxSessionService().getServiceCandidates({ + baseBuildUuid, + }); + + return successResponse( + { + status: 'needs_service_selection', + services, + }, + { status: 200 }, + req + ); + } catch (err) { + if (err instanceof Error && /not found/i.test(err.message)) { + return errorResponse(err, { status: 404 }, req); + } + + return errorResponse(err, { status: 400 }, req); + } +}; + +const postHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const body = (await req.json()) as CreateSandboxSessionBody; + if (!body.baseBuildUuid) { + return errorResponse(new Error('baseBuildUuid is required'), { status: 400 }, req); + } + + try { + if (!body.service) { + return errorResponse(new Error('service is required'), { status: 400 }, req); + } + + const runtimeConfig = await resolveAgentSessionRuntimeConfig(); + const githubToken = await resolveRequestGitHubToken(req); + const launchId = uuid(); + const now = new Date().toISOString(); + await setSandboxLaunchState(redisClient.getRedis(), { + launchId, + userId: userIdentity.userId, + status: 'queued', + stage: 'queued', + message: `Queued sandbox launch for ${body.service}`, + createdAt: now, + updatedAt: now, + baseBuildUuid: body.baseBuildUuid, + service: body.service, + }); + + await sandboxLaunchQueue.add( + 'launch', + { + launchId, + userId: userIdentity.userId, + userIdentity, + encryptedGithubToken: githubToken ? encrypt(githubToken) : null, + baseBuildUuid: body.baseBuildUuid, + service: body.service, + model: body.model, + agentImage: runtimeConfig.image, + editorImage: runtimeConfig.editorImage, + } as SandboxSessionLaunchJob, + { + jobId: launchId, + } + ); + + return successResponse( + toPublicSandboxLaunchState({ + launchId, + userId: userIdentity.userId, + status: 'queued', + stage: 'queued', + message: `Queued sandbox launch for ${body.service}`, + createdAt: now, + updatedAt: now, + baseBuildUuid: body.baseBuildUuid, + service: body.service, + }), + { status: 200 }, + req + ); + } catch (err) { + if (err instanceof Error && err.message === 'API_KEY_REQUIRED') { + return errorResponse( + new Error('An Anthropic API key is required. Please add one in settings.'), + { status: 400 }, + req + ); + } + + if (err instanceof Error && /not found/i.test(err.message)) { + return errorResponse(err, { status: 404 }, req); + } + + if (err instanceof AgentSessionRuntimeConfigError) { + return errorResponse(err, { status: 503 }, req); + } + + return errorResponse(err, { status: 400 }, req); + } +}; + +export const GET = createApiHandler(getHandler); +export const POST = createApiHandler(postHandler); diff --git a/src/app/api/v2/ai/agent/session-candidates/route.ts b/src/app/api/v2/ai/agent/session-candidates/route.ts new file mode 100644 index 0000000..1f2a3ed --- /dev/null +++ b/src/app/api/v2/ai/agent/session-candidates/route.ts @@ -0,0 +1,133 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import AgentSessionService from 'server/services/agentSession'; +import { loadAgentSessionServiceCandidates } from 'server/services/agentSessionCandidates'; + +/** + * @openapi + * /api/v2/ai/agent/session-candidates: + * get: + * summary: List dev-mode service candidates for an environment-backed agent session + * tags: + * - Agent Sessions + * operationId: getAgentSessionCandidates + * parameters: + * - in: query + * name: buildUuid + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Candidate services returned + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - services + * - activeSession + * properties: + * services: + * type: array + * items: + * type: object + * required: + * - name + * - type + * properties: + * name: + * type: string + * type: + * type: string + * detail: + * type: string + * activeSession: + * type: object + * nullable: true + * required: + * - id + * - status + * - ownerGithubUsername + * - ownedByCurrentUser + * properties: + * id: + * type: string + * nullable: true + * status: + * type: string + * enum: [starting, active] + * ownerGithubUsername: + * type: string + * nullable: true + * ownedByCurrentUser: + * type: boolean + * error: + * nullable: true + * '400': + * description: Invalid request + * '401': + * description: Unauthorized + * '404': + * description: Build or lifecycle config not found + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const { searchParams } = new URL(req.url); + const buildUuid = searchParams.get('buildUuid'); + if (!buildUuid) { + return errorResponse(new Error('buildUuid is required'), { status: 400 }, req); + } + + try { + const [services, activeSession] = await Promise.all([ + loadAgentSessionServiceCandidates(buildUuid), + AgentSessionService.getEnvironmentActiveSession(buildUuid, userIdentity.userId), + ]); + + return successResponse( + { + services: services + .map(({ name, type, detail }) => ({ name, type, detail })) + .sort((a, b) => a.name.localeCompare(b.name)), + activeSession, + }, + { status: 200 }, + req + ); + } catch (err) { + if (err instanceof Error && /not found/i.test(err.message)) { + return errorResponse(err, { status: 404 }, req); + } + + return errorResponse(err, { status: 400 }, req); + } +}; + +export const GET = createApiHandler(getHandler); diff --git a/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..b9dce5d --- /dev/null +++ b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts @@ -0,0 +1,222 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, errorResponse } from 'server/lib/response'; +import { getRequestUserSub } from 'server/lib/get-user'; +import AgentSessionService from 'server/services/agentSession'; + +function serializeSessionSummary(session: T) { + const sessionId = session.uuid || String(session.id); + const { + id: _internalId, + uuid: _uuid, + ...serialized + } = session as T & { + uuid?: string | null; + id: string | number; + }; + + return { + ...serialized, + id: sessionId, + websocketUrl: `/api/agent/session?sessionId=${sessionId}`, + editorUrl: `/api/agent/editor/${sessionId}/`, + }; +} + +/** + * @openapi + * /api/v2/ai/agent/sessions/{sessionId}: + * get: + * summary: Get an agent session by id + * tags: + * - Agent Sessions + * operationId: getAgentSession + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Agent session + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - id + * - buildUuid + * - baseBuildUuid + * - buildKind + * - userId + * - ownerGithubUsername + * - podName + * - namespace + * - model + * - status + * - lastActivity + * - createdAt + * - updatedAt + * - endedAt + * - websocketUrl + * - editorUrl + * properties: + * id: + * type: string + * buildUuid: + * type: string + * nullable: true + * baseBuildUuid: + * type: string + * nullable: true + * buildKind: + * $ref: '#/components/schemas/BuildKind' + * userId: + * type: string + * ownerGithubUsername: + * type: string + * nullable: true + * podName: + * type: string + * namespace: + * type: string + * model: + * type: string + * status: + * type: string + * enum: [starting, active, ended, error] + * lastActivity: + * type: string + * format: date-time + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * endedAt: + * type: string + * nullable: true + * format: date-time + * websocketUrl: + * type: string + * editorUrl: + * type: string + * error: + * nullable: true + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Session not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * delete: + * summary: End an agent session + * tags: + * - Agent Sessions + * operationId: deleteAgentSession + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Session ended + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - ended + * properties: + * ended: + * type: boolean + * error: + * nullable: true + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Session not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest, { params }: { params: Promise<{ sessionId: string }> }) => { + const userId = getRequestUserSub(req); + if (!userId) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const { sessionId } = await params; + const session = await AgentSessionService.getSession(sessionId); + if (!session) { + return errorResponse(new Error('Session not found'), { status: 404 }, req); + } + + if (session.userId !== userId) { + return errorResponse(new Error('Forbidden: you do not own this session'), { status: 401 }, req); + } + + return successResponse(serializeSessionSummary(session), { status: 200 }, req); +}; + +const deleteHandler = async (req: NextRequest, { params }: { params: Promise<{ sessionId: string }> }) => { + const userId = getRequestUserSub(req); + if (!userId) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const { sessionId } = await params; + const session = await AgentSessionService.getSession(sessionId); + if (!session) { + return errorResponse(new Error('Session not found'), { status: 404 }, req); + } + + if (session.userId !== userId) { + return errorResponse(new Error('Forbidden: you do not own this session'), { status: 401 }, req); + } + + await AgentSessionService.endSession(sessionId); + return successResponse({ ended: true }, { status: 200 }, req); +}; + +export const GET = createApiHandler(getHandler); +export const DELETE = createApiHandler(deleteHandler); diff --git a/src/app/api/v2/ai/agent/sessions/route.ts b/src/app/api/v2/ai/agent/sessions/route.ts new file mode 100644 index 0000000..a806c0b --- /dev/null +++ b/src/app/api/v2/ai/agent/sessions/route.ts @@ -0,0 +1,469 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, errorResponse } from 'server/lib/response'; +import { getRequestUserIdentity } from 'server/lib/get-user'; +import { resolveRequestGitHubToken } from 'server/lib/agentSession/githubToken'; +import { + AgentSessionRuntimeConfigError, + resolveAgentSessionRuntimeConfig, +} from 'server/lib/agentSession/runtimeConfig'; +import AgentSessionService, { ActiveEnvironmentSessionError } from 'server/services/agentSession'; +import { + loadAgentSessionServiceCandidates, + resolveRequestedAgentSessionServices, +} from 'server/services/agentSessionCandidates'; +import Build from 'server/models/Build'; +import type { DevConfig } from 'server/models/yaml/YamlService'; +import { BuildKind } from 'shared/constants'; + +interface ResolvedSessionService { + name: string; + deployId: number; + devConfig: DevConfig; + resourceName?: string; +} + +interface CreateSessionBody { + buildUuid?: string; + services?: unknown[]; + model?: string; + repoUrl?: string; + branch?: string; + prNumber?: number; + namespace?: string; +} + +function repoNameFromRepoUrl(repoUrl?: string | null) { + if (!repoUrl) { + return null; + } + + const normalized = repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, ''); + return normalized || null; +} + +function serializeSessionSummary(session: T) { + const sessionId = session.uuid || String(session.id); + const { + id: _internalId, + uuid: _uuid, + ...serialized + } = session as T & { + uuid?: string | null; + id: string | number; + }; + + return { + ...serialized, + id: sessionId, + websocketUrl: `/api/agent/session?sessionId=${sessionId}`, + editorUrl: `/api/agent/editor/${sessionId}/`, + }; +} + +function isResolvedSessionService(value: unknown): value is ResolvedSessionService { + return ( + value != null && + typeof value === 'object' && + typeof (value as ResolvedSessionService).name === 'string' && + typeof (value as ResolvedSessionService).deployId === 'number' && + (value as ResolvedSessionService).devConfig != null + ); +} + +async function resolveBuildContext(buildUuid: string) { + return Build.query().findOne({ uuid: buildUuid }).withGraphFetched('[pullRequest, deploys.[deployable]]'); +} + +async function resolveRequestedServices( + buildUuid: string | undefined, + requestedServices: unknown[] | undefined +): Promise { + if (!Array.isArray(requestedServices) || requestedServices.length === 0) { + return []; + } + + if (requestedServices.every(isResolvedSessionService)) { + return requestedServices; + } + + if (!buildUuid) { + throw new Error('buildUuid is required when services are specified'); + } + + const requestedNames = requestedServices.filter((service): service is string => typeof service === 'string'); + if (requestedNames.length !== requestedServices.length) { + throw new Error('services must be an array of service names'); + } + + return resolveRequestedAgentSessionServices(await loadAgentSessionServiceCandidates(buildUuid), requestedNames).map( + ({ name, deployId, devConfig, baseDeploy }) => ({ + name, + deployId, + devConfig, + resourceName: baseDeploy.uuid || undefined, + }) + ); +} + +/** + * @openapi + * /api/v2/ai/agent/sessions: + * get: + * summary: List agent sessions for the authenticated user + * tags: + * - Agent Sessions + * operationId: getAgentSessions + * parameters: + * - in: query + * name: includeEnded + * schema: + * type: boolean + * description: When true, include ended and errored sessions in the response. + * responses: + * '200': + * description: Agent sessions + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: array + * items: + * type: object + * required: + * - id + * - buildUuid + * - baseBuildUuid + * - buildKind + * - userId + * - ownerGithubUsername + * - podName + * - namespace + * - model + * - status + * - repo + * - branch + * - services + * - lastActivity + * - createdAt + * - updatedAt + * - endedAt + * - websocketUrl + * - editorUrl + * properties: + * id: + * type: string + * buildUuid: + * type: string + * nullable: true + * baseBuildUuid: + * type: string + * nullable: true + * buildKind: + * $ref: '#/components/schemas/BuildKind' + * userId: + * type: string + * ownerGithubUsername: + * type: string + * nullable: true + * podName: + * type: string + * namespace: + * type: string + * model: + * type: string + * status: + * type: string + * enum: [starting, active, ended, error] + * repo: + * type: string + * nullable: true + * branch: + * type: string + * nullable: true + * services: + * type: array + * items: + * type: string + * lastActivity: + * type: string + * format: date-time + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * endedAt: + * type: string + * nullable: true + * format: date-time + * websocketUrl: + * type: string + * editorUrl: + * type: string + * error: + * nullable: true + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * post: + * summary: Create a new interactive agent session + * tags: + * - Agent Sessions + * operationId: createAgentSession + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - buildUuid + * properties: + * buildUuid: + * type: string + * services: + * type: array + * description: Optional service names to enable dev mode for. + * items: + * type: string + * model: + * type: string + * responses: + * '201': + * description: Agent session created + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - id + * - buildUuid + * - baseBuildUuid + * - buildKind + * - userId + * - ownerGithubUsername + * - podName + * - namespace + * - model + * - status + * - repo + * - branch + * - services + * - websocketUrl + * - editorUrl + * - lastActivity + * - createdAt + * - updatedAt + * - endedAt + * properties: + * id: + * type: string + * buildUuid: + * type: string + * nullable: true + * baseBuildUuid: + * type: string + * nullable: true + * buildKind: + * $ref: '#/components/schemas/BuildKind' + * userId: + * type: string + * ownerGithubUsername: + * type: string + * nullable: true + * podName: + * type: string + * namespace: + * type: string + * model: + * type: string + * status: + * type: string + * enum: [starting, active, ended, error] + * repo: + * type: string + * nullable: true + * branch: + * type: string + * nullable: true + * services: + * type: array + * items: + * type: string + * websocketUrl: + * type: string + * editorUrl: + * type: string + * lastActivity: + * type: string + * format: date-time + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * endedAt: + * type: string + * nullable: true + * format: date-time + * error: + * nullable: true + * '400': + * description: Invalid request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Build not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '409': + * description: An active environment session already exists for the requested environment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const getHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const includeEnded = req.nextUrl.searchParams.get('includeEnded') === 'true'; + const sessions = await AgentSessionService.getSessions(userIdentity.userId, { includeEnded }); + return successResponse( + sessions.map((session) => serializeSessionSummary(session)), + { status: 200 }, + req + ); +}; + +const postHandler = async (req: NextRequest) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const body = (await req.json()) as CreateSessionBody; + const { buildUuid, services, model } = body; + + let repoUrl = body.repoUrl; + let branch = body.branch; + let prNumber = body.prNumber; + let namespace = body.namespace; + let buildKind = BuildKind.ENVIRONMENT; + + if (buildUuid) { + const build = await resolveBuildContext(buildUuid); + if (!build?.pullRequest) { + return errorResponse(new Error('Build not found'), { status: 404 }, req); + } + + buildKind = build.kind || BuildKind.ENVIRONMENT; + repoUrl = repoUrl || `https://github.com/${build.pullRequest.fullName}.git`; + branch = branch || build.pullRequest.branchName; + prNumber = prNumber ?? build.pullRequest.pullRequestNumber; + namespace = namespace || build.namespace; + } + + let resolvedServices: ResolvedSessionService[]; + try { + resolvedServices = await resolveRequestedServices(buildUuid, services); + } catch (err) { + return errorResponse(err, { status: 400 }, req); + } + + if (!repoUrl || !branch || !namespace) { + return errorResponse(new Error('repoUrl, branch, and namespace are required'), { status: 400 }, req); + } + + try { + const runtimeConfig = await resolveAgentSessionRuntimeConfig(); + const githubToken = await resolveRequestGitHubToken(req); + const session = await AgentSessionService.createSession({ + userId: userIdentity.userId, + userIdentity, + githubToken, + buildUuid, + buildKind, + services: resolvedServices, + model, + repoUrl, + branch, + prNumber, + namespace, + agentImage: runtimeConfig.image, + editorImage: runtimeConfig.editorImage, + }); + + return successResponse( + serializeSessionSummary({ + ...session, + baseBuildUuid: null, + repo: repoNameFromRepoUrl(repoUrl), + branch, + services: resolvedServices.map((service) => service.name), + }), + { status: 201 }, + req + ); + } catch (err) { + if (err instanceof ActiveEnvironmentSessionError) { + return errorResponse(err, { status: 409 }, req); + } + + if (err instanceof Error && err.message === 'API_KEY_REQUIRED') { + return errorResponse( + new Error('An Anthropic API key is required. Please add one in settings.'), + { status: 400 }, + req + ); + } + if (err instanceof AgentSessionRuntimeConfigError) { + return errorResponse(err, { status: 503 }, req); + } + throw err; + } +}; + +export const GET = createApiHandler(getHandler); +export const POST = createApiHandler(postHandler); diff --git a/src/app/api/v2/ai/chat/[buildUuid]/feedback/route.test.ts b/src/app/api/v2/ai/chat/[buildUuid]/feedback/route.test.ts index 3a4657a..351b62c 100644 --- a/src/app/api/v2/ai/chat/[buildUuid]/feedback/route.test.ts +++ b/src/app/api/v2/ai/chat/[buildUuid]/feedback/route.test.ts @@ -124,7 +124,7 @@ describe('POST /api/v2/ai/chat/[buildUuid]/feedback', () => { }); it('derives userIdentifier from auth claims when present', async () => { - const response = await POST(makeRequest({ rating: 'up' }, { github_username: 'vmelikyan' }), { + const response = await POST(makeRequest({ rating: 'up' }, { github_username: 'sample-user' }), { params: { buildUuid: 'uuid-4' }, }); @@ -133,7 +133,7 @@ describe('POST /api/v2/ai/chat/[buildUuid]/feedback', () => { buildUuid: 'uuid-4', rating: 'up', text: undefined, - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', repo: 'org/repo', prNumber: 123, }); diff --git a/src/app/api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback/route.test.ts b/src/app/api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback/route.test.ts index f727137..56ec644 100644 --- a/src/app/api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback/route.test.ts +++ b/src/app/api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback/route.test.ts @@ -116,7 +116,7 @@ describe('POST /api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback', () => it('derives userIdentifier from auth claims when present', async () => { const response = await POST( - makeRequest({ rating: 'up', messageTimestamp: 1700000000000 }, { preferred_username: 'vmelikyan' }), + makeRequest({ rating: 'up', messageTimestamp: 1700000000000 }, { preferred_username: 'sample-user' }), { params: { buildUuid: 'uuid-3', messageId: '0' }, } @@ -129,7 +129,7 @@ describe('POST /api/v2/ai/chat/[buildUuid]/messages/[messageId]/feedback', () => messageTimestamp: 1700000000000, rating: 'up', text: undefined, - userIdentifier: 'vmelikyan', + userIdentifier: 'sample-user', repo: 'org/repo', prNumber: 123, }); diff --git a/src/app/api/v2/ai/chat/[buildUuid]/route.ts b/src/app/api/v2/ai/chat/[buildUuid]/route.ts index 2f13d1a..c946fca 100644 --- a/src/app/api/v2/ai/chat/[buildUuid]/route.ts +++ b/src/app/api/v2/ai/chat/[buildUuid]/route.ts @@ -34,6 +34,7 @@ import { isAuthError, } from 'server/services/ai/errors'; import { isBrokenCircuitError } from 'cockatiel'; +import type { ConfirmationDetails } from 'server/services/ai/types/tool'; import type { AIChatSSEEvent, SSEErrorEvent } from 'shared/types/aiChat'; export const dynamic = 'force-dynamic'; @@ -336,9 +337,11 @@ const postHandler = async (req: NextRequest, { params }: { params: { buildUuid: const repoFullName = context.lifecycleContext?.pullRequest?.fullName; + const mode = requestedMode === 'fix' ? 'fix' : 'investigate'; + try { if (provider && modelId) { - await llmService.initializeWithMode('investigate', provider, modelId, repoFullName); + await llmService.initializeWithMode(mode, provider, modelId, repoFullName); } else { await llmService.initialize(repoFullName); } @@ -381,10 +384,14 @@ const postHandler = async (req: NextRequest, { params }: { params: { buildUuid: >(); let collectedDebugMetrics: any = null; try { - const mode = requestedMode === 'fix' ? 'fix' : 'investigate'; getLogger().info(`AI: using mode=${mode} (requestedMode=${requestedMode})`); - const onToolConfirmation = mode === 'fix' ? async () => true : undefined; + const onToolConfirmation = + mode === 'fix' + ? async (details: ConfirmationDetails) => { + return true; + } + : undefined; const onToolAuthorization = mode === 'fix' && fixTarget ? async ( diff --git a/src/server/database.ts b/src/server/database.ts index 064ed50..7764a88 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -28,7 +28,7 @@ export default class Database { services: IServices; config: any = {}; - private __knexInstance: Knex; + private __knexInstance: Knex | null = null; private knexConfig: Knex.Config; constructor(knexConfig?: Knex.Config) { @@ -41,7 +41,7 @@ export default class Database { this.connect(); } - return this.__knexInstance; + return this.__knexInstance!; } setKnexConfig(knexConfig: Knex.Config = {}) { @@ -57,11 +57,13 @@ export default class Database { this.close(); this.setKnexConfig(knexConfig); - if ( - typeof this.knexConfig.connection === 'object' && - !(this.knexConfig.connection as Knex.ConnectionConfig).database - ) { - delete (this.knexConfig.connection as Knex.ConnectionConfig).database; + if (typeof this.knexConfig.connection === 'object' && this.knexConfig.connection) { + const connection = { ...(this.knexConfig.connection as Knex.ConnectionConfig) }; + if (!connection.database) { + this.knexConfig.connection = Object.fromEntries( + Object.entries(connection).filter(([, value]) => value != null && value !== '') + ) as unknown as Knex.ConnectionConfig; + } } this.__knexInstance = knex(this.knexConfig); diff --git a/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts new file mode 100644 index 0000000..fd7f60f --- /dev/null +++ b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts @@ -0,0 +1,138 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + const existingAgentSessionDefaults = await knex('global_config').where('key', 'agentSessionDefaults').first(); + const defaultAgentSessionDefaults = { + image: process.env.AGENT_IMAGE || null, + editorImage: process.env.AGENT_EDITOR_IMAGE || null, + claude: { + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + }, + }; + + await knex.schema.createTable('agent_sessions', (table) => { + table.increments('id').primary(); + table.uuid('uuid').notNullable().unique().defaultTo(knex.raw('gen_random_uuid()')); + table.string('buildUuid').nullable().index(); + table.string('buildKind').notNullable().defaultTo('environment').index(); + table.string('userId').notNullable().index(); + table.string('ownerGithubUsername').nullable().index(); + table.string('podName').notNullable(); + table.string('namespace').notNullable(); + table.string('pvcName').notNullable(); + table.string('model').notNullable(); + table.string('status').notNullable().defaultTo('starting'); + table.timestamp('lastActivity').notNullable().defaultTo(knex.fn.now()); + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); + table.timestamp('endedAt').nullable(); + table.jsonb('devModeSnapshots').notNullable().defaultTo('{}'); + table.jsonb('forwardedAgentSecretProviders').notNullable().defaultTo('[]'); + }); + await knex.raw(` + create unique index agent_sessions_active_environment_build_unique + on agent_sessions ("buildUuid") + where "buildUuid" is not null + and "buildKind" = 'environment' + and "status" in ('starting', 'active') + `); + + await knex.schema.createTable('user_api_keys', (table) => { + table.increments('id').primary(); + table.string('userId').notNullable().index(); + table.string('ownerGithubUsername').notNullable().index(); + table.string('provider').notNullable().defaultTo('anthropic'); + table.text('encryptedKey').notNullable(); + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); + table.unique(['ownerGithubUsername', 'provider']); + }); + + await knex.schema.alterTable('deploys', (table) => { + table.boolean('devMode').notNullable().defaultTo(false); + table.integer('devModeSessionId').nullable().references('id').inTable('agent_sessions'); + }); + + await knex.schema.alterTable('builds', (table) => { + table.string('kind').notNullable().defaultTo('environment'); + table.integer('baseBuildId').nullable().references('id').inTable('builds').onUpdate('CASCADE').onDelete('SET NULL'); + table.index(['kind']); + table.index(['baseBuildId']); + }); + + if (!existingAgentSessionDefaults) { + await knex('global_config').insert({ + key: 'agentSessionDefaults', + config: defaultAgentSessionDefaults, + createdAt: knex.fn.now(), + updatedAt: knex.fn.now(), + deletedAt: null, + description: 'Default configuration for agent session runtime images and Claude Code bootstrap behavior.', + }); + } +} + +export async function down(knex: Knex): Promise { + const hasDeploysTable = await knex.schema.hasTable('deploys'); + if (hasDeploysTable) { + const hasDevModeSessionId = await knex.schema.hasColumn('deploys', 'devModeSessionId'); + const hasDevMode = await knex.schema.hasColumn('deploys', 'devMode'); + if (hasDevModeSessionId || hasDevMode) { + await knex.schema.alterTable('deploys', (table) => { + if (hasDevModeSessionId) { + table.dropColumn('devModeSessionId'); + } + if (hasDevMode) { + table.dropColumn('devMode'); + } + }); + } + } + + await knex.raw('drop index if exists agent_sessions_active_environment_build_unique'); + + const hasBuildsTable = await knex.schema.hasTable('builds'); + if (hasBuildsTable) { + const hasBaseBuildId = await knex.schema.hasColumn('builds', 'baseBuildId'); + const hasKind = await knex.schema.hasColumn('builds', 'kind'); + if (hasBaseBuildId || hasKind) { + await knex.schema.alterTable('builds', (table) => { + if (hasBaseBuildId) { + table.dropIndex(['baseBuildId']); + table.dropColumn('baseBuildId'); + } + if (hasKind) { + table.dropIndex(['kind']); + table.dropColumn('kind'); + } + }); + } + } + + await knex.schema.dropTableIfExists('user_api_keys'); + await knex.schema.dropTableIfExists('agent_sessions'); + await knex('global_config').where('key', 'agentSessionDefaults').delete(); +} diff --git a/src/server/jobs/__tests__/agentSessionCleanup.test.ts b/src/server/jobs/__tests__/agentSessionCleanup.test.ts new file mode 100644 index 0000000..b9cc4e8 --- /dev/null +++ b/src/server/jobs/__tests__/agentSessionCleanup.test.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/models/AgentSession'); +jest.mock('server/services/agentSession'); +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + })), +})); + +import AgentSession from 'server/models/AgentSession'; +import AgentSessionService from 'server/services/agentSession'; +import { processAgentSessionCleanup } from '../agentSessionCleanup'; + +describe('agentSessionCleanup', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2026-03-23T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('cleans up both idle active sessions and stale starting sessions', async () => { + const activeSessions = [ + { + id: 1, + uuid: 'active-session', + status: 'active', + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:00:00.000Z', + }, + ]; + const startingSessions = [ + { + id: 2, + uuid: 'starting-session', + status: 'starting', + lastActivity: '2026-03-23T11:50:00.000Z', + updatedAt: '2026-03-23T11:40:00.000Z', + }, + ]; + + const activeQuery = { where: jest.fn() }; + activeQuery.where + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce(() => Promise.resolve(activeSessions)); + + const startingQuery = { where: jest.fn() }; + startingQuery.where + .mockImplementationOnce(() => startingQuery) + .mockImplementationOnce(() => Promise.resolve(startingSessions)); + + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValueOnce(activeQuery).mockReturnValueOnce(startingQuery); + (AgentSessionService.endSession as jest.Mock).mockResolvedValue(undefined); + + await processAgentSessionCleanup(); + + expect(AgentSession.query).toHaveBeenCalledTimes(2); + expect(AgentSessionService.endSession).toHaveBeenCalledTimes(2); + expect(AgentSessionService.endSession).toHaveBeenNthCalledWith(1, 'active-session'); + expect(AgentSessionService.endSession).toHaveBeenNthCalledWith(2, 'starting-session'); + }); +}); diff --git a/src/server/jobs/agentSandboxSessionLaunch.ts b/src/server/jobs/agentSandboxSessionLaunch.ts new file mode 100644 index 0000000..07bf84e --- /dev/null +++ b/src/server/jobs/agentSandboxSessionLaunch.ts @@ -0,0 +1,115 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Job } from 'bullmq'; + +import RedisClient from 'server/lib/redisClient'; +import { getLogger } from 'server/lib/logger'; +import { decrypt } from 'server/lib/encryption'; +import { + buildSandboxFocusUrl, + getSandboxLaunchState, + patchSandboxLaunchState, + SandboxLaunchStage, + setSandboxLaunchState, +} from 'server/lib/agentSession/sandboxLaunchState'; +import AgentSandboxSessionService, { LaunchSandboxSessionOptions } from 'server/services/agentSandboxSession'; + +const logger = getLogger(); + +export interface SandboxSessionLaunchJob extends Omit { + launchId: string; + encryptedGithubToken?: string | null; +} + +export async function processAgentSandboxSessionLaunch(job: Job): Promise { + const redis = RedisClient.getInstance().getRedis(); + const { + launchId, + userId, + userIdentity, + encryptedGithubToken, + baseBuildUuid, + service, + model, + agentImage, + editorImage, + } = job.data; + + const reportProgress = async (stage: SandboxLaunchStage, message: string): Promise => { + await patchSandboxLaunchState(redis, launchId, { + status: stage === 'queued' ? 'queued' : 'running', + stage, + message, + }); + }; + + try { + const result = await new AgentSandboxSessionService().launch({ + userId, + userIdentity, + githubToken: encryptedGithubToken ? decrypt(encryptedGithubToken) : null, + baseBuildUuid, + service, + model, + agentImage, + editorImage, + onProgress: reportProgress, + }); + + if (result.status !== 'created') { + throw new Error('Sandbox launch job completed without creating a session'); + } + + const existingState = await getSandboxLaunchState(redis, launchId); + await setSandboxLaunchState(redis, { + launchId, + userId, + status: 'created', + stage: 'ready', + message: 'Sandbox session is ready', + createdAt: existingState?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + baseBuildUuid, + service: result.service, + buildUuid: result.buildUuid, + namespace: result.namespace, + sessionId: result.session.uuid, + focusUrl: buildSandboxFocusUrl({ + buildUuid: result.buildUuid, + sessionId: result.session.uuid, + baseBuildUuid, + }), + }); + } catch (error) { + logger.error( + { + error, + launchId, + baseBuildUuid, + service, + }, + 'Sandbox launch job failed' + ); + await patchSandboxLaunchState(redis, launchId, { + status: 'error', + stage: 'error', + message: error instanceof Error ? error.message : 'Sandbox launch failed unexpectedly', + error: error instanceof Error ? error.message : 'Sandbox launch failed unexpectedly', + }); + throw error; + } +} diff --git a/src/server/jobs/agentSessionCleanup.ts b/src/server/jobs/agentSessionCleanup.ts new file mode 100644 index 0000000..1d81e3d --- /dev/null +++ b/src/server/jobs/agentSessionCleanup.ts @@ -0,0 +1,44 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AgentSession from 'server/models/AgentSession'; +import AgentSessionService from 'server/services/agentSession'; +import { getLogger } from 'server/lib/logger'; + +const logger = getLogger(); +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; +const STARTING_TIMEOUT_MS = 15 * 60 * 1000; + +export async function processAgentSessionCleanup(): Promise { + const activeCutoff = new Date(Date.now() - IDLE_TIMEOUT_MS); + const startingCutoff = new Date(Date.now() - STARTING_TIMEOUT_MS); + const staleSessions = [ + ...(await AgentSession.query().where('status', 'active').where('lastActivity', '<', activeCutoff)), + ...(await AgentSession.query().where('status', 'starting').where('updatedAt', '<', startingCutoff)), + ]; + + for (const session of staleSessions) { + const sessionId = session.uuid || String(session.id); + try { + logger.info( + `Cleaning up stale agent session: sessionId=${sessionId} status=${session.status} lastActivity=${session.lastActivity}` + ); + await AgentSessionService.endSession(sessionId); + } catch (err) { + logger.error(`Failed to clean up session: sessionId=${sessionId} err=${(err as Error).message}`); + } + } +} diff --git a/src/server/jobs/index.ts b/src/server/jobs/index.ts index a4c9dfa..dc78d57 100644 --- a/src/server/jobs/index.ts +++ b/src/server/jobs/index.ts @@ -20,6 +20,8 @@ import { defaultDb, redisClient } from 'server/lib/dependencies'; import RedisClient from 'server/lib/redisClient'; import QueueManager from 'server/lib/queueManager'; import { MAX_GITHUB_API_REQUEST, GITHUB_API_REQUEST_INTERVAL, QUEUE_NAMES } from 'shared/config'; +import { processAgentSessionCleanup } from './agentSessionCleanup'; +import { processAgentSandboxSessionLaunch } from './agentSandboxSessionLaunch'; let isBootstrapped = false; @@ -103,6 +105,35 @@ export default function bootstrapJobs(services: IServices) { concurrency: 10, }); + queueManager.registerWorker(QUEUE_NAMES.AGENT_SESSION_CLEANUP, processAgentSessionCleanup, { + connection: redisClient.getConnection(), + concurrency: 1, + }); + + queueManager.registerWorker(QUEUE_NAMES.AGENT_SANDBOX_SESSION_LAUNCH, processAgentSandboxSessionLaunch, { + connection: redisClient.getConnection(), + concurrency: 5, + }); + + const agentCleanupQueue = queueManager.registerQueue(QUEUE_NAMES.AGENT_SESSION_CLEANUP, { + connection: redisClient.getConnection(), + defaultJobOptions: { + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, + }); + + agentCleanupQueue.add( + 'agent-session-cleanup', + {}, + { + repeat: { + every: 5 * 60 * 1000, + }, + } + ); + defaultDb.services = services; if (process.env.NEXT_MANUAL_SIG_HANDLE) { diff --git a/src/server/lib/__tests__/encryption.test.ts b/src/server/lib/__tests__/encryption.test.ts new file mode 100644 index 0000000..4bdde44 --- /dev/null +++ b/src/server/lib/__tests__/encryption.test.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { encrypt, decrypt, maskApiKey } from 'server/lib/encryption'; + +beforeAll(() => { + process.env.ENCRYPTION_KEY = 'a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b'; +}); + +afterAll(() => { + delete process.env.ENCRYPTION_KEY; +}); + +describe('encryption', () => { + describe('encrypt/decrypt', () => { + test('round-trips a string', () => { + const plaintext = 'sk-ant-api03-abcdefghijklmnop'; + const ciphertext = encrypt(plaintext); + const decrypted = decrypt(ciphertext); + expect(decrypted).toBe(plaintext); + }); + + test('round-trips an empty string', () => { + const plaintext = ''; + const ciphertext = encrypt(plaintext); + const decrypted = decrypt(ciphertext); + expect(decrypted).toBe(plaintext); + }); + + test('round-trips a long string', () => { + const plaintext = 'x'.repeat(10000); + const ciphertext = encrypt(plaintext); + const decrypted = decrypt(ciphertext); + expect(decrypted).toBe(plaintext); + }); + + test('produces different ciphertexts for the same input (random IV)', () => { + const plaintext = 'sk-ant-api03-abcdefghijklmnop'; + const ciphertext1 = encrypt(plaintext); + const ciphertext2 = encrypt(plaintext); + expect(ciphertext1).not.toBe(ciphertext2); + }); + + test('throws when ENCRYPTION_KEY is not set', () => { + const originalKey = process.env.ENCRYPTION_KEY; + delete process.env.ENCRYPTION_KEY; + expect(() => encrypt('test')).toThrow(); + process.env.ENCRYPTION_KEY = originalKey; + }); + }); + + describe('maskApiKey', () => { + test('masks the middle of a key', () => { + const key = 'sk-ant-api03-abcdefghijklmnop'; + const masked = maskApiKey(key); + expect(masked).toBe('sk-ant...mnop'); + }); + + test('handles short keys (less than 10 chars)', () => { + const key = 'abcd'; + const masked = maskApiKey(key); + expect(masked).toBe('****'); + }); + + test('handles keys exactly 10 chars long', () => { + const key = 'abcdefghij'; + const masked = maskApiKey(key); + expect(masked).toBe('abcdef...ghij'); + }); + }); +}); diff --git a/src/server/lib/__tests__/get-user.test.ts b/src/server/lib/__tests__/get-user.test.ts new file mode 100644 index 0000000..bdab371 --- /dev/null +++ b/src/server/lib/__tests__/get-user.test.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { NextRequest } from 'next/server'; + +import { getRequestUserIdentity, getRequestUserSub, getUser } from '../get-user'; + +function makeRequest(userClaims?: Record): NextRequest { + const headers = new Headers(); + if (userClaims) { + headers.set('x-user', Buffer.from(JSON.stringify(userClaims), 'utf8').toString('base64url')); + } + + return { headers } as unknown as NextRequest; +} + +describe('get-user helpers', () => { + const originalEnableAuth = process.env.ENABLE_AUTH; + const originalLocalDevUserId = process.env.LOCAL_DEV_USER_ID; + + const restoreEnv = () => { + if (originalEnableAuth === undefined) { + delete process.env.ENABLE_AUTH; + } else { + process.env.ENABLE_AUTH = originalEnableAuth; + } + + if (originalLocalDevUserId === undefined) { + delete process.env.LOCAL_DEV_USER_ID; + } else { + process.env.LOCAL_DEV_USER_ID = originalLocalDevUserId; + } + }; + + afterEach(() => { + restoreEnv(); + }); + + it('decodes x-user payloads', () => { + const payload = getUser(makeRequest({ sub: 'user-123', github_username: 'sample-user' })); + expect(payload?.sub).toBe('user-123'); + expect(payload?.github_username).toBe('sample-user'); + }); + + it('returns null when auth is enabled and no user is present', () => { + process.env.ENABLE_AUTH = 'true'; + + expect(getRequestUserSub(makeRequest())).toBeNull(); + }); + + it('returns a stable local dev user when auth is disabled', () => { + process.env.ENABLE_AUTH = 'false'; + + expect(getRequestUserSub(makeRequest())).toBe('local-dev-user'); + }); + + it('uses LOCAL_DEV_USER_ID when configured', () => { + process.env.ENABLE_AUTH = 'false'; + process.env.LOCAL_DEV_USER_ID = 'vm-local'; + + expect(getRequestUserSub(makeRequest())).toBe('vm-local'); + }); + + it('builds a rich request user identity from token claims', () => { + const identity = getRequestUserIdentity( + makeRequest({ + sub: 'user-123', + github_username: 'sample-user', + given_name: 'Sample', + family_name: 'User', + email: 'sample-user@example.com', + }) + ); + + expect(identity).toEqual( + expect.objectContaining({ + userId: 'user-123', + githubUsername: 'sample-user', + email: 'sample-user@example.com', + displayName: 'Sample User', + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + }) + ); + }); + + it('falls back to local-dev identity when auth is disabled', () => { + process.env.ENABLE_AUTH = 'false'; + + expect(getRequestUserIdentity(makeRequest())).toEqual( + expect.objectContaining({ + userId: 'local-dev-user', + displayName: 'local-dev-user', + gitUserName: 'local-dev-user', + gitUserEmail: 'local-dev-user@users.noreply.github.com', + }) + ); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/configSeeder.test.ts b/src/server/lib/agentSession/__tests__/configSeeder.test.ts new file mode 100644 index 0000000..9f6e2b2 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/configSeeder.test.ts @@ -0,0 +1,133 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { generateInitScript } from '../configSeeder'; + +describe('configSeeder', () => { + describe('generateInitScript', () => { + const baseOpts = { + repoUrl: 'https://github.com/org/repo.git', + branch: 'feature/test', + workspacePath: '/workspace', + }; + + it('contains git clone with branch', () => { + const script = generateInitScript(baseOpts); + expect(script).toContain('git clone --branch "feature/test"'); + expect(script).toContain('"https://github.com/org/repo.git"'); + expect(script).toContain('"/workspace"'); + }); + + it('marks the workspace as a safe git directory before checkout steps', () => { + const script = generateInitScript(baseOpts); + expect(script).toContain('mkdir -p "/workspace"'); + expect(script).toContain('git config --global --add safe.directory "/workspace"'); + }); + + it('contains install command when provided', () => { + const script = generateInitScript({ ...baseOpts, installCommand: 'pnpm install' }); + expect(script).toContain('pnpm install'); + }); + + it('does not contain install command when not provided', () => { + const script = generateInitScript(baseOpts); + expect(script).not.toContain('pnpm install'); + }); + + it('writes CLAUDE.md when content is provided', () => { + const script = generateInitScript({ ...baseOpts, claudeMdContent: 'Project rules here' }); + expect(script).toContain('CLAUDE.md'); + expect(script).toContain('Project rules here'); + }); + + it('writes settings.json with permissions', () => { + const script = generateInitScript(baseOpts); + expect(script).toContain('settings.json'); + expect(script).toContain('Bash(*)'); + expect(script).toContain('Read(*)'); + expect(script).toContain('Write(*)'); + expect(script).toContain('Edit(*)'); + expect(script).toContain('Glob(*)'); + expect(script).toContain('Grep(*)'); + }); + + it('writes Claude attribution settings when provided', () => { + const script = generateInitScript({ + ...baseOpts, + claudeCommitAttribution: 'Generated with (sample-lifecycle-app)', + claudePrAttribution: 'Generated with (sample-lifecycle-app)', + }); + + expect(script).toContain('"attribution"'); + expect(script).toContain('"commit": "Generated with (sample-lifecycle-app)"'); + expect(script).toContain('"pr": "Generated with (sample-lifecycle-app)"'); + }); + + it('writes custom Claude permissions when provided', () => { + const script = generateInitScript({ + ...baseOpts, + claudePermissions: { + allow: ['Read(*)'], + deny: ['Bash(*)'], + }, + }); + + expect(script).toContain('"allow": ['); + expect(script).toContain('"Read(*)"'); + expect(script).toContain('"deny": ['); + expect(script).toContain('"Bash(*)"'); + }); + + it('sets up pre-push branch protection hook', () => { + const script = generateInitScript(baseOpts); + expect(script).toContain('pre-push'); + expect(script).toContain('Pushing to $branch_name is not allowed'); + expect(script).toContain('main'); + expect(script).toContain('master'); + expect(script).toContain('chmod +x .git/hooks/pre-push'); + }); + + it('starts with shebang', () => { + const script = generateInitScript(baseOpts); + expect(script.startsWith('#!/bin/sh')).toBe(true); + }); + + it('configures git identity when user context is provided', () => { + const script = generateInitScript({ + ...baseOpts, + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + githubUsername: 'sample-user', + }); + + expect(script).toContain('git config --global user.name "Sample User"'); + expect(script).toContain('git config --global user.email "sample-user@example.com"'); + expect(script).toContain('git config --global github.user "sample-user"'); + }); + + it('configures a GitHub credential helper when token auth is enabled', () => { + const script = generateInitScript({ + ...baseOpts, + useGitHubToken: true, + }); + + expect(script).toContain('if [ -n "${GITHUB_TOKEN:-}" ]; then'); + expect(script).toContain( + 'git config --global credential.helper \'!f() { test "$1" = get || exit 0; echo username=x-access-token; echo password=$GITHUB_TOKEN; }; f\'' + ); + }); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/devModeManager.test.ts b/src/server/lib/agentSession/__tests__/devModeManager.test.ts new file mode 100644 index 0000000..2df7115 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/devModeManager.test.ts @@ -0,0 +1,692 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; + +const mockReadDeployment = jest.fn(); +const mockListDeployments = jest.fn(); +const mockReadService = jest.fn(); +const mockListServices = jest.fn(); +const mockPatchDeployment = jest.fn(); +const mockPatchService = jest.fn(); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockImplementation((apiClass: any) => { + if (apiClass === actual.AppsV1Api) { + return { + readNamespacedDeployment: mockReadDeployment, + listNamespacedDeployment: mockListDeployments, + patchNamespacedDeployment: mockPatchDeployment, + }; + } + if (apiClass === actual.CoreV1Api) { + return { + readNamespacedService: mockReadService, + listNamespacedService: mockListServices, + patchNamespacedService: mockPatchService, + }; + } + return {}; + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), +})); + +import { DevModeManager, DevModeOptions } from '../devModeManager'; + +describe('DevModeManager', () => { + let manager: DevModeManager; + + beforeEach(() => { + jest.clearAllMocks(); + mockReadDeployment.mockResolvedValue({ + body: { + spec: { + template: { + spec: { + containers: [{ name: 'web-app' }], + }, + }, + }, + }, + }); + mockListDeployments.mockResolvedValue({ body: { items: [] } }); + mockReadService.mockResolvedValue({ + body: { + metadata: { name: 'my-app-svc' }, + spec: { + ports: [{ name: 'http', port: 8080, protocol: 'TCP' }], + }, + }, + }); + mockListServices.mockResolvedValue({ body: { items: [] } }); + mockPatchDeployment.mockResolvedValue({}); + mockPatchService.mockResolvedValue({}); + manager = new DevModeManager(); + }); + + it('patches deployment with dev image and PVC', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev', workDir: '/workspace' }, + }; + + await manager.enableDevMode(opts); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'my-app', + 'test-ns', + expect.objectContaining({ + metadata: { + annotations: { + 'lifecycle.goodrx.com/dev-mode-deployment-snapshot': expect.any(String), + }, + }, + spec: { + template: { + spec: { + volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }], + containers: [ + expect.objectContaining({ + name: 'web-app', + image: 'node:20-slim', + command: ['/bin/sh', '-c', 'pnpm dev'], + workingDir: '/workspace', + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }), + ], + }, + }, + }, + }), + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + }); + + it('mounts the shared workspace root when workDir points at a service subdirectory', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'node --watch app.js', workDir: '/workspace/my-express-app' }, + }; + + await manager.enableDevMode(opts); + + const patchBody = mockPatchDeployment.mock.calls[0][2]; + expect(patchBody.spec.template.spec.containers[0].workingDir).toBe('/workspace/my-express-app'); + expect(patchBody.spec.template.spec.containers[0].volumeMounts).toEqual([ + { name: 'workspace', mountPath: '/workspace' }, + ]); + }); + + it('patches service targetPort when dev ports specified', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app-svc', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev', ports: [3000] }, + }; + + await manager.enableDevMode(opts); + + expect(mockPatchService).toHaveBeenCalledWith( + 'my-app-svc', + 'test-ns', + expect.objectContaining({ + metadata: { + annotations: { + 'lifecycle.goodrx.com/dev-mode-service-snapshot': expect.any(String), + }, + }, + spec: { + ports: [{ name: 'http', port: 8080, protocol: 'TCP', targetPort: 3000 }], + }, + }), + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + }); + + it('does not patch service when no dev ports specified', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app-svc', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev' }, + }; + + await manager.enableDevMode(opts); + + expect(mockPatchService).not.toHaveBeenCalled(); + }); + + it('reads actual container name from existing deployment', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + spec: { + template: { + spec: { + containers: [{ name: 'custom-container' }], + }, + }, + }, + }, + }); + + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev' }, + }; + + await manager.enableDevMode(opts); + + const patchBody = mockPatchDeployment.mock.calls[0][2]; + expect(patchBody.spec.template.spec.containers[0].name).toBe('custom-container'); + }); + + it('uses default workDir when not specified in devConfig', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev' }, + }; + + await manager.enableDevMode(opts); + + const patchBody = mockPatchDeployment.mock.calls[0][2]; + expect(patchBody.spec.template.spec.containers[0].workingDir).toBe('/workspace'); + }); + + it('maps devConfig env to k8s env format', async () => { + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { + image: 'node:20-slim', + command: 'pnpm dev', + env: { NODE_ENV: 'development', PORT: '3000' }, + }, + }; + + await manager.enableDevMode(opts); + + const patchBody = mockPatchDeployment.mock.calls[0][2]; + expect(patchBody.spec.template.spec.containers[0].env).toEqual( + expect.arrayContaining([ + { name: 'NODE_ENV', value: 'development' }, + { name: 'PORT', value: '3000' }, + ]) + ); + }); + + it('removes dev-mode-only deployment fields not present in last-applied configuration', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { + name: 'my-app-resolved', + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': JSON.stringify({ + spec: { + template: { + spec: { + containers: [ + { + name: 'web-app', + volumeMounts: [{ name: 'config-volume', mountPath: '/config' }], + }, + ], + volumes: [{ name: 'config-volume' }], + }, + }, + }, + }), + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: 'web-app', + command: ['/bin/sh', '-c', 'npm run dev'], + workingDir: '/workspace/my-express-app', + volumeMounts: [ + { name: 'workspace', mountPath: '/workspace' }, + { name: 'config-volume', mountPath: '/config' }, + ], + }, + ], + volumes: [ + { name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }, + { name: 'config-volume', emptyDir: {} }, + ], + }, + }, + }, + }, + }); + + await manager.disableDevMode('test-ns', 'my-app'); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'my-app-resolved', + 'test-ns', + [ + { op: 'remove', path: '/spec/template/spec/containers/0/command' }, + { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, + { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts/0' }, + { op: 'remove', path: '/spec/template/spec/volumes/0' }, + ], + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + }); + + it('preserves deployment fields that are present in last-applied configuration', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { + name: 'my-app-resolved', + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': JSON.stringify({ + spec: { + template: { + spec: { + containers: [ + { + name: 'web-app', + command: ['/bin/sh', '-c', 'node server.js'], + workingDir: '/app', + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }, + ], + volumes: [{ name: 'workspace' }], + }, + }, + }, + }), + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: 'web-app', + command: ['/bin/sh', '-c', 'node server.js'], + workingDir: '/app', + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }, + ], + volumes: [{ name: 'workspace', emptyDir: {} }], + }, + }, + }, + }, + }); + + await manager.disableDevMode('test-ns', 'my-app'); + + expect(mockPatchDeployment).not.toHaveBeenCalled(); + }); + + it('restores deployment fields from dev mode snapshot annotations', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { + name: 'grpc-echo-resolved', + annotations: { + 'lifecycle.goodrx.com/dev-mode-deployment-snapshot': JSON.stringify({ + containerName: 'grpc-echo', + image: 'registry.example/grpc-echo:built', + env: [ + { name: 'COMPONENT', value: 'app' }, + { name: 'ENV', value: 'lifecycle' }, + ], + volumeMounts: [{ name: 'config-volume', mountPath: '/config' }], + volumes: [{ name: 'config-volume', emptyDir: {} }], + }), + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: 'grpc-echo', + image: 'golang:1.20', + command: ['/bin/sh', '-c', 'go run ./server.go'], + workingDir: '/workspace/grpc-echo', + env: [], + volumeMounts: [ + { name: 'workspace', mountPath: '/workspace' }, + { name: 'config-volume', mountPath: '/config' }, + ], + }, + ], + volumes: [ + { name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }, + { name: 'config-volume', emptyDir: {} }, + ], + }, + }, + }, + }, + }); + + await manager.disableDevMode('test-ns', 'grpc-echo'); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'grpc-echo-resolved', + 'test-ns', + [ + { + op: 'remove', + path: '/metadata/annotations/lifecycle.goodrx.com~1dev-mode-deployment-snapshot', + }, + { + op: 'replace', + path: '/spec/template/spec/containers/0/image', + value: 'registry.example/grpc-echo:built', + }, + { op: 'remove', path: '/spec/template/spec/containers/0/command' }, + { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, + { + op: 'replace', + path: '/spec/template/spec/containers/0/env', + value: [ + { name: 'COMPONENT', value: 'app' }, + { name: 'ENV', value: 'lifecycle' }, + ], + }, + { + op: 'replace', + path: '/spec/template/spec/containers/0/volumeMounts', + value: [{ name: 'config-volume', mountPath: '/config' }], + }, + { + op: 'replace', + path: '/spec/template/spec/volumes', + value: [{ name: 'config-volume', emptyDir: {} }], + }, + ], + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + }); + + it('falls back to removing dev-mode-only deployment fields when last-applied annotation is missing', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { + name: 'grpc-echo-resolved', + annotations: { + 'meta.helm.sh/release-name': 'grpc-echo', + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: 'grpc-echo', + image: 'registry.example/grpc-echo:built', + command: ['/bin/sh', '-c', 'go run ./server.go'], + workingDir: '/workspace/grpc-echo', + volumeMounts: [ + { name: 'workspace', mountPath: '/workspace' }, + { name: 'config-volume', mountPath: '/config' }, + ], + }, + ], + volumes: [ + { name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }, + { name: 'config-volume', emptyDir: {} }, + ], + }, + }, + }, + }, + }); + + await manager.disableDevMode('test-ns', 'grpc-echo'); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'grpc-echo-resolved', + 'test-ns', + [ + { op: 'remove', path: '/spec/template/spec/containers/0/command' }, + { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, + { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts/0' }, + { op: 'remove', path: '/spec/template/spec/volumes/0' }, + ], + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + }); + + it('restores deployment and service fields from an explicit snapshot', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { + name: 'grpc-echo-resolved', + annotations: { + 'meta.helm.sh/release-name': 'grpc-echo', + }, + }, + spec: { + template: { + spec: { + containers: [ + { + name: 'lc-apps', + image: 'registry.example/grpc-echo:dev', + command: ['/bin/sh', '-c', 'go run ./server.go'], + workingDir: '/workspace/grpc-echo', + env: [{ name: 'DEV_ONLY', value: 'true' }], + volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + }, + ], + volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }], + }, + }, + }, + }, + }); + mockReadService.mockResolvedValue({ + body: { + metadata: { name: 'grpc-echo-service' }, + spec: { + ports: [{ name: 'tcp', port: 8080, targetPort: 8080, protocol: 'TCP' }], + }, + }, + }); + + await manager.disableDevMode('test-ns', 'grpc-echo', 'grpc-echo', { + deployment: { + deploymentName: 'grpc-echo-resolved', + containerName: 'lc-apps', + image: 'registry.example/grpc-echo:built', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: { + serviceName: 'grpc-echo-service', + ports: [{ name: 'tcp', port: 8080, targetPort: 8080, protocol: 'TCP' }], + }, + }); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'grpc-echo-resolved', + 'test-ns', + [ + { + op: 'replace', + path: '/spec/template/spec/containers/0/image', + value: 'registry.example/grpc-echo:built', + }, + { op: 'remove', path: '/spec/template/spec/containers/0/command' }, + { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, + { op: 'remove', path: '/spec/template/spec/containers/0/env' }, + { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts' }, + { op: 'remove', path: '/spec/template/spec/volumes' }, + ], + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + expect(mockPatchService).toHaveBeenCalledWith( + 'grpc-echo-service', + 'test-ns', + { + metadata: { + annotations: { + 'lifecycle.goodrx.com/dev-mode-service-snapshot': null, + }, + }, + spec: { + ports: [{ name: 'tcp', port: 8080, targetPort: 8080, protocol: 'TCP' }], + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + }); + + it('resolves build-specific deployment and service names when logical names do not exist directly', async () => { + mockReadDeployment.mockRejectedValueOnce(new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404)); + mockListDeployments.mockResolvedValue({ + body: { + items: [ + { + metadata: { + name: 'lc-test-gh-type-wispy-frog-035797', + labels: { 'tags.datadoghq.com/service': 'lc-test-gh-type' }, + }, + spec: { + selector: { matchLabels: { name: 'lc-test-gh-type-wispy-frog-035797' } }, + template: { spec: { containers: [{ name: 'lc-test-gh-type' }] } }, + }, + }, + ], + }, + }); + mockReadService + .mockRejectedValueOnce(new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404)) + .mockResolvedValueOnce({ + body: { + metadata: { name: 'lc-test-gh-type-wispy-frog-035797' }, + spec: { ports: [{ name: 'provided-8080', port: 8080, protocol: 'TCP' }] }, + }, + }); + + const opts: DevModeOptions = { + namespace: 'env-wispy-frog-035797', + deploymentName: 'lc-test-gh-type', + serviceName: 'lc-test-gh-type', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'node --watch app.js', ports: [8080] }, + }; + + await manager.enableDevMode(opts); + + expect(mockPatchDeployment).toHaveBeenCalledWith( + 'lc-test-gh-type-wispy-frog-035797', + 'env-wispy-frog-035797', + expect.any(Object), + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + expect(mockPatchService).toHaveBeenCalledWith( + 'lc-test-gh-type-wispy-frog-035797', + 'env-wispy-frog-035797', + expect.objectContaining({ + spec: { + ports: [{ name: 'provided-8080', port: 8080, protocol: 'TCP', targetPort: 8080 }], + }, + }), + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/editorServiceFactory.test.ts b/src/server/lib/agentSession/__tests__/editorServiceFactory.test.ts new file mode 100644 index 0000000..5bdde78 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/editorServiceFactory.test.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; + +const mockCreateService = jest.fn(); +const mockDeleteService = jest.fn(); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + createNamespacedService: mockCreateService, + deleteNamespacedService: mockDeleteService, + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +import { createAgentEditorService, deleteAgentEditorService } from '../editorServiceFactory'; +import { AGENT_EDITOR_PORT } from '../podFactory'; + +describe('editorServiceFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a ClusterIP editor service for the session pod', async () => { + mockCreateService.mockResolvedValue({ body: { metadata: { name: 'agent-abc123' } } }); + + await createAgentEditorService('test-ns', 'agent-abc123'); + + expect(mockCreateService).toHaveBeenCalledWith( + 'test-ns', + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'agent-abc123' }), + spec: expect.objectContaining({ + selector: { 'app.kubernetes.io/name': 'agent-abc123' }, + ports: [{ name: 'editor', port: AGENT_EDITOR_PORT, targetPort: AGENT_EDITOR_PORT }], + }), + }) + ); + }); + + it('deletes the editor service', async () => { + mockDeleteService.mockResolvedValue({}); + + await deleteAgentEditorService('test-ns', 'agent-abc123'); + + expect(mockDeleteService).toHaveBeenCalledWith('agent-abc123', 'test-ns'); + }); + + it('ignores 404 errors when deleting the editor service', async () => { + const error = new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404); + mockDeleteService.mockRejectedValue(error); + + await expect(deleteAgentEditorService('test-ns', 'agent-abc123')).resolves.toBeUndefined(); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/execProxy.test.ts b/src/server/lib/agentSession/__tests__/execProxy.test.ts new file mode 100644 index 0000000..a7bdd54 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/execProxy.test.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildClaudeExecScript } from '../execProxy'; + +describe('execProxy', () => { + describe('buildClaudeExecScript', () => { + it('does not add a system prompt flag when one is not configured', () => { + const script = buildClaudeExecScript('claude-sonnet-4-6'); + + expect(script).toContain("exec claude -p --model 'claude-sonnet-4-6'"); + expect(script).not.toContain('--append-system-prompt'); + }); + + it('adds the configured system prompt flag when provided', () => { + const script = buildClaudeExecScript('claude-sonnet-4-6', 'Follow the repository instructions.'); + + expect(script).toContain("--append-system-prompt 'Follow the repository instructions.'"); + }); + + it('shell-escapes single quotes in the configured system prompt', () => { + const script = buildClaudeExecScript('claude-sonnet-4-6', "Use repo's style guide."); + + expect(script).toContain("--append-system-prompt 'Use repo'\"'\"'s style guide.'"); + }); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/forwardedEnv.test.ts b/src/server/lib/agentSession/__tests__/forwardedEnv.test.ts new file mode 100644 index 0000000..2061dc8 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/forwardedEnv.test.ts @@ -0,0 +1,284 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/models/Deploy'); +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getAllConfigs: jest.fn().mockResolvedValue({}), + })), + }, +})); +jest.mock('server/services/secretProcessor'); +jest.mock('server/lib/kubernetes/externalSecret', () => { + const actual = jest.requireActual('server/lib/kubernetes/externalSecret'); + return { + ...actual, + deleteExternalSecret: jest.fn().mockResolvedValue(undefined), + }; +}); +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + })), +})); + +const mockDeleteSecret = jest.fn().mockResolvedValue(undefined); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + deleteNamespacedSecret: mockDeleteSecret, + }), + })), + }; +}); + +import Deploy from 'server/models/Deploy'; +import GlobalConfigService from 'server/services/globalConfig'; +import { SecretProcessor } from 'server/services/secretProcessor'; +import { deleteExternalSecret } from 'server/lib/kubernetes/externalSecret'; +import { + cleanupForwardedAgentEnvSecrets, + getForwardedAgentEnvSecretServiceName, + resolveForwardedAgentEnv, +} from '../forwardedEnv'; + +const mockDeployQuery = { + whereIn: jest.fn().mockReturnThis(), + select: jest.fn(), +}; +(Deploy.query as jest.Mock) = jest.fn().mockReturnValue(mockDeployQuery); + +describe('forwardedEnv', () => { + beforeEach(() => { + jest.clearAllMocks(); + (Deploy.query as jest.Mock) = jest.fn().mockReturnValue(mockDeployQuery); + mockDeployQuery.whereIn.mockReturnThis(); + mockDeployQuery.select.mockResolvedValue([]); + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue({}), + }); + (SecretProcessor as unknown as jest.Mock).mockImplementation(() => ({ + processEnvSecrets: jest.fn().mockResolvedValue({ + secretRefs: [], + secretNames: [], + warnings: [], + }), + waitForSecretSync: jest.fn().mockResolvedValue(undefined), + })); + mockDeleteSecret.mockResolvedValue(undefined); + }); + + it('returns empty forwarded env when no services are selected', async () => { + const result = await resolveForwardedAgentEnv([], 'test-ns', 'session-123'); + + expect(result).toEqual({ + env: {}, + secretRefs: [], + secretProviders: [], + secretServiceName: 'agent-env-session-123', + }); + }); + + it('collects allowlisted env vars from selected deploys', async () => { + mockDeployQuery.select.mockResolvedValue([ + { + id: 10, + env: { + PRIVATE_REGISTRY_TOKEN: 'plain-token', + TURBO_TOKEN: 'turbo-token', + }, + }, + ]); + + const result = await resolveForwardedAgentEnv( + [ + { + name: 'web', + deployId: 10, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + ], + 'test-ns', + 'session-123' + ); + + expect(result).toEqual({ + env: { + PRIVATE_REGISTRY_TOKEN: 'plain-token', + }, + secretRefs: [], + secretProviders: [], + secretServiceName: 'agent-env-session-123', + }); + }); + + it('throws when selected services resolve the same forwarded key to different values', async () => { + mockDeployQuery.select.mockResolvedValue([ + { id: 10, env: { PRIVATE_REGISTRY_TOKEN: 'token-one' } }, + { id: 11, env: { PRIVATE_REGISTRY_TOKEN: 'token-two' } }, + ]); + + await expect( + resolveForwardedAgentEnv( + [ + { + name: 'web', + deployId: 10, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + { + name: 'api', + deployId: 11, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + ], + 'test-ns', + 'session-123' + ) + ).rejects.toThrow('Agent env forwarding conflict for PRIVATE_REGISTRY_TOKEN'); + }); + + it('processes secret refs through the configured secret providers', async () => { + mockDeployQuery.select.mockResolvedValue([ + { + id: 10, + env: { + PRIVATE_REGISTRY_TOKEN: '{{aws:apps/sample:npmToken}}', + }, + }, + ]); + + const processEnvSecrets = jest.fn().mockResolvedValue({ + secretRefs: [ + { + envKey: 'PRIVATE_REGISTRY_TOKEN', + provider: 'aws', + path: 'apps/sample', + key: 'npmToken', + }, + ], + secretNames: ['agent-env-session-123-aws-secrets'], + warnings: [], + }); + const waitForSecretSync = jest.fn().mockResolvedValue(undefined); + (SecretProcessor as unknown as jest.Mock).mockImplementation(() => ({ + processEnvSecrets, + waitForSecretSync, + })); + (GlobalConfigService.getInstance as jest.Mock).mockReturnValue({ + getAllConfigs: jest.fn().mockResolvedValue({ + secretProviders: { + aws: { + enabled: true, + clusterSecretStore: 'aws-secrets', + refreshInterval: '1m', + secretSyncTimeout: 30, + }, + }, + }), + }); + + const result = await resolveForwardedAgentEnv( + [ + { + name: 'web', + deployId: 10, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + ], + 'test-ns', + 'session-123', + 'build-123' + ); + + expect(processEnvSecrets).toHaveBeenCalledWith({ + env: { PRIVATE_REGISTRY_TOKEN: '{{aws:apps/sample:npmToken}}' }, + serviceName: 'agent-env-session-123', + namespace: 'test-ns', + buildUuid: 'build-123', + }); + expect(waitForSecretSync).toHaveBeenCalledWith(['agent-env-session-123-aws-secrets'], 'test-ns', 30000); + expect(result.secretProviders).toEqual(['aws']); + }); + + it('throws when secret refs are forwarded without configured secret providers', async () => { + mockDeployQuery.select.mockResolvedValue([ + { + id: 10, + env: { + PRIVATE_REGISTRY_TOKEN: '{{aws:apps/sample:npmToken}}', + }, + }, + ]); + + await expect( + resolveForwardedAgentEnv( + [ + { + name: 'web', + deployId: 10, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + ], + 'test-ns', + 'session-123' + ) + ).rejects.toThrow('requires configured secret providers'); + }); + + it('cleans up session-scoped ExternalSecrets and synced Secrets for forwarded env', async () => { + await cleanupForwardedAgentEnvSecrets('test-ns', 'session-123', ['aws', 'gcp']); + + expect(deleteExternalSecret).toHaveBeenCalledWith('agent-env-session-123-aws-secrets', 'test-ns'); + expect(deleteExternalSecret).toHaveBeenCalledWith('agent-env-session-123-gcp-secrets', 'test-ns'); + expect(mockDeleteSecret).toHaveBeenCalledWith('agent-env-session-123-aws-secrets', 'test-ns'); + expect(mockDeleteSecret).toHaveBeenCalledWith('agent-env-session-123-gcp-secrets', 'test-ns'); + }); + + it('derives a stable secret service name from the session uuid', () => { + expect(getForwardedAgentEnvSecretServiceName('session-123')).toBe('agent-env-session-123'); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/githubToken.test.ts b/src/server/lib/agentSession/__tests__/githubToken.test.ts new file mode 100644 index 0000000..9f8556a --- /dev/null +++ b/src/server/lib/agentSession/__tests__/githubToken.test.ts @@ -0,0 +1,143 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { fetchGitHubBrokerToken, resolveRequestGitHubToken } from '../githubToken'; + +const mockGetGithubClientToken = jest.fn(); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + warn: jest.fn(), + }), +})); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + getGithubClientToken: mockGetGithubClientToken, + }), + }, +})); + +describe('githubToken', () => { + const originalEnableAuth = process.env.ENABLE_AUTH; + const originalIssuer = process.env.KEYCLOAK_ISSUER; + const originalInternalIssuer = process.env.KEYCLOAK_ISSUER_INTERNAL; + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.KEYCLOAK_ISSUER = 'https://keycloak.example.com/realms/test'; + delete process.env.KEYCLOAK_ISSUER_INTERNAL; + global.fetch = jest.fn(); + }); + + afterAll(() => { + process.env.ENABLE_AUTH = originalEnableAuth; + process.env.KEYCLOAK_ISSUER = originalIssuer; + process.env.KEYCLOAK_ISSUER_INTERNAL = originalInternalIssuer; + global.fetch = originalFetch; + }); + + it('returns the cached GitHub app token when auth is disabled', async () => { + process.env.ENABLE_AUTH = 'false'; + mockGetGithubClientToken.mockResolvedValue('ghs_cached_app_token'); + + const token = await resolveRequestGitHubToken(new NextRequest('http://localhost/api')); + + expect(mockGetGithubClientToken).toHaveBeenCalledTimes(1); + expect(token).toBe('ghs_cached_app_token'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns null when auth is disabled and cached GitHub app token lookup fails', async () => { + process.env.ENABLE_AUTH = 'false'; + mockGetGithubClientToken.mockRejectedValue(new Error('cache unavailable')); + + const token = await resolveRequestGitHubToken(new NextRequest('http://localhost/api')); + + expect(mockGetGithubClientToken).toHaveBeenCalledTimes(1); + expect(token).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('fetches broker token from Keycloak when auth is enabled', async () => { + process.env.ENABLE_AUTH = 'true'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify({ access_token: 'gho_broker_token' })), + }); + + const token = await resolveRequestGitHubToken( + new NextRequest('http://localhost/api', { + headers: { + authorization: 'Bearer keycloak-access-token', + }, + }) + ); + + expect(global.fetch).toHaveBeenCalledWith('https://keycloak.example.com/realms/test/broker/github/token', { + method: 'GET', + headers: { + Authorization: 'Bearer keycloak-access-token', + }, + }); + expect(token).toBe('gho_broker_token'); + }); + + it('prefers the internal issuer when it is configured', async () => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER_INTERNAL = 'http://keycloak.internal/realms/test'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify({ access_token: 'gho_internal_token' })), + }); + + const token = await resolveRequestGitHubToken( + new NextRequest('http://localhost/api', { + headers: { + authorization: 'Bearer keycloak-access-token', + }, + }) + ); + + expect(global.fetch).toHaveBeenCalledWith('http://keycloak.internal/realms/test/broker/github/token', { + method: 'GET', + headers: { + Authorization: 'Bearer keycloak-access-token', + }, + }); + expect(token).toBe('gho_internal_token'); + }); + + it('parses query string token responses from Keycloak', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('access_token=gho_query_token&expires_in=300'), + }); + + await expect(fetchGitHubBrokerToken('keycloak-access-token')).resolves.toBe('gho_query_token'); + }); + + it('returns null when auth is enabled but no bearer token is present', async () => { + process.env.ENABLE_AUTH = 'true'; + + await expect(resolveRequestGitHubToken(new NextRequest('http://localhost/api'))).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/gvisorCheck.test.ts b/src/server/lib/agentSession/__tests__/gvisorCheck.test.ts new file mode 100644 index 0000000..b8b0783 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/gvisorCheck.test.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; + +const mockReadRuntimeClass = jest.fn(); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + readRuntimeClass: mockReadRuntimeClass, + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +import { isGvisorAvailable, resetGvisorCache } from '../gvisorCheck'; + +describe('isGvisorAvailable', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetGvisorCache(); + }); + + it('returns true when RuntimeClass exists', async () => { + mockReadRuntimeClass.mockResolvedValue({ metadata: { name: 'gvisor' } }); + const result = await isGvisorAvailable(); + expect(result).toBe(true); + expect(mockReadRuntimeClass).toHaveBeenCalledWith('gvisor'); + }); + + it('returns false when RuntimeClass returns 404', async () => { + const error = new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404); + mockReadRuntimeClass.mockRejectedValue(error); + const result = await isGvisorAvailable(); + expect(result).toBe(false); + }); + + it('returns false and logs warning on other errors', async () => { + mockReadRuntimeClass.mockRejectedValue(new Error('connection refused')); + const result = await isGvisorAvailable(); + expect(result).toBe(false); + }); + + it('caches results within TTL', async () => { + mockReadRuntimeClass.mockResolvedValue({ metadata: { name: 'gvisor' } }); + await isGvisorAvailable(); + await isGvisorAvailable(); + expect(mockReadRuntimeClass).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/jsonlParser.test.ts b/src/server/lib/agentSession/__tests__/jsonlParser.test.ts new file mode 100644 index 0000000..27f7d4e --- /dev/null +++ b/src/server/lib/agentSession/__tests__/jsonlParser.test.ts @@ -0,0 +1,232 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AgentWSServerMessage } from 'shared/types/agentSession'; +import { JsonlParser } from '../jsonlParser'; + +describe('JsonlParser', () => { + let messages: AgentWSServerMessage[]; + let parser: JsonlParser; + + beforeEach(() => { + messages = []; + parser = new JsonlParser((msg) => messages.push(msg)); + }); + + it('parses a complete JSONL line', () => { + parser.feed('{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}\n'); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ type: 'chunk', content: 'hello' }); + }); + + it('buffers partial lines until newline', () => { + parser.feed('{"type":"assis'); + expect(messages).toHaveLength(0); + + parser.feed('tant","message":{"content":[{"type":"text","text":"hi"}]}}\n'); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ type: 'chunk', content: 'hi' }); + }); + + it('handles multiple lines in one feed', () => { + parser.feed( + '{"type":"assistant","message":{"content":[{"type":"text","text":"one"}]}}\n{"type":"assistant","message":{"content":[{"type":"text","text":"two"}]}}\n' + ); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ type: 'chunk', content: 'one' }); + expect(messages[1]).toEqual({ type: 'chunk', content: 'two' }); + }); + + it('maps assistant events to chunk', () => { + parser.feed('{"type":"assistant","message":{"content":[{"type":"text","text":"test content"}]}}\n'); + expect(messages[0]).toEqual({ type: 'chunk', content: 'test content' }); + }); + + it('maps assistant usage to session metrics', () => { + parser.feed( + '{"type":"stream_event","event":{"type":"message_start"}}\n' + + '{"type":"assistant","message":{"id":"msg_step_1","content":[{"type":"text","text":"test content"}],"usage":{"input_tokens":123,"output_tokens":45,"cache_creation_input_tokens":12,"cache_read_input_tokens":7}}}\n' + ); + + expect(messages).toEqual([ + { type: 'status', status: 'working' }, + { type: 'chunk', content: 'test content' }, + { + type: 'usage', + scope: 'step', + messageId: 'msg_step_1', + metrics: expect.objectContaining({ + iterations: 1, + totalToolCalls: 0, + inputTokens: 123, + outputTokens: 45, + cacheCreationInputTokens: 12, + cacheReadInputTokens: 7, + }), + }, + ]); + }); + + it('maps assistant tool_use blocks', () => { + parser.feed( + '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}\n' + ); + expect(messages[0]).toEqual({ type: 'tool_use', tool: 'Bash', args: { command: 'ls' } }); + }); + + it('ignores assistant messages that only contain thinking blocks', () => { + parser.feed( + '{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Let me inspect the file."}]}}\n' + ); + expect(messages).toEqual([ + { + type: 'phase', + phase: 'thinking', + label: 'Thinking through next step', + }, + ]); + }); + + it('maps tool_use events', () => { + parser.feed('{"type":"tool_use","tool":{"name":"Bash","args":{"command":"ls"}}}\n'); + expect(messages[0]).toEqual({ type: 'tool_use', tool: 'Bash', args: { command: 'ls' } }); + }); + + it('maps tool_result events', () => { + parser.feed('{"type":"tool_result","tool":"Bash","result":"file1\\nfile2","success":true}\n'); + expect(messages).toEqual([ + { + type: 'phase', + phase: 'reviewing_tool', + label: 'Reviewing Bash output', + tool: 'Bash', + }, + { type: 'tool_result', tool: 'Bash', result: 'file1\nfile2', success: true }, + ]); + }); + + it('maps tool_result with success defaulting to true', () => { + parser.feed('{"type":"tool_result","tool":"Read","result":"contents"}\n'); + expect(messages).toEqual([ + { + type: 'phase', + phase: 'reviewing_tool', + label: 'Reviewing Read output', + tool: 'Read', + }, + { type: 'tool_result', tool: 'Read', result: 'contents', success: true }, + ]); + }); + + it('sends raw chunk for unparseable lines', () => { + parser.feed('this is not json\n'); + expect(messages[0]).toEqual({ type: 'chunk', content: 'this is not json' }); + }); + + it('sends raw chunk for unknown event types', () => { + parser.feed('{"type":"unknown_event","data":"test"}\n'); + expect(messages[0]).toEqual({ type: 'chunk', content: '{"type":"unknown_event","data":"test"}' }); + }); + + it('ignores system init events', () => { + parser.feed('{"type":"system","subtype":"init","cwd":"/workspace"}\n'); + expect(messages).toHaveLength(0); + }); + + it('maps stream_event message boundaries to status changes', () => { + parser.feed( + '{"type":"stream_event","event":{"type":"message_start"}}\n{"type":"stream_event","event":{"type":"message_stop"}}\n' + ); + expect(messages).toEqual([ + { type: 'status', status: 'working' }, + { type: 'status', status: 'ready' }, + ]); + }); + + it('maps stream_event tool phases', () => { + parser.feed( + '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","name":"Read","input":{}}}}\n' + + '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"filePath\\":\\"app.js\\"}"}}}\n' + + '{"type":"stream_event","event":{"type":"content_block_stop","index":0}}\n' + ); + expect(messages).toEqual([ + { + type: 'phase', + phase: 'preparing_tool', + label: 'Preparing Read', + tool: 'Read', + }, + { + type: 'phase', + phase: 'preparing_tool', + label: 'Preparing Read arguments', + tool: 'Read', + }, + { + type: 'phase', + phase: 'running_tool', + label: 'Running Read', + tool: 'Read', + }, + ]); + }); + + it('maps stream_event text deltas to drafting', () => { + parser.feed( + '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}\n' + + '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}\n' + ); + expect(messages).toEqual([ + { type: 'phase', phase: 'drafting', label: 'Drafting response' }, + { type: 'phase', phase: 'drafting', label: 'Drafting response' }, + ]); + }); + + it('maps successful result envelopes to ready', () => { + parser.feed( + '{"type":"stream_event","event":{"type":"message_start"}}\n' + + '{"type":"result","subtype":"success","result":"done","total_cost_usd":0.0025,"usage":{"input_tokens":200,"output_tokens":50,"cache_creation_input_tokens":40,"cache_read_input_tokens":10}}\n' + ); + expect(messages).toEqual([ + { type: 'status', status: 'working' }, + { + type: 'usage', + scope: 'session', + metrics: expect.objectContaining({ + iterations: 1, + totalToolCalls: 0, + inputTokens: 200, + outputTokens: 50, + cacheCreationInputTokens: 40, + cacheReadInputTokens: 10, + totalCostUsd: 0.0025, + }), + }, + { type: 'status', status: 'ready' }, + ]); + }); + + it('maps result errors to a chunk', () => { + parser.feed('{"type":"result","subtype":"error_during_execution","errors":["Tool failed"]}\n'); + expect(messages[0]).toEqual({ type: 'chunk', content: 'Tool failed' }); + }); + + it('skips empty lines', () => { + parser.feed('\n\n{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n\n'); + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual({ type: 'chunk', content: 'hi' }); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts new file mode 100644 index 0000000..c42b658 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -0,0 +1,503 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; + +const mockCreatePod = jest.fn(); +const mockReadPod = jest.fn(); +const mockDeletePod = jest.fn(); +const mockReadPodLog = jest.fn(); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + createNamespacedPod: mockCreatePod, + readNamespacedPod: mockReadPod, + readNamespacedPodLog: mockReadPodLog, + deleteNamespacedPod: mockDeletePod, + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +import { buildAgentPodSpec, createAgentPod, deleteAgentPod, AgentPodOpts } from '../podFactory'; + +const baseOpts: AgentPodOpts = { + podName: 'agent-abc123', + namespace: 'test-ns', + pvcName: 'agent-pvc-abc123', + image: 'lifecycle-agent:latest', + editorImage: 'codercom/code-server:4.98.2', + apiKeySecretName: 'agent-secret-abc123', + hasGitHubToken: true, + model: 'claude-sonnet-4-20250514', + repoUrl: 'https://github.com/org/repo.git', + branch: 'feature/test', + workspacePath: '/workspace', + userIdentity: { + userId: 'user-123', + githubUsername: 'sample-user', + preferredUsername: 'sample-user', + email: 'sample-user@example.com', + firstName: 'Sample', + lastName: 'User', + displayName: 'Sample User', + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + }, +}; + +describe('podFactory', () => { + const originalTimeout = process.env.AGENT_POD_READY_TIMEOUT_MS; + const originalPoll = process.env.AGENT_POD_READY_POLL_MS; + const originalCpuRequest = process.env.AGENT_POD_CPU_REQUEST; + const originalCpuLimit = process.env.AGENT_POD_CPU_LIMIT; + const originalMemoryRequest = process.env.AGENT_POD_MEMORY_REQUEST; + const originalMemoryLimit = process.env.AGENT_POD_MEMORY_LIMIT; + const originalNodeOptions = process.env.AGENT_NODE_OPTIONS; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.AGENT_POD_READY_TIMEOUT_MS = '10'; + process.env.AGENT_POD_READY_POLL_MS = '0'; + delete process.env.AGENT_POD_CPU_REQUEST; + delete process.env.AGENT_POD_CPU_LIMIT; + delete process.env.AGENT_POD_MEMORY_REQUEST; + delete process.env.AGENT_POD_MEMORY_LIMIT; + delete process.env.AGENT_NODE_OPTIONS; + mockReadPod.mockResolvedValue({ + body: { + status: { + phase: 'Running', + conditions: [{ type: 'Ready', status: 'True' }], + }, + }, + }); + mockReadPodLog.mockResolvedValue({ body: 'init logs' }); + }); + + afterAll(() => { + if (originalTimeout === undefined) delete process.env.AGENT_POD_READY_TIMEOUT_MS; + else process.env.AGENT_POD_READY_TIMEOUT_MS = originalTimeout; + if (originalPoll === undefined) delete process.env.AGENT_POD_READY_POLL_MS; + else process.env.AGENT_POD_READY_POLL_MS = originalPoll; + if (originalCpuRequest === undefined) delete process.env.AGENT_POD_CPU_REQUEST; + else process.env.AGENT_POD_CPU_REQUEST = originalCpuRequest; + if (originalCpuLimit === undefined) delete process.env.AGENT_POD_CPU_LIMIT; + else process.env.AGENT_POD_CPU_LIMIT = originalCpuLimit; + if (originalMemoryRequest === undefined) delete process.env.AGENT_POD_MEMORY_REQUEST; + else process.env.AGENT_POD_MEMORY_REQUEST = originalMemoryRequest; + if (originalMemoryLimit === undefined) delete process.env.AGENT_POD_MEMORY_LIMIT; + else process.env.AGENT_POD_MEMORY_LIMIT = originalMemoryLimit; + if (originalNodeOptions === undefined) delete process.env.AGENT_NODE_OPTIONS; + else process.env.AGENT_NODE_OPTIONS = originalNodeOptions; + }); + + describe('buildAgentPodSpec', () => { + it('creates a pod with init and main containers', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.initContainers).toHaveLength(1); + expect(pod.spec!.containers).toHaveLength(2); + expect(pod.spec!.initContainers![0].name).toBe('init-workspace'); + expect(pod.spec!.containers[0].name).toBe('agent'); + expect(pod.spec!.containers[1].name).toBe('editor'); + }); + + it('main container runs sleep infinity', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.containers[0].command).toEqual(['sleep', 'infinity']); + expect(pod.spec!.containers[0].imagePullPolicy).toBe('IfNotPresent'); + expect(pod.spec!.initContainers![0].imagePullPolicy).toBe('IfNotPresent'); + }); + + it('mounts PVC as workspace volume', () => { + const pod = buildAgentPodSpec(baseOpts); + const workspaceVolume = pod.spec!.volumes!.find((v: any) => v.name === 'workspace'); + expect(workspaceVolume).toBeDefined(); + expect(workspaceVolume!.persistentVolumeClaim!.claimName).toBe('agent-pvc-abc123'); + }); + + it('includes claude-config emptyDir volume', () => { + const pod = buildAgentPodSpec(baseOpts); + const configVolume = pod.spec!.volumes!.find((v: any) => v.name === 'claude-config'); + expect(configVolume).toBeDefined(); + expect(configVolume!.emptyDir).toEqual({}); + }); + + it('includes writable tmp emptyDir volume', () => { + const pod = buildAgentPodSpec(baseOpts); + const tmpVolume = pod.spec!.volumes!.find((v: any) => v.name === 'tmp'); + expect(tmpVolume).toBeDefined(); + expect(tmpVolume!.emptyDir).toEqual({}); + }); + + it('includes editor-home emptyDir volume', () => { + const pod = buildAgentPodSpec(baseOpts); + const editorHomeVolume = pod.spec!.volumes!.find((v: any) => v.name === 'editor-home'); + expect(editorHomeVolume).toBeDefined(); + expect(editorHomeVolume!.emptyDir).toEqual({}); + }); + + it('sets security context with non-root UID 1000', () => { + const pod = buildAgentPodSpec(baseOpts); + const mainSec = pod.spec!.containers[0].securityContext!; + expect(mainSec.runAsUser).toBe(1000); + expect(mainSec.runAsNonRoot).toBe(true); + expect(mainSec.readOnlyRootFilesystem).toBe(true); + expect(mainSec.capabilities!.drop).toEqual(['ALL']); + expect(mainSec.allowPrivilegeEscalation).toBe(false); + }); + + it('sets pod-level seccomp profile', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.securityContext!.seccompProfile).toEqual({ type: 'RuntimeDefault' }); + }); + + it('sets ANTHROPIC_API_KEY from a secret and CLAUDE_MODEL as an env var', () => { + const pod = buildAgentPodSpec(baseOpts); + const envVars = pod.spec!.containers[0].env; + expect(envVars).toEqual( + expect.arrayContaining([ + { + name: 'GITHUB_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GITHUB_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { + name: 'GH_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GH_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { + name: 'ANTHROPIC_API_KEY', + valueFrom: { + secretKeyRef: { + key: 'ANTHROPIC_API_KEY', + name: 'agent-secret-abc123', + }, + }, + }, + { name: 'CLAUDE_MODEL', value: 'claude-sonnet-4-20250514' }, + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + { name: 'NODE_OPTIONS', value: '--max-old-space-size=2048' }, + { name: 'LIFECYCLE_USER_ID', value: 'user-123' }, + { name: 'LIFECYCLE_GITHUB_USERNAME', value: 'sample-user' }, + { name: 'LIFECYCLE_USER_EMAIL', value: 'sample-user@example.com' }, + { name: 'LIFECYCLE_USER_NAME', value: 'Sample User' }, + { name: 'GIT_AUTHOR_NAME', value: 'Sample User' }, + { name: 'GIT_AUTHOR_EMAIL', value: 'sample-user@example.com' }, + { name: 'GIT_COMMITTER_NAME', value: 'Sample User' }, + { name: 'GIT_COMMITTER_EMAIL', value: 'sample-user@example.com' }, + ]) + ); + }); + + it('passes user identity env vars to the init container', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.initContainers![0].env).toEqual( + expect.arrayContaining([ + { + name: 'GITHUB_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GITHUB_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { + name: 'GH_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GH_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { name: 'LIFECYCLE_USER_ID', value: 'user-123' }, + { name: 'GIT_AUTHOR_NAME', value: 'Sample User' }, + { name: 'GIT_AUTHOR_EMAIL', value: 'sample-user@example.com' }, + ]) + ); + }); + + it('forwards plain and secret-ref agent env vars into init and agent containers', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + forwardedAgentEnv: { + PACKAGE_REGISTRY_TOKEN: 'plain-token', + PRIVATE_REGISTRY_TOKEN: '{{aws:apps/sample:token}}', + }, + forwardedAgentSecretRefs: [ + { + envKey: 'PRIVATE_REGISTRY_TOKEN', + provider: 'aws', + path: 'apps/sample', + key: 'token', + }, + ], + forwardedAgentSecretServiceName: 'agent-env-abc123', + }); + + expect(pod.spec!.initContainers![0].env).toEqual( + expect.arrayContaining([ + { name: 'PACKAGE_REGISTRY_TOKEN', value: 'plain-token' }, + { + name: 'PRIVATE_REGISTRY_TOKEN', + valueFrom: { + secretKeyRef: { + name: 'agent-env-abc123-aws-secrets', + key: 'PRIVATE_REGISTRY_TOKEN', + }, + }, + }, + ]) + ); + expect(pod.spec!.containers[0].env).toEqual( + expect.arrayContaining([ + { name: 'PACKAGE_REGISTRY_TOKEN', value: 'plain-token' }, + { + name: 'PRIVATE_REGISTRY_TOKEN', + valueFrom: { + secretKeyRef: { + name: 'agent-env-abc123-aws-secrets', + key: 'PRIVATE_REGISTRY_TOKEN', + }, + }, + }, + ]) + ); + }); + + it('omits GitHub token secret refs when token forwarding is disabled', () => { + const pod = buildAgentPodSpec({ ...baseOpts, hasGitHubToken: false }); + const agentEnv = pod.spec!.containers[0].env || []; + const initEnv = pod.spec!.initContainers![0].env || []; + + expect(agentEnv.find((env) => env.name === 'GITHUB_TOKEN')).toBeUndefined(); + expect(agentEnv.find((env) => env.name === 'GH_TOKEN')).toBeUndefined(); + expect(initEnv.find((env) => env.name === 'GITHUB_TOKEN')).toBeUndefined(); + expect(initEnv.find((env) => env.name === 'GH_TOKEN')).toBeUndefined(); + }); + + it('mounts writable /tmp in both init and main containers', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.initContainers![0].volumeMounts).toEqual( + expect.arrayContaining([{ name: 'tmp', mountPath: '/tmp' }]) + ); + expect(pod.spec!.containers[0].volumeMounts).toEqual( + expect.arrayContaining([{ name: 'tmp', mountPath: '/tmp' }]) + ); + expect(pod.spec!.containers[1].volumeMounts).toEqual( + expect.arrayContaining([{ name: 'tmp', mountPath: '/tmp' }]) + ); + }); + + it('sets default resource requests and limits for init and agent containers', () => { + const pod = buildAgentPodSpec(baseOpts); + const expectedResources = { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, + }; + + expect(pod.spec!.initContainers![0].resources).toEqual(expectedResources); + expect(pod.spec!.containers[0].resources).toEqual(expectedResources); + expect(pod.spec!.containers[1].resources).toEqual({ + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, + }); + }); + + it('allows agent resource and node option overrides through env vars', () => { + process.env.AGENT_POD_CPU_REQUEST = '750m'; + process.env.AGENT_POD_CPU_LIMIT = '3'; + process.env.AGENT_POD_MEMORY_REQUEST = '2Gi'; + process.env.AGENT_POD_MEMORY_LIMIT = '6Gi'; + process.env.AGENT_NODE_OPTIONS = '--max-old-space-size=3072'; + + const pod = buildAgentPodSpec(baseOpts); + + expect(pod.spec!.containers[0].resources).toEqual({ + requests: { + cpu: '750m', + memory: '2Gi', + }, + limits: { + cpu: '3', + memory: '6Gi', + }, + }); + expect(pod.spec!.containers[0].env).toEqual( + expect.arrayContaining([{ name: 'NODE_OPTIONS', value: '--max-old-space-size=3072' }]) + ); + expect(pod.spec!.containers[1].resources).toEqual({ + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, + }); + }); + + it('starts a code-server editor sidecar on the editor port', () => { + const pod = buildAgentPodSpec(baseOpts); + + expect(pod.spec!.containers[1]).toEqual( + expect.objectContaining({ + name: 'editor', + image: 'codercom/code-server:4.98.2', + args: [ + '/workspace', + '--auth', + 'none', + '--bind-addr', + '0.0.0.0:13337', + '--disable-telemetry', + '--disable-update-check', + ], + ports: [{ containerPort: 13337, name: 'editor' }], + readinessProbe: expect.objectContaining({ + httpGet: { path: '/healthz', port: 13337 }, + }), + }) + ); + }); + + it('does not set runtimeClassName when gVisor not requested', () => { + const pod = buildAgentPodSpec(baseOpts); + expect(pod.spec!.runtimeClassName).toBeUndefined(); + }); + + it('sets runtimeClassName to gvisor when requested', () => { + const pod = buildAgentPodSpec({ ...baseOpts, useGvisor: true }); + expect(pod.spec!.runtimeClassName).toBe('gvisor'); + }); + }); + + describe('createAgentPod', () => { + it('creates pod via K8s API', async () => { + mockCreatePod.mockResolvedValue({ body: { metadata: { name: 'agent-abc123' } } }); + + await createAgentPod(baseOpts); + + expect(mockCreatePod).toHaveBeenCalledTimes(1); + const [ns, podBody] = mockCreatePod.mock.calls[0]; + expect(ns).toBe('test-ns'); + expect(podBody.metadata.name).toBe('agent-abc123'); + expect(mockReadPod).toHaveBeenCalledWith('agent-abc123', 'test-ns'); + }); + + it('fails fast when pod enters image pull backoff', async () => { + mockCreatePod.mockResolvedValue({ body: { metadata: { name: 'agent-abc123' } } }); + mockReadPodLog.mockResolvedValue({ body: 'pull failed for test image' }); + mockReadPod.mockResolvedValue({ + body: { + status: { + phase: 'Pending', + initContainerStatuses: [ + { + name: 'init-workspace', + state: { + waiting: { + reason: 'ImagePullBackOff', + message: 'image not found', + }, + }, + }, + ], + }, + }, + }); + + await expect(createAgentPod(baseOpts)).rejects.toThrow( + 'Agent pod failed to start: init-workspace: ImagePullBackOff - image not found - pull failed for test image' + ); + expect(mockReadPodLog).toHaveBeenCalledWith( + 'agent-abc123', + 'test-ns', + 'init-workspace', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 200 + ); + }); + }); + + describe('deleteAgentPod', () => { + it('deletes pod via K8s API', async () => { + mockDeletePod.mockResolvedValue({}); + + await deleteAgentPod('test-ns', 'agent-abc123'); + + expect(mockDeletePod).toHaveBeenCalledWith('agent-abc123', 'test-ns'); + }); + + it('ignores 404 errors', async () => { + const error = new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404); + mockDeletePod.mockRejectedValue(error); + + await expect(deleteAgentPod('test-ns', 'agent-abc123')).resolves.toBeUndefined(); + }); + + it('rethrows non-404 errors', async () => { + mockDeletePod.mockRejectedValue(new Error('server error')); + + await expect(deleteAgentPod('test-ns', 'agent-abc123')).rejects.toThrow('server error'); + }); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/pvcFactory.test.ts b/src/server/lib/agentSession/__tests__/pvcFactory.test.ts new file mode 100644 index 0000000..98393b1 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/pvcFactory.test.ts @@ -0,0 +1,130 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; + +const mockCreatePvc = jest.fn(); +const mockDeletePvc = jest.fn(); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + createNamespacedPersistentVolumeClaim: mockCreatePvc, + deleteNamespacedPersistentVolumeClaim: mockDeletePvc, + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +import { createAgentPvc, deleteAgentPvc } from '../pvcFactory'; + +describe('pvcFactory', () => { + const originalEnvironment = process.env.ENVIRONMENT; + const originalAppEnv = process.env.APP_ENV; + const originalAccessMode = process.env.AGENT_SESSION_PVC_ACCESS_MODE; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ENVIRONMENT; + delete process.env.APP_ENV; + delete process.env.AGENT_SESSION_PVC_ACCESS_MODE; + }); + + afterAll(() => { + process.env.ENVIRONMENT = originalEnvironment; + process.env.APP_ENV = originalAppEnv; + process.env.AGENT_SESSION_PVC_ACCESS_MODE = originalAccessMode; + }); + + describe('createAgentPvc', () => { + it('creates a PVC with ReadWriteMany access mode', async () => { + mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); + + await createAgentPvc('test-ns', 'test-pvc', '10Gi'); + + expect(mockCreatePvc).toHaveBeenCalledTimes(1); + const [ns, pvcBody] = mockCreatePvc.mock.calls[0]; + expect(ns).toBe('test-ns'); + expect(pvcBody.metadata.name).toBe('test-pvc'); + expect(pvcBody.spec.accessModes).toEqual(['ReadWriteMany']); + expect(pvcBody.spec.resources.requests.storage).toBe('10Gi'); + }); + + it('uses default storage size when not provided', async () => { + mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); + + await createAgentPvc('test-ns', 'test-pvc'); + + const [, pvcBody] = mockCreatePvc.mock.calls[0]; + expect(pvcBody.spec.resources.requests.storage).toBe('10Gi'); + }); + + it('uses ReadWriteOnce in local dev environments', async () => { + process.env.ENVIRONMENT = 'dev'; + mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); + + await createAgentPvc('test-ns', 'test-pvc'); + + const [, pvcBody] = mockCreatePvc.mock.calls[0]; + expect(pvcBody.spec.accessModes).toEqual(['ReadWriteOnce']); + }); + + it('honors AGENT_SESSION_PVC_ACCESS_MODE when configured', async () => { + process.env.AGENT_SESSION_PVC_ACCESS_MODE = 'ReadWriteOnce'; + mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); + + await createAgentPvc('test-ns', 'test-pvc'); + + const [, pvcBody] = mockCreatePvc.mock.calls[0]; + expect(pvcBody.spec.accessModes).toEqual(['ReadWriteOnce']); + }); + }); + + describe('deleteAgentPvc', () => { + it('deletes a PVC', async () => { + mockDeletePvc.mockResolvedValue({}); + + await deleteAgentPvc('test-ns', 'test-pvc'); + + expect(mockDeletePvc).toHaveBeenCalledWith('test-pvc', 'test-ns'); + }); + + it('ignores 404 errors', async () => { + const error = new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404); + mockDeletePvc.mockRejectedValue(error); + + await expect(deleteAgentPvc('test-ns', 'test-pvc')).resolves.toBeUndefined(); + }); + + it('rethrows non-404 errors', async () => { + mockDeletePvc.mockRejectedValue(new Error('server error')); + + await expect(deleteAgentPvc('test-ns', 'test-pvc')).rejects.toThrow('server error'); + }); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts new file mode 100644 index 0000000..d022f89 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts @@ -0,0 +1,161 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const getAllConfigs = jest.fn(); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getAllConfigs, + })), + }, +})); + +import { + AgentSessionRuntimeConfigError, + renderAgentSessionClaudeAttribution, + resolveAgentSessionClaudeConfig, + resolveAgentSessionClaudeConfigFromDefaults, + resolveAgentSessionRuntimeConfig, +} from '../runtimeConfig'; + +describe('runtimeConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the configured agent and editor images', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + claude: { + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + }, + }); + }); + + it('returns the configured Claude settings when present', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + claude: { + permissions: { + allow: ['Read(*)'], + deny: ['Bash(*)'], + }, + attribution: { + commitTemplate: 'Commit from ({appName})', + prTemplate: 'PR from ({appName})', + }, + appendSystemPrompt: 'Use concise responses.', + }, + }, + }); + + await expect(resolveAgentSessionClaudeConfig()).resolves.toEqual({ + permissions: { + allow: ['Read(*)'], + deny: ['Bash(*)'], + }, + attribution: { + commitTemplate: 'Commit from ({appName})', + prTemplate: 'PR from ({appName})', + }, + appendSystemPrompt: 'Use concise responses.', + }); + }); + + it('falls back to default Claude settings when config is empty', () => { + expect(resolveAgentSessionClaudeConfigFromDefaults()).toEqual({ + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + appendSystemPrompt: undefined, + }); + }); + + it('renders attribution from the app name placeholder', () => { + expect(renderAgentSessionClaudeAttribution('Generated with ({appName})', 'sample-app')).toBe( + 'Generated with (sample-app)' + ); + expect(renderAgentSessionClaudeAttribution('Generated with ({appName})', null)).toBe(''); + expect(renderAgentSessionClaudeAttribution('Static attribution', 'sample-app')).toBe('Static attribution'); + }); + + it('throws when the agent image is missing', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: null, + editorImage: 'codercom/code-server:4.98.2', + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).rejects.toEqual( + expect.objectContaining>({ + name: 'AgentSessionRuntimeConfigError', + missingFields: ['image'], + }) + ); + }); + + it('throws when the editor image is missing', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: 'lifecycle-agent:sha-123', + editorImage: ' ', + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).rejects.toEqual( + expect.objectContaining>({ + name: 'AgentSessionRuntimeConfigError', + missingFields: ['editorImage'], + }) + ); + }); + + it('throws when both runtime images are missing', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: {}, + }); + + await expect(resolveAgentSessionRuntimeConfig()).rejects.toEqual( + expect.objectContaining>({ + name: 'AgentSessionRuntimeConfigError', + missingFields: ['image', 'editorImage'], + }) + ); + }); +}); diff --git a/src/server/lib/agentSession/apiKeySecretFactory.ts b/src/server/lib/agentSession/apiKeySecretFactory.ts new file mode 100644 index 0000000..e35c91d --- /dev/null +++ b/src/server/lib/agentSession/apiKeySecretFactory.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; + +function getCoreApi(): k8s.CoreV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +export async function createAgentApiKeySecret( + namespace: string, + secretName: string, + apiKey: string, + githubToken?: string | null, + buildUuid?: string +): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + const secret: k8s.V1Secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: secretName, + namespace, + labels: { + ...buildLifecycleLabels({ buildUuid }), + 'app.kubernetes.io/component': 'agent-session-secret', + }, + }, + type: 'Opaque', + stringData: { + ANTHROPIC_API_KEY: apiKey, + ...(githubToken + ? { + GITHUB_TOKEN: githubToken, + GH_TOKEN: githubToken, + } + : {}), + }, + }; + + const { body: result } = await coreApi.createNamespacedSecret(namespace, secret); + logger.info(`apiKeySecretFactory: created Secret name=${secretName} namespace=${namespace}`); + return result; +} + +export async function deleteAgentApiKeySecret(namespace: string, secretName: string): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + try { + await coreApi.deleteNamespacedSecret(secretName, namespace); + logger.info(`apiKeySecretFactory: deleted Secret name=${secretName} namespace=${namespace}`); + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + logger.info(`apiKeySecretFactory: Secret not found (already deleted) name=${secretName} namespace=${namespace}`); + return; + } + + throw error; + } +} diff --git a/src/server/lib/agentSession/configSeeder.ts b/src/server/lib/agentSession/configSeeder.ts new file mode 100644 index 0000000..85b1ca1 --- /dev/null +++ b/src/server/lib/agentSession/configSeeder.ts @@ -0,0 +1,149 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface InitScriptOpts { + repoUrl: string; + branch: string; + revision?: string; + workspacePath: string; + installCommand?: string; + claudeMdContent?: string; + claudePermissions?: { + allow: string[]; + deny: string[]; + }; + claudeCommitAttribution?: string; + claudePrAttribution?: string; + gitUserName?: string; + gitUserEmail?: string; + githubUsername?: string; + useGitHubToken?: boolean; +} + +function escapeDoubleQuotedShell(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`'); +} + +export function generateInitScript(opts: InitScriptOpts): string { + const { + repoUrl, + branch, + revision, + workspacePath, + installCommand, + claudeMdContent, + claudePermissions, + claudeCommitAttribution, + claudePrAttribution, + gitUserName, + gitUserEmail, + githubUsername, + useGitHubToken, + } = opts; + + const settings = { + permissions: { + allow: claudePermissions?.allow || ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: claudePermissions?.deny || [], + }, + ...(claudeCommitAttribution !== undefined || claudePrAttribution !== undefined + ? { + attribution: { + commit: claudeCommitAttribution || '', + pr: claudePrAttribution || '', + }, + } + : {}), + }; + + const settingsJson = JSON.stringify(settings, null, 2); + + const lines = [ + '#!/bin/sh', + 'set -e', + '', + `mkdir -p "${escapeDoubleQuotedShell(workspacePath)}"`, + `git config --global --add safe.directory "${escapeDoubleQuotedShell(workspacePath)}"`, + ]; + + if (gitUserName) { + lines.push(`git config --global user.name "${escapeDoubleQuotedShell(gitUserName)}"`); + } + + if (gitUserEmail) { + lines.push(`git config --global user.email "${escapeDoubleQuotedShell(gitUserEmail)}"`); + } + + if (githubUsername) { + lines.push(`git config --global github.user "${escapeDoubleQuotedShell(githubUsername)}"`); + } + + if (useGitHubToken) { + lines.push( + 'if [ -n "${GITHUB_TOKEN:-}" ]; then', + ' git config --global credential.helper \'!f() { test "$1" = get || exit 0; echo username=x-access-token; echo password=$GITHUB_TOKEN; }; f\'', + 'fi' + ); + } + + lines.push( + `git clone --branch "${escapeDoubleQuotedShell(branch)}" --single-branch "${escapeDoubleQuotedShell( + repoUrl + )}" "${escapeDoubleQuotedShell(workspacePath)}"`, + `cd "${escapeDoubleQuotedShell(workspacePath)}"` + ); + + if (revision) { + lines.push(`git checkout "${escapeDoubleQuotedShell(revision)}"`); + } + + if (installCommand) { + lines.push('', installCommand); + } + + lines.push('', 'mkdir -p ~/.claude', ''); + + if (claudeMdContent) { + lines.push(`cat > ~/.claude/CLAUDE.md << 'CLAUDE_MD_EOF'`); + lines.push(claudeMdContent); + lines.push('CLAUDE_MD_EOF'); + lines.push(''); + } + + lines.push(`cat > ~/.claude/settings.json << 'SETTINGS_EOF'`); + lines.push(settingsJson); + lines.push('SETTINGS_EOF'); + lines.push(''); + + lines.push( + 'mkdir -p .git/hooks', + `cat > .git/hooks/pre-push << 'HOOK_EOF'`, + '#!/bin/sh', + 'remote="$1"', + 'while read local_ref local_sha remote_ref remote_sha; do', + ` branch_name="\${remote_ref##refs/heads/}"`, + ` if [ "$branch_name" = "main" ] || [ "$branch_name" = "master" ]; then`, + ' echo "ERROR: Pushing to $branch_name is not allowed"', + ' exit 1', + ' fi', + 'done', + 'exit 0', + 'HOOK_EOF', + 'chmod +x .git/hooks/pre-push' + ); + + return lines.join('\n') + '\n'; +} diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts new file mode 100644 index 0000000..570de6c --- /dev/null +++ b/src/server/lib/agentSession/devModeManager.ts @@ -0,0 +1,738 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { DevConfig } from 'server/models/yaml/YamlService'; +import { getLogger } from 'server/lib/logger'; + +const logger = getLogger(); +const AGENT_WORKSPACE_ROOT = '/workspace'; +const DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-deployment-snapshot'; +const DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-service-snapshot'; + +export interface DevModeOptions { + namespace: string; + deploymentName: string; + serviceName: string; + pvcName: string; + devConfig: DevConfig; +} + +export interface DevModeDeploymentSnapshot { + deploymentName: string; + containerName: string; + image: string | null; + command: string[] | null; + workingDir: string | null; + env: k8s.V1EnvVar[] | null; + volumeMounts: k8s.V1VolumeMount[] | null; + volumes: k8s.V1Volume[] | null; +} + +export interface DevModeServiceSnapshot { + serviceName: string; + ports: k8s.V1ServicePort[] | null; +} + +export interface DevModeResourceSnapshot { + deployment: DevModeDeploymentSnapshot; + service: DevModeServiceSnapshot | null; +} + +interface AppliedDeploymentTemplate { + spec?: { + template?: { + spec?: { + containers?: Array<{ + name?: string; + command?: string[]; + workingDir?: string; + volumeMounts?: Array<{ + name?: string; + mountPath?: string; + }>; + }>; + volumes?: Array<{ + name?: string; + }>; + }; + }; + }; +} + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function isNotFoundError(error: unknown): error is k8s.HttpError { + return error instanceof k8s.HttpError && error.response?.statusCode === 404; +} + +function selectorMatches( + selector: Record | undefined, + expected: Record | undefined +): boolean { + if (!selector || !expected) { + return false; + } + + return Object.entries(expected).every(([key, value]) => selector[key] === value); +} + +export class DevModeManager { + private kc: k8s.KubeConfig; + private appsApi: k8s.AppsV1Api; + private coreApi: k8s.CoreV1Api; + + constructor() { + this.kc = new k8s.KubeConfig(); + this.kc.loadFromDefault(); + this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); + this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api); + } + + async enableDevMode(opts: DevModeOptions): Promise { + const deployment = await this.resolveDeployment(opts.namespace, opts.deploymentName); + const resolvedDeploymentName = deployment.metadata?.name || opts.deploymentName; + const service = opts.devConfig.ports?.length + ? await this.resolveService(opts.namespace, opts.serviceName, resolvedDeploymentName, deployment) + : null; + const snapshot = this.captureSnapshot(deployment, service); + + await this.patchDeployment(opts, deployment); + + if (service) { + await this.patchService(opts, service); + } + + logger.info(`Enabled dev mode: deployment=${resolvedDeploymentName} namespace=${opts.namespace}`); + return snapshot; + } + + async disableDevMode( + namespace: string, + deploymentName: string, + serviceName?: string, + snapshot?: DevModeResourceSnapshot | null + ): Promise { + const deployment = await this.resolveDeployment(namespace, deploymentName); + const resolvedDeploymentName = deployment.metadata?.name || deploymentName; + + if (snapshot?.deployment) { + await this.restoreDeploymentFromSnapshot(namespace, deployment, snapshot.deployment); + } else { + await this.cleanupDeploymentPatch(namespace, resolvedDeploymentName, deployment); + } + + if (serviceName) { + try { + const service = await this.resolveService(namespace, serviceName, resolvedDeploymentName, deployment); + if (snapshot?.service) { + await this.restoreServiceFromSnapshot(namespace, service, snapshot.service); + } else { + await this.cleanupServicePatch(namespace, service); + } + } catch (error) { + logger.warn( + `Failed to revert service ports during dev mode cleanup: service=${serviceName} namespace=${namespace} err=${ + (error as Error).message + }` + ); + } + } + + logger.info(`Disabled dev mode patch: deployment=${resolvedDeploymentName} namespace=${namespace}`); + } + + private async resolveDeployment(namespace: string, deploymentName: string): Promise { + try { + const response = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); + return response.body; + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + + const namespaceSuffix = namespace.startsWith('env-') ? namespace.slice(4) : namespace; + const { body } = await this.appsApi.listNamespacedDeployment(namespace); + const candidates = body.items.filter((deployment) => { + const metadataName = deployment.metadata?.name; + const labels = deployment.metadata?.labels || {}; + + return ( + metadataName === deploymentName || + metadataName === `${deploymentName}-${namespaceSuffix}` || + metadataName?.startsWith(`${deploymentName}-`) || + labels['tags.datadoghq.com/service'] === deploymentName || + labels['app.kubernetes.io/instance'] === deploymentName + ); + }); + + return ( + candidates.find((deployment) => deployment.metadata?.name === `${deploymentName}-${namespaceSuffix}`) || + candidates.find((deployment) => deployment.metadata?.name === deploymentName) || + candidates.find((deployment) => deployment.metadata?.labels?.['tags.datadoghq.com/service'] === deploymentName) || + candidates[0] || + Promise.reject(new Error(`Deployment not found for dev mode: ${deploymentName} in namespace ${namespace}`)) + ); + } + + private async resolveService( + namespace: string, + serviceName: string, + deploymentName: string, + deployment: k8s.V1Deployment + ): Promise { + for (const candidateName of [serviceName, deploymentName]) { + try { + const response = await this.coreApi.readNamespacedService(candidateName, namespace); + return response.body; + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + } + + const deploymentSelector = deployment.spec?.selector?.matchLabels; + const { body } = await this.coreApi.listNamespacedService(namespace); + const candidates = body.items.filter((service) => { + const metadataName = service.metadata?.name; + return ( + metadataName === deploymentName || + metadataName === serviceName || + selectorMatches(service.spec?.selector as Record | undefined, deploymentSelector) + ); + }); + + return ( + candidates.find((service) => service.metadata?.name === deploymentName) || + candidates.find((service) => service.metadata?.name === serviceName) || + candidates.find((service) => !service.metadata?.name?.startsWith('internal-lb-')) || + candidates[0] || + Promise.reject(new Error(`Service not found for dev mode: ${serviceName} in namespace ${namespace}`)) + ); + } + + private async patchDeployment(opts: DevModeOptions, existing: k8s.V1Deployment): Promise { + const { namespace, pvcName, devConfig } = opts; + const deploymentName = existing.metadata?.name || opts.deploymentName; + const workDir = devConfig.workDir || '/workspace'; + const existingContainerName = existing.spec?.template?.spec?.containers?.[0]?.name || deploymentName; + const deploymentSnapshot = this.buildDeploymentSnapshot(existing, existingContainerName); + + const patch = { + metadata: { + annotations: { + [DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION]: JSON.stringify(deploymentSnapshot), + }, + }, + spec: { + template: { + spec: { + volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: pvcName } }], + containers: [ + { + name: existingContainerName, + image: devConfig.image, + command: ['/bin/sh', '-c', devConfig.command], + workingDir: workDir, + env: Object.entries(devConfig.env || {}).map(([name, value]) => ({ name, value })), + // Mount the shared repo root once and let workingDir target the service subdirectory. + volumeMounts: [{ name: 'workspace', mountPath: AGENT_WORKSPACE_ROOT }], + }, + ], + }, + }, + }, + }; + + await this.appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + } + + private async patchService(opts: DevModeOptions, existing: k8s.V1Service): Promise { + const { namespace, devConfig } = opts; + const serviceName = existing.metadata?.name || opts.serviceName; + const devPort = devConfig.ports![0]; + const existingPorts = existing.spec?.ports || []; + const serviceSnapshot = this.buildServiceSnapshot(existing); + const patch = { + metadata: { + annotations: { + [DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION]: JSON.stringify(serviceSnapshot), + }, + }, + spec: { + ports: + existingPorts.length > 0 + ? existingPorts.map((port) => ({ + ...(port.name ? { name: port.name } : {}), + ...(port.protocol ? { protocol: port.protocol } : {}), + ...(port.nodePort ? { nodePort: port.nodePort } : {}), + ...(port.appProtocol ? { appProtocol: port.appProtocol } : {}), + port: port.port ?? devPort, + targetPort: devPort, + })) + : [{ port: devPort, targetPort: devPort }], + }, + }; + + await this.coreApi.patchNamespacedService( + serviceName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + logger.info(`Patched service targetPort: service=${serviceName} port=${devPort}`); + } + + private async cleanupDeploymentPatch( + namespace: string, + deploymentName: string, + existing: k8s.V1Deployment + ): Promise { + const deploymentSnapshot = this.getDeploymentSnapshot(existing); + if (deploymentSnapshot) { + await this.restoreDeploymentFromSnapshot(namespace, existing, deploymentSnapshot); + return; + } + + const desiredTemplate = this.getLastAppliedTemplate(existing); + if (!desiredTemplate) { + const fallbackPatch = this.buildFallbackCleanupPatch(existing); + if (fallbackPatch.length === 0) { + logger.warn(`Skipping dev mode cleanup; last-applied annotation missing deployment=${deploymentName}`); + return; + } + + logger.warn(`Using fallback dev mode cleanup; last-applied annotation missing deployment=${deploymentName}`); + await this.appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + fallbackPatch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + return; + } + + const liveSpec = existing.spec?.template?.spec; + const liveContainers = liveSpec?.containers || []; + const liveContainerIndex = liveContainers.findIndex(Boolean); + if (liveContainerIndex < 0) { + return; + } + + const liveContainer = liveContainers[liveContainerIndex]; + const desiredContainer = + desiredTemplate.spec?.template?.spec?.containers?.find((container) => container.name === liveContainer.name) || + desiredTemplate.spec?.template?.spec?.containers?.[liveContainerIndex]; + const desiredVolumes = desiredTemplate.spec?.template?.spec?.volumes || []; + + const patch: Array> = []; + + if (liveContainer.command && !desiredContainer?.command) { + patch.push({ op: 'remove', path: `/spec/template/spec/containers/${liveContainerIndex}/command` }); + } + + if (liveContainer.workingDir && !desiredContainer?.workingDir) { + patch.push({ op: 'remove', path: `/spec/template/spec/containers/${liveContainerIndex}/workingDir` }); + } + + const liveVolumeMounts = liveContainer.volumeMounts || []; + const desiredVolumeMounts = desiredContainer?.volumeMounts || []; + for (let index = liveVolumeMounts.length - 1; index >= 0; index--) { + const mount = liveVolumeMounts[index]; + const isWorkspaceMount = mount.name === 'workspace' && mount.mountPath === AGENT_WORKSPACE_ROOT; + const desiredHasMount = desiredVolumeMounts.some( + (desiredMount) => desiredMount.name === mount.name && desiredMount.mountPath === mount.mountPath + ); + + if (isWorkspaceMount && !desiredHasMount) { + patch.push({ + op: 'remove', + path: `/spec/template/spec/containers/${liveContainerIndex}/volumeMounts/${index}`, + }); + } + } + + const liveVolumes = liveSpec?.volumes || []; + for (let index = liveVolumes.length - 1; index >= 0; index--) { + const volume = liveVolumes[index]; + const isWorkspaceVolume = volume.name === 'workspace'; + const desiredHasVolume = desiredVolumes.some((desiredVolume) => desiredVolume.name === volume.name); + + if (isWorkspaceVolume && !desiredHasVolume) { + patch.push({ op: 'remove', path: `/spec/template/spec/volumes/${index}` }); + } + } + + if (patch.length === 0) { + return; + } + + await this.appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + } + + private async cleanupServicePatch(namespace: string, existing: k8s.V1Service): Promise { + const serviceName = existing.metadata?.name; + if (!serviceName) { + return; + } + + const serviceSnapshot = this.getServiceSnapshot(existing); + if (serviceSnapshot) { + await this.restoreServiceFromSnapshot(namespace, existing, serviceSnapshot); + return; + } + + const annotation = existing.metadata?.annotations?.['kubectl.kubernetes.io/last-applied-configuration']; + if (!annotation) { + logger.warn(`Skipping service port revert; last-applied annotation missing service=${serviceName}`); + return; + } + + let originalSpec: { + spec?: { ports?: Array<{ name?: string; port?: number; targetPort?: number | string; protocol?: string }> }; + }; + try { + originalSpec = JSON.parse(annotation); + } catch { + logger.warn(`Failed to parse last-applied service annotation: service=${serviceName}`); + return; + } + + const originalPorts = originalSpec.spec?.ports; + if (!originalPorts || originalPorts.length === 0) { + return; + } + + const patch = { + spec: { + ports: originalPorts.map((port) => ({ + ...(port.name ? { name: port.name } : {}), + ...(port.protocol ? { protocol: port.protocol } : {}), + port: port.port, + targetPort: port.targetPort ?? port.port, + })), + }, + }; + + await this.coreApi.patchNamespacedService( + serviceName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + logger.info(`Reverted service ports: service=${serviceName} namespace=${namespace}`); + } + + private getLastAppliedTemplate(existing: k8s.V1Deployment): AppliedDeploymentTemplate | null { + const annotation = existing.metadata?.annotations?.['kubectl.kubernetes.io/last-applied-configuration']; + if (!annotation) { + return null; + } + + try { + return JSON.parse(annotation) as AppliedDeploymentTemplate; + } catch (error) { + logger.warn({ error }, 'Failed to parse last-applied deployment annotation during dev mode cleanup'); + return null; + } + } + + private buildDeploymentSnapshot(existing: k8s.V1Deployment, containerName: string): DevModeDeploymentSnapshot { + const liveSpec = existing.spec?.template?.spec; + const liveContainers = liveSpec?.containers || []; + const liveContainer = liveContainers.find((container) => container.name === containerName) || liveContainers[0]; + + return { + deploymentName: existing.metadata?.name || '', + containerName, + image: liveContainer?.image || null, + command: liveContainer?.command ? deepClone(liveContainer.command) : null, + workingDir: liveContainer?.workingDir || null, + env: liveContainer?.env ? deepClone(liveContainer.env) : null, + volumeMounts: liveContainer?.volumeMounts ? deepClone(liveContainer.volumeMounts) : null, + volumes: liveSpec?.volumes ? deepClone(liveSpec.volumes) : null, + }; + } + + private buildServiceSnapshot(existing: k8s.V1Service): DevModeServiceSnapshot { + return { + serviceName: existing.metadata?.name || '', + ports: existing.spec?.ports ? deepClone(existing.spec.ports) : null, + }; + } + + private getDeploymentSnapshot(existing: k8s.V1Deployment): DevModeDeploymentSnapshot | null { + const annotation = existing.metadata?.annotations?.[DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION]; + if (!annotation) { + return null; + } + + try { + return JSON.parse(annotation) as DevModeDeploymentSnapshot; + } catch (error) { + logger.warn({ error }, 'Failed to parse dev mode deployment snapshot annotation'); + return null; + } + } + + private getServiceSnapshot(existing: k8s.V1Service): DevModeServiceSnapshot | null { + const annotation = existing.metadata?.annotations?.[DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION]; + if (!annotation) { + return null; + } + + try { + return JSON.parse(annotation) as DevModeServiceSnapshot; + } catch (error) { + logger.warn({ error }, 'Failed to parse dev mode service snapshot annotation'); + return null; + } + } + + private captureSnapshot(deployment: k8s.V1Deployment, service: k8s.V1Service | null): DevModeResourceSnapshot { + const container = + deployment.spec?.template?.spec?.containers?.[0] || + ({ name: deployment.metadata?.name || 'container' } as k8s.V1Container); + + return { + deployment: { + deploymentName: deployment.metadata?.name || '', + containerName: container.name || deployment.metadata?.name || 'container', + image: container.image || null, + command: this.cloneValue(container.command ?? null), + workingDir: container.workingDir ?? null, + env: this.cloneValue(container.env ?? null), + volumeMounts: this.cloneValue(container.volumeMounts ?? null), + volumes: this.cloneValue(deployment.spec?.template?.spec?.volumes ?? null), + }, + service: service + ? { + serviceName: service.metadata?.name || '', + ports: this.cloneValue(service.spec?.ports ?? null), + } + : null, + }; + } + + private async restoreDeploymentFromSnapshot( + namespace: string, + existing: k8s.V1Deployment, + snapshot: DevModeDeploymentSnapshot + ): Promise { + const deploymentName = existing.metadata?.name || snapshot.deploymentName; + const liveContainers = existing.spec?.template?.spec?.containers || []; + const liveContainerIndex = + liveContainers.findIndex((container) => container.name === snapshot.containerName) >= 0 + ? liveContainers.findIndex((container) => container.name === snapshot.containerName) + : liveContainers.findIndex(Boolean); + + if (liveContainerIndex < 0) { + return; + } + + const liveContainer = liveContainers[liveContainerIndex]; + const patch: Array> = []; + const containerPath = `/spec/template/spec/containers/${liveContainerIndex}`; + + if (existing.metadata?.annotations?.[DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION]) { + patch.push({ + op: 'remove', + path: `/metadata/annotations/${DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION.replace('/', '~1')}`, + }); + } + + this.appendValuePatch(patch, `${containerPath}/image`, liveContainer.image, snapshot.image); + this.appendValuePatch(patch, `${containerPath}/command`, liveContainer.command, snapshot.command); + this.appendValuePatch(patch, `${containerPath}/workingDir`, liveContainer.workingDir, snapshot.workingDir); + this.appendValuePatch(patch, `${containerPath}/env`, liveContainer.env, snapshot.env); + this.appendValuePatch(patch, `${containerPath}/volumeMounts`, liveContainer.volumeMounts, snapshot.volumeMounts); + this.appendValuePatch( + patch, + '/spec/template/spec/volumes', + existing.spec?.template?.spec?.volumes, + snapshot.volumes + ); + + if (patch.length === 0) { + return; + } + + await this.appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } } + ); + } + + private async restoreServiceFromSnapshot( + namespace: string, + existing: k8s.V1Service, + snapshot: DevModeServiceSnapshot + ): Promise { + const serviceName = existing.metadata?.name || snapshot.serviceName; + const patch = { + metadata: { + annotations: { + [DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION]: null, + }, + }, + spec: { + ports: this.cloneValue(snapshot.ports ?? []), + }, + }; + + await this.coreApi.patchNamespacedService( + serviceName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + logger.info(`Restored service ports from snapshot: service=${serviceName} namespace=${namespace}`); + } + + private appendValuePatch( + patch: Array>, + path: string, + liveValue: unknown, + desiredValue: unknown + ): void { + const hasLiveValue = typeof liveValue !== 'undefined'; + const hasDesiredValue = desiredValue !== null && typeof desiredValue !== 'undefined'; + + if (!hasDesiredValue) { + if (hasLiveValue) { + patch.push({ op: 'remove', path }); + } + return; + } + + if (!hasLiveValue) { + patch.push({ op: 'add', path, value: this.cloneValue(desiredValue) }); + return; + } + + if (JSON.stringify(liveValue) !== JSON.stringify(desiredValue)) { + patch.push({ op: 'replace', path, value: this.cloneValue(desiredValue) }); + } + } + + private cloneValue(value: T): T { + if (value == null) { + return value; + } + + return JSON.parse(JSON.stringify(value)) as T; + } + + private buildFallbackCleanupPatch(existing: k8s.V1Deployment): Array> { + const liveSpec = existing.spec?.template?.spec; + const liveContainers = liveSpec?.containers || []; + const liveContainerIndex = liveContainers.findIndex(Boolean); + if (liveContainerIndex < 0) { + return []; + } + + const liveContainer = liveContainers[liveContainerIndex]; + const liveVolumeMounts = liveContainer.volumeMounts || []; + const hasWorkspaceMount = liveVolumeMounts.some( + (mount) => mount.name === 'workspace' && mount.mountPath === AGENT_WORKSPACE_ROOT + ); + const hasWorkspaceWorkingDir = liveContainer.workingDir?.startsWith(AGENT_WORKSPACE_ROOT) ?? false; + const isLikelyDevPatched = hasWorkspaceMount || hasWorkspaceWorkingDir; + + const patch: Array> = []; + + if (isLikelyDevPatched && liveContainer.command) { + patch.push({ op: 'remove', path: `/spec/template/spec/containers/${liveContainerIndex}/command` }); + } + + if (hasWorkspaceWorkingDir) { + patch.push({ op: 'remove', path: `/spec/template/spec/containers/${liveContainerIndex}/workingDir` }); + } + + for (let index = liveVolumeMounts.length - 1; index >= 0; index--) { + const mount = liveVolumeMounts[index]; + const isWorkspaceMount = mount.name === 'workspace' && mount.mountPath === AGENT_WORKSPACE_ROOT; + + if (isWorkspaceMount) { + patch.push({ + op: 'remove', + path: `/spec/template/spec/containers/${liveContainerIndex}/volumeMounts/${index}`, + }); + } + } + + const liveVolumes = liveSpec?.volumes || []; + for (let index = liveVolumes.length - 1; index >= 0; index--) { + const volume = liveVolumes[index]; + if (volume.name === 'workspace') { + patch.push({ op: 'remove', path: `/spec/template/spec/volumes/${index}` }); + } + } + + return patch; + } +} diff --git a/src/server/lib/agentSession/editorServiceFactory.ts b/src/server/lib/agentSession/editorServiceFactory.ts new file mode 100644 index 0000000..2b7a752 --- /dev/null +++ b/src/server/lib/agentSession/editorServiceFactory.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; +import { AGENT_EDITOR_PORT } from './podFactory'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; + +function getCoreApi(): k8s.CoreV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +export async function createAgentEditorService( + namespace: string, + serviceName: string, + buildUuid?: string +): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + const service: k8s.V1Service = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: serviceName, + namespace, + labels: { + ...buildLifecycleLabels({ buildUuid }), + 'app.kubernetes.io/component': 'agent-session-editor', + }, + }, + spec: { + selector: { + 'app.kubernetes.io/name': serviceName, + }, + ports: [ + { + name: 'editor', + port: AGENT_EDITOR_PORT, + targetPort: AGENT_EDITOR_PORT, + }, + ], + }, + }; + + const { body: result } = await coreApi.createNamespacedService(namespace, service); + logger.info( + `editorServiceFactory: created Service name=${serviceName} namespace=${namespace} port=${AGENT_EDITOR_PORT}` + ); + return result; +} + +export async function deleteAgentEditorService(namespace: string, serviceName: string): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + try { + await coreApi.deleteNamespacedService(serviceName, namespace); + logger.info(`editorServiceFactory: deleted Service name=${serviceName} namespace=${namespace}`); + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + logger.info( + `editorServiceFactory: Service not found (already deleted) name=${serviceName} namespace=${namespace}` + ); + return; + } + + throw error; + } +} diff --git a/src/server/lib/agentSession/execProxy.ts b/src/server/lib/agentSession/execProxy.ts new file mode 100644 index 0000000..7ef01bc --- /dev/null +++ b/src/server/lib/agentSession/execProxy.ts @@ -0,0 +1,182 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { Writable, Readable } from 'stream'; +import { getLogger } from 'server/lib/logger'; +import { resolveAgentSessionClaudeConfig } from './runtimeConfig'; + +const logger = getLogger(); +const CLAUDE_HOME = '/home/claude/.claude'; + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +export function buildClaudeExecScript(model: string, appendSystemPrompt?: string): string { + const nestedClaudeDir = `${CLAUDE_HOME}/.claude`; + const legacySettingsPath = `${CLAUDE_HOME}/settings.json`; + const settingsPath = `${nestedClaudeDir}/settings.json`; + const appendSystemPromptFlag = appendSystemPrompt?.trim() + ? ` --append-system-prompt ${shellEscape(appendSystemPrompt.trim())}` + : ''; + + return [ + `mkdir -p ${shellEscape(nestedClaudeDir)}`, + `if [ -f ${shellEscape(legacySettingsPath)} ] && [ ! -f ${shellEscape(settingsPath)} ]; then cp ${shellEscape( + legacySettingsPath + )} ${shellEscape(settingsPath)}; fi`, + `export HOME=${shellEscape(CLAUDE_HOME)}`, + `exec claude -p --model ${shellEscape( + model + )} --output-format stream-json --input-format stream-json --permission-mode bypassPermissions${appendSystemPromptFlag} --verbose`, + ].join('; '); +} + +export interface ExecConnection { + write(data: string): void; + cancel(): void; + close(): void; + onStdout(handler: (data: string) => void): void; + onStderr(handler: (data: string) => void): void; + onClose(handler: () => void): void; + onError(handler: (err: Error) => void): void; +} + +async function sendSignalToClaudeProcess( + namespace: string, + podName: string, + container: string, + signal: string +): Promise { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const exec = new k8s.Exec(kc); + + const discard = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }); + + try { + await exec.exec( + namespace, + podName, + container, + ['sh', '-c', `kill -${signal} $(pgrep -f "claude" | head -1) 2>/dev/null || true`], + discard, + discard, + null, + false + ); + } catch (err: any) { + logger.warn(`Failed to send ${signal} to claude process: pod=${podName} err=${err?.message}`); + } +} + +export async function attachToAgentPod( + namespace: string, + podName: string, + model: string, + container = 'agent' +): Promise { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const exec = new k8s.Exec(kc); + + const stdoutHandlers: Array<(data: string) => void> = []; + const stderrHandlers: Array<(data: string) => void> = []; + const closeHandlers: Array<() => void> = []; + const errorHandlers: Array<(err: Error) => void> = []; + + const stdout = new Writable({ + write(chunk, _encoding, callback) { + const str = chunk.toString(); + stdoutHandlers.forEach((h) => h(str)); + callback(); + }, + }); + + const stderr = new Writable({ + write(chunk, _encoding, callback) { + const str = chunk.toString(); + stderrHandlers.forEach((h) => h(str)); + callback(); + }, + }); + + const stdin = new Readable({ read() {} }); + let closed = false; + const claudeConfig = await resolveAgentSessionClaudeConfig(); + + try { + const ws = await exec.exec( + namespace, + podName, + container, + ['sh', '-lc', buildClaudeExecScript(model, claudeConfig.appendSystemPrompt)], + stdout, + stderr, + stdin, + false + ); + if (ws && typeof ws.on === 'function') { + ws.on('close', () => { + if (!closed) { + closed = true; + closeHandlers.forEach((h) => h()); + } + }); + ws.on('error', (err: Error) => { + errorHandlers.forEach((h) => h(err)); + }); + } + } catch (err: any) { + logger.error(`Failed to exec into agent pod: name=${podName} err=${err?.message}`); + throw err; + } + + return { + write(data: string) { + if (!closed) stdin.push(data); + }, + cancel() { + if (!closed) { + sendSignalToClaudeProcess(namespace, podName, container, 'INT'); + } + }, + close() { + if (!closed) { + closed = true; + stdin.push(null); + closeHandlers.forEach((h) => h()); + } + }, + onStdout(handler) { + stdoutHandlers.push(handler); + }, + onStderr(handler) { + stderrHandlers.push(handler); + }, + onClose(handler) { + closeHandlers.push(handler); + }, + onError(handler) { + errorHandlers.push(handler); + }, + }; +} diff --git a/src/server/lib/agentSession/forwardedEnv.ts b/src/server/lib/agentSession/forwardedEnv.ts new file mode 100644 index 0000000..6a7a128 --- /dev/null +++ b/src/server/lib/agentSession/forwardedEnv.ts @@ -0,0 +1,197 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import Deploy from 'server/models/Deploy'; +import type { DevConfig } from 'server/models/yaml/YamlService'; +import { deleteExternalSecret, generateSecretName } from 'server/lib/kubernetes/externalSecret'; +import { getLogger } from 'server/lib/logger'; +import { parseSecretRefsFromEnv, SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { SecretProcessor } from 'server/services/secretProcessor'; +import GlobalConfigService from 'server/services/globalConfig'; + +const logger = getLogger(); + +export interface ForwardedAgentEnvService { + name: string; + deployId: number; + devConfig: DevConfig; +} + +export interface ForwardedAgentEnvResolution { + env: Record; + secretRefs: SecretRefWithEnvKey[]; + secretProviders: string[]; + secretServiceName: string; +} + +function getCoreApi(): k8s.CoreV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +function getEnvValueForForwarding(deployEnv: Record, serviceName: string, envKey: string): string { + if (!Object.prototype.hasOwnProperty.call(deployEnv, envKey)) { + throw new Error(`Agent env forwarding key ${envKey} is not defined for service ${serviceName}.`); + } + + const rawValue = deployEnv[envKey]; + if (rawValue == null || typeof rawValue === 'object') { + throw new Error(`Agent env forwarding key ${envKey} for service ${serviceName} must resolve to a scalar value.`); + } + + return String(rawValue); +} + +export function getForwardedAgentEnvSecretServiceName(sessionUuid: string): string { + return `agent-env-${sessionUuid}`; +} + +export async function resolveForwardedAgentEnv( + services: ForwardedAgentEnvService[] | undefined, + namespace: string, + sessionUuid: string, + buildUuid?: string +): Promise { + const forwardedEnv: Record = {}; + const selectedServices = services || []; + const servicesRequestingForwardedEnv = selectedServices.filter( + (service) => (service.devConfig.forwardEnvVarsToAgent || []).length > 0 + ); + if (selectedServices.length === 0) { + return { + env: forwardedEnv, + secretRefs: [], + secretProviders: [], + secretServiceName: getForwardedAgentEnvSecretServiceName(sessionUuid), + }; + } + if (servicesRequestingForwardedEnv.length === 0) { + return { + env: forwardedEnv, + secretRefs: [], + secretProviders: [], + secretServiceName: getForwardedAgentEnvSecretServiceName(sessionUuid), + }; + } + + const deployIds = [...new Set(servicesRequestingForwardedEnv.map((service) => service.deployId))]; + const deployRows = await Deploy.query().whereIn('id', deployIds).select('id', 'env'); + const deployById = new Map(deployRows.map((deploy) => [deploy.id, deploy])); + + for (const service of servicesRequestingForwardedEnv) { + const envKeys = service.devConfig.forwardEnvVarsToAgent || []; + if (envKeys.length === 0) { + continue; + } + + const deploy = deployById.get(service.deployId); + if (!deploy) { + throw new Error(`Selected deploy ${service.deployId} was not found for service ${service.name}.`); + } + + const deployEnv = (deploy.env || {}) as Record; + for (const envKey of envKeys) { + const nextValue = getEnvValueForForwarding(deployEnv, service.name, envKey); + const existingValue = forwardedEnv[envKey]; + if (existingValue != null && existingValue !== nextValue) { + throw new Error( + `Agent env forwarding conflict for ${envKey}: selected services resolve it to different values.` + ); + } + + forwardedEnv[envKey] = nextValue; + } + } + + const secretRefs = parseSecretRefsFromEnv(forwardedEnv); + const secretServiceName = getForwardedAgentEnvSecretServiceName(sessionUuid); + if (secretRefs.length === 0) { + return { + env: forwardedEnv, + secretRefs: [], + secretProviders: [], + secretServiceName, + }; + } + + const globalConfigs = await GlobalConfigService.getInstance().getAllConfigs(); + const secretProviders = globalConfigs.secretProviders; + if (!secretProviders) { + const secretKeys = secretRefs.map((ref) => ref.envKey).join(', '); + throw new Error( + `Agent env forwarding for ${secretKeys} requires configured secret providers because the selected service uses native secret references.` + ); + } + + const secretProcessor = new SecretProcessor(secretProviders); + const secretResult = await secretProcessor.processEnvSecrets({ + env: forwardedEnv, + serviceName: secretServiceName, + namespace, + buildUuid, + }); + + if (secretResult.warnings.length > 0) { + throw new Error(secretResult.warnings.join(' ')); + } + + if (secretResult.secretNames.length > 0) { + const providerTimeouts = Object.values(secretProviders) + .map((provider) => provider.secretSyncTimeout) + .filter((timeout): timeout is number => timeout !== undefined); + const timeout = providerTimeouts.length > 0 ? Math.max(...providerTimeouts) * 1000 : 60000; + await secretProcessor.waitForSecretSync(secretResult.secretNames, namespace, timeout); + } + + return { + env: forwardedEnv, + secretRefs: secretResult.secretRefs, + secretProviders: [...new Set(secretResult.secretRefs.map((ref) => ref.provider))], + secretServiceName, + }; +} + +export async function cleanupForwardedAgentEnvSecrets( + namespace: string, + sessionUuid: string, + providers: string[] | undefined +): Promise { + const uniqueProviders = [...new Set((providers || []).filter(Boolean))]; + if (uniqueProviders.length === 0) { + return; + } + + const secretServiceName = getForwardedAgentEnvSecretServiceName(sessionUuid); + const coreApi = getCoreApi(); + + for (const provider of uniqueProviders) { + const secretName = generateSecretName(secretServiceName, provider); + + await deleteExternalSecret(secretName, namespace); + + try { + await coreApi.deleteNamespacedSecret(secretName, namespace); + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + continue; + } + + logger.warn({ error, namespace, secretName }, 'Agent forwarded env secret cleanup failed'); + } + } +} diff --git a/src/server/lib/agentSession/githubToken.ts b/src/server/lib/agentSession/githubToken.ts new file mode 100644 index 0000000..6772e84 --- /dev/null +++ b/src/server/lib/agentSession/githubToken.ts @@ -0,0 +1,105 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { NextRequest } from 'next/server'; +import { getLogger } from 'server/lib/logger'; +import GlobalConfigService from 'server/services/globalConfig'; + +const logger = getLogger(); + +function getBearerToken(req: NextRequest): string | null { + const authHeader = req.headers.get('authorization') || req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.slice('Bearer '.length).trim(); + return token || null; +} + +function parseBrokerTokenResponse(body: string): string | null { + const trimmed = body.trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = JSON.parse(trimmed) as Record; + const jsonToken = + (typeof parsed.access_token === 'string' && parsed.access_token) || + (typeof parsed.token === 'string' && parsed.token); + if (jsonToken) { + return jsonToken; + } + } catch { + // ignore and fall through to query-string parsing + } + + const params = new URLSearchParams(trimmed); + return params.get('access_token') || params.get('token'); +} + +export async function fetchGitHubBrokerToken(keycloakAccessToken: string): Promise { + const issuer = process.env.KEYCLOAK_ISSUER_INTERNAL?.trim() || process.env.KEYCLOAK_ISSUER?.trim(); + if (!issuer) { + logger.warn( + 'GitHub broker token lookup skipped because neither KEYCLOAK_ISSUER_INTERNAL nor KEYCLOAK_ISSUER is configured' + ); + return null; + } + + const response = await fetch(`${issuer}/broker/github/token`, { + method: 'GET', + headers: { + Authorization: `Bearer ${keycloakAccessToken}`, + }, + }); + + if (!response.ok) { + logger.warn( + { + status: response.status, + }, + 'GitHub broker token lookup failed' + ); + return null; + } + + return parseBrokerTokenResponse(await response.text()); +} + +export async function resolveRequestGitHubToken(req: NextRequest): Promise { + if (process.env.ENABLE_AUTH !== 'true') { + try { + return await GlobalConfigService.getInstance().getGithubClientToken(); + } catch (error) { + logger.warn({ error }, 'GitHub app token lookup failed while auth is disabled'); + return null; + } + } + + const keycloakAccessToken = getBearerToken(req); + if (!keycloakAccessToken) { + return null; + } + + try { + return await fetchGitHubBrokerToken(keycloakAccessToken); + } catch (error) { + logger.warn({ error }, 'GitHub broker token lookup threw unexpectedly'); + return null; + } +} diff --git a/src/server/lib/agentSession/gvisorCheck.ts b/src/server/lib/agentSession/gvisorCheck.ts new file mode 100644 index 0000000..6095417 --- /dev/null +++ b/src/server/lib/agentSession/gvisorCheck.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; + +const CACHE_TTL_MS = 5 * 60 * 1000; + +let cachedResult: boolean | null = null; +let cacheTimestamp = 0; + +export function resetGvisorCache(): void { + cachedResult = null; + cacheTimestamp = 0; +} + +export async function isGvisorAvailable(): Promise { + const now = Date.now(); + if (cachedResult !== null && now - cacheTimestamp < CACHE_TTL_MS) { + return cachedResult; + } + + try { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const nodeApi = kc.makeApiClient(k8s.NodeV1Api); + await nodeApi.readRuntimeClass('gvisor'); + cachedResult = true; + cacheTimestamp = now; + return true; + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + cachedResult = false; + cacheTimestamp = now; + return false; + } + const logger = getLogger(); + logger.warn(`gvisorCheck: failed to check RuntimeClass error=${error?.message || error}`); + cachedResult = false; + cacheTimestamp = now; + return false; + } +} diff --git a/src/server/lib/agentSession/jsonlParser.ts b/src/server/lib/agentSession/jsonlParser.ts new file mode 100644 index 0000000..a0b4c8e --- /dev/null +++ b/src/server/lib/agentSession/jsonlParser.ts @@ -0,0 +1,341 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AgentWSServerMessage } from 'shared/types/agentSession'; + +export class JsonlParser { + private buffer = ''; + private streamToolNames = new Map(); + private currentTurnStartedAt: number | null = null; + private currentTurnToolCalls = 0; + private onMessage: (msg: AgentWSServerMessage) => void; + + constructor(onMessage: (msg: AgentWSServerMessage) => void) { + this.onMessage = onMessage; + } + + feed(data: string): void { + this.buffer += data; + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + this.parseLine(trimmed); + } + } + + private parseLine(line: string): void { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + this.onMessage({ type: 'chunk', content: line }); + return; + } + + const mappedMessages = this.mapEvent(parsed); + if (mappedMessages.length > 0) { + mappedMessages.forEach((message) => this.onMessage(message)); + return; + } + + if ( + parsed?.type === 'assistant' || + parsed?.type === 'system' || + parsed?.type === 'user' || + parsed?.type === 'result' + ) { + return; + } + + this.onMessage({ type: 'chunk', content: line }); + } + + private mapEvent(event: any): AgentWSServerMessage[] { + switch (event.type) { + case 'assistant': + return this.mapAssistantMessage(event.message); + case 'result': + return this.mapResultMessage(event); + case 'stream_event': + return this.mapStreamEvent(event.event); + case 'tool_use': + this.currentTurnToolCalls += 1; + return [{ type: 'tool_use', tool: event.tool?.name || '', args: event.tool?.args || {} }]; + case 'tool_result': + return [ + { + type: 'phase', + phase: 'reviewing_tool', + label: event.tool ? `Reviewing ${event.tool} output` : 'Reviewing tool output', + tool: event.tool || undefined, + }, + { + type: 'tool_result', + tool: event.tool || '', + result: event.result || '', + success: event.success ?? true, + }, + ]; + default: + return []; + } + } + + private mapAssistantMessage(message: any): AgentWSServerMessage[] { + const messageId = typeof message?.id === 'string' ? message.id : undefined; + const content = message?.content; + if (typeof content === 'string') { + return content ? [{ type: 'chunk', content }] : []; + } + + if (!Array.isArray(content)) { + return []; + } + + const mappedMessages: AgentWSServerMessage[] = []; + + for (const block of content) { + if (!block || typeof block !== 'object') { + continue; + } + + if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 0) { + mappedMessages.push({ type: 'chunk', content: block.text }); + continue; + } + + if (block.type === 'thinking') { + mappedMessages.push({ + type: 'phase', + phase: 'thinking', + label: 'Thinking through next step', + }); + continue; + } + + if (block.type === 'tool_use') { + this.currentTurnToolCalls += 1; + mappedMessages.push({ + type: 'tool_use', + tool: block.name || '', + args: block.input || {}, + }); + } + } + + const usageMessage = this.mapUsageMetrics(message?.usage, { + scope: 'step', + messageId, + totalCostUsd: this.parseFiniteNumber(message?.total_cost_usd), + }); + if (usageMessage) { + mappedMessages.push(usageMessage); + } + + return mappedMessages; + } + + private mapResultMessage(event: any): AgentWSServerMessage[] { + const usageMessage = this.mapUsageMetrics(event?.usage, { + scope: 'session', + totalCostUsd: this.parseFiniteNumber(event?.total_cost_usd), + }); + + if (event?.subtype === 'success') { + this.streamToolNames.clear(); + this.currentTurnStartedAt = null; + this.currentTurnToolCalls = 0; + return usageMessage ? [usageMessage, { type: 'status', status: 'ready' }] : [{ type: 'status', status: 'ready' }]; + } + + if (Array.isArray(event?.errors) && event.errors.length > 0) { + const content = event.errors.filter((error: unknown): error is string => typeof error === 'string').join('\n'); + const messages: AgentWSServerMessage[] = []; + if (usageMessage) { + messages.push(usageMessage); + } + if (content) { + messages.push({ type: 'chunk', content }); + } + return messages; + } + + return usageMessage ? [usageMessage] : []; + } + + private mapStreamEvent(event: any): AgentWSServerMessage[] { + if (!event || typeof event !== 'object') { + return []; + } + + switch (event.type) { + case 'message_start': + this.currentTurnStartedAt = Date.now(); + this.currentTurnToolCalls = 0; + return [{ type: 'status', status: 'working' }]; + case 'message_stop': + this.streamToolNames.clear(); + this.currentTurnStartedAt = null; + this.currentTurnToolCalls = 0; + return [{ type: 'status', status: 'ready' }]; + case 'content_block_start': { + const block = event.content_block; + if (!block || typeof block !== 'object') { + return []; + } + + if (block.type === 'text') { + return [{ type: 'phase', phase: 'drafting', label: 'Drafting response' }]; + } + + if (block.type === 'tool_use') { + const index = typeof event.index === 'number' ? event.index : null; + const toolName = typeof block.name === 'string' ? block.name : ''; + + if (index != null && toolName) { + this.streamToolNames.set(index, toolName); + } + + return [ + { + type: 'phase', + phase: 'preparing_tool', + label: toolName ? `Preparing ${toolName}` : 'Preparing tool', + tool: toolName || undefined, + }, + ]; + } + + return []; + } + case 'content_block_delta': { + const delta = event.delta; + if (!delta || typeof delta !== 'object') { + return []; + } + + if (delta.type === 'text_delta') { + return [{ type: 'phase', phase: 'drafting', label: 'Drafting response' }]; + } + + if (delta.type === 'input_json_delta') { + const toolName = typeof event.index === 'number' ? this.streamToolNames.get(event.index) : undefined; + + return [ + { + type: 'phase', + phase: 'preparing_tool', + label: toolName ? `Preparing ${toolName} arguments` : 'Preparing tool arguments', + tool: toolName, + }, + ]; + } + + return []; + } + case 'content_block_stop': { + if (typeof event.index !== 'number') { + return []; + } + + const toolName = this.streamToolNames.get(event.index); + if (!toolName) { + return []; + } + + this.streamToolNames.delete(event.index); + return [ + { + type: 'phase', + phase: 'running_tool', + label: `Running ${toolName}`, + tool: toolName, + }, + ]; + } + default: + return []; + } + } + + private mapUsageMetrics( + usage: any, + options: { + scope: 'step' | 'session'; + messageId?: string; + totalCostUsd?: number | null; + } + ): AgentWSServerMessage | null { + const totalCostUsd = options.totalCostUsd ?? null; + + if (!usage || typeof usage !== 'object') { + if (totalCostUsd == null) { + return null; + } + + return { + type: 'usage', + scope: options.scope, + ...(options.messageId ? { messageId: options.messageId } : {}), + metrics: { + iterations: 1, + totalToolCalls: this.currentTurnToolCalls, + totalDurationMs: this.currentTurnStartedAt != null ? Math.max(0, Date.now() - this.currentTurnStartedAt) : 0, + totalCostUsd, + }, + }; + } + + const inputTokens = this.parseFiniteNumber(usage.input_tokens); + const outputTokens = this.parseFiniteNumber(usage.output_tokens); + const cacheCreationInputTokens = this.parseFiniteNumber(usage.cache_creation_input_tokens); + const cacheReadInputTokens = this.parseFiniteNumber(usage.cache_read_input_tokens); + + if ( + inputTokens == null && + outputTokens == null && + cacheCreationInputTokens == null && + cacheReadInputTokens == null && + totalCostUsd == null + ) { + return null; + } + + const totalDurationMs = this.currentTurnStartedAt != null ? Math.max(0, Date.now() - this.currentTurnStartedAt) : 0; + + return { + type: 'usage', + scope: options.scope, + ...(options.messageId ? { messageId: options.messageId } : {}), + metrics: { + iterations: 1, + totalToolCalls: this.currentTurnToolCalls, + totalDurationMs, + inputTokens: inputTokens ?? undefined, + outputTokens: outputTokens ?? undefined, + cacheCreationInputTokens: cacheCreationInputTokens ?? undefined, + cacheReadInputTokens: cacheReadInputTokens ?? undefined, + totalCostUsd: totalCostUsd ?? undefined, + }, + }; + } + + private parseFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } +} diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts new file mode 100644 index 0000000..b65e419 --- /dev/null +++ b/src/server/lib/agentSession/podFactory.ts @@ -0,0 +1,577 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; +import { generateInitScript, InitScriptOpts } from './configSeeder'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; +import type { RequestUserIdentity } from 'server/lib/get-user'; +import { buildPodEnvWithSecrets } from 'server/lib/secretEnvBuilder'; +import type { SecretRefWithEnvKey } from 'server/lib/secretRefs'; + +export const AGENT_EDITOR_PORT = parseInt(process.env.AGENT_EDITOR_PORT || '13337', 10); + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function getPodStartupFailure(pod: k8s.V1Pod): string | null { + const statuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])]; + + for (const status of statuses) { + const waiting = status.state?.waiting; + if ( + waiting?.reason && + [ + 'ErrImagePull', + 'ImagePullBackOff', + 'CrashLoopBackOff', + 'CreateContainerConfigError', + 'RunContainerError', + ].includes(waiting.reason) + ) { + return `${status.name}: ${waiting.reason}${waiting.message ? ` - ${waiting.message}` : ''}`; + } + + const terminated = status.state?.terminated; + if (terminated?.reason && terminated.exitCode !== 0) { + return `${status.name}: ${terminated.reason}${terminated.message ? ` - ${terminated.message}` : ''}`; + } + } + + if (pod.status?.phase === 'Failed') { + return pod.status?.message || 'pod failed'; + } + + return null; +} + +function isPodReady(pod: k8s.V1Pod): boolean { + return (pod.status?.conditions || []).some((condition) => condition.type === 'Ready' && condition.status === 'True'); +} + +function summarizePodState(pod: k8s.V1Pod): string { + const initStates = (pod.status?.initContainerStatuses || []) + .map( + (status) => + status.state?.waiting?.reason || + status.state?.terminated?.reason || + status.state?.running?.startedAt || + 'unknown' + ) + .join(','); + const mainStates = (pod.status?.containerStatuses || []) + .map( + (status) => + status.state?.waiting?.reason || + status.state?.terminated?.reason || + status.state?.running?.startedAt || + 'unknown' + ) + .join(','); + + return `phase=${pod.status?.phase || 'Unknown'} init=[${initStates}] containers=[${mainStates}]`; +} + +export interface AgentPodOpts { + podName: string; + namespace: string; + pvcName: string; + image: string; + editorImage: string; + apiKeySecretName: string; + hasGitHubToken?: boolean; + model: string; + repoUrl: string; + branch: string; + revision?: string; + workspacePath: string; + installCommand?: string; + claudeMdContent?: string; + claudePermissions?: { + allow: string[]; + deny: string[]; + }; + claudeCommitAttribution?: string; + claudePrAttribution?: string; + forwardedAgentEnv?: Record; + forwardedAgentSecretRefs?: SecretRefWithEnvKey[]; + forwardedAgentSecretServiceName?: string; + useGvisor?: boolean; + buildUuid?: string; + userIdentity?: RequestUserIdentity; +} + +function buildAgentResources(): k8s.V1ResourceRequirements { + return { + requests: { + cpu: process.env.AGENT_POD_CPU_REQUEST || '500m', + memory: process.env.AGENT_POD_MEMORY_REQUEST || '1Gi', + }, + limits: { + cpu: process.env.AGENT_POD_CPU_LIMIT || '2', + memory: process.env.AGENT_POD_MEMORY_LIMIT || '4Gi', + }, + }; +} + +function buildEditorResources(): k8s.V1ResourceRequirements { + return { + requests: { + cpu: process.env.AGENT_EDITOR_CPU_REQUEST || '250m', + memory: process.env.AGENT_EDITOR_MEMORY_REQUEST || '512Mi', + }, + limits: { + cpu: process.env.AGENT_EDITOR_CPU_LIMIT || '1', + memory: process.env.AGENT_EDITOR_MEMORY_LIMIT || '1Gi', + }, + }; +} + +function buildUserIdentityEnv(userIdentity?: RequestUserIdentity): k8s.V1EnvVar[] { + if (!userIdentity) { + return []; + } + + const envVars: k8s.V1EnvVar[] = [ + { name: 'LIFECYCLE_USER_ID', value: userIdentity.userId }, + { name: 'LIFECYCLE_USER_NAME', value: userIdentity.displayName }, + { name: 'GIT_AUTHOR_NAME', value: userIdentity.gitUserName }, + { name: 'GIT_AUTHOR_EMAIL', value: userIdentity.gitUserEmail }, + { name: 'GIT_COMMITTER_NAME', value: userIdentity.gitUserName }, + { name: 'GIT_COMMITTER_EMAIL', value: userIdentity.gitUserEmail }, + ]; + + if (userIdentity.githubUsername) { + envVars.push({ name: 'LIFECYCLE_GITHUB_USERNAME', value: userIdentity.githubUsername }); + } + + if (userIdentity.email) { + envVars.push({ name: 'LIFECYCLE_USER_EMAIL', value: userIdentity.email }); + } + + return envVars; +} + +function buildGitHubTokenEnv(secretName: string, enabled?: boolean): k8s.V1EnvVar[] { + if (!enabled) { + return []; + } + + return [ + { + name: 'GITHUB_TOKEN', + valueFrom: { + secretKeyRef: { + name: secretName, + key: 'GITHUB_TOKEN', + }, + }, + }, + { + name: 'GH_TOKEN', + valueFrom: { + secretKeyRef: { + name: secretName, + key: 'GH_TOKEN', + }, + }, + }, + ]; +} + +export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { + const { + podName, + namespace, + pvcName, + image, + editorImage, + apiKeySecretName, + hasGitHubToken, + model, + repoUrl, + branch, + revision, + workspacePath, + installCommand, + claudeMdContent, + claudePermissions, + claudeCommitAttribution, + claudePrAttribution, + useGvisor, + userIdentity, + } = opts; + + const initScriptOpts: InitScriptOpts = { + repoUrl, + branch, + revision, + workspacePath, + installCommand, + claudeMdContent, + claudePermissions, + claudeCommitAttribution, + claudePrAttribution, + gitUserName: userIdentity?.gitUserName, + gitUserEmail: userIdentity?.gitUserEmail, + githubUsername: userIdentity?.githubUsername || undefined, + useGitHubToken: hasGitHubToken, + }; + + const initScript = generateInitScript(initScriptOpts); + const resources = buildAgentResources(); + const editorResources = buildEditorResources(); + const userEnv = buildUserIdentityEnv(userIdentity); + const githubTokenEnv = buildGitHubTokenEnv(apiKeySecretName, hasGitHubToken); + const forwardedAgentEnv = opts.forwardedAgentEnv || {}; + const forwardedAgentSecretEnv = buildPodEnvWithSecrets( + forwardedAgentEnv, + opts.forwardedAgentSecretRefs || [], + opts.forwardedAgentSecretServiceName || podName + ); + + const securityContext: k8s.V1SecurityContext = { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + readOnlyRootFilesystem: true, + allowPrivilegeEscalation: false, + capabilities: { + drop: ['ALL'], + }, + }; + + const pod: k8s.V1Pod = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: podName, + namespace, + labels: { + ...buildLifecycleLabels({ buildUuid: opts.buildUuid }), + 'app.kubernetes.io/component': 'agent-session', + 'app.kubernetes.io/name': podName, + }, + }, + spec: { + ...(useGvisor ? { runtimeClassName: 'gvisor' } : {}), + securityContext: { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + fsGroup: 1000, + seccompProfile: { + type: 'RuntimeDefault', + }, + }, + initContainers: [ + { + name: 'init-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', initScript], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + { + name: 'workspace', + mountPath: workspacePath, + }, + { + name: 'claude-config', + mountPath: '/home/claude/.claude', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ...forwardedAgentSecretEnv, + ...githubTokenEnv, + ...userEnv, + ], + }, + ], + containers: [ + { + name: 'agent', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sleep', 'infinity'], + resources, + securityContext, + env: [ + { + name: 'ANTHROPIC_API_KEY', + valueFrom: { + secretKeyRef: { + name: apiKeySecretName, + key: 'ANTHROPIC_API_KEY', + }, + }, + }, + { name: 'CLAUDE_MODEL', value: model }, + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + { name: 'NODE_OPTIONS', value: process.env.AGENT_NODE_OPTIONS || '--max-old-space-size=2048' }, + ...forwardedAgentSecretEnv, + ...githubTokenEnv, + ...userEnv, + ], + volumeMounts: [ + { + name: 'workspace', + mountPath: workspacePath, + }, + { + name: 'claude-config', + mountPath: '/home/claude/.claude', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + }, + { + name: 'editor', + image: editorImage, + imagePullPolicy: 'IfNotPresent', + args: [ + workspacePath, + '--auth', + 'none', + '--bind-addr', + `0.0.0.0:${AGENT_EDITOR_PORT}`, + '--disable-telemetry', + '--disable-update-check', + ], + ports: [{ containerPort: AGENT_EDITOR_PORT, name: 'editor' }], + resources: editorResources, + securityContext, + env: [ + { name: 'HOME', value: '/home/coder' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + readinessProbe: { + httpGet: { + path: '/healthz', + port: AGENT_EDITOR_PORT, + }, + initialDelaySeconds: 2, + periodSeconds: 5, + }, + volumeMounts: [ + { + name: 'workspace', + mountPath: workspacePath, + }, + { + name: 'editor-home', + mountPath: '/home/coder', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + }, + ], + volumes: [ + { + name: 'workspace', + persistentVolumeClaim: { + claimName: pvcName, + }, + }, + { + name: 'claude-config', + emptyDir: {}, + }, + { + name: 'editor-home', + emptyDir: {}, + }, + { + name: 'tmp', + emptyDir: {}, + }, + ], + restartPolicy: 'Never', + }, + }; + + return pod; +} + +function getCoreApi(): k8s.CoreV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +async function getContainerLogs( + coreApi: k8s.CoreV1Api, + namespace: string, + podName: string, + containerName: string, + tailLines = 200 +): Promise { + try { + const { body } = await coreApi.readNamespacedPodLog( + podName, + namespace, + containerName, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + tailLines + ); + return typeof body === 'string' ? body.trim() : null; + } catch (error) { + getLogger().debug( + { error, namespace, podName, containerName }, + `podFactory: unable to fetch logs for container name=${containerName} namespace=${namespace}` + ); + return null; + } +} + +function summarizeLogLine(logs: string | null): string | null { + if (!logs) { + return null; + } + + const firstLine = logs + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + + return firstLine || null; +} + +async function waitForAgentPodReady(coreApi: k8s.CoreV1Api, namespace: string, podName: string): Promise { + const readyTimeoutMs = parseInt(process.env.AGENT_POD_READY_TIMEOUT_MS || '60000', 10); + const readyPollMs = parseInt(process.env.AGENT_POD_READY_POLL_MS || '2000', 10); + const deadline = Date.now() + readyTimeoutMs; + let lastObservedState = 'pending'; + + while (Date.now() < deadline) { + const { body: pod } = await coreApi.readNamespacedPod(podName, namespace); + const failure = getPodStartupFailure(pod); + if (failure) { + const failingContainer = + [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])].find((status) => { + const waiting = status.state?.waiting; + if ( + waiting?.reason && + [ + 'ErrImagePull', + 'ImagePullBackOff', + 'CrashLoopBackOff', + 'CreateContainerConfigError', + 'RunContainerError', + ].includes(waiting.reason) + ) { + return true; + } + + const terminated = status.state?.terminated; + return !!(terminated?.reason && terminated.exitCode !== 0); + })?.name || null; + + const containerLogs = failingContainer + ? await getContainerLogs(coreApi, namespace, podName, failingContainer) + : null; + if (containerLogs) { + getLogger().error( + { namespace, podName, containerName: failingContainer, logs: containerLogs }, + `podFactory: startup logs for failing container name=${failingContainer} namespace=${namespace}` + ); + } + + const logSummary = summarizeLogLine(containerLogs); + throw new Error(`Agent pod failed to start: ${failure}${logSummary ? ` - ${logSummary}` : ''}`); + } + + if (isPodReady(pod)) { + return pod; + } + + lastObservedState = summarizePodState(pod); + await sleep(readyPollMs); + } + + const timeoutLogs = await getContainerLogs(coreApi, namespace, podName, 'init-workspace'); + if (timeoutLogs) { + getLogger().error( + { namespace, podName, containerName: 'init-workspace', logs: timeoutLogs }, + `podFactory: init-workspace logs after startup timeout namespace=${namespace}` + ); + } + + const timeoutSummary = summarizeLogLine(timeoutLogs); + throw new Error( + `Agent pod did not become ready within ${readyTimeoutMs}ms: ${lastObservedState}${ + timeoutSummary ? ` - ${timeoutSummary}` : '' + }` + ); +} + +export async function createAgentPod(opts: AgentPodOpts): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + const pod = buildAgentPodSpec(opts); + + await coreApi.createNamespacedPod(opts.namespace, pod); + const result = await waitForAgentPodReady(coreApi, opts.namespace, opts.podName); + logger.info(`podFactory: created pod name=${opts.podName} namespace=${opts.namespace}`); + return result; +} + +export async function deleteAgentPod(namespace: string, podName: string): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + try { + await coreApi.deleteNamespacedPod(podName, namespace); + logger.info(`podFactory: deleted pod name=${podName} namespace=${namespace}`); + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + logger.info(`podFactory: pod not found (already deleted) name=${podName} namespace=${namespace}`); + return; + } + throw error; + } +} diff --git a/src/server/lib/agentSession/pvcFactory.ts b/src/server/lib/agentSession/pvcFactory.ts new file mode 100644 index 0000000..e4e1fb8 --- /dev/null +++ b/src/server/lib/agentSession/pvcFactory.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; + +function getCoreApi(): k8s.CoreV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.CoreV1Api); +} + +function getAccessMode(): 'ReadWriteMany' | 'ReadWriteOnce' { + const configured = process.env.AGENT_SESSION_PVC_ACCESS_MODE; + if (configured === 'ReadWriteMany' || configured === 'ReadWriteOnce') { + return configured; + } + + if (process.env.ENVIRONMENT === 'dev' || process.env.APP_ENV === 'dev' || process.env.NODE_ENV === 'development') { + return 'ReadWriteOnce'; + } + + return 'ReadWriteMany'; +} + +export async function createAgentPvc( + namespace: string, + pvcName: string, + storageSize = '10Gi', + buildUuid?: string +): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + const accessMode = getAccessMode(); + + const pvc: k8s.V1PersistentVolumeClaim = { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { + name: pvcName, + namespace, + labels: { + ...buildLifecycleLabels({ buildUuid }), + 'app.kubernetes.io/component': 'agent-session', + }, + }, + spec: { + accessModes: [accessMode], + resources: { + requests: { + storage: storageSize, + }, + }, + }, + }; + + const { body: result } = await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc); + logger.info( + `pvcFactory: created PVC name=${pvcName} namespace=${namespace} size=${storageSize} accessMode=${accessMode}` + ); + return result; +} + +export async function deleteAgentPvc(namespace: string, pvcName: string): Promise { + const logger = getLogger(); + const coreApi = getCoreApi(); + + try { + await coreApi.deleteNamespacedPersistentVolumeClaim(pvcName, namespace); + logger.info(`pvcFactory: deleted PVC name=${pvcName} namespace=${namespace}`); + } catch (error: any) { + if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { + logger.info(`pvcFactory: PVC not found (already deleted) name=${pvcName} namespace=${namespace}`); + return; + } + throw error; + } +} diff --git a/src/server/lib/agentSession/runtimeConfig.ts b/src/server/lib/agentSession/runtimeConfig.ts new file mode 100644 index 0000000..3146657 --- /dev/null +++ b/src/server/lib/agentSession/runtimeConfig.ts @@ -0,0 +1,150 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GlobalConfigService from 'server/services/globalConfig'; +import type { + AgentSessionClaudeAttribution, + AgentSessionClaudeConfig, + AgentSessionClaudePermissions, +} from 'server/services/types/globalConfig'; + +export interface AgentSessionRuntimeConfig { + image: string; + editorImage: string; + claude: ResolvedAgentSessionClaudeConfig; +} + +export interface ResolvedAgentSessionClaudePermissions { + allow: string[]; + deny: string[]; +} + +export interface ResolvedAgentSessionClaudeAttribution { + commitTemplate: string; + prTemplate: string; +} + +export interface ResolvedAgentSessionClaudeConfig { + permissions: ResolvedAgentSessionClaudePermissions; + attribution: ResolvedAgentSessionClaudeAttribution; + appendSystemPrompt?: string; +} + +const DEFAULT_CLAUDE_PERMISSION_ALLOW = ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)']; +const DEFAULT_CLAUDE_PERMISSION_DENY: string[] = []; +const DEFAULT_CLAUDE_COMMIT_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; +const DEFAULT_CLAUDE_PR_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; + +function normalizeStringArray(values: unknown, fallback: string[]): string[] { + if (!Array.isArray(values)) { + return [...fallback]; + } + + const normalized = values + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean); + + return normalized.length > 0 ? normalized : [...fallback]; +} + +function normalizeAttributionTemplate(template: unknown, fallback: string): string { + return typeof template === 'string' && template.trim() ? template.trim() : fallback; +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function resolveAgentSessionClaudeConfigFromDefaults( + claudeDefaults?: AgentSessionClaudeConfig | null +): ResolvedAgentSessionClaudeConfig { + const permissions: AgentSessionClaudePermissions | undefined = claudeDefaults?.permissions; + const attribution: AgentSessionClaudeAttribution | undefined = claudeDefaults?.attribution; + + return { + permissions: { + allow: normalizeStringArray(permissions?.allow, DEFAULT_CLAUDE_PERMISSION_ALLOW), + deny: normalizeStringArray(permissions?.deny, DEFAULT_CLAUDE_PERMISSION_DENY), + }, + attribution: { + commitTemplate: normalizeAttributionTemplate( + attribution?.commitTemplate, + DEFAULT_CLAUDE_COMMIT_ATTRIBUTION_TEMPLATE + ), + prTemplate: normalizeAttributionTemplate(attribution?.prTemplate, DEFAULT_CLAUDE_PR_ATTRIBUTION_TEMPLATE), + }, + appendSystemPrompt: normalizeOptionalString(claudeDefaults?.appendSystemPrompt), + }; +} + +export async function resolveAgentSessionClaudeConfig(): Promise { + const { agentSessionDefaults } = await GlobalConfigService.getInstance().getAllConfigs(); + return resolveAgentSessionClaudeConfigFromDefaults(agentSessionDefaults?.claude); +} + +export function renderAgentSessionClaudeAttribution(template: string, appName: string | null): string { + const trimmedTemplate = template.trim(); + if (!trimmedTemplate) { + return ''; + } + + if (trimmedTemplate.includes('{appName}')) { + const trimmedAppName = appName?.trim(); + if (!trimmedAppName) { + return ''; + } + + return trimmedTemplate.replace(/\{appName\}/g, trimmedAppName).trim(); + } + + return trimmedTemplate; +} + +export class AgentSessionRuntimeConfigError extends Error { + readonly missingFields: Array<'image' | 'editorImage'>; + + constructor(missingFields: Array<'image' | 'editorImage'>) { + super(`Agent session runtime is not configured. Missing ${missingFields.join(' and ')}.`); + this.name = 'AgentSessionRuntimeConfigError'; + this.missingFields = missingFields; + } +} + +export async function resolveAgentSessionRuntimeConfig(): Promise { + const { agentSessionDefaults } = await GlobalConfigService.getInstance().getAllConfigs(); + const image = agentSessionDefaults?.image?.trim() || ''; + const editorImage = agentSessionDefaults?.editorImage?.trim() || ''; + const missingFields: Array<'image' | 'editorImage'> = []; + + if (!image) { + missingFields.push('image'); + } + + if (!editorImage) { + missingFields.push('editorImage'); + } + + if (missingFields.length > 0) { + throw new AgentSessionRuntimeConfigError(missingFields); + } + + return { + image, + editorImage, + claude: resolveAgentSessionClaudeConfigFromDefaults(agentSessionDefaults?.claude), + }; +} diff --git a/src/server/lib/agentSession/sandboxLaunchState.ts b/src/server/lib/agentSession/sandboxLaunchState.ts new file mode 100644 index 0000000..e4b78a0 --- /dev/null +++ b/src/server/lib/agentSession/sandboxLaunchState.ts @@ -0,0 +1,107 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Redis } from 'ioredis'; + +const SANDBOX_LAUNCH_REDIS_PREFIX = 'lifecycle:agent:sandbox-launch:'; +const SANDBOX_LAUNCH_TTL_SECONDS = 60 * 60; + +export type SandboxLaunchStage = + | 'queued' + | 'resolving_base_build' + | 'resolving_services' + | 'creating_sandbox_build' + | 'resolving_environment' + | 'deploying_resources' + | 'creating_agent_session' + | 'ready' + | 'error'; + +export interface SandboxLaunchState { + launchId: string; + userId: string; + status: 'queued' | 'running' | 'created' | 'error'; + stage: SandboxLaunchStage; + message: string; + createdAt: string; + updatedAt: string; + baseBuildUuid?: string; + service?: string; + buildUuid?: string; + namespace?: string; + sessionId?: string; + focusUrl?: string; + error?: string; +} + +export interface PublicSandboxLaunchState extends Omit {} + +function sandboxLaunchKey(launchId: string): string { + return `${SANDBOX_LAUNCH_REDIS_PREFIX}${launchId}`; +} + +export function buildSandboxFocusUrl(params: { buildUuid: string; sessionId: string; baseBuildUuid: string }): string { + const searchParams = new URLSearchParams({ + baseBuildUuid: params.baseBuildUuid, + }); + + return `/environments/${params.buildUuid}/agent-session/${params.sessionId}?${searchParams.toString()}`; +} + +export function buildSandboxLaunchStatusUrl(launchId: string): string { + return `/api/v2/ai/agent/sandbox-sessions/launches/${launchId}`; +} + +export async function setSandboxLaunchState(redis: Redis, state: SandboxLaunchState): Promise { + await redis.setex(sandboxLaunchKey(state.launchId), SANDBOX_LAUNCH_TTL_SECONDS, JSON.stringify(state)); +} + +export async function getSandboxLaunchState(redis: Redis, launchId: string): Promise { + const raw = await redis.get(sandboxLaunchKey(launchId)); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as SandboxLaunchState; + } catch { + return null; + } +} + +export async function patchSandboxLaunchState( + redis: Redis, + launchId: string, + patch: Partial +): Promise { + const current = await getSandboxLaunchState(redis, launchId); + if (!current) { + return null; + } + + const next: SandboxLaunchState = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + await setSandboxLaunchState(redis, next); + return next; +} + +export function toPublicSandboxLaunchState(state: SandboxLaunchState): PublicSandboxLaunchState { + const { userId: _userId, ...publicState } = state; + return publicState; +} diff --git a/src/server/lib/auth.ts b/src/server/lib/auth.ts index f8a98c5..156d328 100644 --- a/src/server/lib/auth.ts +++ b/src/server/lib/auth.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { NextRequest } from 'next/server'; import { createRemoteJWKSet, JWTPayload, jwtVerify } from 'jose'; interface AuthResult { @@ -26,19 +25,36 @@ interface AuthResult { }; } -export async function verifyAuth(request: NextRequest): Promise { - // 1. Extract token from the "Bearer " format in the Authorization header. - const authHeader = request.headers.get('Authorization'); +type HeadersLike = { + get(name: string): string | null | undefined; +}; - if (!authHeader) { - return { - success: false, - error: { message: 'Authorization header is missing', status: 401 }, - }; +type RequestWithHeaders = { + headers?: HeadersLike | null; +}; + +type RemoteJwksUrl = Parameters[0]; +type UrlConstructor = new (input: string, base?: string) => unknown; + +function getAuthorizationHeader(request: RequestWithHeaders): string | null { + const headers = request.headers; + if (!headers || typeof headers.get !== 'function') { + return null; } - const token = authHeader.split(' ')[1]; + return headers.get('Authorization') || headers.get('authorization') || null; +} + +function buildJwksUrl(input: string): RemoteJwksUrl { + const urlCtor = (globalThis as unknown as { URL?: UrlConstructor }).URL; + if (typeof urlCtor !== 'function') { + throw new Error('URL constructor is not available'); + } + + return new urlCtor(input) as RemoteJwksUrl; +} +export async function verifyBearerToken(token: string | null | undefined): Promise { if (!token) { return { success: false, @@ -61,12 +77,12 @@ export async function verifyAuth(request: NextRequest): Promise { try { // 3. Fetch the JSON Web Key Set (JWKS) from your Keycloak server. - const JWKS = createRemoteJWKSet(new URL(jwksUrl)); + const JWKS = createRemoteJWKSet(buildJwksUrl(jwksUrl)); // 4. Verify the token. This function checks the signature, expiration, issuer, and audience. const { payload } = await jwtVerify(token, JWKS, { - issuer: issuer, - audience: audience, + issuer, + audience, }); // 5. If verification is successful, return a success result. @@ -82,3 +98,17 @@ export async function verifyAuth(request: NextRequest): Promise { }; } } + +export async function verifyAuth(request: RequestWithHeaders): Promise { + const authHeader = getAuthorizationHeader(request); + + if (!authHeader) { + return { + success: false, + error: { message: 'Authorization header is missing', status: 401 }, + }; + } + + const token = authHeader.split(' ')[1]; + return verifyBearerToken(token); +} diff --git a/src/server/lib/codefresh/index.ts b/src/server/lib/codefresh/index.ts index ec0ff5e..1d3a167 100644 --- a/src/server/lib/codefresh/index.ts +++ b/src/server/lib/codefresh/index.ts @@ -23,7 +23,7 @@ import { Metrics } from 'server/lib/metrics'; import { ENVIRONMENT } from 'shared/config'; import GlobalConfigService from 'server/services/globalConfig'; -export const tagExists = async ({ tag, ecrRepo = 'lifecycle-deployments', uuid = '' }) => { +export const tagExists = async ({ tag, ecrRepo = 'lifecycle-deployments', uuid: _uuid = '' }) => { const { lifecycleDefaults } = await GlobalConfigService.getInstance().getAllConfigs(); const repoName = ecrRepo; const registryId = (lifecycleDefaults.ecrDomain?.split?.('.') || [])[0] || ''; diff --git a/src/server/lib/encryption.ts b/src/server/lib/encryption.ts new file mode 100644 index 0000000..b06defa --- /dev/null +++ b/src/server/lib/encryption.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +function getKey(): Buffer { + const hex = process.env.ENCRYPTION_KEY; + if (!hex || hex.length !== 64) { + throw new Error( + 'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes). ' + + 'For local development, set secrets.encryptionKey in helm/environments/local/secrets.yaml ' + + 'or export ENCRYPTION_KEY.' + ); + } + return Buffer.from(hex, 'hex'); +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); +} + +export function decrypt(ciphertext: string): string { + const key = getKey(); + const data = Buffer.from(ciphertext, 'base64'); + const iv = data.subarray(0, IV_LENGTH); + const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export function maskApiKey(key: string): string { + if (key.length < 10) { + return '*'.repeat(key.length); + } + return key.slice(0, 6) + '...' + key.slice(-4); +} diff --git a/src/server/lib/envVariables.ts b/src/server/lib/envVariables.ts index f9f0eed..bb38802 100644 --- a/src/server/lib/envVariables.ts +++ b/src/server/lib/envVariables.ts @@ -68,7 +68,7 @@ export abstract class EnvironmentVariables { buildUUID: string, fullYamlSupport: boolean, build: Build, - additionalVariables?: Record, + additionalVariables?: Record ): Promise> { let availableEnv: Record; @@ -185,7 +185,7 @@ export abstract class EnvironmentVariables { if (build == null) { throw Error( - 'Critical problem. Attempt retrieving environment Variables from empty build, which should NEVER happen.', + 'Critical problem. Attempt retrieving environment Variables from empty build, which should NEVER happen.' ); } @@ -196,7 +196,7 @@ export abstract class EnvironmentVariables { throw new LifecycleError( build.runUUID, '', - 'Critical problem. Missing associated deploys with the build, which should NEVER happen.', + 'Critical problem. Missing associated deploys with the build, which should NEVER happen.' ); } @@ -219,7 +219,7 @@ export abstract class EnvironmentVariables { */ async configurationServiceEnvironments( deploys: Deploy[], - fullYamlSupport: boolean, + fullYamlSupport: boolean ): Promise>> { const configurationDeploys = deploys.filter((deploy) => { const serviceType: DeployTypes = fullYamlSupport ? deploy.deployable?.type : deploy.service?.type; @@ -237,7 +237,7 @@ export abstract class EnvironmentVariables { .where('key', deploy.branchName) .first(); } - }), + }) ); return _.compact(configurations.map((configuration) => (configuration ? configuration.data : null))); @@ -262,7 +262,7 @@ export abstract class EnvironmentVariables { envString: Record, availableEnv: Record, useDefaultUUID: boolean, - namespace: string, + namespace: string ) { const str = JSON.stringify(envString || '').replace(/-/g, HYPHEN_REPLACEMENT); return await this.compileEnvironmentWithAvailableEnvironment(str, availableEnv, useDefaultUUID, namespace); @@ -278,7 +278,7 @@ export abstract class EnvironmentVariables { environment: string, availableEnv: Record, useDefaultUUID: boolean, - namespace: string, + namespace: string ) { return await this.customRender(environment, availableEnv, useDefaultUUID, namespace); } @@ -344,7 +344,15 @@ export abstract class EnvironmentVariables { const globalConfig = await GlobalConfigService.getInstance().getAllConfigs(); const defaultUuid = useDefaultUUID ? globalConfig.lifecycleDefaults.defaultUUID : NO_DEFAULT_ENV_UUID; const staticEnvNamespace = useDefaultUUID - ? await this.db.services.BuildService.getNamespace({ uuid: defaultUuid }) + ? await this.db.models.Build.query() + .findOne({ uuid: defaultUuid }) + .select('namespace') + .then((build) => { + if (!build?.namespace) { + throw new Error(`[BUILD ${defaultUuid}] Build not found when looking for namespace`); + } + return build.namespace; + }) : 'no-namespace'; const templateMatches = template.matchAll(regex); for (const match of templateMatches) { @@ -364,7 +372,7 @@ export abstract class EnvironmentVariables { if (captureGroup.includes('_internalHostname')) { template = template.replace( fullMatch, - this.buildHostname({ host: data[captureGroup], suffix, rest, namespace: nsForDeploy }), + this.buildHostname({ host: data[captureGroup], suffix, rest, namespace: nsForDeploy }) ); } continue; @@ -378,14 +386,14 @@ export abstract class EnvironmentVariables { const defaultedInternalHostname = serviceToUpdate.replace(/_internalHostname$/, `-${defaultUuid}`); template = template.replace( fullMatch, - this.buildHostname({ host: defaultedInternalHostname, namespace: staticEnvNamespace, suffix, rest }), + this.buildHostname({ host: defaultedInternalHostname, namespace: staticEnvNamespace, suffix, rest }) ); } if (captureGroup.includes('_publicUrl')) { const serviceToUpdate = captureGroup.replace(HYPHEN_REPLACEMENT_REGEX, '-'); const defaultedPublicUrl = serviceToUpdate.replace( /_publicUrl$/, - `-${globalConfig.lifecycleDefaults.defaultPublicUrl}`, + `-${globalConfig.lifecycleDefaults.defaultPublicUrl}` ); getLogger().debug(`publicUrl for ${serviceToUpdate} defaulted to ${defaultedPublicUrl} using global_config`); template = template.replace(fullMatch, defaultedPublicUrl); @@ -405,6 +413,6 @@ export abstract class EnvironmentVariables { // eslint-disable-next-line no-unused-vars build: Build, // eslint-disable-next-line no-unused-vars - webhook?: any, + webhook?: any ): Promise>; } diff --git a/src/server/lib/fastly.ts b/src/server/lib/fastly.ts index 15753d6..f03f840 100644 --- a/src/server/lib/fastly.ts +++ b/src/server/lib/fastly.ts @@ -106,7 +106,7 @@ class Fastly { * Purge all the cache for the Fastly service for the corresponding LC environment. * @param serviceId Fastly Service ID */ - async purgeAllServiceCache(serviceId: string, uuid: string, fastlyServiceType: string) { + async purgeAllServiceCache(serviceId: string, _uuid: string, fastlyServiceType: string) { try { if (!serviceId) throw new Error('Service ID is missing'); await fastlyPurge.purgeAll({ service_id: serviceId }); diff --git a/src/server/lib/get-user.ts b/src/server/lib/get-user.ts index 4a672bb..78adf1e 100644 --- a/src/server/lib/get-user.ts +++ b/src/server/lib/get-user.ts @@ -30,3 +30,94 @@ export function getUser(req: NextRequest): JWTPayload | null { const raw = req.headers.get('x-user'); return decode(raw); } + +function normalizeClaim(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function getLocalDevUserId(): string { + const configured = process.env.LOCAL_DEV_USER_ID?.trim(); + return configured || 'local-dev-user'; +} + +function buildGitFallbackEmail(identifier: string): string { + if (/^[A-Za-z0-9-]+$/.test(identifier)) { + return `${identifier}@users.noreply.github.com`; + } + + return `${identifier.replace(/[^A-Za-z0-9._-]+/g, '-') || 'local-dev-user'}@local.lifecycle`; +} + +export interface RequestUserIdentity { + userId: string; + githubUsername: string | null; + preferredUsername: string | null; + email: string | null; + firstName: string | null; + lastName: string | null; + displayName: string; + gitUserName: string; + gitUserEmail: string; +} + +function buildUserIdentity(payload: JWTPayload | null, userId: string): RequestUserIdentity { + const claims = (payload || {}) as Record; + const githubUsername = normalizeClaim(claims.github_username) || normalizeClaim(claims.githubUsername); + const preferredUsername = normalizeClaim(claims.preferred_username) || normalizeClaim(claims.preferredUsername); + const email = normalizeClaim(claims.email); + const firstName = normalizeClaim(claims.given_name) || normalizeClaim(claims.firstName); + const lastName = normalizeClaim(claims.family_name) || normalizeClaim(claims.lastName); + const explicitName = normalizeClaim(claims.name); + const displayName = + explicitName || + [firstName, lastName].filter(Boolean).join(' ').trim() || + githubUsername || + preferredUsername || + userId; + const gitUserName = displayName; + const gitUserEmail = email || buildGitFallbackEmail(githubUsername || preferredUsername || userId); + + return { + userId, + githubUsername, + preferredUsername, + email, + firstName, + lastName, + displayName, + gitUserName, + gitUserEmail, + }; +} + +export function getRequestUserSub(req: NextRequest): string | null { + const sub = normalizeClaim(getUser(req)?.sub); + if (sub) { + return sub; + } + + if (process.env.ENABLE_AUTH === 'true') { + return null; + } + + return getLocalDevUserId(); +} + +export function getRequestUserIdentity(req: NextRequest): RequestUserIdentity | null { + const payload = getUser(req); + const sub = normalizeClaim(payload?.sub); + if (sub) { + return buildUserIdentity(payload, sub); + } + + if (process.env.ENABLE_AUTH === 'true') { + return null; + } + + return buildUserIdentity(null, getLocalDevUserId()); +} diff --git a/src/server/lib/github/index.ts b/src/server/lib/github/index.ts index 8a19ee2..92a2db1 100644 --- a/src/server/lib/github/index.ts +++ b/src/server/lib/github/index.ts @@ -261,13 +261,10 @@ export async function getYamlFileContentFromPullRequest(fullName: string, pullRe } } -export async function getYamlFileContentFromBranch( - fullName: string, - branchName: string -): Promise { +export async function getYamlFileContentFromBranch(fullName: string, branchName: string): Promise { try { const config = await getYamlFileContent({ fullName, branch: branchName }); - return config; + return config as string; } catch (error) { getLogger({ error, diff --git a/src/server/lib/github/types.ts b/src/server/lib/github/types.ts index d86e41a..3b581cc 100644 --- a/src/server/lib/github/types.ts +++ b/src/server/lib/github/types.ts @@ -95,6 +95,7 @@ export interface CacheRequestDataYHeaders { export interface CacheRequestData { headers?: CacheRequestDataYHeaders; + data?: Record; owner?: string; branch?: string; repo?: string; diff --git a/src/server/lib/jsonschema/schemas/1.0.0.json b/src/server/lib/jsonschema/schemas/1.0.0.json index 4fb544c..9554ef4 100644 --- a/src/server/lib/jsonschema/schemas/1.0.0.json +++ b/src/server/lib/jsonschema/schemas/1.0.0.json @@ -1346,6 +1346,43 @@ "defaultTag", "branchName" ] + }, + "dev": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "string" + }, + "command": { + "type": "string" + }, + "installCommand": { + "type": "string" + }, + "workDir": { + "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "number" + } + }, + "env": { + "type": "object" + }, + "forwardEnvVarsToAgent": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "image", + "command" + ] } }, "required": [ diff --git a/src/server/lib/kubernetes.ts b/src/server/lib/kubernetes.ts index 2df7382..139ef7c 100644 --- a/src/server/lib/kubernetes.ts +++ b/src/server/lib/kubernetes.ts @@ -32,6 +32,7 @@ import { setupServiceAccountWithRBAC } from './kubernetes/rbac'; import { staticEnvTolerations } from './helm/constants'; import { parseSecretRefsFromEnv, SecretRefWithEnvKey } from './secretRefs'; import { generateSecretName } from './kubernetes/externalSecret'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; interface VOLUME { name: string; @@ -41,6 +42,14 @@ interface VOLUME { }; } +type NamedKubernetesObject = k8s.KubernetesObject & { + metadata: k8s.V1ObjectMeta & { name: string; namespace?: string }; +}; + +function hasMetadataName(spec: k8s.KubernetesObject | undefined): spec is NamedKubernetesObject { + return Boolean(spec?.kind && spec?.metadata?.name); +} + async function namespaceExists(client: k8s.CoreV1Api, name: string): Promise { try { await client.readNamespace(name); @@ -57,7 +66,7 @@ async function namespaceExists(client: k8s.CoreV1Api, name: string): Promise { +async function getTTLConfig(_buildUUID: string): Promise<{ daysToExpire: number }> { let daysToExpire = DEFAULT_TTL_INACTIVITY_DAYS; try { const globalConfig = await GlobalConfigService.getInstance().getAllConfigs(); @@ -363,7 +372,7 @@ export async function applyManifests(build: Build): Promise s && s.kind && s.metadata); + const validSpecs = specs.filter(hasMetadataName); const created: k8s.KubernetesObject[] = []; for (const spec of validSpecs) { try { @@ -448,7 +457,7 @@ async function generateHttpScaleObject(deploy: Deploy): Promise { metadata: { name: `${deploy.uuid}-external-service`, labels: { - lc_uuid: deploy.deployable?.buildUUID, + ...buildLifecycleLabels({ buildUuid: deploy.deployable?.buildUUID }), }, }, spec: { @@ -583,9 +592,32 @@ export async function waitForPodReady(build: Build) { */ export async function deleteBuild(build: Build) { try { + await shellPromise(`kubectl delete all,pvc -l lc_uuid=${build.uuid} --namespace ${build.namespace}`).catch((e) => { + getLogger({ + namespace: build.namespace, + error: e, + }).warn('Resources: base delete failed'); + return null; + }); + + await shellPromise(`kubectl delete mapping -l lc_uuid=${build.uuid} --namespace ${build.namespace}`).catch((e) => { + getLogger({ + namespace: build.namespace, + error: e, + }).debug('Resources: mapping delete skipped'); + return null; + }); + await shellPromise( - `kubectl delete all,pvc,mapping,Httpscaledobjects -l lc_uuid=${build.uuid} --namespace ${build.namespace}` - ); + `kubectl delete Httpscaledobjects -l lc_uuid=${build.uuid} --namespace ${build.namespace}` + ).catch((e) => { + getLogger({ + namespace: build.namespace, + error: e, + }).debug('Resources: Httpscaledobjects delete skipped'); + return null; + }); + getLogger({ namespace: build.namespace }).info('Deploy: resources deleted'); } catch (e) { getLogger({ @@ -600,7 +632,7 @@ export async function deleteBuild(build: Build) { * @param name namespace to delete */ export async function deleteNamespace(name: string) { - if (!name.startsWith('env-')) return; + if (!name.startsWith('env-') && !name.startsWith('sbx-')) return; try { await shellPromise(`kubectl delete ns ${name} --grace-period 120`); @@ -693,7 +725,7 @@ export function generatePersistentDisks( namespace, name: `${name}-${disk.name}-claim`, labels: { - lc_uuid: buildUUID, + ...buildLifecycleLabels({ buildUuid: buildUUID }), name: buildUUID, }, }, @@ -726,7 +758,7 @@ export function generatePersistentDisks( namespace, name: `${name}-${disk.name}-claim`, labels: { - lc_uuid: buildUUID, + ...buildLifecycleLabels({ buildUuid: buildUUID }), name: buildUUID, }, }, @@ -1235,8 +1267,8 @@ export function generateDeployManifests( * Labels for the Kubernetes deployment */ const labels = { + ...buildLifecycleLabels({ buildUuid: buildUUID }), name: buildUUID, - lc_uuid: buildUUID, 'tags.datadoghq.com/env': `lifecycle-${buildUUID}`, 'tags.datadoghq.com/service': serviceName, 'tags.datadoghq.com/version': buildUUID, @@ -1247,8 +1279,8 @@ export function generateDeployManifests( * spec template */ const metaDataLabels = { + ...buildLifecycleLabels({ buildUuid: buildUUID }), name, - lc_uuid: buildUUID, dd_name: `lifecycle-${buildUUID}`, 'tags.datadoghq.com/env': `lifecycle-${buildUUID}`, 'tags.datadoghq.com/service': serviceName, @@ -1461,8 +1493,8 @@ export function generateNodePortManifests( namespace, name, labels: { + ...buildLifecycleLabels({ buildUuid: buildUUID }), name, - lc_uuid: buildUUID, dd_name: `lifecycle-${buildUUID}`, 'tags.datadoghq.com/env': 'lifecycle', 'tags.datadoghq.com/service': name, @@ -1509,8 +1541,8 @@ export function generateGRPCMappings(deploys: Deploy[], buildUUID: string, enabl namespace, name, labels: { + ...buildLifecycleLabels({ buildUuid: buildUUID }), name, - lc_uuid: buildUUID, dd_name: `lifecycle-${buildUUID}`, 'tags.datadoghq.com/env': 'lifecycle', 'tags.datadoghq.com/service': name, @@ -1555,7 +1587,7 @@ export function generateExternalNameManifests(deploys: Deploy[], buildUUID: stri namespace, name, labels: { - lc_uuid: buildUUID, + ...buildLifecycleLabels({ buildUuid: buildUUID }), name: buildUUID, }, }, @@ -1609,8 +1641,8 @@ export function generateLoadBalancerManifests( namespace, name: `internal-lb-${name}`, labels: { + ...buildLifecycleLabels({ buildUuid: buildUUID }), name: buildUUID, - lc_uuid: buildUUID, }, }, spec: { @@ -1778,9 +1810,8 @@ export function generateDeployManifest({ namespace, name: deploy.uuid, labels: { + ...buildLifecycleLabels({ buildUuid: build.uuid, deployUuid: deploy.uuid }), name: build.uuid, - lc_uuid: build.uuid, - deploy_uuid: deploy.uuid, }, }, spec: { @@ -2205,9 +2236,8 @@ function generateSingleDeploymentManifest({ 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'true', }, labels: { + ...buildLifecycleLabels({ buildUuid: build.uuid, deployUuid: deploy.uuid }), name, - lc_uuid: build.uuid, - deploy_uuid: deploy.uuid, dd_name: `lifecycle-${build.uuid}`, 'app.kubernetes.io/instance': `${serviceName}-${build.uuid}`, ...datadogLabels, @@ -2230,9 +2260,8 @@ function generateSingleDeploymentManifest({ 'cluster-autoscaler.kubernetes.io/safe-to-evict': 'true', }, labels: { + ...buildLifecycleLabels({ buildUuid: build.uuid, deployUuid: deploy.uuid }), name, - lc_uuid: build.uuid, - deploy_uuid: deploy.uuid, dd_name: `lifecycle-${build.uuid}`, 'app.kubernetes.io/instance': `${serviceName}-${build.uuid}`, ...datadogLabels, diff --git a/src/server/lib/kubernetes/JobMonitor.ts b/src/server/lib/kubernetes/JobMonitor.ts index 1ca5184..eca281b 100644 --- a/src/server/lib/kubernetes/JobMonitor.ts +++ b/src/server/lib/kubernetes/JobMonitor.ts @@ -260,15 +260,19 @@ export class JobMonitor { await this.sleep(JobMonitor.POLL_INTERVAL); } } catch (error: any) { - getLogger().debug( - `Job status check failed for ${this.jobName}, will retry: ${error.message || 'Unknown error'}` - ); + const errMsg = typeof error === 'string' ? error : error.message || error.stderr || 'Unknown error'; + if (errMsg.includes('NotFound') || errMsg.includes('not found')) { + getLogger().info(`Job deleted externally, treating as completed: ${this.jobName}`); + jobCompleted = true; + break; + } + getLogger().debug(`Job status check failed for ${this.jobName}, will retry: ${errMsg}`); await this.sleep(JobMonitor.POLL_INTERVAL); } } } - private async getJobStatus(logPrefix?: string): Promise<{ + private async getJobStatus(_logPrefix?: string): Promise<{ success: boolean; status: 'succeeded' | 'failed' | 'superseded'; startedAt?: string; diff --git a/src/server/lib/kubernetes/__tests__/labels.test.ts b/src/server/lib/kubernetes/__tests__/labels.test.ts new file mode 100644 index 0000000..ffce6ef --- /dev/null +++ b/src/server/lib/kubernetes/__tests__/labels.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildLifecycleLabels } from '../labels'; + +describe('buildLifecycleLabels', () => { + it('returns managed-by label when called with no arguments', () => { + expect(buildLifecycleLabels()).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + }); + }); + + it('returns managed-by label when called with empty object', () => { + expect(buildLifecycleLabels({})).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + }); + }); + + it('includes lc_uuid when buildUuid is provided', () => { + expect(buildLifecycleLabels({ buildUuid: 'build-123' })).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + lc_uuid: 'build-123', + }); + }); + + it('includes deploy_uuid when deployUuid is provided', () => { + expect(buildLifecycleLabels({ deployUuid: 'deploy-456' })).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + deploy_uuid: 'deploy-456', + }); + }); + + it('includes both lc_uuid and deploy_uuid when both are provided', () => { + expect(buildLifecycleLabels({ buildUuid: 'build-123', deployUuid: 'deploy-456' })).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + lc_uuid: 'build-123', + deploy_uuid: 'deploy-456', + }); + }); + + it('omits lc_uuid when buildUuid is undefined', () => { + const labels = buildLifecycleLabels({ buildUuid: undefined, deployUuid: 'deploy-456' }); + expect(labels).not.toHaveProperty('lc_uuid'); + expect(labels.deploy_uuid).toBe('deploy-456'); + }); + + it('omits deploy_uuid when deployUuid is undefined', () => { + const labels = buildLifecycleLabels({ buildUuid: 'build-123', deployUuid: undefined }); + expect(labels).not.toHaveProperty('deploy_uuid'); + expect(labels.lc_uuid).toBe('build-123'); + }); + + it('can be spread with other labels without conflicts', () => { + const labels = { + ...buildLifecycleLabels({ buildUuid: 'build-123' }), + name: 'my-pod', + dd_name: 'lifecycle-build-123', + }; + expect(labels).toEqual({ + 'app.kubernetes.io/managed-by': 'lifecycle', + lc_uuid: 'build-123', + name: 'my-pod', + dd_name: 'lifecycle-build-123', + }); + }); + + it('caller labels can override common labels when spread after', () => { + const labels = { + ...buildLifecycleLabels({ buildUuid: 'build-123' }), + 'app.kubernetes.io/managed-by': 'custom-controller', + }; + expect(labels['app.kubernetes.io/managed-by']).toBe('custom-controller'); + }); +}); diff --git a/src/server/lib/kubernetes/externalSecret.ts b/src/server/lib/kubernetes/externalSecret.ts index a631745..642f699 100644 --- a/src/server/lib/kubernetes/externalSecret.ts +++ b/src/server/lib/kubernetes/externalSecret.ts @@ -20,6 +20,7 @@ import { shellPromise } from 'server/lib/shell'; import { getLogger } from 'server/lib/logger'; import { SecretRefWithEnvKey } from 'server/lib/secretRefs'; import { SecretProviderConfig } from 'server/services/types/globalConfig'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; export interface ExternalSecretManifest { apiVersion: string; @@ -106,7 +107,7 @@ export function generateExternalSecretManifest(options: GenerateExternalSecretOp }); const labels: Record = { - 'app.kubernetes.io/managed-by': 'lifecycle', + ...buildLifecycleLabels({ buildUuid }), 'lfc/secret-provider': provider, }; diff --git a/src/server/lib/kubernetes/jobFactory.ts b/src/server/lib/kubernetes/jobFactory.ts index 67df1bc..2ea0c95 100644 --- a/src/server/lib/kubernetes/jobFactory.ts +++ b/src/server/lib/kubernetes/jobFactory.ts @@ -17,6 +17,7 @@ import { V1Job } from '@kubernetes/client-node'; import { normalizeKubernetesLabelValue } from './utils'; import { RegistryAuthConfig, createRegistryAuthInitContainer } from 'server/lib/nativeHelm/registryAuth'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; export interface JobConfig { name: string; @@ -64,9 +65,9 @@ export function createKubernetesJob(config: JobConfig): V1Job { name, namespace, labels: { + ...buildLifecycleLabels(), 'app.kubernetes.io/name': appName, 'app.kubernetes.io/component': component, - 'app.kubernetes.io/managed-by': 'lifecycle', ...labels, }, annotations: { @@ -91,6 +92,9 @@ export function createKubernetesJob(config: JobConfig): V1Job { serviceAccountName: serviceAccount, restartPolicy: 'Never', terminationGracePeriodSeconds, + hostNetwork: false, + hostPID: false, + hostIPC: false, ...(initContainers.length > 0 && { initContainers }), containers, ...(volumes.length > 0 && { volumes }), diff --git a/src/server/lib/kubernetes/labels.ts b/src/server/lib/kubernetes/labels.ts new file mode 100644 index 0000000..e6e6986 --- /dev/null +++ b/src/server/lib/kubernetes/labels.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function buildLifecycleLabels(opts?: { buildUuid?: string; deployUuid?: string }): Record { + const labels: Record = { + 'app.kubernetes.io/managed-by': 'lifecycle', + }; + if (opts?.buildUuid) { + labels.lc_uuid = opts.buildUuid; + } + if (opts?.deployUuid) { + labels.deploy_uuid = opts.deployUuid; + } + return labels; +} diff --git a/src/server/lib/kubernetes/networkPolicyFactory.ts b/src/server/lib/kubernetes/networkPolicyFactory.ts new file mode 100644 index 0000000..2ae9198 --- /dev/null +++ b/src/server/lib/kubernetes/networkPolicyFactory.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { V1NetworkPolicy } from '@kubernetes/client-node'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; + +export function buildAgentNetworkPolicy(namespace: string): V1NetworkPolicy { + return { + metadata: { + name: 'lifecycle-agent-egress', + namespace, + labels: { + ...buildLifecycleLabels(), + 'app.kubernetes.io/component': 'agent', + }, + }, + spec: { + podSelector: { + matchLabels: { + 'app.kubernetes.io/component': 'agent-session', + }, + }, + policyTypes: ['Egress'], + egress: [ + { + ports: [ + { port: 53, protocol: 'UDP' }, + { port: 53, protocol: 'TCP' }, + ], + to: [], + }, + { + ports: [{ port: 443, protocol: 'TCP' }], + to: [ + { + ipBlock: { + cidr: '0.0.0.0/0', + except: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16'], + }, + }, + ], + }, + ], + }, + }; +} diff --git a/src/server/lib/kubernetes/webhookJobFactory.ts b/src/server/lib/kubernetes/webhookJobFactory.ts index 91b0852..09c9d80 100644 --- a/src/server/lib/kubernetes/webhookJobFactory.ts +++ b/src/server/lib/kubernetes/webhookJobFactory.ts @@ -17,6 +17,7 @@ import { V1Job } from '@kubernetes/client-node'; import { createKubernetesJob, JobConfig } from './jobFactory'; import { randomAlphanumeric } from 'server/lib/random'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; export interface WebhookJobConfig { name: string; @@ -91,7 +92,7 @@ export function createWebhookJob(config: WebhookJobConfig): V1Job { timeout, ttl, labels: { - lc_uuid: config.buildUuid, + ...buildLifecycleLabels({ buildUuid: config.buildUuid }), 'lfc/uuid': config.buildUuid, 'lfc/build_id': String(config.buildId), 'lfc/webhook_name': sanitizedWebhookName, diff --git a/src/server/lib/kubernetesApply/applyManifest.ts b/src/server/lib/kubernetesApply/applyManifest.ts index 1900c7a..df1e574 100644 --- a/src/server/lib/kubernetesApply/applyManifest.ts +++ b/src/server/lib/kubernetesApply/applyManifest.ts @@ -21,6 +21,7 @@ import { getLogger } from 'server/lib/logger'; import GlobalConfigService from 'server/services/globalConfig'; import { buildDeployJobName } from 'server/lib/kubernetes/jobNames'; import { JobMonitor } from 'server/lib/kubernetes/JobMonitor'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; export interface KubernetesApplyJobConfig { deploy: Deploy; @@ -28,6 +29,15 @@ export interface KubernetesApplyJobConfig { jobId: string; } +export function getKubernetesApplyJobName(deploy: Deploy, jobId: string): string { + const shortSha = deploy.sha?.substring(0, 7) || 'unknown'; + return buildDeployJobName({ + deployUuid: deploy.uuid, + jobId, + shortSha, + }); +} + export async function createKubernetesApplyJob({ deploy, namespace, @@ -36,12 +46,7 @@ export async function createKubernetesApplyJob({ const kc = new k8s.KubeConfig(); kc.loadFromDefault(); const batchApi = kc.makeApiClient(k8s.BatchV1Api); - const shortSha = deploy.sha?.substring(0, 7) || 'unknown'; - const jobName = buildDeployJobName({ - deployUuid: deploy.uuid, - jobId, - shortSha, - }); + const jobName = getKubernetesApplyJobName(deploy, jobId); const serviceName = deploy.deployable?.name || deploy.service?.name || ''; getLogger().info(`Job: creating name=${jobName} service=${serviceName}`); @@ -56,8 +61,7 @@ export async function createKubernetesApplyJob({ name: jobName, namespace, labels: { - lc_uuid: deploy.build.uuid, - deploy_uuid: deploy.uuid, + ...buildLifecycleLabels({ buildUuid: deploy.build.uuid, deployUuid: deploy.uuid }), app: 'lifecycle-deploy', type: 'kubernetes-apply', ...(serviceName ? { service: serviceName } : {}), @@ -74,8 +78,7 @@ export async function createKubernetesApplyJob({ template: { metadata: { labels: { - lc_uuid: deploy.build.uuid, - deploy_uuid: deploy.uuid, + ...buildLifecycleLabels({ buildUuid: deploy.build.uuid, deployUuid: deploy.uuid }), 'job-name': jobName, ...(serviceName ? { service: serviceName } : {}), }, @@ -159,8 +162,7 @@ async function createManifestConfigMap(deploy: Deploy, configMapName: string, na name: configMapName, namespace, labels: { - lc_uuid: deploy.build.uuid, - deploy_uuid: deploy.uuid, + ...buildLifecycleLabels({ buildUuid: deploy.build.uuid, deployUuid: deploy.uuid }), app: 'lifecycle-deploy', }, }, diff --git a/src/server/lib/logger/types.ts b/src/server/lib/logger/types.ts index 936ee18..e7e5c09 100644 --- a/src/server/lib/logger/types.ts +++ b/src/server/lib/logger/types.ts @@ -17,6 +17,7 @@ export interface LogContext { correlationId: string; buildUuid?: string; + newUuid?: string; deployUuid?: string; serviceName?: string; sender?: string; @@ -25,11 +26,15 @@ export interface LogContext { pr?: number; branch?: string; sha?: string; + pipelineId?: string; + webhookName?: string; + webhookType?: string; } export interface JobDataWithContext { correlationId?: string; buildUuid?: string; + newUuid?: string; deployUuid?: string; serviceName?: string; sender?: string; @@ -37,5 +42,8 @@ export interface JobDataWithContext { pr?: number; branch?: string; sha?: string; + pipelineId?: string; + webhookName?: string; + webhookType?: string; _ddTraceContext?: Record; } diff --git a/src/server/lib/metrics/index.ts b/src/server/lib/metrics/index.ts index 1b038e7..ce57119 100644 --- a/src/server/lib/metrics/index.ts +++ b/src/server/lib/metrics/index.ts @@ -74,12 +74,12 @@ export class Metrics { }; public updateEventDetails = (eventDetails: MetricsEvent) => { - this.config.eventDetails = Object.assign(this.config.eventDetails, eventDetails); + this.config.eventDetails = Object.assign({}, this.config.eventDetails, eventDetails); return this; }; public updateConfigTags = (tags) => { - this.config.tags = Object.assign(this.config.tags, tags); + this.config.tags = Object.assign({}, this.config.tags, tags); return this; }; diff --git a/src/server/lib/nativeHelm/helm.ts b/src/server/lib/nativeHelm/helm.ts index 98c61c4..8370f93 100644 --- a/src/server/lib/nativeHelm/helm.ts +++ b/src/server/lib/nativeHelm/helm.ts @@ -17,6 +17,7 @@ import yaml from 'js-yaml'; import fs from 'fs'; import Deploy from 'server/models/Deploy'; +import Deployable from 'server/models/Deployable'; import GlobalConfigService from 'server/services/globalConfig'; import { getLogger, withSpan, withLogContext } from 'server/lib/logger'; import { shellPromise } from 'server/lib/shell'; @@ -40,6 +41,7 @@ import { getHelmConfiguration, generateHelmInstallScript, validateHelmConfiguration, + resolveHelmReleaseConflicts, } from './utils'; import { detectRegistryAuth, RegistryAuthConfig } from './registryAuth'; import { HELM_IMAGE_PREFIX } from './constants'; @@ -61,6 +63,14 @@ export interface JobResult { status: string; } +function requireDeployable(deploy: Deploy): Deployable { + if (!deploy.deployable) { + throw new Error(`Deployable missing for deploy ${deploy.uuid}`); + } + + return deploy.deployable; +} + export async function createHelmContainer( repoName: string, chartPath: string, @@ -122,7 +132,8 @@ export async function generateHelmManifest( await deploy.$fetchGraph('deployable.repository'); await deploy.$fetchGraph('build'); - const { deployable, build } = deploy; + const deployable = requireDeployable(deploy); + const { build } = deploy; const repository = deployable.repository; const helmConfig = await getHelmConfiguration(deploy); @@ -155,7 +166,7 @@ export async function generateHelmManifest( helmConfig.customValues, helmConfig.valuesFiles, helmConfig.chartType, - deploy.deployable.name, + deployable.name, jobName, helmArgs, chartRepoUrl, @@ -185,7 +196,7 @@ export async function generateHelmManifest( name: jobName, namespace: options.namespace, serviceAccount: serviceAccountName, - serviceName: deploy.deployable.name, + serviceName: deployable.name, buildUUID: build.uuid, isStatic: build.isStatic, gitUsername: GIT_USERNAME, @@ -205,11 +216,17 @@ export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOption await deploy.$fetchGraph('build.pullRequest.repository'); await deploy.$fetchGraph('deployable.repository'); + const deployable = requireDeployable(deploy); const jobId = randomAlphanumeric(4).toLowerCase(); const { namespace } = options; + const releaseName = deploy.uuid.toLowerCase(); + + await resolveHelmReleaseConflicts(releaseName, namespace); await ensureServiceAccountForJob(namespace, 'deploy'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const shortSha = deploy.sha ? deploy.sha.substring(0, 7) : 'no-sha'; const jobName = buildDeployJobName({ deployUuid: deploy.uuid, @@ -235,7 +252,7 @@ export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOption { jobName, jobType: 'deploy', - serviceName: deploy.deployable.name, + serviceName: deployable.name, namespace, status: jobResult.success ? 'Complete' : 'Failed', sha: deploy.sha || '', @@ -261,11 +278,16 @@ export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOption } export async function shouldUseNativeHelm(deploy: Deploy): Promise { - if (deploy.deployable.helm?.deploymentMethod) { - return deploy.deployable.helm.deploymentMethod === 'native'; + const deployable = deploy.deployable; + if (!deployable) { + return false; + } + + if (deployable.helm?.deploymentMethod) { + return deployable.helm.deploymentMethod === 'native'; } - if (deploy.deployable.helm?.nativeHelm?.enabled) { + if (deployable.helm?.nativeHelm?.enabled) { return true; } @@ -273,7 +295,8 @@ export async function shouldUseNativeHelm(deploy: Deploy): Promise { } export async function deployNativeHelm(deploy: Deploy): Promise { - const { deployable, build } = deploy; + const deployable = requireDeployable(deploy); + const { build } = deploy; getLogger().info('Helm: deploying method=native'); @@ -327,7 +350,8 @@ export async function deployNativeHelm(deploy: Deploy): Promise { } async function deployCodefreshHelm(deploy: Deploy, deployService: DeployService, runUUID: string): Promise { - const { deployable, build } = deploy; + const deployable = requireDeployable(deploy); + const { build } = deploy; if (deploy?.kedaScaleToZero?.type === 'http' && !build.isStatic) { await applyHttpScaleObjectManifestYaml(deploy, build.namespace); diff --git a/src/server/lib/nativeHelm/utils.ts b/src/server/lib/nativeHelm/utils.ts index 7ea2de1..dc8a40e 100644 --- a/src/server/lib/nativeHelm/utils.ts +++ b/src/server/lib/nativeHelm/utils.ts @@ -29,6 +29,7 @@ import { import { HelmConfigBuilder } from 'server/lib/config/ConfigBuilder'; import { getLogger } from 'server/lib/logger'; import { normalizeKubernetesLabelValue } from 'server/lib/kubernetes/utils'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; export interface HelmDeployOptions { namespace: string; @@ -367,6 +368,7 @@ export async function createNamespacedRoleAndBinding(namespace: string, serviceA name: roleName, namespace: namespace, labels: { + ...buildLifecycleLabels(), 'app.kubernetes.io/name': 'native-helm', 'app.kubernetes.io/component': 'rbac', }, @@ -387,6 +389,7 @@ export async function createNamespacedRoleAndBinding(namespace: string, serviceA name: roleBindingName, namespace: namespace, labels: { + ...buildLifecycleLabels(), 'app.kubernetes.io/name': 'native-helm', 'app.kubernetes.io/component': 'rbac', }, diff --git a/src/server/lib/queueManager.ts b/src/server/lib/queueManager.ts index 6be81f3..88b62bf 100644 --- a/src/server/lib/queueManager.ts +++ b/src/server/lib/queueManager.ts @@ -19,7 +19,9 @@ import { Redis } from 'ioredis'; import { getLogger } from 'server/lib/logger'; interface RegisteredQueue { - queue: Queue; + name: string; + queue: Queue | null; + queueProxy?: Queue; worker?: Worker; } @@ -43,20 +45,59 @@ export default class QueueManager { defaultJobOptions?: QueueOptions['defaultJobOptions']; } ): Queue { - const existing = this.registeredQueues.find((r) => r.queue?.name === queueName); - if (existing && existing.queue) { - return existing.queue; + const existing = this.registeredQueues.find((r) => r.name === queueName); + if (existing?.queueProxy) { + return existing.queueProxy; } - getLogger().debug(`Registering queue: queueName=${queueName}`); + const registered: RegisteredQueue = { + name: queueName, + queue: null, + }; - const queue = new Queue(queueName, { - connection: options.connection.duplicate ? options.connection.duplicate() : options.connection, - defaultJobOptions: options.defaultJobOptions, + const getOrCreateQueue = (): Queue => { + if (registered.queue) { + return registered.queue; + } + + getLogger().debug(`Registering queue: queueName=${queueName}`); + registered.queue = new Queue(queueName, { + connection: options.connection.duplicate ? options.connection.duplicate() : options.connection, + defaultJobOptions: options.defaultJobOptions, + }); + + return registered.queue; + }; + + const queueProxy = new Proxy({} as Queue, { + get(_target, prop) { + if (prop === 'name') { + return queueName; + } + + const queue = getOrCreateQueue(); + const value = Reflect.get(queue, prop, queue); + return typeof value === 'function' ? value.bind(queue) : value; + }, + set(_target, prop, value) { + const queue = getOrCreateQueue(); + return Reflect.set(queue, prop, value, queue); + }, + has(_target, prop) { + const queue = getOrCreateQueue(); + return prop in queue; + }, + ownKeys() { + return Reflect.ownKeys(getOrCreateQueue()); + }, + getOwnPropertyDescriptor(_target, prop) { + return Object.getOwnPropertyDescriptor(getOrCreateQueue(), prop); + }, }); - this.registeredQueues.push({ queue }); - return queue; + registered.queueProxy = queueProxy; + this.registeredQueues.push(registered); + return queueProxy; } public registerWorker( @@ -88,18 +129,18 @@ export default class QueueManager { }); // find queue to associate with worker - const registered = this.registeredQueues.find((r) => r.queue?.name === queueName); + const registered = this.registeredQueues.find((r) => r.name === queueName); if (registered) { registered.worker = worker; } else { - this.registeredQueues.push({ queue: null, worker }); + this.registeredQueues.push({ name: queueName, queue: null, worker }); } return worker; } public getQueues(): Queue[] { - return this.registeredQueues.map((r) => r.queue).filter(Boolean); + return this.registeredQueues.map((r) => r.queue).filter((queue): queue is Queue => queue != null); } public async emptyAndCloseAllQueues(): Promise { diff --git a/src/server/lib/redisClient.ts b/src/server/lib/redisClient.ts index 13448b8..10ecb02 100644 --- a/src/server/lib/redisClient.ts +++ b/src/server/lib/redisClient.ts @@ -32,6 +32,7 @@ export class RedisClient { const redisConfig: any = { host: APP_REDIS_HOST, port: APP_REDIS_PORT ? parseInt(APP_REDIS_PORT, 10) : 6379, + lazyConnect: true, }; if (APP_REDIS_PASSWORD) { @@ -46,7 +47,7 @@ export class RedisClient { this.redis = new Redis(redisConfig); } else if (REDIS_URL) { - this.redis = new Redis(REDIS_URL); + this.redis = new Redis(REDIS_URL, { lazyConnect: true }); } else { throw new Error( 'Redis configuration not found. Please provide either REDIS_URL or individual APP_REDIS_* environment variables.' diff --git a/src/server/lib/response.ts b/src/server/lib/response.ts index 30b3c6d..57c7e6b 100644 --- a/src/server/lib/response.ts +++ b/src/server/lib/response.ts @@ -24,7 +24,7 @@ interface Metadata { type SuccessStatusCode = 200 | 201; -type ErrorStatusCode = 400 | 401 | 404 | 500 | 502; +type ErrorStatusCode = 400 | 401 | 404 | 409 | 500 | 502; interface SuccessResponse { request_id: string; diff --git a/src/server/lib/tests/envVariables.test.ts b/src/server/lib/tests/envVariables.test.ts index f3b2919..21f56aa 100644 --- a/src/server/lib/tests/envVariables.test.ts +++ b/src/server/lib/tests/envVariables.test.ts @@ -19,7 +19,6 @@ import { EnvironmentVariables } from '../envVariables'; import GlobalConfigService from 'server/services/globalConfig'; import { IServices } from 'server/services/types'; import * as models from 'server/models'; -import { QueryBuilder } from 'objection'; jest.mock('server/database'); jest.mock('redlock', () => { @@ -78,10 +77,30 @@ describe('EnvironmentVariables library', () => { const db = new Database(); const globalConfigService = GlobalConfigService.getInstance(); const buildService = { getNamespace: jest.fn().mockResolvedValue('testns') }; + const defaultLifecycleConfig = { + lifecycleDefaults: { + defaultUUID: 'dev-0', + defaultPublicUrl: 'dev-0.lifecycle.dev.example.com', + }, + }; db.services = { GlobalConfig: globalConfigService, BuildService: buildService } as unknown as IServices; db.models = models; const envVariables = new TestEnvironmentVariables(db); + + beforeEach(() => { + jest.spyOn(globalConfigService, 'getAllConfigs').mockResolvedValue(defaultLifecycleConfig as any); + jest.spyOn(models.Build, 'query').mockReturnValue({ + findOne: jest.fn().mockReturnValue({ + select: jest.fn().mockResolvedValue({ namespace: 'testns' }), + }), + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + test('custom render uses global config default uuid', async () => { const template = JSON.stringify({ VAR_1: '{{test______something_internalHostname}}', @@ -96,16 +115,6 @@ describe('EnvironmentVariables library', () => { test______something_internalPort: '1234', }; - const queryResult = { - id: 2, - key: 'defaultUUID', - value: 'dev-0', - }; - - const globalConfigQueryBuilder = QueryBuilder.forClass(models.GlobalConfig); - globalConfigQueryBuilder.where = jest.fn().mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - jest.spyOn(models.GlobalConfig, 'query').mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - const result = { VAR_1: 'test.testns.svc.cluster.local', VAR_2: '1234', @@ -133,16 +142,6 @@ describe('EnvironmentVariables library', () => { test______something_internalPort: '1234', }; - const queryResult = { - id: 2, - key: 'defaultPublicUrl', - value: 'dev-0.lifecycle.dev.example.com', - }; - - const globalConfigQueryBuilder = QueryBuilder.forClass(models.GlobalConfig); - globalConfigQueryBuilder.where = jest.fn().mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - jest.spyOn(models.GlobalConfig, 'query').mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - const result = { VAR_1: 'test', VAR_2: '1234', @@ -249,15 +248,6 @@ describe('EnvironmentVariables library', () => { unrelated_var: 'value', }; - const queryResult = { - id: 2, - key: 'defaultUUID', - value: 'dev-0', - }; - const globalConfigQueryBuilder = QueryBuilder.forClass(models.GlobalConfig); - globalConfigQueryBuilder.where = jest.fn().mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - jest.spyOn(models.GlobalConfig, 'query').mockImplementation(() => globalConfigQueryBuilder.resolve(queryResult)); - const result = { VAR_1: 'dev-0', VAR_2: 'dev-0', diff --git a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts index 2e8637a..983243a 100644 --- a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts +++ b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts @@ -207,6 +207,20 @@ const schema_1_0_0 = { }, required: ['defaultTag', 'branchName'], }, + dev: { + type: 'object', + additionalProperties: false, + properties: { + image: { type: 'string' }, + command: { type: 'string' }, + installCommand: { type: 'string' }, + workDir: { type: 'string' }, + ports: { type: 'array', items: { type: 'number' } }, + env: { type: 'object' }, + forwardEnvVarsToAgent: { type: 'array', items: { type: 'string' } }, + }, + required: ['image', 'command'], + }, }, required: ['name'], }, diff --git a/src/server/models/AgentSession.ts b/src/server/models/AgentSession.ts new file mode 100644 index 0000000..77fb2fc --- /dev/null +++ b/src/server/models/AgentSession.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Model from './_Model'; +import type { DevModeResourceSnapshot } from 'server/lib/agentSession/devModeManager'; +import { BuildKind } from 'shared/constants'; + +export default class AgentSession extends Model { + uuid!: string; + buildUuid!: string | null; + buildKind!: BuildKind; + userId!: string; + ownerGithubUsername!: string | null; + podName!: string; + namespace!: string; + pvcName!: string; + model!: string; + status!: 'starting' | 'active' | 'ended' | 'error'; + lastActivity!: string; + endedAt!: string | null; + devModeSnapshots!: Record; + forwardedAgentSecretProviders!: string[]; + + static tableName = 'agent_sessions'; + static timestamps = true; + static idColumn = 'id'; + + static jsonSchema = { + type: 'object', + required: ['uuid', 'userId', 'podName', 'namespace', 'pvcName', 'model', 'buildKind'], + properties: { + id: { type: 'integer' }, + uuid: { + type: 'string', + pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + }, + buildUuid: { type: ['string', 'null'] }, + buildKind: { type: 'string', enum: Object.values(BuildKind), default: BuildKind.ENVIRONMENT }, + userId: { type: 'string' }, + ownerGithubUsername: { type: ['string', 'null'] }, + podName: { type: 'string' }, + namespace: { type: 'string' }, + pvcName: { type: 'string' }, + model: { type: 'string' }, + status: { type: 'string', enum: ['starting', 'active', 'ended', 'error'], default: 'starting' }, + lastActivity: { type: 'string' }, + endedAt: { type: ['string', 'null'] }, + devModeSnapshots: { type: 'object', default: {} }, + forwardedAgentSecretProviders: { type: 'array', items: { type: 'string' }, default: [] }, + }, + }; + + static get jsonAttributes() { + return ['devModeSnapshots', 'forwardedAgentSecretProviders']; + } + + static get relationMappings() { + const Deploy = require('./Deploy').default; + return { + deploys: { + relation: Model.HasManyRelation, + modelClass: Deploy, + join: { + from: 'agent_sessions.id', + to: 'deploys.devModeSessionId', + }, + }, + }; + } +} diff --git a/src/server/models/Build.ts b/src/server/models/Build.ts index 1cd71db..4ed6370 100644 --- a/src/server/models/Build.ts +++ b/src/server/models/Build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DeployStatus } from 'shared/constants'; +import { BuildKind, DeployStatus } from 'shared/constants'; import { BuildServiceOverride, Deploy, Deployable, Environment, PullRequest, Service } from '.'; import Model from './_Model'; @@ -23,11 +23,14 @@ export default class Build extends Model { status!: string; statusMessage!: string; manifest!: string; + kind!: BuildKind; sha?: string; environmentId: number; + baseBuildId?: number | null; environment?: Environment; + baseBuild?: Build; deploys?: Deploy[]; services?: Service[]; buildServiceOverrides?: BuildServiceOverride[]; @@ -73,6 +76,10 @@ export default class Build extends Model { type: 'string', default: DeployStatus.QUEUED, }, + kind: { + type: 'string', + default: BuildKind.ENVIRONMENT, + }, name: { type: 'string', }, @@ -88,6 +95,14 @@ export default class Build extends Model { to: 'environments.id', }, }, + baseBuild: { + relation: Model.BelongsToOneRelation, + modelClass: () => Build, + join: { + from: 'builds.baseBuildId', + to: 'builds.id', + }, + }, services: { relation: Model.ManyToManyRelation, modelClass: () => Service, diff --git a/src/server/models/Deploy.ts b/src/server/models/Deploy.ts index 27c15fc..cc2cf42 100644 --- a/src/server/models/Deploy.ts +++ b/src/server/models/Deploy.ts @@ -66,6 +66,8 @@ export default class Deploy extends Model { deployOutput: string; buildJobName: string; manifest: string; + devMode: boolean; + devModeSessionId: number | null; static tableName = 'deploys'; static timestamps = true; @@ -80,40 +82,51 @@ export default class Deploy extends Model { }, }; - static relationMappings = { - service: { - relation: Model.BelongsToOneRelation, - modelClass: Service, - join: { - from: 'deploys.serviceId', - to: 'services.id', + static get relationMappings() { + const AgentSession = require('./AgentSession').default; + return { + service: { + relation: Model.BelongsToOneRelation, + modelClass: Service, + join: { + from: 'deploys.serviceId', + to: 'services.id', + }, }, - }, - build: { - relation: Model.BelongsToOneRelation, - modelClass: () => Build, - join: { - from: 'deploys.buildId', - to: 'builds.id', + build: { + relation: Model.BelongsToOneRelation, + modelClass: () => Build, + join: { + from: 'deploys.buildId', + to: 'builds.id', + }, }, - }, - repository: { - relation: Model.BelongsToOneRelation, - modelClass: () => Repository, - join: { - from: 'deploys.githubRepositoryId', - to: 'repositories.githubRepositoryId', + repository: { + relation: Model.BelongsToOneRelation, + modelClass: () => Repository, + join: { + from: 'deploys.githubRepositoryId', + to: 'repositories.githubRepositoryId', + }, }, - }, - deployable: { - relation: Model.BelongsToOneRelation, - modelClass: () => Deployable, - join: { - from: 'deploys.deployableId', - to: 'deployables.id', + deployable: { + relation: Model.BelongsToOneRelation, + modelClass: () => Deployable, + join: { + from: 'deploys.deployableId', + to: 'deployables.id', + }, }, - }, - }; + agentSession: { + relation: Model.BelongsToOneRelation, + modelClass: AgentSession, + join: { + from: 'deploys.devModeSessionId', + to: 'agent_sessions.id', + }, + }, + }; + } static get jsonAttributes() { return ['env', 'initEnv']; diff --git a/src/server/models/Service.ts b/src/server/models/Service.ts index 5ac33a8..cfab248 100644 --- a/src/server/models/Service.ts +++ b/src/server/models/Service.ts @@ -29,7 +29,7 @@ export default class Service extends Model { // Dockerfile path specifically for the init container. initDockerfilePath: string; branchName: string; - repositoryId: string; + repositoryId: number; defaultTag: string; port: string; env!: Record; @@ -63,6 +63,8 @@ export default class Service extends Model { readinessTcpSocketPort: number; readinessHttpGetPath: string; readinessHttpGetPort: number; + livenessProbe: string; + readinessProbe: string; serviceDisks: ServiceDisk[]; diff --git a/src/server/models/UserApiKey.ts b/src/server/models/UserApiKey.ts new file mode 100644 index 0000000..01986a4 --- /dev/null +++ b/src/server/models/UserApiKey.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Model from './_Model'; + +export default class UserApiKey extends Model { + userId!: string; + ownerGithubUsername!: string; + provider!: string; + encryptedKey!: string; + + static tableName = 'user_api_keys'; + static timestamps = true; + + static jsonSchema = { + type: 'object', + required: ['userId', 'ownerGithubUsername', 'provider', 'encryptedKey'], + properties: { + id: { type: 'integer' }, + userId: { type: 'string' }, + ownerGithubUsername: { type: 'string' }, + provider: { type: 'string' }, + encryptedKey: { type: 'string' }, + }, + }; +} diff --git a/src/server/models/WebhookInvocations.ts b/src/server/models/WebhookInvocations.ts index f63d0db..31ac4f2 100644 --- a/src/server/models/WebhookInvocations.ts +++ b/src/server/models/WebhookInvocations.ts @@ -26,7 +26,7 @@ export default class WebhookInvocations extends Model { state!: string; yamlConfig!: string; owner!: string; // could be enum? - metadata: string; + metadata!: Record; status!: string; static tableName = 'webhook_invocations'; @@ -56,4 +56,8 @@ export default class WebhookInvocations extends Model { }, }, }; + + static get jsonAttributes() { + return ['metadata']; + } } diff --git a/src/server/models/_Model.ts b/src/server/models/_Model.ts index 7adf468..a1ef8a9 100644 --- a/src/server/models/_Model.ts +++ b/src/server/models/_Model.ts @@ -16,15 +16,7 @@ /* eslint-disable no-unused-vars */ import { omit, pick } from 'lodash'; -import objection, { - Model as ObjectionModel, - ModelOptions, - Pojo, - QueryBuilder, - QueryContext, - QueryInterface, - Transaction, -} from 'objection'; +import objection, { Model as ObjectionModel, ModelOptions, Pojo, QueryContext, Transaction } from 'objection'; import { getUtcTimestamp } from '../lib/time'; type Constructor = (new (...args: any[]) => M) & typeof Model; @@ -33,7 +25,7 @@ interface IFindOptions { required?: boolean; eager?: string; eagerOpts?: Pojo; - modify?: (qb: QueryBuilder) => QueryInterface; + modify?: (qb: any) => unknown; cache?: boolean; } @@ -67,7 +59,7 @@ export default class Model extends ObjectionModel { static pickJsonSchemaProperties = false; - static find(this: Constructor, scope?: Pojo | null, options: IFindOptions = {}): QueryBuilder { + static find(this: Constructor, scope?: Pojo | null, options: IFindOptions = {}): any { const { eager, eagerOpts = {}, modify } = options; const query = this.query(); @@ -94,7 +86,7 @@ export default class Model extends ObjectionModel { if (options.required && !record) { throw Error(`${this.name} could not be found: ${JSON.stringify(query)}`); } - return record; + return record as T; }); } @@ -102,7 +94,7 @@ export default class Model extends ObjectionModel { let records; let offset = 0; - const modify = (qb: QueryBuilder) => { + const modify = (qb: any) => { if (options.modify) { options.modify(qb); } @@ -128,7 +120,7 @@ export default class Model extends ObjectionModel { } static async create(this: Constructor, attributes: object, trx?: Transaction): Promise { - return this.query(trx).insert(attributes); + return this.query(trx).insert(attributes) as unknown as Promise; } static async upsert(data: Pojo, unique = ['id'], trx?: Transaction) { diff --git a/src/server/models/index.ts b/src/server/models/index.ts index 34da627..6d00684 100644 --- a/src/server/models/index.ts +++ b/src/server/models/index.ts @@ -32,6 +32,8 @@ import Conversation from './Conversation'; import ConversationMessage from './ConversationMessage'; import MessageFeedback from './MessageFeedback'; import ConversationFeedback from './ConversationFeedback'; +import AgentSession from './AgentSession'; +import UserApiKey from './UserApiKey'; export interface IModels { Build: typeof Build; @@ -52,6 +54,8 @@ export interface IModels { ConversationMessage: typeof ConversationMessage; MessageFeedback: typeof MessageFeedback; ConversationFeedback: typeof ConversationFeedback; + AgentSession: typeof AgentSession; + UserApiKey: typeof UserApiKey; } export { @@ -73,4 +77,6 @@ export { ConversationMessage, MessageFeedback, ConversationFeedback, + AgentSession, + UserApiKey, }; diff --git a/src/server/models/yaml/YamlService.ts b/src/server/models/yaml/YamlService.ts index 1616f9e..d586c9a 100644 --- a/src/server/models/yaml/YamlService.ts +++ b/src/server/models/yaml/YamlService.ts @@ -28,6 +28,16 @@ export interface Service001 { }; } +export interface DevConfig { + image: string; + command: string; + installCommand?: string; + workDir?: string; + ports?: number[]; + env?: Record; + forwardEnvVarsToAgent?: string[]; +} + export interface Service { readonly name: string; appShort?: string; @@ -35,6 +45,7 @@ export interface Service { readonly requires?: DependencyService[]; readonly deploymentDependsOn?: string[]; readonly kedaScaleToZero?: KedaScaleToZero; + readonly dev?: DevConfig; } export interface KedaScaleToZero { @@ -390,6 +401,22 @@ export function getDeployType(service: Service): DeployTypes { return result; } +/** + * Returns true when Lifecycle owns the service image build inputs. + * This is narrower than "participates in build flow" because some services + * only reference externally managed images and are not safe dev-mode targets. + */ +export function hasLifecycleManagedDockerBuild(service: Service): boolean { + switch (getDeployType(service)) { + case DeployTypes.GITHUB: + return !!(service as GithubService)?.github?.docker?.app?.dockerfilePath; + case DeployTypes.HELM: + return !!(service as HelmService)?.helm?.docker?.app?.dockerfilePath; + default: + return false; + } +} + /** * Helper function to quickly retrieve the environment variables from varies Lifecycle service type (because they are in different locations within the JSON structure). * @param service Valid Lifecycle Service YAML model diff --git a/src/server/models/yaml/tests/YamlService.test.ts b/src/server/models/yaml/tests/YamlService.test.ts index 63c9191..a159648 100644 --- a/src/server/models/yaml/tests/YamlService.test.ts +++ b/src/server/models/yaml/tests/YamlService.test.ts @@ -104,6 +104,33 @@ describe('Yaml Service', () => { branchName: 'main' `; + describe('validation', () => { + test('accepts forwardEnvVarsToAgent in dev config', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +services: + - name: 'agent-app' + dev: + image: 'node:20-slim' + command: 'pnpm dev' + installCommand: 'pnpm install --frozen-lockfile' + forwardEnvVarsToAgent: + - PRIVATE_REGISTRY_TOKEN + - TURBO_TOKEN + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'apps/agent-app/Dockerfile' +`); + + expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); + }); + }); + describe('isGithubService', () => { test('GithubService', () => { const parser = new YamlConfigParser(); @@ -262,6 +289,70 @@ describe('Yaml Service', () => { }); }); + describe('hasLifecycleManagedDockerBuild', () => { + test('returns true for github services with docker config', () => { + const parser = new YamlConfigParser(); + const config: YamlService.LifecycleConfig = parser.parseYamlConfigFromString(lifecycleConfigContent); + const service: YamlService.Service = YamlService.getDeployingServicesByName(config, 'githubApp'); + + expect(YamlService.hasLifecycleManagedDockerBuild(service)).toEqual(true); + }); + + test('returns true for helm services with docker config', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +services: + - name: 'grpc-echo' + dev: + image: 'repo/app:dev' + command: 'npm run dev' + helm: + repository: 'org/example' + branchName: 'main' + chart: + name: './helm/app' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'grpc-echo/Dockerfile' +`); + + const service: YamlService.Service = YamlService.getDeployingServicesByName(config, 'grpc-echo'); + + expect(YamlService.hasLifecycleManagedDockerBuild(service)).toEqual(true); + }); + + test('returns false for helm services without docker config', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +services: + - name: 'redis' + dev: + image: 'repo/redis:dev' + command: 'redis-server' + helm: + repository: 'org/example' + branchName: 'main' + chart: + name: 'redis' +`); + + const service: YamlService.Service = YamlService.getDeployingServicesByName(config, 'redis'); + + expect(YamlService.hasLifecycleManagedDockerBuild(service)).toEqual(false); + }); + + test('returns false for docker services that only reference external images', () => { + const parser = new YamlConfigParser(); + const config: YamlService.LifecycleConfig = parser.parseYamlConfigFromString(lifecycleConfigContent); + const service: YamlService.Service = YamlService.getDeployingServicesByName(config, 'dockerApp'); + + expect(YamlService.hasLifecycleManagedDockerBuild(service)).toEqual(false); + }); + }); + describe('getEnvironmentVariables', () => { test('GithubService', () => { const parser = new YamlConfigParser(); diff --git a/src/server/services/__tests__/agentSandboxSession.test.ts b/src/server/services/__tests__/agentSandboxSession.test.ts new file mode 100644 index 0000000..3d9ae13 --- /dev/null +++ b/src/server/services/__tests__/agentSandboxSession.test.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/lib/dependencies', () => ({ + defaultDb: {}, + defaultRedis: {}, + defaultRedlock: {}, + defaultQueueManager: {}, +})); + +jest.mock('../build', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../deploy', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + hostForDeployableDeploy: jest.fn(), + })), +})); + +jest.mock('../agentSession', () => ({ + __esModule: true, + default: { + createSession: jest.fn(), + }, +})); + +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + warn: jest.fn(), + })), +})); + +jest.mock('server/models/yaml', () => ({ + fetchLifecycleConfig: jest.fn(), + getDeployingServicesByName: jest.fn(), +})); + +import AgentSandboxSessionService from '../agentSandboxSession'; +import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml'; + +describe('agentSandboxSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps repository identity when resolving duplicate dependency names', async () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + const baseBuild = { + uuid: 'base-build', + deploys: [ + { + id: 1, + active: true, + deployable: { name: 'frontend' }, + repository: { fullName: 'org/frontend' }, + }, + { + id: 2, + active: true, + deployable: { name: 'shared-api' }, + repository: { fullName: 'org/api-a' }, + }, + { + id: 3, + active: true, + deployable: { name: 'shared-api' }, + repository: { fullName: 'org/api-b' }, + }, + ], + } as any; + const selectedService = { + name: 'frontend', + devConfig: { image: 'node:20', command: 'pnpm dev' }, + baseDeploy: baseBuild.deploys[0], + serviceRepo: 'org/frontend', + serviceBranch: 'main', + yamlService: { + name: 'frontend', + requires: [{ name: 'shared-api', repository: 'org/api-b' }], + }, + } as any; + + (fetchLifecycleConfig as jest.Mock).mockResolvedValue({}); + (getDeployingServicesByName as jest.Mock).mockReturnValue({ + name: 'shared-api', + requires: [], + }); + + const includedKeys = await (service as any).resolveDependencyClosure(baseBuild, selectedService, { + repo: 'env/static-environments', + branch: 'main', + }); + + expect([...includedKeys]).toEqual(expect.arrayContaining(['org/frontend::frontend', 'org/api-b::shared-api'])); + expect(includedKeys.has('org/api-a::shared-api')).toBe(false); + }); + + it('fails closed when multiple top-level sandbox candidates share the same name', () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + + expect(() => + (service as any).resolveSelectedService('shared-api', [ + { name: 'shared-api', serviceRepo: 'org/api-a' }, + { name: 'shared-api', serviceRepo: 'org/api-b' }, + ]) + ).toThrow('Multiple sandbox services matched shared-api'); + }); +}); diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts new file mode 100644 index 0000000..7dc9a30 --- /dev/null +++ b/src/server/services/__tests__/agentSession.test.ts @@ -0,0 +1,1083 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; + +mockRedisClient(); + +jest.mock('server/models/AgentSession'); +jest.mock('server/models/Build'); +jest.mock('server/models/Deploy'); +jest.mock('server/lib/dependencies', () => ({})); +jest.mock('server/lib/agentSession/pvcFactory'); +jest.mock('server/lib/agentSession/apiKeySecretFactory'); +jest.mock('server/lib/agentSession/podFactory'); +jest.mock('server/lib/agentSession/editorServiceFactory'); +jest.mock('server/lib/agentSession/gvisorCheck'); +jest.mock('server/lib/agentSession/configSeeder'); +jest.mock('server/lib/agentSession/devModeManager'); +jest.mock('server/lib/agentSession/forwardedEnv'); +jest.mock('server/lib/kubernetes/networkPolicyFactory'); +jest.mock('server/services/userApiKey'); +jest.mock('server/lib/nativeHelm/helm', () => ({ + deployHelm: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('server/lib/deploymentManager/deploymentManager', () => ({ + DeploymentManager: jest.fn().mockImplementation(() => ({ + deploy: jest.fn().mockResolvedValue(undefined), + })), +})); +jest.mock('uuid', () => ({ v4: () => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' })); + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + createNamespacedNetworkPolicy: jest.fn().mockResolvedValue({}), + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + })), + withLogContext: jest.fn((_ctx, fn) => fn()), + withSpan: jest.fn((_name, fn) => fn()), + extractContextForQueue: jest.fn(() => ({})), + LogStage: {}, +})); + +jest.mock('server/services/build', () => { + const deleteQueueAdd = jest.fn().mockResolvedValue(undefined); + const deleteBuild = jest.fn().mockResolvedValue(undefined); + + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + deleteQueue: { + add: deleteQueueAdd, + }, + deleteBuild, + })), + __mocked: { + deleteQueueAdd, + deleteBuild, + }, + }; +}); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({ + getAllConfigs: jest.fn().mockResolvedValue({}), + getOrgChartName: jest.fn().mockResolvedValue('org-chart'), + getGithubAppName: jest.fn().mockResolvedValue('sample-lifecycle-app'), + })), + }, +})); + +import AgentSessionService, { CreateSessionOptions } from 'server/services/agentSession'; +import AgentSession from 'server/models/AgentSession'; +import Build from 'server/models/Build'; +import Deploy from 'server/models/Deploy'; +import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFactory'; +import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; +import { createAgentPod, deleteAgentPod } from 'server/lib/agentSession/podFactory'; +import { createAgentEditorService, deleteAgentEditorService } from 'server/lib/agentSession/editorServiceFactory'; +import { isGvisorAvailable } from 'server/lib/agentSession/gvisorCheck'; +import { DevModeManager } from 'server/lib/agentSession/devModeManager'; +import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; +import UserApiKeyService from 'server/services/userApiKey'; +import RedisClient from 'server/lib/redisClient'; +import { deployHelm } from 'server/lib/nativeHelm/helm'; +import { DeploymentManager } from 'server/lib/deploymentManager/deploymentManager'; +import BuildServiceModule from 'server/services/build'; + +const mockRedis = { + setex: jest.fn().mockResolvedValue('OK'), + get: jest.fn().mockResolvedValue(null), + del: jest.fn().mockResolvedValue(1), +}; + +const mockedBuildServiceModule = jest.requireMock('server/services/build').__mocked as { + deleteQueueAdd: jest.Mock; + deleteBuild: jest.Mock; +}; + +jest.spyOn(RedisClient, 'getInstance').mockReturnValue({ + getRedis: () => mockRedis as any, + getRedlock: () => ({} as any), + getConnection: () => ({} as any), + close: jest.fn(), +} as any); + +const mockEnableDevMode = jest.fn().mockResolvedValue({ + deployment: { + deploymentName: 'service', + containerName: 'service', + image: 'node:20', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: null, +}); +const mockDisableDevMode = jest.fn().mockResolvedValue(undefined); +(DevModeManager as jest.Mock).mockImplementation(() => ({ + enableDevMode: mockEnableDevMode, + disableDevMode: mockDisableDevMode, +})); + +(isGvisorAvailable as jest.Mock).mockResolvedValue(false); +(createAgentPvc as jest.Mock).mockResolvedValue({}); +(createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); +(createAgentPod as jest.Mock).mockResolvedValue({}); +(createAgentEditorService as jest.Mock).mockResolvedValue({}); +(deleteAgentPod as jest.Mock).mockResolvedValue(undefined); +(deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); +(deleteAgentApiKeySecret as jest.Mock).mockResolvedValue(undefined); +(deleteAgentEditorService as jest.Mock).mockResolvedValue(undefined); +(deployHelm as jest.Mock).mockResolvedValue(undefined); + +const mockSessionQuery = { + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + first: jest.fn(), + findOne: jest.fn(), + select: jest.fn(), + findById: jest.fn().mockReturnThis(), + patch: jest.fn().mockResolvedValue(1), + insert: jest.fn().mockResolvedValue({}), + insertAndFetch: jest.fn(), +}; +(AgentSession.query as jest.Mock) = jest.fn().mockReturnValue(mockSessionQuery); + +const mockDeployQuery = { + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + findById: jest.fn().mockReturnThis(), + patch: jest.fn().mockResolvedValue(1), + withGraphFetched: jest.fn().mockResolvedValue([]), +}; +(Deploy.query as jest.Mock) = jest.fn().mockReturnValue(mockDeployQuery); + +const baseOpts: CreateSessionOptions = { + userId: 'user-123', + namespace: 'test-ns', + repoUrl: 'https://github.com/org/repo.git', + branch: 'feature/test', + agentImage: 'lifecycle-agent:latest', + editorImage: 'codercom/code-server:4.98.2', +}; + +describe('AgentSessionService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue(mockSessionQuery); + (Deploy.query as jest.Mock) = jest.fn().mockReturnValue(mockDeployQuery); + mockSessionQuery.where.mockReturnThis(); + mockSessionQuery.whereIn.mockReturnThis(); + mockSessionQuery.orderBy.mockReturnThis(); + mockSessionQuery.first.mockResolvedValue(null); + mockSessionQuery.findOne.mockResolvedValue(null); + mockSessionQuery.select.mockResolvedValue({ id: 123 }); + mockSessionQuery.findById.mockReturnThis(); + mockSessionQuery.patch.mockResolvedValue(1); + mockSessionQuery.insert.mockResolvedValue({}); + mockSessionQuery.insertAndFetch.mockResolvedValue({ + id: 123, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + userId: 'user-123', + ownerGithubUsername: null, + podName: 'agent-aaaaaaaa', + namespace: 'test-ns', + pvcName: 'agent-pvc-aaaaaaaa', + model: 'claude-sonnet-4-6', + buildKind: 'environment', + status: 'starting', + devModeSnapshots: {}, + forwardedAgentSecretProviders: [], + }); + mockDeployQuery.where.mockReturnThis(); + mockDeployQuery.whereIn.mockReturnThis(); + mockDeployQuery.findById.mockReturnThis(); + mockDeployQuery.patch.mockResolvedValue(1); + mockDeployQuery.withGraphFetched.mockResolvedValue([]); + (UserApiKeyService.getDecryptedKey as jest.Mock) = jest.fn().mockResolvedValue('sk-ant-test-key'); + + jest.spyOn(RedisClient, 'getInstance').mockReturnValue({ + getRedis: () => mockRedis as any, + getRedlock: () => ({} as any), + getConnection: () => ({} as any), + close: jest.fn(), + } as any); + + mockRedis.setex.mockResolvedValue('OK'); + mockRedis.get.mockResolvedValue(null); + mockRedis.del.mockResolvedValue(1); + mockEnableDevMode.mockResolvedValue({ + deployment: { + deploymentName: 'service', + containerName: 'service', + image: 'node:20', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: null, + }); + mockDisableDevMode.mockResolvedValue(undefined); + (isGvisorAvailable as jest.Mock).mockResolvedValue(false); + (createAgentPvc as jest.Mock).mockResolvedValue({}); + (createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); + (createAgentPod as jest.Mock).mockResolvedValue({}); + (createAgentEditorService as jest.Mock).mockResolvedValue({}); + (deleteAgentPod as jest.Mock).mockResolvedValue(undefined); + (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); + (deleteAgentApiKeySecret as jest.Mock).mockResolvedValue(undefined); + (deleteAgentEditorService as jest.Mock).mockResolvedValue(undefined); + (deployHelm as jest.Mock).mockResolvedValue(undefined); + (DeploymentManager as jest.Mock).mockImplementation(() => ({ + deploy: jest.fn().mockResolvedValue(undefined), + })); + mockedBuildServiceModule.deleteQueueAdd.mockResolvedValue(undefined); + mockedBuildServiceModule.deleteBuild.mockResolvedValue(undefined); + (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ + env: {}, + secretRefs: [], + secretProviders: [], + secretServiceName: 'agent-env-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + }); + (cleanupForwardedAgentEnvSecrets as jest.Mock).mockResolvedValue(undefined); + }); + + describe('createSession', () => { + it('throws an active environment session error when another user already owns the environment session', async () => { + const conflictingOpts: CreateSessionOptions = { + ...baseOpts, + buildUuid: 'build-123', + }; + + const insertAndFetch = jest.fn().mockRejectedValue({ + code: '23505', + constraint: 'agent_sessions_active_environment_build_unique', + }); + const activeSessionQuery = { + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue({ + id: 77, + uuid: 'sess-existing', + userId: 'other-user', + ownerGithubUsername: 'other-dev', + status: 'active', + }), + }; + + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce({ + insertAndFetch, + }) + .mockReturnValueOnce(activeSessionQuery); + + await expect(AgentSessionService.createSession(conflictingOpts)).rejects.toThrow( + 'An active environment session is already running for this environment by other-dev. Fork the environment into a sandbox instead.' + ); + }); + + it('throws API_KEY_REQUIRED when no API key found', async () => { + (UserApiKeyService.getDecryptedKey as jest.Mock).mockResolvedValue(null); + + await expect(AgentSessionService.createSession(baseOpts)).rejects.toThrow('API_KEY_REQUIRED'); + }); + + it('creates PVC, pod, network policy, and session record', async () => { + const session = await AgentSessionService.createSession(baseOpts); + + expect(createAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa', '10Gi', undefined); + expect(createAgentApiKeySecret).toHaveBeenCalledWith( + 'test-ns', + 'agent-secret-aaaaaaaa', + 'sk-ant-test-key', + undefined, + undefined + ); + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + podName: 'agent-aaaaaaaa', + namespace: 'test-ns', + pvcName: 'agent-pvc-aaaaaaaa', + image: 'lifecycle-agent:latest', + apiKeySecretName: 'agent-secret-aaaaaaaa', + hasGitHubToken: false, + model: 'claude-sonnet-4-6', + claudePermissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + claudeCommitAttribution: 'Generated with (sample-lifecycle-app)', + claudePrAttribution: 'Generated with (sample-lifecycle-app)', + }) + ); + expect(createAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa', undefined); + expect(mockSessionQuery.insertAndFetch).toHaveBeenCalledWith( + expect.objectContaining({ + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + userId: 'user-123', + ownerGithubUsername: null, + buildKind: 'environment', + status: 'starting', + devModeSnapshots: {}, + }) + ); + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'active', + }) + ); + expect(mockRedis.setex).toHaveBeenCalledWith( + 'lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 7200, + expect.any(String) + ); + expect(session.status).toBe('active'); + }); + + it('passes forwarded service env through to the agent pod when configured', async () => { + (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ + env: { PRIVATE_REGISTRY_TOKEN: 'plain-token' }, + secretRefs: [], + secretProviders: [], + secretServiceName: 'agent-env-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + }); + + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + services: [ + { + name: 'web', + deployId: 1, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_REGISTRY_TOKEN'], + }, + }, + ], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + forwardedAgentEnv: { PRIVATE_REGISTRY_TOKEN: 'plain-token' }, + forwardedAgentSecretRefs: [], + forwardedAgentSecretServiceName: 'agent-env-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + }) + ); + }); + + it('passes user identity through to pod creation when provided', async () => { + const optsWithIdentity: CreateSessionOptions = { + ...baseOpts, + userIdentity: { + userId: 'user-123', + githubUsername: 'sample-user', + preferredUsername: 'sample-user', + email: 'sample-user@example.com', + firstName: 'Sample', + lastName: 'User', + displayName: 'Sample User', + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + }, + }; + + await AgentSessionService.createSession(optsWithIdentity); + + expect(mockSessionQuery.insertAndFetch).toHaveBeenCalledWith( + expect.objectContaining({ + ownerGithubUsername: 'sample-user', + }) + ); + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + userIdentity: expect.objectContaining({ + githubUsername: 'sample-user', + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + }), + }) + ); + }); + + it('writes the GitHub token into the per-session secret when provided', async () => { + const optsWithGitHubToken: CreateSessionOptions = { + ...baseOpts, + githubToken: 'gho_test_token', + }; + + await AgentSessionService.createSession(optsWithGitHubToken); + + expect(createAgentApiKeySecret).toHaveBeenCalledWith( + 'test-ns', + 'agent-secret-aaaaaaaa', + 'sk-ant-test-key', + 'gho_test_token', + undefined + ); + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + hasGitHubToken: true, + }) + ); + }); + + it('enables dev mode for each specified service', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + services: [ + { + name: 'web', + deployId: 1, + resourceName: 'web-build-uuid', + devConfig: { image: 'node:20', command: 'pnpm dev' }, + }, + { + name: 'api', + deployId: 2, + resourceName: 'api-build-uuid', + devConfig: { image: 'node:20', command: 'pnpm start' }, + }, + ], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(mockEnableDevMode).toHaveBeenCalledTimes(2); + expect(mockEnableDevMode).toHaveBeenCalledWith( + expect.objectContaining({ + deploymentName: 'web-build-uuid', + serviceName: 'web-build-uuid', + namespace: 'test-ns', + }) + ); + expect(mockEnableDevMode).toHaveBeenCalledWith( + expect.objectContaining({ + deploymentName: 'api-build-uuid', + serviceName: 'api-build-uuid', + namespace: 'test-ns', + }) + ); + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ + devModeSnapshots: expect.objectContaining({ + '1': expect.any(Object), + '2': expect.any(Object), + }), + }) + ); + }); + + it('runs install commands for all selected services during workspace init', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + services: [ + { + name: 'web', + deployId: 1, + resourceName: 'web-build-uuid', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'cd /workspace/web && pnpm install', + }, + }, + { + name: 'api', + deployId: 2, + resourceName: 'api-build-uuid', + devConfig: { + image: 'node:20', + command: 'pnpm start', + installCommand: 'cd /workspace/api && pnpm install', + }, + }, + ], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + installCommand: 'cd /workspace/web && pnpm install\n\ncd /workspace/api && pnpm install', + }) + ); + }); + + it('rolls back on pod creation failure', async () => { + (createAgentPod as jest.Mock).mockRejectedValue(new Error('pod creation failed')); + + await expect(AgentSessionService.createSession(baseOpts)).rejects.toThrow('pod creation failed'); + + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(cleanupForwardedAgentEnvSecrets).toHaveBeenCalledWith( + 'test-ns', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + [] + ); + expect(mockSessionQuery.patch).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' })); + }); + + it('reverts deploy records and restores non-helm deploys on failure after dev mode', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + services: [{ name: 'web', deployId: 1, devConfig: { image: 'node:20', command: 'pnpm dev' } }], + }; + + mockSessionQuery.patch.mockRejectedValueOnce(new Error('snapshot persist failed')).mockResolvedValue(1); + + const deployManagerDeploy = jest.fn().mockResolvedValue(undefined); + (DeploymentManager as jest.Mock).mockImplementation(() => ({ + deploy: deployManagerDeploy, + })); + + const revertDeploys = [ + { + id: 1, + uuid: 'deploy-1', + build: { namespace: 'test-ns' }, + deployable: { name: 'web', type: 'github', deploymentDependsOn: [] }, + }, + ]; + mockDeployQuery.withGraphFetched.mockResolvedValue(revertDeploys); + + await expect(AgentSessionService.createSession(optsWithServices)).rejects.toThrow('snapshot persist failed'); + + expect(DeploymentManager).toHaveBeenCalledWith(revertDeploys); + expect(deployManagerDeploy).toHaveBeenCalled(); + expect(mockDisableDevMode).toHaveBeenCalledTimes(2); + expect(mockDisableDevMode).toHaveBeenNthCalledWith( + 1, + 'test-ns', + 'deploy-1', + 'deploy-1', + expect.objectContaining({ + deployment: expect.objectContaining({ + deploymentName: 'service', + }), + }) + ); + expect(mockDisableDevMode).toHaveBeenNthCalledWith( + 2, + 'test-ns', + 'deploy-1', + 'deploy-1', + expect.objectContaining({ + deployment: expect.objectContaining({ + deploymentName: 'service', + }), + }) + ); + expect(mockDisableDevMode.mock.invocationCallOrder[0]).toBeLessThan( + deployManagerDeploy.mock.invocationCallOrder[0] + ); + }); + + it('starts agent runtime cleanup before deploy restore finishes during rollback', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + services: [{ name: 'web', deployId: 1, devConfig: { image: 'node:20', command: 'pnpm dev' } }], + }; + + let releaseDeploy!: () => void; + const deployManagerDeploy = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + releaseDeploy = resolve; + }) + ); + (DeploymentManager as jest.Mock).mockImplementation(() => ({ + deploy: deployManagerDeploy, + })); + + mockSessionQuery.patch.mockRejectedValueOnce(new Error('snapshot persist failed')).mockResolvedValue(1); + + const revertDeploys = [ + { + id: 1, + uuid: 'deploy-1', + build: { namespace: 'test-ns' }, + deployable: { name: 'web', type: 'github', deploymentDependsOn: [] }, + }, + ]; + mockDeployQuery.withGraphFetched.mockResolvedValue(revertDeploys); + + const rollbackPromise = AgentSessionService.createSession(optsWithServices); + await new Promise((resolve) => setImmediate(resolve)); + + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(deleteAgentPvc).not.toHaveBeenCalled(); + + releaseDeploy(); + await expect(rollbackPromise).rejects.toThrow('snapshot persist failed'); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); + }); + }); + + describe('endSession', () => { + it('throws if session not found', async () => { + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue({ + findOne: jest.fn().mockResolvedValue(null), + }); + + await expect(AgentSessionService.endSession('nonexistent')).rejects.toThrow('Session not found or already ended'); + }); + + it('throws if session already ended', async () => { + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue({ + findOne: jest.fn().mockResolvedValue({ id: 1, uuid: 'sess-1', status: 'ended' }), + }); + + await expect(AgentSessionService.endSession('sess-1')).rejects.toThrow('Session not found or already ended'); + }); + + it('ends session, reverts deploys, deletes pod and pvc, updates DB and Redis', async () => { + const activeSession = { + id: 1, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + status: 'active', + namespace: 'test-ns', + podName: 'agent-sess1', + pvcName: 'agent-pvc-sess1', + forwardedAgentSecretProviders: ['aws'], + devModeSnapshots: { + '10': { + deployment: { + deploymentName: 'deploy-10', + containerName: 'web', + image: 'node:20', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: null, + }, + }, + }; + + const patchMock = jest.fn().mockResolvedValue(1); + + let agentQueryCount = 0; + (AgentSession.query as jest.Mock) = jest.fn().mockImplementation(() => { + agentQueryCount++; + if (agentQueryCount === 1) { + return { findOne: jest.fn().mockResolvedValue(activeSession) }; + } + return { findById: jest.fn().mockReturnValue({ patch: patchMock }) }; + }); + + const deployManagerDeploy = jest.fn().mockResolvedValue(undefined); + (DeploymentManager as jest.Mock).mockImplementation(() => ({ + deploy: deployManagerDeploy, + })); + + const devModeDeploys = [ + { + id: 10, + uuid: 'deploy-10', + build: { namespace: 'test-ns' }, + deployable: { name: 'web', type: 'github', deploymentDependsOn: [] }, + }, + ]; + let deployQueryCount = 0; + (Deploy.query as jest.Mock) = jest.fn().mockImplementation(() => { + deployQueryCount++; + if (deployQueryCount === 1) { + return { + where: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue(devModeDeploys), + }), + }; + } + return { findById: jest.fn().mockReturnValue({ patch: jest.fn().mockResolvedValue(1) }) }; + }); + + await AgentSessionService.endSession('sess-1'); + + expect(DeploymentManager).toHaveBeenCalledWith(devModeDeploys); + expect(deployManagerDeploy).toHaveBeenCalled(); + expect(mockDisableDevMode).toHaveBeenCalledTimes(2); + expect(mockDisableDevMode).toHaveBeenNthCalledWith( + 1, + 'test-ns', + 'deploy-10', + 'deploy-10', + activeSession.devModeSnapshots['10'] + ); + expect(mockDisableDevMode).toHaveBeenNthCalledWith( + 2, + 'test-ns', + 'deploy-10', + 'deploy-10', + activeSession.devModeSnapshots['10'] + ); + expect(mockDisableDevMode.mock.invocationCallOrder[0]).toBeLessThan( + deployManagerDeploy.mock.invocationCallOrder[0] + ); + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-sess1'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(cleanupForwardedAgentEnvSecrets).toHaveBeenCalledWith('test-ns', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', [ + 'aws', + ]); + expect(patchMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'ended', devModeSnapshots: {} })); + expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + }); + + it('starts runtime cleanup before deploy restore finishes and deletes PVC after restore', async () => { + const activeSession = { + id: 1, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + status: 'active', + namespace: 'test-ns', + podName: 'agent-sess1', + pvcName: 'agent-pvc-sess1', + devModeSnapshots: { + '10': { + deployment: { + deploymentName: 'deploy-10', + containerName: 'web', + image: 'node:20', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: null, + }, + }, + }; + + const patchMock = jest.fn().mockResolvedValue(1); + + let agentQueryCount = 0; + (AgentSession.query as jest.Mock) = jest.fn().mockImplementation(() => { + agentQueryCount++; + if (agentQueryCount === 1) { + return { findOne: jest.fn().mockResolvedValue(activeSession) }; + } + return { findById: jest.fn().mockReturnValue({ patch: patchMock }) }; + }); + + let releaseDeploy!: () => void; + const deployManagerDeploy = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + releaseDeploy = resolve; + }) + ); + (DeploymentManager as jest.Mock).mockImplementation(() => ({ + deploy: deployManagerDeploy, + })); + + const devModeDeploys = [ + { + id: 10, + uuid: 'deploy-10', + build: { namespace: 'test-ns' }, + deployable: { name: 'web', type: 'github', deploymentDependsOn: [] }, + }, + ]; + let deployQueryCount = 0; + (Deploy.query as jest.Mock) = jest.fn().mockImplementation(() => { + deployQueryCount++; + if (deployQueryCount === 1) { + return { + where: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue(devModeDeploys), + }), + }; + } + return { findById: jest.fn().mockReturnValue({ patch: jest.fn().mockResolvedValue(1) }) }; + }); + + const endPromise = AgentSessionService.endSession('sess-1'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(deleteAgentPvc).not.toHaveBeenCalled(); + + releaseDeploy(); + await endPromise; + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-sess1'); + }); + + it('queues sandbox cleanup instead of waiting on synchronous build deletion', async () => { + const activeSandboxSession = { + id: 444, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + status: 'active', + namespace: 'sbx-test-build', + podName: 'agent-sbx', + pvcName: 'agent-pvc-sbx', + buildUuid: 'sandbox-build-uuid', + }; + + const patchMock = jest.fn().mockResolvedValue(1); + + let agentQueryCount = 0; + (AgentSession.query as jest.Mock) = jest.fn().mockImplementation(() => { + agentQueryCount++; + if (agentQueryCount === 1) { + return { findOne: jest.fn().mockResolvedValue(activeSandboxSession) }; + } + + return { findById: jest.fn().mockReturnValue({ patch: patchMock }) }; + }); + + const sandboxBuild = { + id: 444, + uuid: 'sandbox-build-uuid', + kind: 'sandbox', + }; + + const buildGraphFetch = jest.fn().mockResolvedValue(sandboxBuild); + const buildFindOne = jest.fn().mockReturnValue({ + withGraphFetched: buildGraphFetch, + }); + + (Build.query as unknown as jest.Mock) = jest.fn().mockReturnValue({ + findOne: buildFindOne, + }); + + await AgentSessionService.endSession('sess-sbx'); + + expect(BuildServiceModule).toHaveBeenCalled(); + expect(mockedBuildServiceModule.deleteQueueAdd).toHaveBeenCalledWith( + 'delete', + expect.objectContaining({ + buildId: 444, + buildUuid: 'sandbox-build-uuid', + sender: 'agent-session', + }) + ); + expect(mockedBuildServiceModule.deleteBuild).not.toHaveBeenCalled(); + expect(patchMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'ended' })); + expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + }); + }); + + describe('getSession', () => { + it('returns session by id', async () => { + const session = { id: 1, uuid: 'sess-1', status: 'active', buildUuid: null, devModeSnapshots: {} }; + mockSessionQuery.findOne.mockResolvedValue(session); + + const result = await AgentSessionService.getSession('sess-1'); + + expect(result).toEqual( + expect.objectContaining({ + id: 'sess-1', + status: 'active', + }) + ); + }); + }); + + describe('getActiveSessions', () => { + it('returns active sessions for user', async () => { + await AgentSessionService.getActiveSessions('user-123'); + + expect(mockSessionQuery.where).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(mockSessionQuery.whereIn).toHaveBeenCalledWith('status', ['starting', 'active']); + expect(mockSessionQuery.orderBy).toHaveBeenNthCalledWith(1, 'updatedAt', 'desc'); + expect(mockSessionQuery.orderBy).toHaveBeenNthCalledWith(2, 'createdAt', 'desc'); + }); + }); + + describe('getSessions', () => { + it('returns enriched session metadata for active and ended sessions', async () => { + const sessions = [ + { + id: 101, + uuid: 'sess-active', + userId: 'user-123', + buildUuid: 'build-1', + status: 'active', + devModeSnapshots: {}, + }, + { + id: 202, + uuid: 'sess-ended', + userId: 'user-123', + buildUuid: 'build-2', + status: 'ended', + devModeSnapshots: { + '22': { + deployment: { + deploymentName: 'api', + containerName: 'api', + image: 'node:20', + command: null, + workingDir: null, + env: null, + volumeMounts: null, + volumes: null, + }, + service: null, + }, + }, + }, + ]; + + const sessionsQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest + .fn() + .mockImplementationOnce(() => sessionsQuery) + .mockImplementationOnce(() => Promise.resolve(sessions)), + }; + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue(sessionsQuery); + + const buildGraph = jest.fn().mockResolvedValue([ + { + uuid: 'build-1', + pullRequest: { + fullName: 'example-org/example-repo', + branchName: 'feature/live', + }, + }, + { + uuid: 'build-2', + baseBuild: { + pullRequest: { + fullName: 'example-org/example-repo', + branchName: 'feature/sandbox', + }, + }, + }, + ]); + (Build.query as jest.Mock) = jest.fn().mockReturnValue({ + whereIn: jest.fn().mockReturnValue({ + withGraphFetched: buildGraph, + }), + }); + + let deployQueryCount = 0; + (Deploy.query as jest.Mock) = jest.fn().mockImplementation(() => { + deployQueryCount += 1; + + if (deployQueryCount === 1) { + return { + whereIn: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue([ + { + id: 10, + devModeSessionId: 101, + branchName: 'feature/live', + repository: { fullName: 'example-org/example-repo' }, + deployable: { name: 'grpc-echo' }, + }, + ]), + }), + }; + } + + return { + whereIn: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue([ + { + id: 22, + branchName: 'feature/sandbox', + repository: { fullName: 'example-org/example-repo' }, + deployable: { name: 'lc-test-gh-type' }, + }, + ]), + }), + }; + }); + + const result = await AgentSessionService.getSessions('user-123', { includeEnded: true }); + + expect(sessionsQuery.where).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(result).toEqual([ + expect.objectContaining({ + id: 'sess-active', + repo: 'example-org/example-repo', + branch: 'feature/live', + services: ['grpc-echo'], + }), + expect.objectContaining({ + id: 'sess-ended', + repo: 'example-org/example-repo', + branch: 'feature/sandbox', + services: ['lc-test-gh-type'], + }), + ]); + }); + }); + + describe('touchActivity', () => { + it('updates lastActivity timestamp', async () => { + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce({ + findOne: jest.fn().mockReturnValue({ + select: jest.fn().mockResolvedValue({ id: 123 }), + }), + }) + .mockReturnValueOnce({ + findById: jest.fn().mockReturnValue({ + patch: mockSessionQuery.patch, + }), + }); + + await AgentSessionService.touchActivity('sess-1'); + + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ lastActivity: expect.any(String) }) + ); + }); + }); +}); diff --git a/src/server/services/__tests__/agentSessionCandidates.test.ts b/src/server/services/__tests__/agentSessionCandidates.test.ts new file mode 100644 index 0000000..55741cd --- /dev/null +++ b/src/server/services/__tests__/agentSessionCandidates.test.ts @@ -0,0 +1,123 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { YamlConfigParser } from 'server/lib/yamlConfigParser'; +import type { Deploy } from 'server/models'; +import { resolveAgentSessionServiceCandidates } from '../agentSessionCandidates'; +import { DeployStatus, DeployTypes } from 'shared/constants'; + +describe('agentSessionCandidates', () => { + test('includes only repo-local dev services backed by lifecycle-managed image builds', () => { + const parser = new YamlConfigParser(); + const lifecycleConfig = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +services: + - name: 'github-app' + dev: + image: 'repo/github-app:dev' + command: 'npm run dev' + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'app/Dockerfile' + - name: 'helm-app' + dev: + image: 'repo/helm-app:dev' + command: 'npm run dev' + helm: + repository: 'org/example' + branchName: 'main' + chart: + name: './helm/app' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'helm-app/Dockerfile' + - name: 'redis' + dev: + image: 'repo/redis:dev' + command: 'redis-server' + helm: + repository: 'org/example' + branchName: 'main' + chart: + name: 'redis' + - name: 'external-image' + dev: + image: 'repo/external-image:dev' + command: 'sleep infinity' + docker: + dockerImage: 'docker.io/org/external-image' + defaultTag: 'latest' +`); + + const deploys = [ + { + id: 11, + active: true, + status: DeployStatus.DEPLOYED, + deployable: { name: 'github-app', type: DeployTypes.GITHUB }, + }, + { + id: 12, + active: true, + status: DeployStatus.READY, + deployable: { name: 'helm-app', type: DeployTypes.HELM }, + }, + { + id: 13, + active: true, + status: DeployStatus.DEPLOYED, + deployable: { name: 'redis', type: DeployTypes.HELM }, + }, + { + id: 14, + active: true, + status: DeployStatus.DEPLOYED, + deployable: { name: 'external-image', type: DeployTypes.DOCKER }, + }, + { + id: 15, + active: true, + status: DeployStatus.DEPLOYED, + deployable: { name: 'other-repo-service', type: DeployTypes.GITHUB }, + }, + ] as unknown as Deploy[]; + + expect(resolveAgentSessionServiceCandidates(deploys, lifecycleConfig)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'github-app', + type: DeployTypes.GITHUB, + detail: DeployStatus.DEPLOYED, + deployId: 11, + }), + expect.objectContaining({ + name: 'helm-app', + type: DeployTypes.HELM, + detail: DeployStatus.READY, + deployId: 12, + }), + ]) + ); + + const names = resolveAgentSessionServiceCandidates(deploys, lifecycleConfig).map((candidate) => candidate.name); + expect(names).toEqual(['github-app', 'helm-app']); + }); +}); diff --git a/src/server/services/__tests__/globalConfig.test.ts b/src/server/services/__tests__/globalConfig.test.ts index 3d68ea4..afaa3a2 100644 --- a/src/server/services/__tests__/globalConfig.test.ts +++ b/src/server/services/__tests__/globalConfig.test.ts @@ -17,6 +17,10 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); +const mockAppAuth = jest.fn(); +const mockOctokitRequest = jest.fn(); + +import { Queue } from 'bullmq'; import GlobalConfigService from '../globalConfig'; jest.mock('redlock', () => { @@ -28,6 +32,14 @@ jest.mock('ioredis', () => { hmset: jest.fn(), })); }); +jest.mock('@octokit/auth-app', () => ({ + createAppAuth: jest.fn(() => mockAppAuth), +})); +jest.mock('@octokit/core', () => ({ + Octokit: jest.fn().mockImplementation(() => ({ + request: mockOctokitRequest, + })), +})); jest.mock('server/database'); jest.mock('bullmq', () => ({ @@ -81,7 +93,10 @@ describe('GlobalConfigService', () => { it('should set up a cache refresh job', async () => { await service.setupCacheRefreshJob(); - expect(service.cacheRefreshQueue.add).toHaveBeenCalled(); + const mockedQueueConstructor = Queue as unknown as jest.Mock; + const createdQueue = mockedQueueConstructor.mock.results[0]?.value as { add: jest.Mock }; + + expect(createdQueue.add).toHaveBeenCalled(); }); }); @@ -146,6 +161,41 @@ describe('GlobalConfigService', () => { }); }); + describe('getGithubAppName', () => { + it('returns the live GitHub app name when metadata lookup succeeds', async () => { + service.redis.hgetall.mockResolvedValueOnce({}); + mockAppAuth.mockResolvedValueOnce({ token: 'app-token' }); + mockOctokitRequest.mockResolvedValueOnce({ + data: { + name: 'Sample Lifecycle App', + slug: 'sample-lifecycle-app', + }, + }); + + const result = await service.getGithubAppName(); + + expect(result).toBe('Sample Lifecycle App'); + expect(service.redis.hmset).toHaveBeenCalledWith('github_cached_app_info', { + name: 'Sample Lifecycle App', + }); + }); + + it('falls back to stored setup metadata when live lookup fails', async () => { + service.redis.hgetall.mockResolvedValueOnce({}); + mockAppAuth.mockRejectedValueOnce(new Error('GitHub unavailable')); + const mockGetAllConfigs = jest.spyOn(service, 'getAllConfigs').mockResolvedValueOnce({ + app_setup: { + name: 'sample-lifecycle-app', + }, + } as any); + + const result = await service.getGithubAppName(); + + expect(result).toBe('sample-lifecycle-app'); + mockGetAllConfigs.mockRestore(); + }); + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/src/server/services/__tests__/userApiKey.test.ts b/src/server/services/__tests__/userApiKey.test.ts new file mode 100644 index 0000000..b3c6ffd --- /dev/null +++ b/src/server/services/__tests__/userApiKey.test.ts @@ -0,0 +1,316 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/models/UserApiKey'); +jest.mock('server/lib/encryption'); +jest.mock('server/lib/dependencies', () => ({})); + +import UserApiKeyService from 'server/services/userApiKey'; +import UserApiKey from 'server/models/UserApiKey'; +import { encrypt, decrypt, maskApiKey } from 'server/lib/encryption'; + +const mockEncrypt = encrypt as jest.MockedFunction; +const mockDecrypt = decrypt as jest.MockedFunction; +const mockMaskApiKey = maskApiKey as jest.MockedFunction; + +const mockQuery = { + where: jest.fn().mockReturnThis(), + first: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), +}; + +(UserApiKey.query as jest.Mock) = jest.fn().mockReturnValue(mockQuery); +(UserApiKey as any).query.insertAndFetch = undefined; + +describe('UserApiKeyService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (UserApiKey.query as jest.Mock) = jest.fn().mockReturnValue(mockQuery); + mockQuery.where.mockReturnThis(); + mockQuery.first.mockReset(); + mockQuery.patch.mockReset(); + mockQuery.delete.mockReset(); + }); + + describe('storeKey', () => { + test('stores a new key under the canonical owner key', async () => { + mockEncrypt.mockReturnValue('encrypted-value'); + mockQuery.first.mockResolvedValue(null); + + const insertAndFetchMock = jest.fn().mockResolvedValue({ + id: 1, + userId: 'user-1', + ownerGithubUsername: 'user-1', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + (UserApiKey.query as jest.Mock) + .mockReturnValueOnce(mockQuery) + .mockReturnValueOnce({ insertAndFetch: insertAndFetchMock }); + + await UserApiKeyService.storeKey('user-1', 'anthropic', 'sk-ant-api03-abc'); + + expect(mockEncrypt).toHaveBeenCalledWith('sk-ant-api03-abc'); + expect(mockQuery.where).toHaveBeenCalledWith({ + ownerGithubUsername: 'user-1', + provider: 'anthropic', + }); + expect(insertAndFetchMock).toHaveBeenCalledWith({ + userId: 'user-1', + ownerGithubUsername: 'user-1', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + }); + + test('updates an existing owner-matched key', async () => { + mockEncrypt.mockReturnValue('encrypted-value-updated'); + mockQuery.first.mockResolvedValue({ + id: 1, + userId: 'user-1', + ownerGithubUsername: 'sample-user', + provider: 'anthropic', + encryptedKey: 'old-encrypted-value', + }); + + const patchMock = jest.fn().mockResolvedValue(1); + const whereForPatch = jest.fn().mockReturnValue({ patch: patchMock }); + (UserApiKey.query as jest.Mock).mockReturnValueOnce(mockQuery).mockReturnValueOnce({ where: whereForPatch }); + + await UserApiKeyService.storeKey('user-1', 'anthropic', 'sk-ant-api03-xyz', 'sample-user'); + + expect(mockEncrypt).toHaveBeenCalledWith('sk-ant-api03-xyz'); + expect(whereForPatch).toHaveBeenCalledWith({ id: 1 }); + expect(patchMock).toHaveBeenCalledWith({ + userId: 'user-1', + encryptedKey: 'encrypted-value-updated', + ownerGithubUsername: 'sample-user', + }); + }); + + test('falls back to userId and rebinds the row to the current github username', async () => { + mockEncrypt.mockReturnValue('encrypted-value-updated'); + mockQuery.first.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: 9, + userId: 'user-1', + ownerGithubUsername: 'old-handle', + provider: 'anthropic', + encryptedKey: 'old-encrypted-value', + }); + + const patchMock = jest.fn().mockResolvedValue(1); + const whereForPatch = jest.fn().mockReturnValue({ patch: patchMock }); + (UserApiKey.query as jest.Mock) + .mockReturnValueOnce(mockQuery) + .mockReturnValueOnce(mockQuery) + .mockReturnValueOnce({ where: whereForPatch }) + .mockReturnValueOnce({ where: whereForPatch }); + + await UserApiKeyService.storeKey('user-1', 'anthropic', 'sk-ant-api03-xyz', 'sample-user'); + + expect(mockQuery.where).toHaveBeenNthCalledWith(1, { + ownerGithubUsername: 'sample-user', + provider: 'anthropic', + }); + expect(mockQuery.where).toHaveBeenNthCalledWith(2, { + userId: 'user-1', + provider: 'anthropic', + }); + expect(whereForPatch).toHaveBeenNthCalledWith(1, { id: 9 }); + expect(patchMock).toHaveBeenNthCalledWith(1, { + userId: 'user-1', + ownerGithubUsername: 'sample-user', + }); + expect(whereForPatch).toHaveBeenNthCalledWith(2, { id: 9 }); + expect(patchMock).toHaveBeenNthCalledWith(2, { + userId: 'user-1', + encryptedKey: 'encrypted-value-updated', + ownerGithubUsername: 'sample-user', + }); + }); + }); + + describe('getMaskedKey', () => { + test('returns masked key info when an owner-matched record exists', async () => { + mockQuery.first.mockResolvedValue({ + id: 1, + userId: 'user-1', + ownerGithubUsername: 'user-1', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + updatedAt: '2025-01-01T00:00:00Z', + }); + mockDecrypt.mockReturnValue('sk-ant-api03-abcdefghijklmnop'); + mockMaskApiKey.mockReturnValue('sk-ant...mnop'); + + const result = await UserApiKeyService.getMaskedKey('user-1', 'anthropic'); + + expect(result).toEqual({ + provider: 'anthropic', + maskedKey: 'sk-ant...mnop', + updatedAt: '2025-01-01T00:00:00Z', + }); + expect(mockDecrypt).toHaveBeenCalledWith('encrypted-value'); + expect(mockMaskApiKey).toHaveBeenCalledWith('sk-ant-api03-abcdefghijklmnop'); + }); + + test('returns null when no key exists', async () => { + mockQuery.first.mockResolvedValue(null); + + const result = await UserApiKeyService.getMaskedKey('user-1', 'anthropic'); + + expect(result).toBeNull(); + expect(mockDecrypt).not.toHaveBeenCalled(); + }); + + test('reconciles userId when ownerGithubUsername matches', async () => { + mockQuery.first.mockResolvedValue({ + id: 2, + userId: 'old-user', + ownerGithubUsername: 'sample-user', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + updatedAt: '2025-01-01T00:00:00Z', + }); + mockDecrypt.mockReturnValue('sk-ant-api03-abcdefghijklmnop'); + mockMaskApiKey.mockReturnValue('sk-ant...mnop'); + + const patchMock = jest.fn().mockResolvedValue(1); + const whereForPatch = jest.fn().mockReturnValue({ patch: patchMock }); + (UserApiKey.query as jest.Mock).mockReturnValueOnce(mockQuery).mockReturnValueOnce({ where: whereForPatch }); + + const result = await UserApiKeyService.getMaskedKey('user-1', 'anthropic', 'sample-user'); + + expect(result).toEqual({ + provider: 'anthropic', + maskedKey: 'sk-ant...mnop', + updatedAt: '2025-01-01T00:00:00Z', + }); + expect(whereForPatch).toHaveBeenCalledWith({ id: 2 }); + expect(patchMock).toHaveBeenCalledWith({ + userId: 'user-1', + ownerGithubUsername: 'sample-user', + }); + }); + }); + + describe('getDecryptedKey', () => { + test('returns decrypted key when an owner-matched record exists', async () => { + mockQuery.first.mockResolvedValue({ + id: 1, + userId: 'user-1', + ownerGithubUsername: 'user-1', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + mockDecrypt.mockReturnValue('sk-ant-api03-abcdefghijklmnop'); + + const result = await UserApiKeyService.getDecryptedKey('user-1', 'anthropic'); + + expect(result).toBe('sk-ant-api03-abcdefghijklmnop'); + expect(mockDecrypt).toHaveBeenCalledWith('encrypted-value'); + }); + + test('returns null when no key exists', async () => { + mockQuery.first.mockResolvedValue(null); + + const result = await UserApiKeyService.getDecryptedKey('user-1', 'anthropic'); + + expect(result).toBeNull(); + }); + + test('reconciles userId during owner-based decryption', async () => { + mockQuery.first.mockResolvedValue({ + id: 3, + userId: 'old-user', + ownerGithubUsername: 'sample-user', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + mockDecrypt.mockReturnValue('sk-ant-api03-abcdefghijklmnop'); + + const patchMock = jest.fn().mockResolvedValue(1); + const whereForPatch = jest.fn().mockReturnValue({ patch: patchMock }); + (UserApiKey.query as jest.Mock).mockReturnValueOnce(mockQuery).mockReturnValueOnce({ where: whereForPatch }); + + const result = await UserApiKeyService.getDecryptedKey('user-1', 'anthropic', 'sample-user'); + + expect(result).toBe('sk-ant-api03-abcdefghijklmnop'); + expect(whereForPatch).toHaveBeenCalledWith({ id: 3 }); + expect(patchMock).toHaveBeenCalledWith({ + userId: 'user-1', + ownerGithubUsername: 'sample-user', + }); + }); + }); + + describe('deleteKey', () => { + test('returns true when key exists and is deleted', async () => { + mockQuery.first.mockResolvedValue({ + id: 1, + userId: 'user-1', + ownerGithubUsername: 'user-1', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + const deleteMock = jest.fn().mockResolvedValue(1); + const whereForDelete = jest.fn().mockReturnValue({ delete: deleteMock }); + (UserApiKey.query as jest.Mock).mockReturnValueOnce(mockQuery).mockReturnValueOnce({ where: whereForDelete }); + + const result = await UserApiKeyService.deleteKey('user-1', 'anthropic'); + + expect(result).toBe(true); + expect(whereForDelete).toHaveBeenCalledWith({ id: 1 }); + }); + + test('returns false when no key exists', async () => { + mockQuery.first.mockResolvedValue(null); + + const result = await UserApiKeyService.deleteKey('user-1', 'anthropic'); + + expect(result).toBe(false); + }); + + test('reconciles userId before deleting an owner-matched key', async () => { + mockQuery.first.mockResolvedValue({ + id: 5, + userId: 'old-user', + ownerGithubUsername: 'sample-user', + provider: 'anthropic', + encryptedKey: 'encrypted-value', + }); + const patchMock = jest.fn().mockResolvedValue(1); + const whereForPatch = jest.fn().mockReturnValue({ patch: patchMock }); + const deleteMock = jest.fn().mockResolvedValue(1); + const whereForDelete = jest.fn().mockReturnValue({ delete: deleteMock }); + (UserApiKey.query as jest.Mock) + .mockReturnValueOnce(mockQuery) + .mockReturnValueOnce({ where: whereForPatch }) + .mockReturnValueOnce({ where: whereForDelete }); + + const result = await UserApiKeyService.deleteKey('user-1', 'anthropic', 'sample-user'); + + expect(result).toBe(true); + expect(whereForPatch).toHaveBeenCalledWith({ id: 5 }); + expect(patchMock).toHaveBeenCalledWith({ + userId: 'user-1', + ownerGithubUsername: 'sample-user', + }); + expect(whereForDelete).toHaveBeenCalledWith({ id: 5 }); + }); + }); +}); diff --git a/src/server/services/activityStream.ts b/src/server/services/activityStream.ts index 004178f..fe8b1a7 100644 --- a/src/server/services/activityStream.ts +++ b/src/server/services/activityStream.ts @@ -24,6 +24,7 @@ import * as psl from 'psl'; import { CommentHelper } from 'server/lib/comment'; import OverrideService from './override'; import { + BuildKind, BuildStatus, DeployStatus, CommentParser, @@ -390,6 +391,10 @@ export default class ActivityStream extends BaseService { queue: boolean = true, targetGithubRepositoryId?: number ) { + if (build?.kind === BuildKind.SANDBOX) { + return; + } + const buildId = build?.id; const uuid = build?.uuid; const isFullYaml = build?.enableFullYaml; @@ -457,7 +462,7 @@ export default class ActivityStream extends BaseService { } } - private async forceUnlock(resource: string, buildUuid: string, fullName: string, branchName: string) { + private async forceUnlock(resource: string, _buildUuid: string, fullName: string, branchName: string) { try { await this.redis.del(resource); } catch (error) { @@ -610,7 +615,7 @@ export default class ActivityStream extends BaseService { private async generateMissionControlComment( build: Build, deploys: Deploy[], - repository: Repository, + _repository: Repository, pullRequest: PullRequest, isBot?: boolean ) { diff --git a/src/server/services/agentSandboxSession.ts b/src/server/services/agentSandboxSession.ts new file mode 100644 index 0000000..a728f82 --- /dev/null +++ b/src/server/services/agentSandboxSession.ts @@ -0,0 +1,700 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Haikunator from 'haikunator'; +import { customAlphabet, nanoid } from 'nanoid'; +import BaseService from './_service'; +import BuildService from './build'; +import AgentSessionService from './agentSession'; +import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; +import { getLogger } from 'server/lib/logger'; +import { Build, Deploy, Deployable } from 'server/models'; +import { fetchLifecycleConfig, getDeployingServicesByName, type LifecycleConfig } from 'server/models/yaml'; +import { + hasLifecycleManagedDockerBuild, + type DependencyService, + type DevConfig, + type Service as LifecycleService, +} from 'server/models/yaml/YamlService'; +import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/constants'; +import DeployService from './deploy'; +import type { SandboxLaunchStage } from 'server/lib/agentSession/sandboxLaunchState'; +import type { RequestUserIdentity } from 'server/lib/get-user'; + +const randomSha = customAlphabet('1234567890abcdef', 6); + +export interface SandboxServiceCandidate { + name: string; + type: DeployTypes; +} + +interface ResolvedSandboxService { + name: string; + devConfig: DevConfig; + baseDeploy: Deploy; + serviceRepo: string; + serviceBranch: string; + yamlService: LifecycleService; +} + +interface EnvironmentSource { + repo: string; + branch: string; +} + +interface SandboxServiceIdentity { + name: string; + repo: string; +} + +export interface LaunchSandboxSessionOptions { + userId: string; + userIdentity?: RequestUserIdentity; + githubToken?: string | null; + baseBuildUuid: string; + service?: string; + model?: string; + agentImage: string; + editorImage: string; + onProgress?: (stage: SandboxLaunchStage, message: string) => Promise | void; +} + +export type LaunchSandboxSessionResult = + | { + status: 'needs_service_selection'; + services: SandboxServiceCandidate[]; + } + | { + status: 'created'; + service: string; + buildUuid: string; + namespace: string; + session: Awaited>; + services: string[]; + }; + +export default class AgentSandboxSessionService extends BaseService { + private readonly buildService = new BuildService(this.db, this.redis, this.redlock, this.queueManager); + private readonly deployService = new DeployService(this.db, this.redis, this.redlock, this.queueManager); + + async launch(opts: LaunchSandboxSessionOptions): Promise { + const { baseBuild, environmentSource, candidates } = await this.loadBaseBuildAndCandidates(opts); + if (candidates.length === 0) { + throw new Error( + `No dev-mode sandboxable services were found in ${environmentSource.repo}:${environmentSource.branch}` + ); + } + + const selectedService = opts.service != null ? this.resolveSelectedService(opts.service, candidates) : null; + + if (!selectedService) { + return { + status: 'needs_service_selection', + services: candidates + .map((candidate) => ({ name: candidate.name, type: getDeployTypeFromBaseDeploy(candidate.baseDeploy) })) + .sort((a, b) => a.name.localeCompare(b.name)), + }; + } + + await opts.onProgress?.('creating_sandbox_build', `Creating sandbox build for ${selectedService.name}`); + const sandboxBuild = await this.createSandboxBuild({ + baseBuild, + environmentSource, + selectedService, + }); + + try { + const runUUID = nanoid(); + await sandboxBuild.$query().patch({ runUUID, status: BuildStatus.QUEUED }); + await sandboxBuild.$fetchGraph('[environment, pullRequest.[repository], deploys.[deployable, repository]]'); + + await opts.onProgress?.('resolving_environment', `Resolving environment variables for ${selectedService.name}`); + await new BuildEnvironmentVariables(this.db).resolve(sandboxBuild); + + await this.buildService.updateStatusAndComment(sandboxBuild, BuildStatus.DEPLOYING, runUUID, false, false); + await opts.onProgress?.('deploying_resources', `Deploying sandbox resources for ${selectedService.name}`); + const deployed = await this.buildService.generateAndApplyManifests({ + build: sandboxBuild, + githubRepositoryId: '', + namespace: sandboxBuild.namespace, + }); + + await this.buildService.updateStatusAndComment( + sandboxBuild, + deployed ? BuildStatus.DEPLOYED : BuildStatus.ERROR, + runUUID, + false, + false + ); + + if (!deployed) { + throw new Error(`Sandbox deployment failed for ${sandboxBuild.uuid}`); + } + + const sandboxDeploy = sandboxBuild.deploys?.find((deploy) => deploy.deployable?.name === selectedService.name); + if (!sandboxDeploy?.id) { + throw new Error(`Sandbox deploy not found for ${selectedService.name}`); + } + + await opts.onProgress?.('creating_agent_session', `Starting agent session for ${selectedService.name}`); + const session = await AgentSessionService.createSession({ + userId: opts.userId, + buildUuid: sandboxBuild.uuid, + githubToken: opts.githubToken, + services: [ + { + name: selectedService.name, + deployId: sandboxDeploy.id, + devConfig: selectedService.devConfig, + resourceName: sandboxDeploy.uuid || undefined, + }, + ], + model: opts.model, + repoUrl: `https://github.com/${selectedService.serviceRepo}.git`, + branch: selectedService.baseDeploy.branchName || selectedService.serviceBranch, + revision: selectedService.baseDeploy.sha || undefined, + prNumber: baseBuild.pullRequest?.pullRequestNumber, + namespace: sandboxBuild.namespace, + agentImage: opts.agentImage, + editorImage: opts.editorImage, + userIdentity: opts.userIdentity, + }); + + return { + status: 'created', + service: selectedService.name, + buildUuid: sandboxBuild.uuid, + namespace: sandboxBuild.namespace, + session, + services: [selectedService.name], + }; + } catch (error) { + await this.buildService.deleteBuild(sandboxBuild).catch((cleanupError) => { + getLogger().warn({ error: cleanupError, buildUuid: sandboxBuild.uuid }, 'Sandbox cleanup failed after launch'); + }); + throw error; + } + } + + async getServiceCandidates({ + baseBuildUuid, + onProgress, + }: Pick) { + const { candidates } = await this.loadBaseBuildAndCandidates({ + baseBuildUuid, + onProgress, + }); + + return candidates + .map((candidate) => ({ + name: candidate.name, + type: getDeployTypeFromBaseDeploy(candidate.baseDeploy), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private async loadBaseBuildAndCandidates({ + baseBuildUuid, + onProgress, + }: Pick) { + await onProgress?.('resolving_base_build', `Loading base build ${baseBuildUuid}`); + const baseBuild = await Build.query() + .findOne({ uuid: baseBuildUuid }) + .withGraphFetched('[environment, pullRequest.[repository], deploys.[deployable, repository]]'); + + if (!baseBuild) { + throw new Error('Base build not found'); + } + + if (baseBuild.kind === BuildKind.SANDBOX) { + throw new Error('Sandbox builds cannot be used as sandbox bases'); + } + + const environmentSource = this.getEnvironmentSource(baseBuild); + await onProgress?.( + 'resolving_services', + `Reading environment config for ${environmentSource.repo} on ${environmentSource.branch}` + ); + const lifecycleConfig = await fetchLifecycleConfig(environmentSource.repo, environmentSource.branch); + if (!lifecycleConfig) { + throw new Error(`Lifecycle config not found for ${environmentSource.repo}:${environmentSource.branch}`); + } + + return { + baseBuild, + environmentSource, + candidates: await this.resolveCandidateServices(baseBuild, lifecycleConfig, environmentSource), + }; + } + + private async resolveCandidateServices( + baseBuild: Build, + lifecycleConfig: LifecycleConfig, + environmentSource: EnvironmentSource + ): Promise { + const configCache = new Map>(); + const activeDeploys = this.getActiveDeploys(baseBuild); + const resolvedCandidates: ResolvedSandboxService[] = []; + + for (const serviceRef of this.getEnvironmentServiceReferences(lifecycleConfig)) { + const serviceName = serviceRef.name; + if (!serviceName) { + continue; + } + + const baseDeploy = this.findActiveDeployForReference(activeDeploys, serviceRef); + if (!baseDeploy) { + continue; + } + + try { + const serviceSource = await this.resolveServiceSource({ + serviceRef, + baseDeploy, + fallbackSource: environmentSource, + configCache, + }); + + if (!serviceSource.yamlService?.dev || !hasLifecycleManagedDockerBuild(serviceSource.yamlService)) { + continue; + } + + resolvedCandidates.push({ + name: serviceSource.yamlService.name, + devConfig: serviceSource.yamlService.dev, + baseDeploy, + serviceRepo: serviceSource.repo, + serviceBranch: serviceSource.branch, + yamlService: serviceSource.yamlService, + }); + } catch (error) { + getLogger({ buildUuid: baseBuild.uuid, serviceName, error }).warn( + 'Sandbox candidate resolution skipped service due to config error' + ); + } + } + + return resolvedCandidates; + } + + private resolveSelectedService(serviceName: string, candidates: ResolvedSandboxService[]): ResolvedSandboxService { + const matches = candidates.filter((candidate) => candidate.name === serviceName); + if (matches.length === 0) { + throw new Error(`Unknown sandbox service: ${serviceName}`); + } + + if (matches.length > 1) { + throw new Error(`Multiple sandbox services matched ${serviceName}`); + } + + return matches[0]; + } + + private async createSandboxBuild({ + baseBuild, + environmentSource, + selectedService, + }: { + baseBuild: Build; + environmentSource: EnvironmentSource; + selectedService: ResolvedSandboxService; + }): Promise { + const includedKeys = await this.resolveDependencyClosure(baseBuild, selectedService, environmentSource); + const baseDeploys = (baseBuild.deploys || []).filter( + (deploy) => + deploy.active && this.getDeployServiceKey(deploy) && includedKeys.has(this.getDeployServiceKey(deploy)!) + ); + + if (baseDeploys.length !== includedKeys.size) { + const foundKeys = new Set(baseDeploys.map((deploy) => this.getDeployServiceKey(deploy)).filter(Boolean)); + const missing = [...includedKeys].filter((key) => !foundKeys.has(key)); + throw new Error(`Base build is missing active deploys for sandbox dependencies: ${missing.join(', ')}`); + } + + const haikunator = new Haikunator({ + defaults: { + tokenLength: 6, + }, + }); + const sandboxUuid = haikunator.haikunate(); + const sandboxNamespace = `sbx-${sandboxUuid}`; + + return Build.transaction(async (trx) => { + const baseBuildJson = baseBuild.$toJson() as Record; + const { + id: _baseBuildId, + uuid: _baseBuildUuid, + deploys: _deploys, + services: _services, + buildServiceOverrides: _buildServiceOverrides, + pullRequest: _pullRequest, + environment: _environment, + deployables: _buildDeployables, + baseBuild: _parentBuild, + createdAt: _createdAt, + updatedAt: _updatedAt, + deletedAt: _deletedAt, + ...restBuild + } = baseBuildJson; + + const sandboxBuild = await Build.query(trx).insertAndFetch({ + ...restBuild, + uuid: sandboxUuid, + namespace: sandboxNamespace, + sha: randomSha(), + kind: BuildKind.SANDBOX, + baseBuildId: baseBuild.id, + status: BuildStatus.QUEUED, + githubDeployments: false, + isStatic: false, + dashboardLinks: {}, + } as unknown as Partial); + + const deployableIdByBaseId = new Map(); + for (const baseDeploy of baseDeploys) { + const baseDeployable = baseDeploy.deployable; + if (!baseDeployable) { + throw new Error(`Deployable missing for base deploy ${baseDeploy.uuid}`); + } + + const baseDeployableJson = baseDeployable.$toJson() as Record; + const { + id: _id, + buildId: _buildId, + buildUUID: _buildUUID, + repository: _repository, + environment: _deployableEnvironment, + serviceDisks: _serviceDisks, + createdAt: _deployableCreatedAt, + updatedAt: _deployableUpdatedAt, + deletedAt: _deployableDeletedAt, + ...restDeployable + } = baseDeployableJson; + + const sandboxDeployable = await Deployable.query(trx).insertAndFetch({ + ...restDeployable, + buildId: sandboxBuild.id, + buildUUID: sandboxBuild.uuid, + active: true, + ipWhitelist: toPostgresTextArrayLiteral(restDeployable.ipWhitelist), + } as Partial); + + deployableIdByBaseId.set(baseDeployable.id, sandboxDeployable.id); + } + + for (const baseDeploy of baseDeploys) { + const baseDeployable = baseDeploy.deployable; + if (!baseDeployable) { + throw new Error(`Deployable missing for base deploy ${baseDeploy.uuid}`); + } + + const sandboxDeployableId = deployableIdByBaseId.get(baseDeployable.id); + if (!sandboxDeployableId) { + throw new Error(`Cloned deployable not found for ${baseDeployable.name}`); + } + + const deployJson = baseDeploy.$toJson() as Record; + const { + id: _deployId, + buildId: _deployBuildId, + deployableId: _deployableId, + service: _service, + build: _deployBuild, + deployable: _deployDeployable, + repository: _deployRepository, + agentSession: _agentSession, + createdAt: _deployCreatedAt, + updatedAt: _deployUpdatedAt, + deletedAt: _deployDeletedAt, + ...restDeploy + } = deployJson; + + const sandboxDeploy = await Deploy.query(trx).insertAndFetch({ + ...restDeploy, + buildId: sandboxBuild.id, + deployableId: sandboxDeployableId, + uuid: `${baseDeployable.name}-${sandboxBuild.uuid}`, + internalHostname: `${baseDeployable.name}-${sandboxBuild.uuid}`, + active: true, + status: DeployStatus.PENDING, + statusMessage: '', + buildLogs: '', + containerLogs: '', + manifest: '', + buildPipelineId: '', + buildOutput: '', + buildJobName: '', + deployPipelineId: '', + deployOutput: '', + devMode: false, + devModeSessionId: null, + } as unknown as Partial); + + const sandboxDeployable = await Deployable.query(trx).findById(sandboxDeployableId); + if (!sandboxDeployable) { + throw new Error(`Sandbox deployable disappeared for ${baseDeployable.name}`); + } + + await sandboxDeploy.$query(trx).patch({ + publicUrl: this.deployService.hostForDeployableDeploy(sandboxDeploy, sandboxDeployable), + }); + } + + for (const baseDeploy of baseDeploys) { + const baseDeployable = baseDeploy.deployable; + if (!baseDeployable || !baseDeployable.dependsOnDeployableId) { + continue; + } + + const sandboxDeployableId = deployableIdByBaseId.get(baseDeployable.id); + const sandboxDependsOnId = deployableIdByBaseId.get(baseDeployable.dependsOnDeployableId); + if (!sandboxDeployableId || !sandboxDependsOnId) { + continue; + } + + await Deployable.query(trx).findById(sandboxDeployableId).patch({ + dependsOnDeployableId: sandboxDependsOnId, + }); + } + + await sandboxBuild.$fetchGraph('[pullRequest.[repository], environment, deploys.[deployable, repository]]'); + return sandboxBuild; + }); + } + + private async resolveDependencyClosure( + baseBuild: Build, + selectedService: ResolvedSandboxService, + environmentSource: EnvironmentSource + ): Promise> { + const activeDeploys = this.getActiveDeploys(baseBuild); + const configCache = new Map>(); + const included = new Set(); + const queue: Array<{ + serviceRef: DependencyService; + baseDeploy: Deploy; + resolvedSource?: ResolvedLifecycleServiceSource; + }> = [ + { + serviceRef: { + name: selectedService.name, + repository: selectedService.serviceRepo, + branch: selectedService.serviceBranch, + }, + baseDeploy: selectedService.baseDeploy, + resolvedSource: { + repo: selectedService.serviceRepo, + branch: selectedService.serviceBranch, + yamlService: selectedService.yamlService, + }, + }, + ]; + + while (queue.length > 0) { + const current = queue.shift(); + const serviceName = current?.serviceRef.name; + if (!current || !serviceName) { + continue; + } + + const baseDeploy = current.baseDeploy; + if (!baseDeploy?.deployable) { + throw new Error(`Active deploy not found for dependency ${serviceName} in base build ${baseBuild.uuid}`); + } + + const serviceSource = + current.resolvedSource ?? + (await this.resolveServiceSource({ + serviceRef: current.serviceRef, + baseDeploy, + fallbackSource: environmentSource, + configCache, + })); + const serviceKey = this.getServiceKey({ + name: serviceName, + repo: serviceSource.repo, + }); + if (included.has(serviceKey)) { + continue; + } + + included.add(serviceKey); + + const yamlService = serviceSource.yamlService; + for (const requiredService of yamlService?.requires || []) { + if (requiredService.name) { + const dependencyDeploy = this.findActiveDeployForReference(activeDeploys, requiredService); + if (!dependencyDeploy) { + throw new Error( + `Active deploy not found for dependency ${requiredService.name} in base build ${baseBuild.uuid}` + ); + } + + queue.push({ + serviceRef: requiredService, + baseDeploy: dependencyDeploy, + }); + } + } + } + + return included; + } + + private getEnvironmentSource(baseBuild: Build): EnvironmentSource { + const repo = baseBuild.pullRequest?.fullName; + const branch = baseBuild.pullRequest?.branchName; + if (!repo || !branch) { + throw new Error('Base environment build is missing source repository/branch'); + } + + return { + repo, + branch, + }; + } + + private getEnvironmentServiceReferences(lifecycleConfig: LifecycleConfig): DependencyService[] { + const seen = new Set(); + const references = [ + ...(lifecycleConfig.environment?.defaultServices ?? []), + ...(lifecycleConfig.environment?.optionalServices ?? []), + ]; + + return references.filter((reference) => { + const key = `${reference.name ?? ''}:${reference.repository ?? ''}:${reference.branch ?? ''}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + private getActiveDeploys(baseBuild: Build): Deploy[] { + return (baseBuild.deploys || []).filter((deploy) => deploy.active && deploy.deployable?.name); + } + + private getServiceKey(service: SandboxServiceIdentity): string { + return `${service.repo}::${service.name}`; + } + + private getDeployServiceKey(deploy: Deploy): string | null { + const name = deploy.deployable?.name; + const repo = deploy.repository?.fullName; + if (!name || !repo) { + return null; + } + + return this.getServiceKey({ name, repo }); + } + + private findActiveDeployForReference(activeDeploys: Deploy[], serviceRef: DependencyService): Deploy | null { + if (!serviceRef.name) { + return null; + } + + const matchesByName = activeDeploys.filter((deploy) => deploy.deployable?.name === serviceRef.name); + const matches = serviceRef.repository + ? matchesByName.filter((deploy) => deploy.repository?.fullName === serviceRef.repository) + : matchesByName; + + if (matches.length === 0) { + return null; + } + + if (matches.length > 1) { + throw new Error( + `Multiple active deploys matched sandbox service ${serviceRef.name}${ + serviceRef.repository ? ` in ${serviceRef.repository}` : '' + }` + ); + } + + return matches[0]; + } + + private async resolveServiceSource({ + serviceRef, + baseDeploy, + fallbackSource, + configCache, + }: { + serviceRef: DependencyService; + baseDeploy: Deploy; + fallbackSource: EnvironmentSource; + configCache: Map>; + }): Promise { + const serviceName = serviceRef.name || baseDeploy.deployable?.name; + const repo = serviceRef.repository || baseDeploy.repository?.fullName || fallbackSource.repo; + const branch = serviceRef.branch || baseDeploy.branchName || fallbackSource.branch; + + if (!serviceName || !repo || !branch) { + throw new Error(`Unable to resolve sandbox service source for ${serviceName ?? 'unknown service'}`); + } + + const lifecycleConfig = await this.fetchCachedLifecycleConfig(repo, branch, configCache); + const yamlService = getDeployingServicesByName(lifecycleConfig, serviceName); + if (!yamlService) { + throw new Error(`Service ${serviceName} not found in ${repo}:${branch}`); + } + + return { + repo, + branch, + yamlService, + }; + } + + private async fetchCachedLifecycleConfig( + repo: string, + branch: string, + configCache: Map> + ): Promise { + const cacheKey = `${repo}::${branch}`; + let configPromise = configCache.get(cacheKey); + if (!configPromise) { + configPromise = fetchLifecycleConfig(repo, branch).then((config) => { + if (!config) { + throw new Error(`Lifecycle config not found for ${repo}:${branch}`); + } + + return config; + }); + configCache.set(cacheKey, configPromise); + } + + return configPromise; + } +} + +interface ResolvedLifecycleServiceSource extends EnvironmentSource { + yamlService: LifecycleService; +} + +function getDeployTypeFromBaseDeploy(deploy: Deploy): DeployTypes { + return deploy.deployable?.type || deploy.service?.type || DeployTypes.GITHUB; +} + +function toPostgresTextArrayLiteral(value: unknown): unknown { + if (value == null || !Array.isArray(value)) { + return value; + } + + const escapedValues = value.map((entry) => `"${String(entry).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`); + return `{${escapedValues.join(',')}}`; +} diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts new file mode 100644 index 0000000..137d269 --- /dev/null +++ b/src/server/services/agentSession.ts @@ -0,0 +1,603 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'server/lib/dependencies'; +import * as k8s from '@kubernetes/client-node'; +import { v4 as uuid } from 'uuid'; +import AgentSession from 'server/models/AgentSession'; +import Build from 'server/models/Build'; +import Deploy from 'server/models/Deploy'; +import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFactory'; +import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; +import { createAgentPod, deleteAgentPod } from 'server/lib/agentSession/podFactory'; +import { createAgentEditorService, deleteAgentEditorService } from 'server/lib/agentSession/editorServiceFactory'; +import { isGvisorAvailable } from 'server/lib/agentSession/gvisorCheck'; +import { DevModeManager } from 'server/lib/agentSession/devModeManager'; +import type { DevModeResourceSnapshot } from 'server/lib/agentSession/devModeManager'; +import { buildAgentNetworkPolicy } from 'server/lib/kubernetes/networkPolicyFactory'; +import UserApiKeyService from 'server/services/userApiKey'; +import GlobalConfigService from 'server/services/globalConfig'; +import { DevConfig } from 'server/models/yaml/YamlService'; +import RedisClient from 'server/lib/redisClient'; +import { extractContextForQueue, getLogger } from 'server/lib/logger'; +import { BuildKind } from 'shared/constants'; +import type { RequestUserIdentity } from 'server/lib/get-user'; +import { + resolveAgentSessionClaudeConfig, + renderAgentSessionClaudeAttribution, +} from 'server/lib/agentSession/runtimeConfig'; +import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; + +const logger = getLogger(); +const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; +const SESSION_REDIS_TTL = 7200; +const ACTIVE_ENVIRONMENT_SESSION_UNIQUE_INDEX = 'agent_sessions_active_environment_build_unique'; +const DEV_MODE_REDEPLOY_GRAPH = '[deployable.[repository], repository, service, build.[pullRequest.[repository]]]'; +const SESSION_DEPLOY_GRAPH = '[deployable, repository, service]'; + +type AgentSessionSummaryRecord = AgentSession & { + id: string; + uuid: string; + baseBuildUuid: string | null; + repo: string | null; + branch: string | null; + services: string[]; +}; + +type ActiveEnvironmentSessionSummary = { + id: string | null; + status: AgentSession['status']; + ownerGithubUsername: string | null; + ownedByCurrentUser: boolean; +}; + +export class ActiveEnvironmentSessionError extends Error { + activeSession: ActiveEnvironmentSessionSummary; + + constructor(activeSession: ActiveEnvironmentSessionSummary) { + super( + activeSession.ownerGithubUsername + ? `An active environment session is already running for this environment by ${activeSession.ownerGithubUsername}. Fork the environment into a sandbox instead.` + : 'An active environment session is already running for this environment. Fork the environment into a sandbox instead.' + ); + this.name = 'ActiveEnvironmentSessionError'; + this.activeSession = activeSession; + } +} + +async function restoreDeploys(deploys: Deploy[]): Promise { + if (deploys.length === 0) { + return; + } + + const { DeploymentManager } = await import('server/lib/deploymentManager/deploymentManager'); + await new DeploymentManager(deploys).deploy(); +} + +type SessionSnapshotMap = Record; + +function getSessionSnapshot( + snapshots: SessionSnapshotMap | null | undefined, + deployId: number +): DevModeResourceSnapshot | null { + const snapshot = snapshots?.[String(deployId)]; + return snapshot ?? null; +} + +async function cleanupDevModePatches( + namespace: string, + snapshots: SessionSnapshotMap | null | undefined, + deploys: Deploy[] +): Promise { + if (deploys.length === 0) { + return; + } + + const devModeManager = new DevModeManager(); + for (const deploy of deploys) { + const deploymentName = deploy.uuid || deploy.deployable?.name || deploy.service?.name; + if (!deploymentName) { + continue; + } + + const serviceName = deploy.uuid || deploy.service?.name || deploymentName; + const snapshot = getSessionSnapshot(snapshots, deploy.id); + await devModeManager.disableDevMode(namespace, deploymentName, serviceName, snapshot); + } +} + +async function restoreDevModeDeploys( + namespace: string, + snapshots: SessionSnapshotMap | null | undefined, + deploys: Deploy[] +): Promise { + if (deploys.length === 0) { + return; + } + + // Revert direct dev-mode mutations first so Helm/native deploys do not roll + // forward from a mixed runtime state. + await cleanupDevModePatches(namespace, snapshots, deploys); + await restoreDeploys(deploys); + await cleanupDevModePatches(namespace, snapshots, deploys); +} + +async function deleteAgentRuntimeResources( + namespace: string, + podName: string, + apiKeySecretName: string +): Promise { + await Promise.all([ + deleteAgentEditorService(namespace, podName), + deleteAgentPod(namespace, podName), + deleteAgentApiKeySecret(namespace, apiKeySecretName), + ]); +} + +function isUniqueConstraintError(error: unknown, constraintName: string): boolean { + const knexError = error as { code?: string; constraint?: string }; + return knexError?.code === '23505' && knexError?.constraint === constraintName; +} + +export interface CreateSessionOptions { + userId: string; + userIdentity?: RequestUserIdentity; + githubToken?: string | null; + buildUuid?: string; + buildKind?: BuildKind; + services?: Array<{ name: string; deployId: number; devConfig: DevConfig; resourceName?: string }>; + model?: string; + repoUrl: string; + branch: string; + revision?: string; + prNumber?: number; + namespace: string; + agentImage: string; + editorImage: string; +} + +export default class AgentSessionService { + static async enrichSessions(sessions: AgentSession[]): Promise { + if (sessions.length === 0) { + return []; + } + + const buildUuids = [...new Set(sessions.map((session) => session.buildUuid).filter(Boolean) as string[])]; + const builds = buildUuids.length + ? await Build.query() + .whereIn('uuid', buildUuids) + .withGraphFetched('[pullRequest.[repository], baseBuild.[pullRequest.[repository]]]') + : []; + const buildByUuid = new Map(builds.map((build) => [build.uuid, build])); + + const snapshotDeployIds = [ + ...new Set( + sessions.flatMap((session) => + Object.keys(session.devModeSnapshots || {}) + .map((deployId) => Number(deployId)) + .filter((deployId) => Number.isInteger(deployId)) + ) + ), + ]; + + const liveSessionIds = sessions + .filter((session) => session.status === 'starting' || session.status === 'active') + .map((session) => session.id); + + const [liveDeploys, snapshotDeploys] = await Promise.all([ + liveSessionIds.length + ? Deploy.query().whereIn('devModeSessionId', liveSessionIds).withGraphFetched(SESSION_DEPLOY_GRAPH) + : Promise.resolve([] as Deploy[]), + snapshotDeployIds.length + ? Deploy.query().whereIn('id', snapshotDeployIds).withGraphFetched(SESSION_DEPLOY_GRAPH) + : Promise.resolve([] as Deploy[]), + ]); + + const liveDeploysBySessionId = new Map(); + for (const deploy of liveDeploys) { + if (!deploy.devModeSessionId) { + continue; + } + + const current = liveDeploysBySessionId.get(deploy.devModeSessionId) || []; + current.push(deploy); + liveDeploysBySessionId.set(deploy.devModeSessionId, current); + } + + const snapshotDeployById = new Map(snapshotDeploys.map((deploy) => [deploy.id, deploy])); + + return sessions.map((session) => { + const build = session.buildUuid ? buildByUuid.get(session.buildUuid) : null; + const sessionDeploys = + liveDeploysBySessionId.get(session.id) || + Object.keys(session.devModeSnapshots || {}) + .map((deployId) => snapshotDeployById.get(Number(deployId))) + .filter((deploy): deploy is Deploy => Boolean(deploy)); + const primaryDeploy = sessionDeploys[0]; + const services = [ + ...new Set( + sessionDeploys + .map((deploy) => deploy.deployable?.name || deploy.service?.name || null) + .filter((name): name is string => Boolean(name)) + ), + ]; + + return { + ...session, + id: session.uuid, + uuid: session.uuid, + baseBuildUuid: build?.baseBuild?.uuid || null, + repo: + primaryDeploy?.repository?.fullName || + build?.pullRequest?.fullName || + build?.pullRequest?.repository?.fullName || + build?.baseBuild?.pullRequest?.fullName || + build?.baseBuild?.pullRequest?.repository?.fullName || + null, + branch: + primaryDeploy?.branchName || + build?.pullRequest?.branchName || + build?.baseBuild?.pullRequest?.branchName || + null, + services, + } as AgentSessionSummaryRecord; + }); + } + + static async getEnvironmentActiveSession( + buildUuid: string, + viewerUserId?: string | null + ): Promise { + const session = await AgentSession.query() + .where({ + buildUuid, + buildKind: BuildKind.ENVIRONMENT, + }) + .whereIn('status', ['starting', 'active']) + .orderBy('updatedAt', 'desc') + .orderBy('createdAt', 'desc') + .first(); + + if (!session) { + return null; + } + + return { + id: session.userId === viewerUserId ? session.uuid : null, + status: session.status, + ownerGithubUsername: session.ownerGithubUsername, + ownedByCurrentUser: session.userId === viewerUserId, + }; + } + + static async createSession(opts: CreateSessionOptions) { + const apiKey = await UserApiKeyService.getDecryptedKey(opts.userId, 'anthropic', opts.userIdentity?.githubUsername); + if (!apiKey) { + throw new Error('API_KEY_REQUIRED'); + } + + const sessionUuid = uuid(); + const buildKind = opts.buildKind || BuildKind.ENVIRONMENT; + const podName = `agent-${sessionUuid.slice(0, 8)}`; + const pvcName = `agent-pvc-${sessionUuid.slice(0, 8)}`; + const apiKeySecretName = `agent-secret-${sessionUuid.slice(0, 8)}`; + const model = opts.model || 'claude-sonnet-4-6'; + const mutatedDeploys: number[] = []; + const devModeSnapshots: SessionSnapshotMap = {}; + let sessionPersisted = false; + let session: AgentSession | null = null; + const redis = RedisClient.getInstance().getRedis(); + const claudeConfig = await resolveAgentSessionClaudeConfig(); + const githubAppName = await GlobalConfigService.getInstance().getGithubAppName(); + const claudeCommitAttribution = renderAgentSessionClaudeAttribution( + claudeConfig.attribution.commitTemplate, + githubAppName + ); + const claudePrAttribution = renderAgentSessionClaudeAttribution(claudeConfig.attribution.prTemplate, githubAppName); + const forwardedAgentEnv = await resolveForwardedAgentEnv( + opts.services, + opts.namespace, + sessionUuid, + opts.buildUuid + ); + + try { + session = await AgentSession.query().insertAndFetch({ + uuid: sessionUuid, + buildUuid: opts.buildUuid || null, + buildKind, + userId: opts.userId, + ownerGithubUsername: opts.userIdentity?.githubUsername || null, + podName, + namespace: opts.namespace, + pvcName, + model, + status: 'starting', + devModeSnapshots, + forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, + } as unknown as Partial); + sessionPersisted = true; + + await Promise.all([ + createAgentPvc(opts.namespace, pvcName, '10Gi', opts.buildUuid), + createAgentApiKeySecret(opts.namespace, apiKeySecretName, apiKey, opts.githubToken, opts.buildUuid), + ]); + + const useGvisor = await isGvisorAvailable(); + const installCommands = (opts.services || []) + .map((service) => service.devConfig.installCommand) + .filter((command): command is string => Boolean(command)); + const combinedInstallCommand = installCommands.length > 0 ? installCommands.join('\n\n') : undefined; + + await createAgentPod({ + podName, + namespace: opts.namespace, + pvcName, + image: opts.agentImage, + editorImage: opts.editorImage, + apiKeySecretName, + hasGitHubToken: Boolean(opts.githubToken), + model, + repoUrl: opts.repoUrl, + branch: opts.branch, + revision: opts.revision, + workspacePath: '/workspace', + installCommand: combinedInstallCommand, + claudePermissions: claudeConfig.permissions, + claudeCommitAttribution, + claudePrAttribution, + forwardedAgentEnv: forwardedAgentEnv.env, + forwardedAgentSecretRefs: forwardedAgentEnv.secretRefs, + forwardedAgentSecretServiceName: forwardedAgentEnv.secretServiceName, + useGvisor, + buildUuid: opts.buildUuid, + userIdentity: opts.userIdentity, + }); + + const devModeManager = new DevModeManager(); + for (const svc of opts.services || []) { + const resourceName = svc.resourceName || svc.name; + const snapshot = await devModeManager.enableDevMode({ + namespace: opts.namespace, + deploymentName: resourceName, + serviceName: resourceName, + pvcName, + devConfig: svc.devConfig, + }); + mutatedDeploys.push(svc.deployId); + devModeSnapshots[String(svc.deployId)] = snapshot; + await AgentSession.query() + .findById(session.id) + .patch({ + devModeSnapshots, + } as unknown as Partial); + await Deploy.query().findById(svc.deployId).patch({ + devMode: true, + devModeSessionId: session.id, + }); + } + + await createAgentEditorService(opts.namespace, podName, opts.buildUuid); + + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const netApi = kc.makeApiClient(k8s.NetworkingV1Api); + const policy = buildAgentNetworkPolicy(opts.namespace); + await netApi.createNamespacedNetworkPolicy(opts.namespace, policy).catch((err: any) => { + if (err?.statusCode !== 409) throw err; + }); + await redis.setex( + `${SESSION_REDIS_PREFIX}${sessionUuid}`, + SESSION_REDIS_TTL, + JSON.stringify({ podName, namespace: opts.namespace, status: 'active' }) + ); + + await AgentSession.query() + .findById(session.id) + .patch({ + status: 'active', + } as unknown as Partial); + + session = { + ...session, + status: 'active', + } as AgentSession; + + return session!; + } catch (err) { + if ( + buildKind === BuildKind.ENVIRONMENT && + opts.buildUuid && + isUniqueConstraintError(err, ACTIVE_ENVIRONMENT_SESSION_UNIQUE_INDEX) + ) { + const activeSession = await AgentSessionService.getEnvironmentActiveSession(opts.buildUuid, opts.userId); + if (activeSession) { + throw new ActiveEnvironmentSessionError(activeSession); + } + } + + logger.error(`Session creation failed, rolling back: sessionId=${sessionUuid} err=${(err as Error).message}`); + + const revertPromise = + mutatedDeploys.length > 0 + ? (async () => { + const deploysToRevert = await Deploy.query() + .whereIn('id', mutatedDeploys) + .withGraphFetched(DEV_MODE_REDEPLOY_GRAPH) + .catch(() => [] as Deploy[]); + for (const deployId of mutatedDeploys) { + await Deploy.query() + .findById(deployId) + .patch({ devMode: false, devModeSessionId: null }) + .catch(() => {}); + } + if (deploysToRevert.length > 0) { + await restoreDevModeDeploys(opts.namespace, devModeSnapshots, deploysToRevert).catch(() => {}); + } + })() + : Promise.resolve(); + + await Promise.all([ + revertPromise, + deleteAgentRuntimeResources(opts.namespace, podName, apiKeySecretName).catch(() => {}), + cleanupForwardedAgentEnvSecrets(opts.namespace, sessionUuid, forwardedAgentEnv.secretProviders).catch(() => {}), + ]); + await deleteAgentPvc(opts.namespace, pvcName).catch(() => {}); + + if (sessionPersisted) { + await AgentSession.query() + .findById(session!.id) + .patch({ + status: 'error', + devModeSnapshots: {}, + } as unknown as Partial) + .catch(() => {}); + } else { + await AgentSession.query() + .insert({ + uuid: sessionUuid, + userId: opts.userId, + ownerGithubUsername: opts.userIdentity?.githubUsername || null, + podName, + namespace: opts.namespace, + pvcName, + model, + status: 'error', + buildUuid: opts.buildUuid || null, + buildKind, + devModeSnapshots: {}, + forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, + } as unknown as Partial) + .catch(() => {}); + } + + throw err; + } + } + + static async endSession(sessionId: string): Promise { + const session = await AgentSession.query().findOne({ uuid: sessionId }); + if (!session || (session.status !== 'active' && session.status !== 'starting')) { + throw new Error('Session not found or already ended'); + } + + const apiKeySecretName = `agent-secret-${session.uuid.slice(0, 8)}`; + const redis = RedisClient.getInstance().getRedis(); + + const build = session.buildUuid + ? await Build.query() + .findOne({ uuid: session.buildUuid }) + .withGraphFetched('[deploys.[service, build], pullRequest.[repository]]') + : null; + + if (build?.kind === BuildKind.SANDBOX) { + await AgentSession.query().findById(session.id).patch({ + status: 'ended', + endedAt: new Date().toISOString(), + }); + + await redis.del(`${SESSION_REDIS_PREFIX}${session.uuid}`); + + const { default: BuildService } = await import('./build'); + const buildService = new BuildService(); + + try { + await buildService.deleteQueue.add('delete', { + buildId: build.id, + buildUuid: build.uuid, + sender: 'agent-session', + ...extractContextForQueue(), + }); + } catch (error) { + logger.warn( + { error, buildUuid: build.uuid, sessionId }, + 'Sandbox delete queue enqueue failed, falling back to synchronous cleanup' + ); + await buildService.deleteBuild(build); + } + + logger.info(`Sandbox session ended and cleanup queued: sessionId=${sessionId} buildUuid=${build.uuid}`); + return; + } + + const devModeDeploys = await Deploy.query() + .where({ devModeSessionId: session.id, devMode: true }) + .withGraphFetched(DEV_MODE_REDEPLOY_GRAPH); + for (const deploy of devModeDeploys) { + await Deploy.query().findById(deploy.id).patch({ devMode: false, devModeSessionId: null }); + } + + const restorePromise = + devModeDeploys.length > 0 + ? restoreDevModeDeploys(session.namespace, session.devModeSnapshots, devModeDeploys) + : Promise.resolve(); + + await Promise.all([ + restorePromise, + deleteAgentRuntimeResources(session.namespace, session.podName, apiKeySecretName), + cleanupForwardedAgentEnvSecrets(session.namespace, session.uuid, session.forwardedAgentSecretProviders), + ]); + await deleteAgentPvc(session.namespace, session.pvcName); + + await AgentSession.query().findById(session.id).patch({ + status: 'ended', + endedAt: new Date().toISOString(), + devModeSnapshots: {}, + }); + + await redis.del(`${SESSION_REDIS_PREFIX}${session.uuid}`); + + logger.info(`Session ended: sessionId=${sessionId}`); + } + + static async getSession(sessionId: string) { + const session = await AgentSession.query().findOne({ uuid: sessionId }); + if (!session) { + return null; + } + + const [enrichedSession] = await AgentSessionService.enrichSessions([session]); + return enrichedSession || null; + } + + static async getActiveSessions(userId: string) { + return AgentSession.query() + .where({ userId }) + .whereIn('status', ['starting', 'active']) + .orderBy('updatedAt', 'desc') + .orderBy('createdAt', 'desc'); + } + + static async getSessions(userId: string, options?: { includeEnded?: boolean }) { + const query = AgentSession.query().where({ userId }); + + if (!options?.includeEnded) { + query.whereIn('status', ['starting', 'active']); + } + + const sessions = await query.orderBy('updatedAt', 'desc').orderBy('createdAt', 'desc'); + return AgentSessionService.enrichSessions(sessions); + } + + static async touchActivity(sessionId: string): Promise { + const session = await AgentSession.query().findOne({ uuid: sessionId }).select('id'); + if (!session) { + return; + } + + await AgentSession.query().findById(session.id).patch({ lastActivity: new Date().toISOString() }); + } +} diff --git a/src/server/services/agentSessionCandidates.ts b/src/server/services/agentSessionCandidates.ts new file mode 100644 index 0000000..b2e1823 --- /dev/null +++ b/src/server/services/agentSessionCandidates.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Build from 'server/models/Build'; +import type { Deploy } from 'server/models'; +import { fetchLifecycleConfig, type LifecycleConfig } from 'server/models/yaml'; +import { + getDeployType, + hasLifecycleManagedDockerBuild, + type DevConfig, + type Service as LifecycleService, +} from 'server/models/yaml/YamlService'; +import { DeployTypes } from 'shared/constants'; + +export interface AgentSessionServiceCandidate { + name: string; + type: DeployTypes; + detail?: string; + deployId: number; + devConfig: DevConfig; + baseDeploy: Deploy; +} + +export async function loadAgentSessionServiceCandidates(buildUuid: string): Promise { + const build = await Build.query() + .findOne({ uuid: buildUuid }) + .withGraphFetched('[pullRequest, deploys.[deployable]]'); + if (!build?.pullRequest) { + throw new Error('Build not found'); + } + + const lifecycleConfig = await fetchLifecycleConfig(build.pullRequest.fullName, build.pullRequest.branchName); + if (!lifecycleConfig) { + throw new Error('Lifecycle config not found for build'); + } + + return resolveAgentSessionServiceCandidates(build.deploys || [], lifecycleConfig); +} + +export function resolveRequestedAgentSessionServices( + candidates: AgentSessionServiceCandidate[], + requestedServices: string[] +): AgentSessionServiceCandidate[] { + const candidatesByName = new Map(candidates.map((candidate) => [candidate.name, candidate])); + const missingServices: string[] = []; + + const resolved = requestedServices.flatMap((serviceName) => { + const candidate = candidatesByName.get(serviceName); + if (!candidate) { + missingServices.push(serviceName); + return []; + } + + return [candidate]; + }); + + if (missingServices.length > 0) { + throw new Error(`Unknown services for build: ${missingServices.join(', ')}`); + } + + return resolved; +} + +export function resolveAgentSessionServiceCandidates( + deploys: Deploy[], + lifecycleConfig: LifecycleConfig +): AgentSessionServiceCandidate[] { + const activeDeploysByName = new Map( + deploys + .filter((deploy) => deploy.active && deploy.deployable?.name && deploy.id != null) + .map((deploy) => [deploy.deployable!.name, deploy]) + ); + + return lifecycleConfig.services.flatMap((service) => { + if (!isSessionSelectableService(service)) { + return []; + } + + const baseDeploy = activeDeploysByName.get(service.name); + if (!baseDeploy?.id) { + return []; + } + + return [ + { + name: service.name, + type: getDeployType(service), + detail: baseDeploy.status, + deployId: baseDeploy.id, + devConfig: service.dev!, + baseDeploy, + }, + ]; + }); +} + +function isSessionSelectableService(service: LifecycleService): boolean { + return !!service.dev && hasLifecycleManagedDockerBuild(service); +} diff --git a/src/server/services/ai/orchestration/__tests__/safety.test.ts b/src/server/services/ai/orchestration/__tests__/safety.test.ts index 5eaabef..0ab752b 100644 --- a/src/server/services/ai/orchestration/__tests__/safety.test.ts +++ b/src/server/services/ai/orchestration/__tests__/safety.test.ts @@ -110,14 +110,14 @@ describe('ToolSafetyManager', () => { expect(callbacks.onToolConfirmation).toHaveBeenCalledWith(confirmDetails); }); - it('user cancellation returns USER_CANCELLED error', async () => { + it('unconfirmed request returns AWAITING_APPROVAL error', async () => { const callbacks = makeCallbacks({ onToolConfirmation: jest.fn().mockResolvedValue(false) }); const tool = makeTool({ safetyLevel: ToolSafetyLevel.DANGEROUS, shouldConfirmExecution: jest.fn().mockResolvedValue(confirmDetails), }); const result = await manager.safeExecute(tool, {}, callbacks); - expect(result.error?.code).toBe('USER_CANCELLED'); + expect(result.error?.code).toBe('AWAITING_APPROVAL'); }); it('missing onToolConfirmation returns NO_CONFIRMATION_HANDLER', async () => { diff --git a/src/server/services/ai/orchestration/orchestrator.ts b/src/server/services/ai/orchestration/orchestrator.ts index e0bbb04..7e0689b 100644 --- a/src/server/services/ai/orchestration/orchestrator.ts +++ b/src/server/services/ai/orchestration/orchestrator.ts @@ -275,8 +275,26 @@ export class ToolOrchestrator { }; } const toolStartTime = Date.now(); + const tool = this.toolRegistry.get(toolCalls[i].name); + if (!tool) { + const registeredNames = this.toolRegistry.getAll().map((t) => t.name); + return { + index: i, + result: { + success: false, + error: { + message: `Tool "${toolCalls[i].name}" is not available. Available tools: ${registeredNames.join( + ', ' + )}`, + code: 'TOOL_NOT_FOUND', + recoverable: true, + }, + }, + toolDuration: 0, + }; + } const result = await this.safetyManager.safeExecute( - this.toolRegistry.get(toolCalls[i].name)!, + tool, toolCalls[i].arguments, callbacks, signal, diff --git a/src/server/services/ai/orchestration/safety.ts b/src/server/services/ai/orchestration/safety.ts index fab7fa6..4e87a9d 100644 --- a/src/server/services/ai/orchestration/safety.ts +++ b/src/server/services/ai/orchestration/safety.ts @@ -101,8 +101,8 @@ export class ToolSafetyManager { return { success: false, error: { - message: 'Operation cancelled by user', - code: 'USER_CANCELLED', + message: `${tool.name} requires user approval. The request has been sent to the user for confirmation. Do not attempt alternative approaches or workarounds — wait for the user to approve or deny this operation.`, + code: 'AWAITING_APPROVAL', recoverable: false, }, }; @@ -110,8 +110,9 @@ export class ToolSafetyManager { } } + const effectiveTimeout = tool.executionTimeout ?? this.toolExecutionTimeout; try { - const result = await this.withTimeout(tool.execute(args, signal), this.toolExecutionTimeout); + const result = await this.withTimeout(tool.execute(args, signal), effectiveTimeout); if (result.success && result.agentContent) { result.agentContent = OutputLimiter.truncate(result.agentContent, this.toolOutputMaxChars); @@ -123,12 +124,12 @@ export class ToolSafetyManager { } catch (error: any) { if (error.message === 'Tool execution timeout') { getLogger().warn( - `AI: tool timeout tool=${tool.name} timeout=${this.toolExecutionTimeout}ms buildUuid=${buildUuid || 'none'}` + `AI: tool timeout tool=${tool.name} timeout=${effectiveTimeout}ms buildUuid=${buildUuid || 'none'}` ); return { success: false, error: { - message: `${tool.name} timed out after ${this.toolExecutionTimeout / 1000} seconds`, + message: `${tool.name} timed out after ${effectiveTimeout / 1000} seconds`, code: 'TIMEOUT', recoverable: true, suggestedAction: 'The operation took too long. Try narrowing your query.', diff --git a/src/server/services/ai/service.ts b/src/server/services/ai/service.ts index 2d12718..040fb67 100644 --- a/src/server/services/ai/service.ts +++ b/src/server/services/ai/service.ts @@ -363,6 +363,9 @@ export class AIAgentCore { if (this.mode === 'investigate') { const writingToolNames = [UpdateFileTool.Name, UpdatePrLabelsTool.Name, PatchK8sResourceTool.Name]; allTools = allTools.filter((tool) => !writingToolNames.includes(tool.name)); + } else if (this.mode === 'fix') { + const directWriteTools = [UpdateFileTool.Name, PatchK8sResourceTool.Name]; + allTools = allTools.filter((tool) => !directWriteTools.includes(tool.name)); } if (this.excludedTools && this.excludedTools.length > 0) { @@ -398,7 +401,7 @@ export class AIAgentCore { if (this.mode === 'investigate') { const existing = new Set(tools.map((tool) => tool.name)); const excluded = new Set(this.excludedTools || []); - const fixModeToolHints = [ + const fixModeToolHints: typeof tools = [ { name: UpdateFileTool.Name, description: 'Update repository files (available in fix mode)', diff --git a/src/server/services/ai/tools/baseTool.ts b/src/server/services/ai/tools/baseTool.ts index fbdbaff..1fb17f5 100644 --- a/src/server/services/ai/tools/baseTool.ts +++ b/src/server/services/ai/tools/baseTool.ts @@ -31,6 +31,7 @@ export abstract class BaseTool implements Tool { public readonly parameters: JSONSchema; public readonly safetyLevel: ToolSafetyLevel; public readonly category: ToolCategory; + public readonly executionTimeout?: number; constructor(description: string, parameters: JSONSchema, safetyLevel: ToolSafetyLevel, category: ToolCategory) { this.description = description; diff --git a/src/server/services/ai/types/tool.ts b/src/server/services/ai/types/tool.ts index a9fe407..97e4293 100644 --- a/src/server/services/ai/types/tool.ts +++ b/src/server/services/ai/types/tool.ts @@ -73,6 +73,7 @@ export interface ConfirmationDetails { description: string; impact: string; confirmButtonText: string; + toolName?: string; onConfirm?(): Promise; } @@ -92,6 +93,7 @@ export interface Tool { parameters: JSONSchema; safetyLevel: ToolSafetyLevel; category: ToolCategory; + executionTimeout?: number; execute(args: Record, signal?: AbortSignal): Promise; diff --git a/src/server/services/build.ts b/src/server/services/build.ts index cec9826..76c4a62 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -24,7 +24,7 @@ import { customAlphabet, nanoid } from 'nanoid'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; import { Build, Deploy, Environment, Service, BuildServiceOverride } from 'server/models'; -import { BuildStatus, CLIDeployTypes, DeployStatus, DeployTypes } from 'shared/constants'; +import { BuildKind, BuildStatus, CLIDeployTypes, DeployStatus, DeployTypes } from 'shared/constants'; import { type DeployOptions } from './deploy'; import DeployService from './deploy'; import BaseService from './_service'; @@ -43,6 +43,7 @@ import { Tracer } from 'server/lib/tracer'; import { redisClient } from 'server/lib/dependencies'; import { generateGraph } from 'server/lib/dependencyGraph'; import GlobalConfigService from './globalConfig'; +import IngressService from './ingress'; import { paginate, PaginationMetadata, PaginationParams } from 'server/lib/paginate'; import { getYamlFileContentFromBranch } from 'server/lib/github'; import WebhookService from './webhook'; @@ -61,6 +62,7 @@ export interface IngressConfiguration { export default class BuildService extends BaseService { fastly = new Fastly(this.redis); + ingressService = new IngressService(this.db, this.redis, this.redlock, this.queueManager); /** * For every build that is not closed * 1. Check if the PR is open, if not, destroy @@ -100,6 +102,7 @@ export default class BuildService extends BaseService { */ async activeBuilds(): Promise { const builds = await this.db.models.Build.query() + .where('kind', BuildKind.ENVIRONMENT) .whereNot('status', 'torn_down') .whereNot('status', 'pending') .withGraphFetched('deploys.[service.[repository]]'); @@ -125,7 +128,8 @@ export default class BuildService extends BaseService { const exclude = excludeStatuses ? excludeStatuses.split(',').map((s) => s.trim()) : []; const baseQuery = this.db.models.Build.query() - .select('id', 'uuid', 'status', 'namespace', 'createdAt', 'updatedAt', 'isStatic') + .select('id', 'uuid', 'status', 'namespace', 'createdAt', 'updatedAt', 'isStatic', 'kind', 'baseBuildId') + .where('kind', BuildKind.ENVIRONMENT) .whereNotIn('status', exclude) .modify((qb) => { if (filterByAuthor) { @@ -181,12 +185,17 @@ export default class BuildService extends BaseService { 'createdAt', 'updatedAt', 'dependencyGraph', - 'isStatic' + 'isStatic', + 'kind', + 'baseBuildId' ) - .withGraphFetched('[pullRequest, deploys.[deployable, repository]]') + .withGraphFetched('[baseBuild, pullRequest, deploys.[deployable, repository]]') .modifyGraph('pullRequest', (b) => { b.select('id', 'title', 'fullName', 'githubLogin', 'pullRequestNumber', 'branchName', 'status', 'labels'); }) + .modifyGraph('baseBuild', (b) => { + b.select('id', 'uuid'); + }) .modifyGraph('deploys', (b) => { b.select( 'id', @@ -194,6 +203,7 @@ export default class BuildService extends BaseService { 'status', 'statusMessage', 'active', + 'devMode', 'deployableId', 'branchName', 'deployPipelineId', @@ -242,7 +252,7 @@ export default class BuildService extends BaseService { throw new Error(`Deployable ${serviceName} not found for ${buildUuid}.`); } - const githubRepositoryId = deploy.deployable.repositoryId; + const githubRepositoryId = Number(deploy.deployable.repositoryId); const runUUID = nanoid(); @@ -595,7 +605,7 @@ export default class BuildService extends BaseService { pullRequestId, environmentId, lifecycleConfig, - }: DeployOptions & { repositoryId: string }) { + }: DeployOptions & { repositoryId: number }) { const environments = await this.getEnvironmentsToBuild(environmentId, repositoryId); if (!environments.length) { @@ -851,7 +861,7 @@ export default class BuildService extends BaseService { private async setupDefaultBuildServiceOverrides( build: Build, environment: Environment, - repositoryId: string, + repositoryId: number, branchName: string ): Promise { // Deal with database configuration first @@ -914,7 +924,6 @@ export default class BuildService extends BaseService { if (build?.uuid) { updateLogContext({ buildUuid: build.uuid }); } - getLogger().debug('Build: triggering cleanup'); await this.updateStatusAndComment(build, BuildStatus.TEARING_DOWN, build.runUUID, true, true).catch((error) => { @@ -937,10 +946,12 @@ export default class BuildService extends BaseService { ); await k8s.deleteNamespace(build.namespace); - await this.db.services.Ingress.ingressCleanupQueue.add('cleanup', { - buildId: build.id, - ...extractContextForQueue(), - }); + if (this.db.services?.Ingress?.ingressCleanupQueue) { + await this.ingressService.ingressCleanupQueue.add('cleanup', { + buildId: build.id, + ...extractContextForQueue(), + }); + } getLogger().info('Build: deleted'); await this.updateStatusAndComment(build, BuildStatus.TORN_DOWN, build.runUUID, true, true).catch((error) => { getLogger().warn({ error }, `Build: status update failed status=${BuildStatus.TORN_DOWN}`); @@ -974,7 +985,8 @@ export default class BuildService extends BaseService { await build?.$fetchGraph('[deploys.[service, deployable], pullRequest.[repository]]'); const { deploys, pullRequest } = build; - const { repository } = pullRequest; + const isSandboxBuild = build.kind === BuildKind.SANDBOX; + const repository = pullRequest?.repository; if (build.runUUID !== runUUID) { return; @@ -998,22 +1010,29 @@ export default class BuildService extends BaseService { } await build.$query().patch({ dashboardLinks }); - await this.db.services.ActivityStream.updatePullRequestActivityStream( - build, - deploys, - pullRequest, - repository, - updateMissionControl, - updateStatus, - error - ).catch((e) => { - getLogger().error({ error: e }, 'ActivityStream: update failed'); - }); + if (!isSandboxBuild && pullRequest && repository) { + await this.db.services.ActivityStream.updatePullRequestActivityStream( + build, + deploys, + pullRequest, + repository, + updateMissionControl, + updateStatus, + error + ).catch((e) => { + getLogger().error({ error: e }, 'ActivityStream: update failed'); + }); + } } } finally { getLogger().debug(`Build status changed: status=${build.status}`); - await this.db.services.Webhook.webhookQueue.add('webhook', { buildId: build.id, ...extractContextForQueue() }); + if (build.kind !== BuildKind.SANDBOX) { + await this.db.services.Webhook.webhookQueue.add('webhook', { + buildId: build.id, + ...extractContextForQueue(), + }); + } } }); } @@ -1203,7 +1222,7 @@ export default class BuildService extends BaseService { namespace, }: { build: Build; - githubRepositoryId: string; + githubRepositoryId: number | null; namespace: string; }): Promise { if (build?.enableFullYaml) { @@ -1267,7 +1286,7 @@ export default class BuildService extends BaseService { } // Queue ingress creation after all deployments - await this.db.services.Ingress.ingressManifestQueue.add('manifest', { + await this.ingressService.ingressManifestQueue.add('manifest', { buildId, ...extractContextForQueue(), }); @@ -1330,7 +1349,7 @@ export default class BuildService extends BaseService { } /* Generate the nginx manifests for this new build */ - await this.db.services.Ingress.ingressManifestQueue.add('manifest', { + await this.ingressService.ingressManifestQueue.add('manifest', { buildId, ...extractContextForQueue(), }); @@ -1367,7 +1386,7 @@ export default class BuildService extends BaseService { * @param environmentId the default environmentId (if one exists) * @param repositoryId the repository to use for finding relevant environments, if needed */ - private async getEnvironmentsToBuild(environmentId: number, repositoryId: string) { + private async getEnvironmentsToBuild(environmentId: number, repositoryId: number) { let environments: Environment[] = []; if (environmentId != null) { environments.push(await this.db.models.Environment.findOne({ id: environmentId })); diff --git a/src/server/services/deploy.ts b/src/server/services/deploy.ts index 0713f46..64ed3b8 100644 --- a/src/server/services/deploy.ts +++ b/src/server/services/deploy.ts @@ -19,7 +19,7 @@ import { Environment, Build, Service, Deploy, Deployable } from 'server/models'; import * as codefresh from 'server/lib/codefresh'; import { getLogger, withLogContext, extractContextForQueue } from 'server/lib/logger'; import hash from 'object-hash'; -import { DeployStatus, DeployTypes } from 'shared/constants'; +import { BuildKind, DeployStatus, DeployTypes } from 'shared/constants'; import * as cli from 'server/lib/cli'; import RDS from 'aws-sdk/clients/rds'; import resourceGroupsTagging from 'aws-sdk/clients/resourcegroupstaggingapi'; @@ -41,7 +41,7 @@ import { SecretProcessor } from 'server/services/secretProcessor'; export interface DeployOptions { ownerId?: number; - repositoryId?: string; + repositoryId?: number; installationId?: number; repositoryBranchName?: string; isDeploy?: boolean; @@ -52,7 +52,6 @@ export interface DeployOptions { export interface PipelineWaitItem { dependentDeploy: Deploy; - awaitingDeploy: Deploy; pipelineId: string; serviceName: string; patternInfo: PatternInfo[]; @@ -89,7 +88,8 @@ export default class DeployService extends BaseService { deployables.map(async (deployable) => { const uuid = `${deployable.name}-${build?.uuid}`; const patchFields: Objection.PartialModelObject = {}; - const isTargetRepo = !githubRepositoryId || deployable.repositoryId === githubRepositoryId; + const deployableRepositoryId = Number(deployable.repositoryId); + const isTargetRepo = !githubRepositoryId || deployableRepositoryId === githubRepositoryId; let deploy = existingDeployMap.get(deployable.id) ?? null; if (!deploy) { @@ -122,7 +122,7 @@ export default class DeployService extends BaseService { deployableId: deployable?.id ?? null, uuid, internalHostname: uuid, - githubRepositoryId: deployable.repositoryId, + githubRepositoryId: deployableRepositoryId, active: deployable.active, }); @@ -270,7 +270,7 @@ export default class DeployService extends BaseService { deployableId: deployable?.id ?? null, uuid, internalHostname: uuid, - githubRepositoryId: service.repositoryId, + githubRepositoryId: Number(service.repositoryId), active, }); @@ -811,6 +811,7 @@ export default class DeployService extends BaseService { await deploy.$fetchGraph('build.pullRequest'); build = deploy?.build; const pullRequest = build?.pullRequest; + const isSandboxBuild = build?.kind === BuildKind.SANDBOX; const terminalStatuses = [ DeployStatus.READY, @@ -822,24 +823,26 @@ export default class DeployService extends BaseService { ]; const isTerminalStatus = terminalStatuses.includes(params.status as DeployStatus); - if (isTerminalStatus && build?.githubDeployments) { + if (isTerminalStatus && build?.githubDeployments && !isSandboxBuild) { await deploy.$fetchGraph('[service, deployable]'); if (await this.shouldTriggerGithubDeployment(deploy)) { await this.triggerGithubDeploymentUpdate(deploy); } } - await this.db.services.ActivityStream.updatePullRequestActivityStream( - build, - [], - pullRequest, - null, - true, - true, - null, - true, - targetGithubRepositoryId - ); + if (!isSandboxBuild && pullRequest) { + await this.db.services.ActivityStream.updatePullRequestActivityStream( + build, + [], + pullRequest, + null, + true, + true, + null, + true, + targetGithubRepositoryId + ); + } } catch (error) { getLogger().warn({ error }, 'ActivityFeed: update failed'); } @@ -885,7 +888,6 @@ export default class DeployService extends BaseService { private async patchDeployWithTag({ tag, deploy, initTag, ecrDomain }) { await deploy.$fetchGraph('[build, service, deployable]'); const { build, deployable, service } = deploy; - const _uuid = build?.uuid; let ecrRepo = deployable?.ecr as string; const serviceName = build?.enableFullYaml ? deployable?.name : service?.name; @@ -1170,13 +1172,11 @@ export default class DeployService extends BaseService { async waitAndResolveForBuildDependentEnvVars(deploy: Deploy, envVariables: Record, runUUID: string) { const pipelineIdsToWaitFor: PipelineWaitItem[] = []; - const awaitingDeploy = deploy; const { build } = deploy; const deploys = build.deploys; const servicesToWaitFor = extractEnvVarsWithBuildDependencies(deploy.deployable.env); for (const [serviceName, patternsInfo] of Object.entries(servicesToWaitFor)) { - const _awaitingService = deploy.uuid; const waitingForService = `${serviceName}-${build.uuid}`; const dependentDeploy = deploys.find((d) => d.uuid === waitingForService); @@ -1195,7 +1195,6 @@ export default class DeployService extends BaseService { if (updatedDeploy?.buildPipelineId) { pipelineIdsToWaitFor.push({ dependentDeploy, - awaitingDeploy, pipelineId: updatedDeploy.buildPipelineId, serviceName, patternInfo: patternsInfo, @@ -1206,7 +1205,7 @@ export default class DeployService extends BaseService { const extractedValues = {}; const pipelinePromises = pipelineIdsToWaitFor.map( - async ({ dependentDeploy, awaitingDeploy, pipelineId, serviceName, patternInfo }: PipelineWaitItem) => { + async ({ dependentDeploy, pipelineId, serviceName, patternInfo }: PipelineWaitItem) => { try { const updatedDeploy = await waitForColumnValue(dependentDeploy, 'buildOutput', 240, 5000); diff --git a/src/server/services/deployable.ts b/src/server/services/deployable.ts index 887908e..1d03924 100644 --- a/src/server/services/deployable.ts +++ b/src/server/services/deployable.ts @@ -34,7 +34,7 @@ export interface DeployableAttributes { layer?: number; type: string; dockerImage?: string; - repositoryId?: string; + repositoryId?: number; defaultTag?: string; dockerfilePath?: string; // buildArgs: NOT IN USE @@ -307,7 +307,7 @@ export default class DeployableService extends BaseService { name: service.name, type: YamlService.getDeployType(service), dockerImage: YamlService.getDockerImage(service), - repositoryId: repositoryId ? `${repositoryId}` : null, + repositoryId: repositoryId ?? null, branchName, defaultBranchName, defaultTag: await YamlService.getDefaultTag(service), diff --git a/src/server/services/github.ts b/src/server/services/github.ts index 98570a1..a6f082f 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -126,7 +126,7 @@ export default class GithubService extends Service { const isDeploy = pullRequest?.deployOnUpdate; // only create build and deploys. do not build or deploy here await this.db.services.BuildService.createBuildAndDeploys({ - repositoryId: repositoryId.toString(), + repositoryId, repositoryBranchName: branch, installationId, pullRequestId, @@ -199,7 +199,7 @@ export default class GithubService extends Service { label: changedLabel, pull_request: { id: githubPullRequestId, labels, state: status }, } = body; - let pullRequest: PullRequest, build: Build, _repository: Repository; + let pullRequest: PullRequest, build: Build; try { const changedLabelName = changedLabel?.name?.toLowerCase(); const isLifecycle = await isLifecycleLabel(changedLabelName); @@ -224,7 +224,6 @@ export default class GithubService extends Service { await pullRequest.$fetchGraph('[build, repository]'); build = pullRequest?.build; - _repository = pullRequest?.repository; await this.patchPullRequest({ pullRequest, labels, @@ -286,6 +285,10 @@ export default class GithubService extends Service { } const deploysToRebuild = allDeploys.filter((deploy) => { if (!deploy?.build) return false; + if (deploy.devMode) { + getLogger().info(`Push: skipping dev mode service deployId=${deploy.id} service=${deploy.service?.name}`); + return false; + } const serviceBranchName: string = deploy.build.enableFullYaml ? deploy.deployable.defaultBranchName : deploy.service.branchName; diff --git a/src/server/services/globalConfig.ts b/src/server/services/globalConfig.ts index e860e51..e05e67b 100644 --- a/src/server/services/globalConfig.ts +++ b/src/server/services/globalConfig.ts @@ -15,6 +15,7 @@ */ import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/core'; import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; import BaseService from './_service'; import { GlobalConfig, LabelsConfig } from './types/globalConfig'; @@ -24,6 +25,7 @@ import { redisClient } from 'server/lib/dependencies'; const REDIS_CACHE_KEY = 'global_config'; const GITHUB_CACHED_CLIENT_TOKEN = 'github_cached_client_token'; +const GITHUB_CACHED_APP_INFO = 'github_cached_app_info'; export default class GlobalConfigService extends BaseService { private static instance: GlobalConfigService; @@ -151,8 +153,12 @@ export default class GlobalConfigService extends BaseService { } private deserialize(config: unknown): GlobalConfig { - const deserializedConfigs = {}; - for (const [key, value] of Object.entries(config)) { + if (!config || typeof config !== 'object') { + return {} as GlobalConfig; + } + + const deserializedConfigs: Partial = {}; + for (const [key, value] of Object.entries(config as Record)) { try { deserializedConfigs[key as keyof GlobalConfig] = JSON.parse(value as string); } catch (e) { @@ -178,6 +184,41 @@ export default class GlobalConfigService extends BaseService { return cachedGithubClientToken?.token; } + async getGithubAppName(refreshCache = false): Promise { + const cachedGithubAppInfo = (await this.redis.hgetall(GITHUB_CACHED_APP_INFO)) || {}; + const cachedName = typeof cachedGithubAppInfo?.name === 'string' ? cachedGithubAppInfo.name.trim() : ''; + if (cachedName && !refreshCache) { + return cachedName; + } + + try { + const appAuth = createAppAuth(APP_AUTH); + const { token } = await appAuth({ type: 'app' }); + const octokit = new Octokit({ auth: token }); + const response = await octokit.request('GET /app'); + const resolvedName = String(response.data?.name || response.data?.slug || '').trim(); + + if (resolvedName) { + await this.redis.hmset(GITHUB_CACHED_APP_INFO, { name: resolvedName }); + return resolvedName; + } + } catch (error) { + getLogger().warn({ error }, 'Config: GitHub app metadata lookup failed'); + } + + try { + const { app_setup } = await this.getAllConfigs(); + const configuredName = app_setup?.name?.trim(); + if (configuredName) { + return configuredName; + } + } catch (error) { + getLogger().warn({ error }, 'Config: app setup fallback lookup failed'); + } + + return null; + } + /** * Setup a job to refresh the global config cache every hour * @@ -188,6 +229,7 @@ export default class GlobalConfigService extends BaseService { if (isDev) { try { await this.getGithubClientToken(true); + await this.getGithubAppName(true); } catch (error) { getLogger().error({ error }, 'Config: cache refresh failed during=boot'); } @@ -211,9 +253,12 @@ export default class GlobalConfigService extends BaseService { return withLogContext({ correlationId: correlationId || `cache-refresh-${Date.now()}` }, async () => { try { - getLogger({ stage: LogStage.CONFIG_REFRESH }).info('Config: refreshing type=global_config,github_token'); + getLogger({ stage: LogStage.CONFIG_REFRESH }).info( + 'Config: refreshing type=global_config,github_token,github_app' + ); await this.getAllConfigs(true); await this.getGithubClientToken(true); + await this.getGithubAppName(true); getLogger({ stage: LogStage.CONFIG_REFRESH }).debug('GlobalConfig and Github cache refreshed successfully'); } catch (error) { getLogger({ stage: LogStage.CONFIG_FAILED }).error({ error }, 'Config: cache refresh failed'); diff --git a/src/server/services/ingress.ts b/src/server/services/ingress.ts index de74e5a..8350f5b 100644 --- a/src/server/services/ingress.ts +++ b/src/server/services/ingress.ts @@ -24,6 +24,7 @@ import { shellPromise } from 'server/lib/shell'; import yaml from 'js-yaml'; import { redisClient } from 'server/lib/dependencies'; import GlobalConfigService from './globalConfig'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; const MANIFEST_PATH = `${TMP_PATH}/ingress`; @@ -144,7 +145,7 @@ export default class IngressService extends BaseService { name: `ingress-${configuration.deployUUID}`, annotations, labels: { - lc_uuid: configuration.deployUUID, + ...buildLifecycleLabels({ buildUuid: configuration.deployUUID }), }, }, spec: { diff --git a/src/server/services/types/github.ts b/src/server/services/types/github.ts index a64ad43..04cbd3c 100644 --- a/src/server/services/types/github.ts +++ b/src/server/services/types/github.ts @@ -14,11 +14,14 @@ * limitations under the License. */ -import * as Webhooks from '@octokit/webhooks'; import { GithubService } from 'server/models/yaml'; import Service from 'server/services/_service'; -export type PullRequestLabel = Partial; +export type PullRequestLabel = Partial<{ + id: number; + name: string; + color: string; +}>; export type BranchData = { action: string; branch: string; @@ -37,7 +40,7 @@ export type BranchData = { }; export type GithubPullRequestData = { - action: Webhooks.EventPayloads.WebhookPayloadPullRequest['action'] | string; + action: string; installation: { id: number; }; @@ -79,13 +82,19 @@ export type GithubIssueCommentData = { }; export type GithubInstallationData = { - action: Webhooks.EventPayloads.WebhookPayloadInstallation['action'] | string; + action: string; installation: { account: { login: string; }; }; - repositories: Partial[]; + repositories: Array< + Partial<{ + id: number; + name: string; + full_name: string; + }> + >; }; // findOrCreateDefaultService take in a Repository Model but only needs an ID diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index 17a94f7..6f4de32 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -20,6 +20,7 @@ export type GlobalConfig = { lifecycleDefaults: LifecycleDefaults; helmDefaults: HelmDefaults; buildDefaults?: BuildDefaults; + agentSessionDefaults?: AgentSessionDefaults; postgresql: Helm; mysql: Helm; redis: Helm; @@ -48,9 +49,32 @@ export type AppSetup = { restarted: boolean; url: string; state: string; + name?: string; org?: string; }; +export type AgentSessionClaudePermissions = { + allow?: string[]; + deny?: string[]; +}; + +export type AgentSessionClaudeAttribution = { + commitTemplate?: string; + prTemplate?: string; +}; + +export type AgentSessionClaudeConfig = { + permissions?: AgentSessionClaudePermissions; + attribution?: AgentSessionClaudeAttribution; + appendSystemPrompt?: string; +}; + +export type AgentSessionDefaults = { + image?: string | null; + editorImage?: string | null; + claude?: AgentSessionClaudeConfig; +}; + export type RoleSettings = { role: string; name?: string; diff --git a/src/server/services/userApiKey.ts b/src/server/services/userApiKey.ts new file mode 100644 index 0000000..ac0695e --- /dev/null +++ b/src/server/services/userApiKey.ts @@ -0,0 +1,116 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'server/lib/dependencies'; +import UserApiKey from 'server/models/UserApiKey'; +import { encrypt, decrypt, maskApiKey } from 'server/lib/encryption'; + +export default class UserApiKeyService { + private static getOwnerKey(userId: string, ownerGithubUsername?: string | null): string { + const normalizedOwner = ownerGithubUsername?.trim(); + return normalizedOwner || userId; + } + + private static async reconcileRecordOwnership( + record: UserApiKey, + userId: string, + ownerGithubUsername: string + ): Promise { + if (record.userId === userId && record.ownerGithubUsername === ownerGithubUsername) { + return record; + } + + await UserApiKey.query().where({ id: record.id }).patch({ userId, ownerGithubUsername }); + + record.userId = userId; + record.ownerGithubUsername = ownerGithubUsername; + return record; + } + + private static async findRecord(userId: string, provider: string, ownerGithubUsername?: string | null) { + const canonicalOwner = this.getOwnerKey(userId, ownerGithubUsername); + + const ownerMatch = await UserApiKey.query().where({ ownerGithubUsername: canonicalOwner, provider }).first(); + if (ownerMatch) { + return this.reconcileRecordOwnership(ownerMatch, userId, canonicalOwner); + } + + if (canonicalOwner === userId) { + return null; + } + + const fallbackMatch = await UserApiKey.query().where({ userId, provider }).first(); + if (!fallbackMatch) { + return null; + } + + return this.reconcileRecordOwnership(fallbackMatch, userId, canonicalOwner); + } + + static async storeKey( + userId: string, + provider: string, + apiKey: string, + ownerGithubUsername?: string | null + ): Promise { + const encryptedKey = encrypt(apiKey); + const canonicalOwner = this.getOwnerKey(userId, ownerGithubUsername); + const existing = await this.findRecord(userId, provider, ownerGithubUsername); + if (existing) { + await UserApiKey.query() + .where({ id: existing.id }) + .patch({ userId, encryptedKey, ownerGithubUsername: canonicalOwner }); + } else { + await UserApiKey.query().insertAndFetch({ + userId, + ownerGithubUsername: canonicalOwner, + provider, + encryptedKey, + }); + } + } + + static async getMaskedKey(userId: string, provider: string, ownerGithubUsername?: string | null) { + const record = await this.findRecord(userId, provider, ownerGithubUsername); + if (!record) return null; + const decrypted = decrypt(record.encryptedKey); + return { + provider: record.provider, + maskedKey: maskApiKey(decrypted), + updatedAt: record.updatedAt, + }; + } + + static async getDecryptedKey( + userId: string, + provider: string, + ownerGithubUsername?: string | null + ): Promise { + const record = await this.findRecord(userId, provider, ownerGithubUsername); + if (!record) return null; + return decrypt(record.encryptedKey); + } + + static async deleteKey(userId: string, provider: string, ownerGithubUsername?: string | null): Promise { + const record = await this.findRecord(userId, provider, ownerGithubUsername); + if (!record) { + return false; + } + + const count = await UserApiKey.query().where({ id: record.id }).delete(); + return count > 0; + } +} diff --git a/src/shared/config.ts b/src/shared/config.ts index 42ebf58..0d1e6b4 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -116,6 +116,8 @@ export const QUEUE_NAMES = { BUILD_QUEUE: `build_queue_${JOB_VERSION}`, GITHUB_DEPLOYMENT: `github_deployment_${JOB_VERSION}`, LABEL: `label_${JOB_VERSION}`, + AGENT_SESSION_CLEANUP: 'agent_session_cleanup', + AGENT_SANDBOX_SESSION_LAUNCH: 'agent_sandbox_session_launch', } as const; export const GITHUB_APP_INSTALLATION_ID = getServerRuntimeConfig('GITHUB_APP_INSTALLATION_ID', 'YOUR_VALUE_HERE'); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index eb014e1..770845d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -73,6 +73,11 @@ export enum BuildStatus { CONFIG_ERROR = 'config_error', } +export enum BuildKind { + ENVIRONMENT = 'environment', + SANDBOX = 'sandbox', +} + export enum PullRequestStatus { OPEN = 'open', CLOSED = 'closed', @@ -83,6 +88,7 @@ export enum DeployStatus { BUILDING = 'building', BUILT = 'built', READY = 'ready', + DEPLOYED = 'deployed', ERROR = 'error', QUEUED = 'queued', PENDING = 'pending', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 637d2dd..6c12f72 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -1,5 +1,5 @@ import { OAS3Options } from 'swagger-jsdoc'; -import { BuildStatus, DeployStatus, DeployTypes } from './constants'; +import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from './constants'; export const openApiSpecificationForV2Api: OAS3Options = { definition: { @@ -529,6 +529,11 @@ export const openApiSpecificationForV2Api: OAS3Options = { enum: Object.values(BuildStatus), }, + BuildKind: { + type: 'string', + enum: Object.values(BuildKind), + }, + /** * @description The main Build object. */ @@ -540,10 +545,20 @@ export const openApiSpecificationForV2Api: OAS3Options = { manifest: { type: 'string', example: 'version: 1.0.0\nservices:\n web:\n image: myapp:web\n' }, uuid: { type: 'string', example: 'white-poetry-596195' }, status: { $ref: '#/components/schemas/BuildStatus' }, + kind: { $ref: '#/components/schemas/BuildKind' }, namespace: { type: 'string', example: 'env-white-poetry-596195' }, isStatic: { type: 'boolean', example: false }, + baseBuildId: { type: 'integer', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, + baseBuild: { + type: 'object', + nullable: true, + properties: { + id: { type: 'integer' }, + uuid: { type: 'string' }, + }, + }, pullRequest: { $ref: '#/components/schemas/PullRequest' }, deploys: { type: 'array', @@ -555,6 +570,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { 'id', 'uuid', 'status', + 'kind', 'namespace', 'manifest', 'isStatic', @@ -621,6 +637,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { dockerImage: { type: 'string', example: 'myapp:web' }, buildLogs: { type: 'string', example: 'https://g.codefresh.io/build/123...' }, active: { type: 'boolean', example: true }, + devMode: { type: 'boolean', example: false }, branchName: { type: 'string', example: 'main' }, publicUrl: { type: 'string', example: 'http://myapp.example.com' }, deployableId: { type: 'integer' }, @@ -642,6 +659,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { 'dockerImage', 'buildLogs', 'active', + 'devMode', 'branchName', 'publicUrl', 'deployableId', diff --git a/src/shared/types/agentSession.ts b/src/shared/types/agentSession.ts new file mode 100644 index 0000000..e2c9793 --- /dev/null +++ b/src/shared/types/agentSession.ts @@ -0,0 +1,75 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type AgentSessionStatus = 'active' | 'ended' | 'error'; + +export interface AgentSessionCreatePayload { + buildUuid?: string; + services?: string[]; + model?: string; +} + +export interface AgentSessionResponse { + id: string; + buildUuid: string | null; + userId: string; + podName: string; + namespace: string; + model: string; + status: AgentSessionStatus; + services: string[]; + createdAt: string; + endedAt: string | null; + websocketUrl: string; + editorUrl?: string; +} + +export type AgentWSClientMessage = + | { type: 'message'; content: string } + | { type: 'cancel' } + | { type: 'set_model'; model: string } + | { type: 'resize'; cols: number; rows: number }; + +export type AgentWSPhase = 'thinking' | 'drafting' | 'preparing_tool' | 'running_tool' | 'reviewing_tool'; + +export interface AgentWSDebugMetrics { + iterations: number; + totalToolCalls: number; + totalDurationMs: number; + inputTokens?: number; + outputTokens?: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + totalCostUsd?: number; + inputCostPerMillion?: number; + outputCostPerMillion?: number; +} + +export type AgentWSServerMessage = + | { type: 'chunk'; content: string } + | { type: 'tool_use'; tool: string; args: Record } + | { type: 'tool_result'; tool: string; result: string; success: boolean } + | { + type: 'usage'; + scope: 'step' | 'session'; + messageId?: string; + metrics: AgentWSDebugMetrics; + } + | { type: 'phase'; phase: AgentWSPhase; label: string; tool?: string } + | { type: 'prompt'; message: string; promptId: string } + | { type: 'status'; status: 'connecting' | 'ready' | 'working' | 'idle' | 'error' | 'ended' } + | { type: 'heartbeat'; ts: number } + | { type: 'dev_reload'; service: string; trigger: string }; diff --git a/sysops/dockerfiles/agent.Dockerfile b/sysops/dockerfiles/agent.Dockerfile new file mode 100644 index 0000000..53dbea0 --- /dev/null +++ b/sysops/dockerfiles/agent.Dockerfile @@ -0,0 +1,37 @@ +# Copyright 2025 GoodRx, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM node:20-slim + +ENV HOME=/home/claude +ENV NPM_CONFIG_UPDATE_NOTIFIER=false + +RUN apt-get update && apt-get install -y \ + bash \ + build-essential \ + ca-certificates \ + curl \ + gh \ + git \ + golang-go \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g pnpm @anthropic-ai/claude-code + +RUN mkdir -p /home/claude /workspace && \ + chown -R 1000:1000 /home/claude /workspace + +WORKDIR /workspace diff --git a/tsconfig.server.json b/tsconfig.server.json index 275f5bc..136026b 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -6,7 +6,8 @@ "noEmit": false, "isolatedModules": false, "jsx": "preserve", - "lib": ["es2022"], + "lib": ["es2022", "dom"], + "strictNullChecks": false, "baseUrl": "." }, "include": [ diff --git a/ws-server.ts b/ws-server.ts index 55f71fc..272f21c 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -27,8 +27,8 @@ moduleAlias.addAliases({ scripts: join(__dirname, 'scripts'), }); -import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { parse } from 'url'; +import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http'; +import { parse, URL } from 'url'; import next from 'next'; import { WebSocketServer, WebSocket } from 'ws'; import { rootLogger } from './src/server/lib/logger'; @@ -43,12 +43,608 @@ const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); const LOG_STREAM_PATH = '/api/logs/stream'; // Path for WebSocket connections +const AGENT_SESSION_PATH = '/api/agent/session'; +const AGENT_EDITOR_PATH_PREFIX = '/api/agent/editor/'; +const LEGACY_AGENT_EDITOR_PATH_PREFIX = '/api/v2/ai/agent/sessions/'; +const LEGACY_AGENT_EDITOR_PATH_SUFFIX = '/editor'; +const AGENT_EDITOR_COOKIE_NAME = 'lfc_agent_editor_auth'; +const AGENT_EDITOR_PORT = parseInt(process.env.AGENT_EDITOR_PORT || '13337', 10); +const AGENT_RUNTIME_IDLE_TIMEOUT_MS = parseInt(process.env.AGENT_RUNTIME_IDLE_TIMEOUT_MS || '60000', 10); +const AGENT_EXEC_ATTACH_RETRY_DELAY_MS = parseInt(process.env.AGENT_EXEC_ATTACH_RETRY_DELAY_MS || '500', 10); +const AGENT_EXEC_ATTACH_MAX_ATTEMPTS = parseInt(process.env.AGENT_EXEC_ATTACH_MAX_ATTEMPTS || '20', 10); const logger = rootLogger.child({ filename: __filename }); +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +type AgentExecConnection = { + write(data: string): void; + cancel(): void; + close(): void; + onStdout(handler: (data: string) => void): void; + onStderr(handler: (data: string) => void): void; + onClose(handler: () => void): void; + onError(handler: (err: Error) => void): void; +}; + +type AgentSessionRuntime = { + sessionId: string; + namespace: string; + podName: string; + model: string; + clients: Set; + execConn: AgentExecConnection | null; + parser: { feed(data: string): void } | null; + startPromise: Promise | null; + idleTimer: ReturnType | null; + isReady: boolean; +}; + +const agentSessionRuntimes = new Map(); + +function parseCookieHeader(cookieHeader: string | string[] | undefined): Record { + if (!cookieHeader) { + return {}; + } + + const raw = Array.isArray(cookieHeader) ? cookieHeader.join(';') : cookieHeader; + return raw.split(';').reduce>((cookies, entry) => { + const separatorIndex = entry.indexOf('='); + if (separatorIndex < 0) { + return cookies; + } + + const key = entry.slice(0, separatorIndex).trim(); + const value = entry.slice(separatorIndex + 1).trim(); + if (!key) { + return cookies; + } + + cookies[key] = decodeURIComponent(value); + return cookies; + }, {}); +} + +type AgentEditorPathMatch = { sessionId: string; forwardPath: string; isLegacy: boolean }; + +function parseAgentEditorPath(pathname: string | null | undefined, includeLegacy = true): AgentEditorPathMatch | null { + const safePathname = pathname || ''; + if (safePathname.startsWith(AGENT_EDITOR_PATH_PREFIX)) { + const remainder = safePathname.slice(AGENT_EDITOR_PATH_PREFIX.length); + const slashIndex = remainder.indexOf('/'); + const sessionId = slashIndex >= 0 ? remainder.slice(0, slashIndex) : remainder; + if (!sessionId) { + return null; + } + + const forwardPath = slashIndex >= 0 ? remainder.slice(slashIndex) : '/'; + return { + sessionId: decodeURIComponent(sessionId), + forwardPath: forwardPath || '/', + isLegacy: false, + }; + } + + if (!includeLegacy || !safePathname.startsWith(LEGACY_AGENT_EDITOR_PATH_PREFIX)) { + return null; + } + + const editorIndex = safePathname.indexOf(LEGACY_AGENT_EDITOR_PATH_SUFFIX, LEGACY_AGENT_EDITOR_PATH_PREFIX.length); + if (editorIndex < 0) { + return null; + } + + const sessionId = safePathname.slice(LEGACY_AGENT_EDITOR_PATH_PREFIX.length, editorIndex); + if (!sessionId) { + return null; + } + + const remainder = safePathname.slice(editorIndex + LEGACY_AGENT_EDITOR_PATH_SUFFIX.length); + return { + sessionId: decodeURIComponent(sessionId), + forwardPath: remainder ? (remainder.startsWith('/') ? remainder : `/${remainder}`) : '/', + isLegacy: true, + }; +} + +function getAgentEditorCookiePath(sessionId: string): string { + return `${AGENT_EDITOR_PATH_PREFIX}${encodeURIComponent(sessionId)}`; +} + +function getAgentEditorRequestPath(sessionId: string, forwardPath = '/'): string { + const normalizedForwardPath = + !forwardPath || forwardPath === '/' ? '/' : forwardPath.startsWith('/') ? forwardPath : `/${forwardPath}`; + + return `${getAgentEditorCookiePath(sessionId)}${normalizedForwardPath}`; +} + +function isSendableCloseCode(code?: number): code is number { + if (typeof code !== 'number') { + return false; + } + + if (code < 1000 || code >= 5000) { + return false; + } + + return ![1004, 1005, 1006, 1015].includes(code); +} + +function buildAgentEditorCookie(request: IncomingMessage, sessionId: string, token: string): string { + const isSecure = + request.headers['x-forwarded-proto'] === 'https' || (request.socket as { encrypted?: boolean }).encrypted === true; + const cookieParts = [ + `${AGENT_EDITOR_COOKIE_NAME}=${encodeURIComponent(token)}`, + `Path=${getAgentEditorCookiePath(sessionId)}`, + 'HttpOnly', + 'SameSite=Lax', + ]; + + if (isSecure) { + cookieParts.push('Secure'); + } + + return cookieParts.join('; '); +} + +function appendSetCookie(res: ServerResponse, value: string) { + const existing = res.getHeader('Set-Cookie'); + if (!existing) { + res.setHeader('Set-Cookie', value); + return; + } + + if (Array.isArray(existing)) { + res.setHeader('Set-Cookie', [...existing, value]); + return; + } + + res.setHeader('Set-Cookie', [existing.toString(), value]); +} + +function buildAgentEditorServiceUrl( + session: { id: string; podName: string; namespace: string }, + forwardPath: string, + query: Record, + isWebSocket = false +) { + const protocol = isWebSocket ? 'ws' : 'http'; + const target = new URL( + `${protocol}://${session.podName}.${session.namespace}.svc.cluster.local:${AGENT_EDITOR_PORT}${forwardPath}` + ); + + for (const [key, value] of Object.entries(query)) { + if (key === 'token' || value == null) { + continue; + } + + if (Array.isArray(value)) { + value.forEach((item) => target.searchParams.append(key, item)); + continue; + } + + target.searchParams.set(key, value); + } + + return target; +} + +function buildProxyHeaders(request: IncomingMessage, target: URL, forwardedPrefix: string): Record { + const headers: Record = {}; + + for (const [key, value] of Object.entries(request.headers)) { + if (value == null) { + continue; + } + + const normalizedKey = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(normalizedKey) || normalizedKey === 'host' || normalizedKey === 'content-length') { + continue; + } + + headers[key] = Array.isArray(value) ? value.join(', ') : value; + } + + headers.host = target.host; + headers['x-forwarded-host'] = request.headers.host || target.host; + headers['x-forwarded-proto'] = + (typeof request.headers['x-forwarded-proto'] === 'string' && request.headers['x-forwarded-proto']) || + ((request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'); + headers['x-forwarded-prefix'] = forwardedPrefix; + + const remoteAddress = request.socket.remoteAddress; + if (remoteAddress) { + headers['x-forwarded-for'] = request.headers['x-forwarded-for'] + ? `${request.headers['x-forwarded-for']}, ${remoteAddress}` + : remoteAddress; + } + + return headers; +} + +async function resolveOwnedAgentSession( + request: IncomingMessage, + sessionId: string, + queryToken?: string | null +): Promise { + const AgentSessionService = (await import('./src/server/services/agentSession')).default; + const session = await AgentSessionService.getSession(sessionId); + if (!session || session.status !== 'active') { + throw new Error('Session not found or not active'); + } + + if (process.env.ENABLE_AUTH === 'true') { + const headerToken = request.headers.authorization?.split(' ')[1]; + const cookieToken = parseCookieHeader(request.headers.cookie)[AGENT_EDITOR_COOKIE_NAME]; + const rawToken = headerToken || cookieToken || queryToken; + + if (!rawToken) { + throw new Error('Authentication token is required'); + } + + const { verifyBearerToken } = await import('./src/server/lib/auth'); + const authResult = await verifyBearerToken(rawToken); + if (!authResult.success || authResult.payload?.sub !== session.userId) { + throw new Error('Forbidden: you do not own this session'); + } + } + + return session; +} + +function closeSocket(ws: WebSocket, code: number, reason: string) { + if (ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) { + return; + } + + const safeReason = Buffer.byteLength(reason, 'utf8') > 123 ? 'Connection error' : reason; + ws.close(code, safeReason); +} + +function buildClaudeUserMessage(content: string): string { + return `${JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: content }], + }, + })}\n`; +} + +function broadcastAgentMessage(runtime: AgentSessionRuntime, payload: Record) { + const message = JSON.stringify(payload); + for (const client of runtime.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } +} + +function notifyAgentClientReady(runtime: AgentSessionRuntime, client: WebSocket, attempt = 0) { + if (!runtime.clients.has(client)) { + return; + } + + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ type: 'status', status: 'ready' })); + return; + } + + if (attempt >= 5) { + return; + } + + setTimeout(() => { + notifyAgentClientReady(runtime, client, attempt + 1); + }, 100); +} + +function clearAgentRuntimeIdleTimer(runtime: AgentSessionRuntime) { + if (runtime.idleTimer) { + clearTimeout(runtime.idleTimer); + runtime.idleTimer = null; + } +} + +function closeAgentClients(runtime: AgentSessionRuntime, code: number, reason: string) { + for (const client of Array.from(runtime.clients)) { + runtime.clients.delete(client); + closeSocket(client, code, reason); + } +} + +function disposeAgentRuntime(sessionId: string, runtime: AgentSessionRuntime, closeExec: boolean) { + clearAgentRuntimeIdleTimer(runtime); + runtime.isReady = false; + runtime.parser = null; + runtime.startPromise = null; + agentSessionRuntimes.delete(sessionId); + + if (closeExec && runtime.execConn) { + const execConn = runtime.execConn; + runtime.execConn = null; + execConn.close(); + return; + } + + runtime.execConn = null; +} + +function scheduleAgentRuntimeCleanup( + sessionId: string, + runtime: AgentSessionRuntime, + agentLogCtx: Record +) { + if (agentSessionRuntimes.get(sessionId) !== runtime || runtime.clients.size > 0) { + return; + } + + clearAgentRuntimeIdleTimer(runtime); + runtime.idleTimer = setTimeout(() => { + const currentRuntime = agentSessionRuntimes.get(sessionId); + if (currentRuntime !== runtime || runtime.clients.size > 0) { + return; + } + + logger.debug(agentLogCtx, 'Closing idle agent runtime after client disconnect'); + disposeAgentRuntime(sessionId, runtime, true); + }, AGENT_RUNTIME_IDLE_TIMEOUT_MS); +} + +async function getOrCreateAgentRuntime( + sessionId: string, + namespace: string, + podName: string, + model: string, + agentLogCtx: Record +): Promise { + let runtime = agentSessionRuntimes.get(sessionId); + + if (runtime && (runtime.namespace !== namespace || runtime.podName !== podName)) { + disposeAgentRuntime(sessionId, runtime, true); + runtime = undefined; + } + + if (!runtime) { + runtime = { + sessionId, + namespace, + podName, + model, + clients: new Set(), + execConn: null, + parser: null, + startPromise: null, + idleTimer: null, + isReady: false, + }; + agentSessionRuntimes.set(sessionId, runtime); + } + + clearAgentRuntimeIdleTimer(runtime); + + if (runtime.execConn) { + return runtime; + } + + if (!runtime.startPromise) { + runtime.startPromise = (async () => { + const { attachToAgentPod } = await import('./src/server/lib/agentSession/execProxy'); + const { JsonlParser } = await import('./src/server/lib/agentSession/jsonlParser'); + + const execConn = (await attachToAgentPodWithRetry( + attachToAgentPod, + namespace, + podName, + model, + agentLogCtx + )) as AgentExecConnection; + runtime!.execConn = execConn; + runtime!.parser = new JsonlParser((msg) => { + broadcastAgentMessage(runtime!, msg as Record); + }); + runtime!.isReady = true; + + execConn.onStdout((data: string) => { + runtime?.parser?.feed(data); + }); + + execConn.onStderr((data: string) => { + broadcastAgentMessage(runtime!, { type: 'chunk', content: data }); + }); + + execConn.onClose(() => { + if (agentSessionRuntimes.get(sessionId) !== runtime) { + return; + } + + logger.debug(agentLogCtx, 'Agent exec connection closed'); + closeAgentClients(runtime!, 1012, 'Agent runtime restarted'); + disposeAgentRuntime(sessionId, runtime!, false); + }); + + execConn.onError((err: Error) => { + if (agentSessionRuntimes.get(sessionId) !== runtime) { + return; + } + + logger.error({ ...agentLogCtx, err }, 'Agent exec error'); + broadcastAgentMessage(runtime!, { type: 'status', status: 'error' }); + closeAgentClients(runtime!, 1011, 'Agent exec error'); + disposeAgentRuntime(sessionId, runtime!, false); + }); + + broadcastAgentMessage(runtime!, { type: 'status', status: 'ready' }); + })() + .catch((error) => { + disposeAgentRuntime(sessionId, runtime!, true); + throw error; + }) + .finally(() => { + if (runtime) { + runtime.startPromise = null; + } + }); + } + + await runtime.startPromise; + return runtime; +} + +async function attachToAgentPodWithRetry( + attachToAgentPod: (namespace: string, podName: string, model: string) => Promise, + namespace: string, + podName: string, + model: string, + agentLogCtx: Record +): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= AGENT_EXEC_ATTACH_MAX_ATTEMPTS; attempt++) { + try { + return await attachToAgentPod(namespace, podName, model); + } catch (error: any) { + lastError = error; + + if (attempt === AGENT_EXEC_ATTACH_MAX_ATTEMPTS) { + break; + } + + logger.warn( + { + ...agentLogCtx, + attempt, + maxAttempts: AGENT_EXEC_ATTACH_MAX_ATTEMPTS, + err: error, + }, + 'Agent exec attach failed; retrying while pod becomes ready' + ); + + await new Promise((resolve) => setTimeout(resolve, AGENT_EXEC_ATTACH_RETRY_DELAY_MS)); + } + } + + throw lastError ?? new Error('Failed to attach to agent pod'); +} + +async function handleAgentEditorHttp( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + query: Record +) { + const match = parseAgentEditorPath(pathname); + if (!match) { + return false; + } + + if (match.isLegacy) { + const target = new URL(pathname, 'http://placeholder'); + target.pathname = getAgentEditorRequestPath(match.sessionId, match.forwardPath); + for (const [key, value] of Object.entries(query)) { + if (value == null) { + continue; + } + if (Array.isArray(value)) { + value.forEach((item) => target.searchParams.append(key, item)); + } else { + target.searchParams.set(key, value); + } + } + + res.statusCode = 307; + res.setHeader('Location', `${target.pathname}${target.search}`); + res.end(); + return true; + } + + try { + const queryToken = typeof query.token === 'string' ? query.token : null; + const session = await resolveOwnedAgentSession(req, match.sessionId, queryToken); + const forwardedPrefix = getAgentEditorCookiePath(match.sessionId); + const targetUrl = buildAgentEditorServiceUrl(session, match.forwardPath, query); + const proxyHeaders = buildProxyHeaders(req, targetUrl, forwardedPrefix); + await new Promise((resolve, reject) => { + const proxyReq = httpRequest( + targetUrl, + { + method: req.method, + headers: proxyHeaders, + }, + (proxyRes) => { + res.statusCode = proxyRes.statusCode || 502; + + Object.entries(proxyRes.headers).forEach(([key, value]) => { + const normalizedKey = key.toLowerCase(); + if (HOP_BY_HOP_HEADERS.has(normalizedKey) || value == null || normalizedKey === 'set-cookie') { + return; + } + + res.setHeader(key, Array.isArray(value) ? value : value.toString()); + }); + + const upstreamSetCookies = proxyRes.headers['set-cookie'] || []; + (Array.isArray(upstreamSetCookies) ? upstreamSetCookies : [upstreamSetCookies]).forEach((cookie) => { + if (cookie) { + appendSetCookie(res, cookie); + } + }); + + if (process.env.ENABLE_AUTH === 'true' && queryToken) { + appendSetCookie(res, buildAgentEditorCookie(req, match.sessionId, queryToken)); + } + + proxyRes.on('error', reject); + proxyRes.on('end', () => resolve()); + proxyRes.pipe(res); + } + ); + + proxyReq.on('error', reject); + + if (req.method && !['GET', 'HEAD'].includes(req.method.toUpperCase())) { + req.pipe(proxyReq); + } else { + proxyReq.end(); + } + }); + + return true; + } catch (error: any) { + logger.error({ err: error, path: pathname, sessionId: match.sessionId }, 'Agent editor proxy request failed'); + res.statusCode = + error?.message?.includes('Forbidden') || error?.message?.includes('Authentication') + ? 401 + : error?.message?.includes('Session not found') + ? 404 + : 502; + res.end(error?.message || 'Editor proxy failed'); + return true; + } +} app.prepare().then(() => { const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { try { const parsedUrl = parse(req.url!, true); + if ( + parsedUrl.pathname && + (await handleAgentEditorHttp( + req, + res, + parsedUrl.pathname, + parsedUrl.query as Record + )) + ) { + return; + } await handle(req, res, parsedUrl); } catch (err) { logger.error({ err }, 'Error handling HTTP request'); @@ -68,6 +664,16 @@ app.prepare().then(() => { wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { wss.emit('connection', ws, request); }); + } else if (parseAgentEditorPath(pathname, false)) { + logger.debug(connectionLogCtx, 'Handling upgrade request for agent editor'); + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { + wss.emit('agent-editor', ws, request); + }); + } else if (pathname?.startsWith(AGENT_SESSION_PATH)) { + logger.debug(connectionLogCtx, 'Handling upgrade request for agent session'); + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { + wss.emit('agent-session', ws, request); + }); } else { socket.destroy(); } @@ -137,7 +743,7 @@ app.prepare().then(() => { logger.error({ ...logCtx, err: error }, 'WebSocket connection setup error'); if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.send(JSON.stringify({ type: 'error', message: `Connection error: ${error.message}` })); - ws.close(1008, `Connection error: ${error.message}`); + closeSocket(ws, 1008, `Connection error: ${error.message}`); } return; } @@ -165,6 +771,223 @@ app.prepare().then(() => { }); }); + wss.on('agent-editor', async (ws: WebSocket, request: IncomingMessage) => { + const parsedUrl = parse(request.url || '', true); + const match = parseAgentEditorPath(parsedUrl.pathname, false); + const editorLogCtx: Record = { + remoteAddress: request.socket.remoteAddress, + path: parsedUrl.pathname, + }; + + if (!match) { + closeSocket(ws, 1008, 'Invalid editor path'); + return; + } + + let upstream: WebSocket | null = null; + + try { + const queryToken = typeof parsedUrl.query.token === 'string' ? parsedUrl.query.token : null; + const session = await resolveOwnedAgentSession(request, match.sessionId, queryToken); + const forwardedPrefix = getAgentEditorCookiePath(match.sessionId); + const targetUrl = buildAgentEditorServiceUrl( + session, + match.forwardPath, + parsedUrl.query as Record, + true + ); + const protocols = + typeof request.headers['sec-websocket-protocol'] === 'string' + ? request.headers['sec-websocket-protocol'] + .split(',') + .map((protocol) => protocol.trim()) + .filter(Boolean) + : undefined; + + upstream = new WebSocket(targetUrl, protocols, { + headers: buildProxyHeaders(request, targetUrl, forwardedPrefix), + }); + + const closeUpstream = (code?: number, reason?: Buffer) => { + if (!upstream || upstream.readyState === WebSocket.CLOSING || upstream.readyState === WebSocket.CLOSED) { + return; + } + + const closeReason = reason?.toString(); + if (isSendableCloseCode(code)) { + upstream.close(code, closeReason); + } else { + upstream.close(); + } + }; + + ws.on('message', (data, isBinary) => { + if (upstream?.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + } + }); + + ws.on('close', (code, reason) => { + closeUpstream(code, reason); + }); + + ws.on('error', (error) => { + logger.warn({ ...editorLogCtx, err: error }, 'Agent editor client WebSocket error'); + closeUpstream(1011, Buffer.from('Client WebSocket error')); + }); + + upstream.on('message', (data, isBinary) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data, { binary: isBinary }); + } + }); + + upstream.on('close', (code, reason) => { + closeSocket(ws, code === 1005 ? 1000 : code, reason.toString() || 'Editor connection closed'); + }); + + upstream.on('error', (error) => { + logger.warn({ ...editorLogCtx, err: error }, 'Agent editor upstream WebSocket error'); + closeSocket(ws, 1011, 'Editor upstream error'); + }); + + upstream.on('unexpected-response', (_req, response) => { + logger.warn( + { ...editorLogCtx, statusCode: response.statusCode }, + 'Agent editor upstream WebSocket rejected the upgrade' + ); + closeSocket(ws, 1011, 'Editor upgrade rejected'); + }); + } catch (error: any) { + logger.error({ ...editorLogCtx, err: error, sessionId: match.sessionId }, 'Agent editor WebSocket setup error'); + closeSocket(ws, 1008, `Connection error: ${error.message}`); + if (upstream && upstream.readyState === WebSocket.CONNECTING) { + upstream.terminate(); + } + } + }); + + wss.on('agent-session', async (ws: WebSocket, request: IncomingMessage) => { + let heartbeatInterval: ReturnType | null = null; + let runtime: AgentSessionRuntime | null = null; + let sessionId: string | null = null; + const agentLogCtx: Record = { remoteAddress: request.socket.remoteAddress }; + + const cleanupLocal = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + }; + + try { + const { query } = parse(request.url || '', true); + const { sessionId: sessionIdFromQuery, token: tokenFromQuery } = query; + + if (!sessionIdFromQuery || typeof sessionIdFromQuery !== 'string') { + throw new Error('Missing required parameter: sessionId'); + } + + sessionId = sessionIdFromQuery; + const activeSessionId = sessionIdFromQuery; + agentLogCtx.sessionId = activeSessionId; + const AgentSessionService = (await import('./src/server/services/agentSession')).default; + + const session = await resolveOwnedAgentSession( + request, + activeSessionId, + typeof tokenFromQuery === 'string' ? tokenFromQuery : null + ); + + agentLogCtx.podName = session.podName; + agentLogCtx.namespace = session.namespace; + logger.debug(agentLogCtx, 'Agent session WebSocket connected'); + + runtime = await getOrCreateAgentRuntime( + activeSessionId, + session.namespace, + session.podName, + session.model, + agentLogCtx + ); + + if (runtime.clients.size > 0) { + closeAgentClients(runtime, 1000, 'Superseded by a new connection'); + } + + runtime.clients.add(ws); + + if (runtime.isReady) { + notifyAgentClientReady(runtime, ws); + } + + heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() })); + } + }, 15_000); + + ws.on('message', async (raw) => { + try { + const msg = JSON.parse(raw.toString()); + await AgentSessionService.touchActivity(activeSessionId); + + switch (msg.type) { + case 'message': + if (runtime?.execConn && msg.content) { + runtime.execConn.write(buildClaudeUserMessage(msg.content)); + } + break; + case 'cancel': + if (runtime?.execConn) { + runtime.execConn.cancel(); + } + break; + case 'set_model': + if (runtime?.execConn && msg.model) { + runtime.execConn.write(buildClaudeUserMessage(`/model ${msg.model}`)); + } + break; + default: + logger.debug({ ...agentLogCtx, msgType: msg.type }, 'Unknown client message type'); + } + } catch (err) { + logger.warn({ ...agentLogCtx, err }, 'Failed to process client message'); + } + }); + + ws.on('close', (code, reason) => { + const reasonString = reason instanceof Buffer ? reason.toString() : String(reason); + logger.debug({ ...agentLogCtx, code, reason: reasonString }, 'Agent WebSocket closed by client'); + cleanupLocal(); + if (runtime && sessionId) { + runtime.clients.delete(ws); + scheduleAgentRuntimeCleanup(sessionId, runtime, agentLogCtx); + } + }); + + ws.on('error', (error) => { + logger.warn({ ...agentLogCtx, err: error }, 'Agent WebSocket error'); + cleanupLocal(); + if (runtime && sessionId) { + runtime.clients.delete(ws); + scheduleAgentRuntimeCleanup(sessionId, runtime, agentLogCtx); + } + }); + } catch (error: any) { + logger.error({ ...agentLogCtx, err: error }, 'Agent session WebSocket setup error'); + cleanupLocal(); + if (runtime && sessionId) { + runtime.clients.delete(ws); + scheduleAgentRuntimeCleanup(sessionId, runtime, agentLogCtx); + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.send(JSON.stringify({ type: 'status', status: 'error' })); + closeSocket(ws, 1008, `Connection error: ${error.message}`); + } + } + }); + httpServer.listen(port); httpServer.on('error', (error) => { @@ -305,3 +1128,46 @@ app.prepare().then(() => { // {"type":"log","payload":"2024-04-14T12:34:56.789Z INFO Starting application..."} // {"type":"error","message":"Kubernetes stream error: Connection refused"} // {"type":"end","reason":"ContainerTerminated"} + +/** + * @openapi + * /api/v2/ai/agent/sessions/{sessionId}/editor: + * get: + * summary: Open the browser editor attached to an active agent session + * description: | + * Proxies a browser-based VS Code session (code-server) running inside the + * agent session pod. The editor uses the same workspace PVC as the agent. + * + * Authentication follows the same session ownership rules as the agent + * WebSocket. The first request may include a bearer token via the + * `Authorization` header or `token` query parameter; the proxy then sets a + * session-scoped HTTP-only cookie for follow-up asset and WebSocket + * requests under the same path prefix. + * + * All nested paths under this prefix are also proxied to the editor + * runtime, including asset requests and WebSocket upgrades required by the + * web IDE. + * tags: + * - Agent Sessions + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: token + * required: false + * schema: + * type: string + * description: Optional bearer token used to seed the editor auth cookie. + * responses: + * '200': + * description: Browser editor HTML or proxied editor assets. + * '401': + * description: Unauthorized + * '404': + * description: Session not found + * '502': + * description: Editor runtime unavailable + */ From b0bb660c68937891b789100239665e3bd5f5b477 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Tue, 24 Mar 2026 11:49:41 -0700 Subject: [PATCH 02/18] change default pvc type --- src/server/lib/agentSession/__tests__/pvcFactory.test.ts | 8 ++++---- src/server/lib/agentSession/pvcFactory.ts | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/server/lib/agentSession/__tests__/pvcFactory.test.ts b/src/server/lib/agentSession/__tests__/pvcFactory.test.ts index 98393b1..a4bf64c 100644 --- a/src/server/lib/agentSession/__tests__/pvcFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/pvcFactory.test.ts @@ -62,7 +62,7 @@ describe('pvcFactory', () => { }); describe('createAgentPvc', () => { - it('creates a PVC with ReadWriteMany access mode', async () => { + it('creates a PVC with ReadWriteOnce access mode by default', async () => { mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); await createAgentPvc('test-ns', 'test-pvc', '10Gi'); @@ -71,7 +71,7 @@ describe('pvcFactory', () => { const [ns, pvcBody] = mockCreatePvc.mock.calls[0]; expect(ns).toBe('test-ns'); expect(pvcBody.metadata.name).toBe('test-pvc'); - expect(pvcBody.spec.accessModes).toEqual(['ReadWriteMany']); + expect(pvcBody.spec.accessModes).toEqual(['ReadWriteOnce']); expect(pvcBody.spec.resources.requests.storage).toBe('10Gi'); }); @@ -95,13 +95,13 @@ describe('pvcFactory', () => { }); it('honors AGENT_SESSION_PVC_ACCESS_MODE when configured', async () => { - process.env.AGENT_SESSION_PVC_ACCESS_MODE = 'ReadWriteOnce'; + process.env.AGENT_SESSION_PVC_ACCESS_MODE = 'ReadWriteMany'; mockCreatePvc.mockResolvedValue({ body: { metadata: { name: 'test-pvc' } } }); await createAgentPvc('test-ns', 'test-pvc'); const [, pvcBody] = mockCreatePvc.mock.calls[0]; - expect(pvcBody.spec.accessModes).toEqual(['ReadWriteOnce']); + expect(pvcBody.spec.accessModes).toEqual(['ReadWriteMany']); }); }); diff --git a/src/server/lib/agentSession/pvcFactory.ts b/src/server/lib/agentSession/pvcFactory.ts index e4e1fb8..872c7e9 100644 --- a/src/server/lib/agentSession/pvcFactory.ts +++ b/src/server/lib/agentSession/pvcFactory.ts @@ -30,11 +30,7 @@ function getAccessMode(): 'ReadWriteMany' | 'ReadWriteOnce' { return configured; } - if (process.env.ENVIRONMENT === 'dev' || process.env.APP_ENV === 'dev' || process.env.NODE_ENV === 'development') { - return 'ReadWriteOnce'; - } - - return 'ReadWriteMany'; + return 'ReadWriteOnce'; } export async function createAgentPvc( From b39589d9c348b75f2866837e7c66cf15d9e4b93d Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Tue, 24 Mar 2026 15:29:51 -0700 Subject: [PATCH 03/18] Agent session pod scheduling --- .../api/v2/ai/agent/sandbox-sessions/route.ts | 1 + src/app/api/v2/ai/agent/sessions/route.ts | 1 + src/server/jobs/agentSandboxSessionLaunch.ts | 2 + .../__tests__/apiKeySecretFactory.test.ts | 78 +++++++++++++++++++ .../agentSession/__tests__/podFactory.test.ts | 23 +++++- .../__tests__/runtimeConfig.test.ts | 34 ++++++++ .../lib/agentSession/apiKeySecretFactory.ts | 1 - src/server/lib/agentSession/podFactory.ts | 4 +- src/server/lib/agentSession/runtimeConfig.ts | 19 +++++ src/server/services/agentSandboxSession.ts | 2 + src/server/services/agentSession.ts | 2 + src/server/services/types/globalConfig.ts | 5 ++ 12 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/server/lib/agentSession/__tests__/apiKeySecretFactory.test.ts diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts index ae59da1..03e9fb6 100644 --- a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts +++ b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts @@ -277,6 +277,7 @@ const postHandler = async (req: NextRequest) => { model: body.model, agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, + nodeSelector: runtimeConfig.nodeSelector, } as SandboxSessionLaunchJob, { jobId: launchId, diff --git a/src/app/api/v2/ai/agent/sessions/route.ts b/src/app/api/v2/ai/agent/sessions/route.ts index a806c0b..3ad2808 100644 --- a/src/app/api/v2/ai/agent/sessions/route.ts +++ b/src/app/api/v2/ai/agent/sessions/route.ts @@ -433,6 +433,7 @@ const postHandler = async (req: NextRequest) => { namespace, agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, + nodeSelector: runtimeConfig.nodeSelector, }); return successResponse( diff --git a/src/server/jobs/agentSandboxSessionLaunch.ts b/src/server/jobs/agentSandboxSessionLaunch.ts index 07bf84e..e878b43 100644 --- a/src/server/jobs/agentSandboxSessionLaunch.ts +++ b/src/server/jobs/agentSandboxSessionLaunch.ts @@ -47,6 +47,7 @@ export async function processAgentSandboxSessionLaunch(job: Job => { @@ -67,6 +68,7 @@ export async function processAgentSandboxSessionLaunch(job: Job { + const actual = jest.requireActual('@kubernetes/client-node'); + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockReturnValue({ + createNamespacedSecret: mockCreateSecret, + deleteNamespacedSecret: mockDeleteSecret, + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + }), +})); + +import { createAgentApiKeySecret } from '../apiKeySecretFactory'; + +describe('apiKeySecretFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateSecret.mockResolvedValue({ body: { metadata: { name: 'agent-secret-abc123' } } }); + }); + + it('stores the GitHub token once in the session secret when provided', async () => { + await createAgentApiKeySecret('test-ns', 'agent-secret-abc123', 'sk-ant-test-key', 'gho_test_token'); + + expect(mockCreateSecret).toHaveBeenCalledWith( + 'test-ns', + expect.objectContaining({ + metadata: expect.objectContaining({ + name: 'agent-secret-abc123', + namespace: 'test-ns', + }), + stringData: { + ANTHROPIC_API_KEY: 'sk-ant-test-key', + GITHUB_TOKEN: 'gho_test_token', + }, + }) + ); + }); + + it('omits the GitHub token keys when no token is provided', async () => { + await createAgentApiKeySecret('test-ns', 'agent-secret-abc123', 'sk-ant-test-key'); + + expect(mockCreateSecret).toHaveBeenCalledWith( + 'test-ns', + expect.objectContaining({ + stringData: { + ANTHROPIC_API_KEY: 'sk-ant-test-key', + }, + }) + ); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts index c42b658..89321f0 100644 --- a/src/server/lib/agentSession/__tests__/podFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -178,6 +178,25 @@ describe('podFactory', () => { expect(pod.spec!.securityContext!.seccompProfile).toEqual({ type: 'RuntimeDefault' }); }); + it('does not set a nodeSelector by default', () => { + const pod = buildAgentPodSpec(baseOpts); + + expect(pod.spec!.nodeSelector).toBeUndefined(); + }); + + it('applies a configured nodeSelector when provided', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + nodeSelector: { + 'app-long': 'deployments-m7i', + }, + }); + + expect(pod.spec!.nodeSelector).toEqual({ + 'app-long': 'deployments-m7i', + }); + }); + it('sets ANTHROPIC_API_KEY from a secret and CLAUDE_MODEL as an env var', () => { const pod = buildAgentPodSpec(baseOpts); const envVars = pod.spec!.containers[0].env; @@ -196,7 +215,7 @@ describe('podFactory', () => { name: 'GH_TOKEN', valueFrom: { secretKeyRef: { - key: 'GH_TOKEN', + key: 'GITHUB_TOKEN', name: 'agent-secret-abc123', }, }, @@ -245,7 +264,7 @@ describe('podFactory', () => { name: 'GH_TOKEN', valueFrom: { secretKeyRef: { - key: 'GH_TOKEN', + key: 'GITHUB_TOKEN', name: 'agent-secret-abc123', }, }, diff --git a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts index d022f89..e0800c3 100644 --- a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts +++ b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts @@ -62,6 +62,40 @@ describe('runtimeConfig', () => { }); }); + it('returns the configured agent scheduling when present', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + scheduling: { + nodeSelector: { + 'app-long': 'deployments-m7i', + pool: 'agents', + }, + }, + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + nodeSelector: { + 'app-long': 'deployments-m7i', + pool: 'agents', + }, + claude: { + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + }, + }); + }); + it('returns the configured Claude settings when present', async () => { getAllConfigs.mockResolvedValue({ agentSessionDefaults: { diff --git a/src/server/lib/agentSession/apiKeySecretFactory.ts b/src/server/lib/agentSession/apiKeySecretFactory.ts index e35c91d..938aa50 100644 --- a/src/server/lib/agentSession/apiKeySecretFactory.ts +++ b/src/server/lib/agentSession/apiKeySecretFactory.ts @@ -51,7 +51,6 @@ export async function createAgentApiKeySecret( ...(githubToken ? { GITHUB_TOKEN: githubToken, - GH_TOKEN: githubToken, } : {}), }, diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index b65e419..cb779b7 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -115,6 +115,7 @@ export interface AgentPodOpts { useGvisor?: boolean; buildUuid?: string; userIdentity?: RequestUserIdentity; + nodeSelector?: Record; } function buildAgentResources(): k8s.V1ResourceRequirements { @@ -188,7 +189,7 @@ function buildGitHubTokenEnv(secretName: string, enabled?: boolean): k8s.V1EnvVa valueFrom: { secretKeyRef: { name: secretName, - key: 'GH_TOKEN', + key: 'GITHUB_TOKEN', }, }, }, @@ -271,6 +272,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { }, spec: { ...(useGvisor ? { runtimeClassName: 'gvisor' } : {}), + ...(opts.nodeSelector ? { nodeSelector: opts.nodeSelector } : {}), securityContext: { runAsUser: 1000, runAsGroup: 1000, diff --git a/src/server/lib/agentSession/runtimeConfig.ts b/src/server/lib/agentSession/runtimeConfig.ts index 3146657..e8b94ef 100644 --- a/src/server/lib/agentSession/runtimeConfig.ts +++ b/src/server/lib/agentSession/runtimeConfig.ts @@ -19,11 +19,13 @@ import type { AgentSessionClaudeAttribution, AgentSessionClaudeConfig, AgentSessionClaudePermissions, + AgentSessionSchedulingConfig, } from 'server/services/types/globalConfig'; export interface AgentSessionRuntimeConfig { image: string; editorImage: string; + nodeSelector?: Record; claude: ResolvedAgentSessionClaudeConfig; } @@ -69,6 +71,22 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +function normalizeNodeSelector(scheduling?: AgentSessionSchedulingConfig | null): Record | undefined { + const nodeSelector = scheduling?.nodeSelector; + + if (!nodeSelector || typeof nodeSelector !== 'object' || Array.isArray(nodeSelector)) { + return undefined; + } + + const normalized = Object.fromEntries( + Object.entries(nodeSelector) + .filter(([key, value]) => typeof key === 'string' && key.trim() && typeof value === 'string' && value.trim()) + .map(([key, value]) => [key.trim(), value.trim()]) + ); + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + export function resolveAgentSessionClaudeConfigFromDefaults( claudeDefaults?: AgentSessionClaudeConfig | null ): ResolvedAgentSessionClaudeConfig { @@ -145,6 +163,7 @@ export async function resolveAgentSessionRuntimeConfig(): Promise; onProgress?: (stage: SandboxLaunchStage, message: string) => Promise | void; } @@ -170,6 +171,7 @@ export default class AgentSandboxSessionService extends BaseService { namespace: sandboxBuild.namespace, agentImage: opts.agentImage, editorImage: opts.editorImage, + nodeSelector: opts.nodeSelector, userIdentity: opts.userIdentity, }); diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 137d269..1a1f709 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -167,6 +167,7 @@ export interface CreateSessionOptions { namespace: string; agentImage: string; editorImage: string; + nodeSelector?: Record; } export default class AgentSessionService { @@ -365,6 +366,7 @@ export default class AgentSessionService { useGvisor, buildUuid: opts.buildUuid, userIdentity: opts.userIdentity, + nodeSelector: opts.nodeSelector, }); const devModeManager = new DevModeManager(); diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index 6f4de32..6d49c0f 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -69,9 +69,14 @@ export type AgentSessionClaudeConfig = { appendSystemPrompt?: string; }; +export type AgentSessionSchedulingConfig = { + nodeSelector?: Record | null; +}; + export type AgentSessionDefaults = { image?: string | null; editorImage?: string | null; + scheduling?: AgentSessionSchedulingConfig; claude?: AgentSessionClaudeConfig; }; From 49f8b32cb794d189a468c946009cb83126dc4dea Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Tue, 24 Mar 2026 16:41:51 -0700 Subject: [PATCH 04/18] fix PVC mount path --- .../__tests__/devModeManager.test.ts | 12 ++-- .../agentSession/__tests__/podFactory.test.ts | 60 ++++++++++++++++--- src/server/lib/agentSession/devModeManager.ts | 6 +- src/server/lib/agentSession/podFactory.ts | 50 ++++++++++++---- src/server/lib/agentSession/workspace.ts | 18 ++++++ src/server/services/agentSession.ts | 3 +- 6 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 src/server/lib/agentSession/workspace.ts diff --git a/src/server/lib/agentSession/__tests__/devModeManager.test.ts b/src/server/lib/agentSession/__tests__/devModeManager.test.ts index 2df7115..d879db7 100644 --- a/src/server/lib/agentSession/__tests__/devModeManager.test.ts +++ b/src/server/lib/agentSession/__tests__/devModeManager.test.ts @@ -122,7 +122,7 @@ describe('DevModeManager', () => { image: 'node:20-slim', command: ['/bin/sh', '-c', 'pnpm dev'], workingDir: '/workspace', - volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + volumeMounts: [{ name: 'workspace', mountPath: '/workspace', subPath: 'repo' }], }), ], }, @@ -152,7 +152,7 @@ describe('DevModeManager', () => { const patchBody = mockPatchDeployment.mock.calls[0][2]; expect(patchBody.spec.template.spec.containers[0].workingDir).toBe('/workspace/my-express-app'); expect(patchBody.spec.template.spec.containers[0].volumeMounts).toEqual([ - { name: 'workspace', mountPath: '/workspace' }, + { name: 'workspace', mountPath: '/workspace', subPath: 'repo' }, ]); }); @@ -301,7 +301,7 @@ describe('DevModeManager', () => { command: ['/bin/sh', '-c', 'npm run dev'], workingDir: '/workspace/my-express-app', volumeMounts: [ - { name: 'workspace', mountPath: '/workspace' }, + { name: 'workspace', mountPath: '/workspace', subPath: 'repo' }, { name: 'config-volume', mountPath: '/config' }, ], }, @@ -413,7 +413,7 @@ describe('DevModeManager', () => { workingDir: '/workspace/grpc-echo', env: [], volumeMounts: [ - { name: 'workspace', mountPath: '/workspace' }, + { name: 'workspace', mountPath: '/workspace', subPath: 'repo' }, { name: 'config-volume', mountPath: '/config' }, ], }, @@ -492,7 +492,7 @@ describe('DevModeManager', () => { command: ['/bin/sh', '-c', 'go run ./server.go'], workingDir: '/workspace/grpc-echo', volumeMounts: [ - { name: 'workspace', mountPath: '/workspace' }, + { name: 'workspace', mountPath: '/workspace', subPath: 'repo' }, { name: 'config-volume', mountPath: '/config' }, ], }, @@ -546,7 +546,7 @@ describe('DevModeManager', () => { command: ['/bin/sh', '-c', 'go run ./server.go'], workingDir: '/workspace/grpc-echo', env: [{ name: 'DEV_ONLY', value: 'true' }], - volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }], + volumeMounts: [{ name: 'workspace', mountPath: '/workspace', subPath: 'repo' }], }, ], volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }], diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts index 89321f0..8edce9f 100644 --- a/src/server/lib/agentSession/__tests__/podFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -72,6 +72,15 @@ const baseOpts: AgentPodOpts = { }, }; +function getInitContainer(pod: k8s.V1Pod, name: string): k8s.V1Container { + const container = pod.spec!.initContainers!.find((entry) => entry.name === name); + if (!container) { + throw new Error(`Init container not found: ${name}`); + } + + return container; +} + describe('podFactory', () => { const originalTimeout = process.env.AGENT_POD_READY_TIMEOUT_MS; const originalPoll = process.env.AGENT_POD_READY_POLL_MS; @@ -121,9 +130,12 @@ describe('podFactory', () => { describe('buildAgentPodSpec', () => { it('creates a pod with init and main containers', () => { const pod = buildAgentPodSpec(baseOpts); - expect(pod.spec!.initContainers).toHaveLength(1); + expect(pod.spec!.initContainers).toHaveLength(2); expect(pod.spec!.containers).toHaveLength(2); - expect(pod.spec!.initContainers![0].name).toBe('init-workspace'); + expect(pod.spec!.initContainers!.map((container) => container.name)).toEqual([ + 'prepare-workspace', + 'init-workspace', + ]); expect(pod.spec!.containers[0].name).toBe('agent'); expect(pod.spec!.containers[1].name).toBe('editor'); }); @@ -132,7 +144,7 @@ describe('podFactory', () => { const pod = buildAgentPodSpec(baseOpts); expect(pod.spec!.containers[0].command).toEqual(['sleep', 'infinity']); expect(pod.spec!.containers[0].imagePullPolicy).toBe('IfNotPresent'); - expect(pod.spec!.initContainers![0].imagePullPolicy).toBe('IfNotPresent'); + expect(getInitContainer(pod, 'init-workspace').imagePullPolicy).toBe('IfNotPresent'); }); it('mounts PVC as workspace volume', () => { @@ -249,7 +261,7 @@ describe('podFactory', () => { it('passes user identity env vars to the init container', () => { const pod = buildAgentPodSpec(baseOpts); - expect(pod.spec!.initContainers![0].env).toEqual( + expect(getInitContainer(pod, 'init-workspace').env).toEqual( expect.arrayContaining([ { name: 'GITHUB_TOKEN', @@ -294,7 +306,7 @@ describe('podFactory', () => { forwardedAgentSecretServiceName: 'agent-env-abc123', }); - expect(pod.spec!.initContainers![0].env).toEqual( + expect(getInitContainer(pod, 'init-workspace').env).toEqual( expect.arrayContaining([ { name: 'PACKAGE_REGISTRY_TOKEN', value: 'plain-token' }, { @@ -327,7 +339,7 @@ describe('podFactory', () => { it('omits GitHub token secret refs when token forwarding is disabled', () => { const pod = buildAgentPodSpec({ ...baseOpts, hasGitHubToken: false }); const agentEnv = pod.spec!.containers[0].env || []; - const initEnv = pod.spec!.initContainers![0].env || []; + const initEnv = getInitContainer(pod, 'init-workspace').env || []; expect(agentEnv.find((env) => env.name === 'GITHUB_TOKEN')).toBeUndefined(); expect(agentEnv.find((env) => env.name === 'GH_TOKEN')).toBeUndefined(); @@ -337,7 +349,13 @@ describe('podFactory', () => { it('mounts writable /tmp in both init and main containers', () => { const pod = buildAgentPodSpec(baseOpts); - expect(pod.spec!.initContainers![0].volumeMounts).toEqual( + expect(getInitContainer(pod, 'prepare-workspace').volumeMounts).toEqual( + expect.arrayContaining([ + { name: 'workspace', mountPath: '/workspace-volume' }, + { name: 'tmp', mountPath: '/tmp' }, + ]) + ); + expect(getInitContainer(pod, 'init-workspace').volumeMounts).toEqual( expect.arrayContaining([{ name: 'tmp', mountPath: '/tmp' }]) ); expect(pod.spec!.containers[0].volumeMounts).toEqual( @@ -361,7 +379,8 @@ describe('podFactory', () => { }, }; - expect(pod.spec!.initContainers![0].resources).toEqual(expectedResources); + expect(getInitContainer(pod, 'prepare-workspace').resources).toEqual(expectedResources); + expect(getInitContainer(pod, 'init-workspace').resources).toEqual(expectedResources); expect(pod.spec!.containers[0].resources).toEqual(expectedResources); expect(pod.spec!.containers[1].resources).toEqual({ requests: { @@ -433,6 +452,31 @@ describe('podFactory', () => { ); }); + it('mounts the repo subpath at /workspace for shared workspace containers', () => { + const pod = buildAgentPodSpec(baseOpts); + const initWorkspaceMount = getInitContainer(pod, 'init-workspace').volumeMounts!.find( + (mount) => mount.name === 'workspace' + ); + const agentWorkspaceMount = pod.spec!.containers[0].volumeMounts!.find((mount) => mount.name === 'workspace'); + const editorWorkspaceMount = pod.spec!.containers[1].volumeMounts!.find((mount) => mount.name === 'workspace'); + + expect(initWorkspaceMount).toEqual({ + name: 'workspace', + mountPath: '/workspace', + subPath: 'repo', + }); + expect(agentWorkspaceMount).toEqual({ + name: 'workspace', + mountPath: '/workspace', + subPath: 'repo', + }); + expect(editorWorkspaceMount).toEqual({ + name: 'workspace', + mountPath: '/workspace', + subPath: 'repo', + }); + }); + it('does not set runtimeClassName when gVisor not requested', () => { const pod = buildAgentPodSpec(baseOpts); expect(pod.spec!.runtimeClassName).toBeUndefined(); diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts index 570de6c..0257082 100644 --- a/src/server/lib/agentSession/devModeManager.ts +++ b/src/server/lib/agentSession/devModeManager.ts @@ -17,9 +17,9 @@ import * as k8s from '@kubernetes/client-node'; import { DevConfig } from 'server/models/yaml/YamlService'; import { getLogger } from 'server/lib/logger'; +import { AGENT_WORKSPACE_ROOT, AGENT_WORKSPACE_SUBPATH } from './workspace'; const logger = getLogger(); -const AGENT_WORKSPACE_ROOT = '/workspace'; const DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-deployment-snapshot'; const DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-service-snapshot'; @@ -253,7 +253,9 @@ export class DevModeManager { workingDir: workDir, env: Object.entries(devConfig.env || {}).map(([name, value]) => ({ name, value })), // Mount the shared repo root once and let workingDir target the service subdirectory. - volumeMounts: [{ name: 'workspace', mountPath: AGENT_WORKSPACE_ROOT }], + volumeMounts: [ + { name: 'workspace', mountPath: AGENT_WORKSPACE_ROOT, subPath: AGENT_WORKSPACE_SUBPATH }, + ], }, ], }, diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index cb779b7..24ab505 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -21,8 +21,10 @@ import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; import type { RequestUserIdentity } from 'server/lib/get-user'; import { buildPodEnvWithSecrets } from 'server/lib/secretEnvBuilder'; import type { SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { AGENT_WORKSPACE_SUBPATH } from './workspace'; export const AGENT_EDITOR_PORT = parseInt(process.env.AGENT_EDITOR_PORT || '13337', 10); +const AGENT_WORKSPACE_VOLUME_ROOT = '/workspace-volume'; function sleep(ms: number): Promise { return new Promise((resolve) => { @@ -196,6 +198,14 @@ function buildGitHubTokenEnv(secretName: string, enabled?: boolean): k8s.V1EnvVa ]; } +function buildWorkspaceVolumeMount(workspacePath: string): k8s.V1VolumeMount { + return { + name: 'workspace', + mountPath: workspacePath, + subPath: AGENT_WORKSPACE_SUBPATH, + }; +} + export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { const { podName, @@ -240,6 +250,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { const editorResources = buildEditorResources(); const userEnv = buildUserIdentityEnv(userIdentity); const githubTokenEnv = buildGitHubTokenEnv(apiKeySecretName, hasGitHubToken); + const workspaceVolumeMount = buildWorkspaceVolumeMount(workspacePath); const forwardedAgentEnv = opts.forwardedAgentEnv || {}; const forwardedAgentSecretEnv = buildPodEnvWithSecrets( forwardedAgentEnv, @@ -284,10 +295,10 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { }, initContainers: [ { - name: 'init-workspace', + name: 'prepare-workspace', image, imagePullPolicy: 'IfNotPresent', - command: ['sh', '-c', initScript], + command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], resources, securityContext: { ...securityContext, @@ -296,8 +307,31 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { volumeMounts: [ { name: 'workspace', - mountPath: workspacePath, + mountPath: AGENT_WORKSPACE_VOLUME_ROOT, }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + }, + { + name: 'init-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', initScript], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + workspaceVolumeMount, { name: 'claude-config', mountPath: '/home/claude/.claude', @@ -347,10 +381,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { ...userEnv, ], volumeMounts: [ - { - name: 'workspace', - mountPath: workspacePath, - }, + workspaceVolumeMount, { name: 'claude-config', mountPath: '/home/claude/.claude', @@ -392,10 +423,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { periodSeconds: 5, }, volumeMounts: [ - { - name: 'workspace', - mountPath: workspacePath, - }, + workspaceVolumeMount, { name: 'editor-home', mountPath: '/home/coder', diff --git a/src/server/lib/agentSession/workspace.ts b/src/server/lib/agentSession/workspace.ts new file mode 100644 index 0000000..d522e17 --- /dev/null +++ b/src/server/lib/agentSession/workspace.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const AGENT_WORKSPACE_ROOT = '/workspace'; +export const AGENT_WORKSPACE_SUBPATH = 'repo'; diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 1a1f709..b0534c5 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -40,6 +40,7 @@ import { renderAgentSessionClaudeAttribution, } from 'server/lib/agentSession/runtimeConfig'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; +import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; const logger = getLogger(); const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; @@ -355,7 +356,7 @@ export default class AgentSessionService { repoUrl: opts.repoUrl, branch: opts.branch, revision: opts.revision, - workspacePath: '/workspace', + workspacePath: AGENT_WORKSPACE_ROOT, installCommand: combinedInstallCommand, claudePermissions: claudeConfig.permissions, claudeCommitAttribution, From ece590ead425b5ea025fc6797587303356f2acf2 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Tue, 24 Mar 2026 16:50:53 -0700 Subject: [PATCH 05/18] fix --- src/server/lib/nativeHelm/helm.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/server/lib/nativeHelm/helm.ts b/src/server/lib/nativeHelm/helm.ts index 8370f93..51d3c93 100644 --- a/src/server/lib/nativeHelm/helm.ts +++ b/src/server/lib/nativeHelm/helm.ts @@ -41,7 +41,6 @@ import { getHelmConfiguration, generateHelmInstallScript, validateHelmConfiguration, - resolveHelmReleaseConflicts, } from './utils'; import { detectRegistryAuth, RegistryAuthConfig } from './registryAuth'; import { HELM_IMAGE_PREFIX } from './constants'; @@ -219,9 +218,6 @@ export async function nativeHelmDeploy(deploy: Deploy, options: HelmDeployOption const deployable = requireDeployable(deploy); const jobId = randomAlphanumeric(4).toLowerCase(); const { namespace } = options; - const releaseName = deploy.uuid.toLowerCase(); - - await resolveHelmReleaseConflicts(releaseName, namespace); await ensureServiceAccountForJob(namespace, 'deploy'); From 70c1bc8a4daccdd2180a5a156c0d1767dd4d714c Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 09:22:35 -0700 Subject: [PATCH 06/18] add bun to agent container --- sysops/dockerfiles/agent.Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sysops/dockerfiles/agent.Dockerfile b/sysops/dockerfiles/agent.Dockerfile index 53dbea0..69918ef 100644 --- a/sysops/dockerfiles/agent.Dockerfile +++ b/sysops/dockerfiles/agent.Dockerfile @@ -15,6 +15,8 @@ FROM node:20-slim ENV HOME=/home/claude +ENV BUN_INSTALL=/home/claude/.bun +ENV PATH=${BUN_INSTALL}/bin:${PATH} ENV NPM_CONFIG_UPDATE_NOTIFIER=false RUN apt-get update && apt-get install -y \ @@ -27,10 +29,13 @@ RUN apt-get update && apt-get install -y \ golang-go \ python3 \ ripgrep \ + unzip \ && rm -rf /var/lib/apt/lists/* RUN npm install -g pnpm @anthropic-ai/claude-code +RUN curl -fsSL https://bun.sh/install | bash + RUN mkdir -p /home/claude /workspace && \ chown -R 1000:1000 /home/claude /workspace From 3ffbc21505e652c81a44bafd62bf1fe6df4815c2 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 09:51:40 -0700 Subject: [PATCH 07/18] pin agent and app to same node --- .../__tests__/devModeManager.test.ts | 58 +++++++++++++++++++ src/server/lib/agentSession/devModeManager.ts | 42 +++++++++++++- .../services/__tests__/agentSession.test.ts | 32 +++++----- src/server/services/agentSession.ts | 49 +++++++++++++--- 4 files changed, 155 insertions(+), 26 deletions(-) diff --git a/src/server/lib/agentSession/__tests__/devModeManager.test.ts b/src/server/lib/agentSession/__tests__/devModeManager.test.ts index d879db7..337560d 100644 --- a/src/server/lib/agentSession/__tests__/devModeManager.test.ts +++ b/src/server/lib/agentSession/__tests__/devModeManager.test.ts @@ -99,6 +99,7 @@ describe('DevModeManager', () => { serviceName: 'my-app', pvcName: 'agent-pvc-abc', devConfig: { image: 'node:20-slim', command: 'pnpm dev', workDir: '/workspace' }, + requiredNodeName: 'agent-node-a', }; await manager.enableDevMode(opts); @@ -115,6 +116,7 @@ describe('DevModeManager', () => { spec: { template: { spec: { + nodeSelector: { 'kubernetes.io/hostname': 'agent-node-a' }, volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }], containers: [ expect.objectContaining({ @@ -138,6 +140,38 @@ describe('DevModeManager', () => { ); }); + it('merges the required agent node with an existing deployment nodeSelector', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + spec: { + template: { + spec: { + nodeSelector: { 'app-long': 'deployments-m7i' }, + containers: [{ name: 'web-app' }], + }, + }, + }, + }, + }); + + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev', workDir: '/workspace' }, + requiredNodeName: 'agent-node-a', + }; + + await manager.enableDevMode(opts); + + const patchBody = mockPatchDeployment.mock.calls[0][2]; + expect(patchBody.spec.template.spec.nodeSelector).toEqual({ + 'app-long': 'deployments-m7i', + 'kubernetes.io/hostname': 'agent-node-a', + }); + }); + it('mounts the shared workspace root when workDir points at a service subdirectory', async () => { const opts: DevModeOptions = { namespace: 'test-ns', @@ -306,6 +340,7 @@ describe('DevModeManager', () => { ], }, ], + nodeSelector: { 'kubernetes.io/hostname': 'agent-node-a' }, volumes: [ { name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }, { name: 'config-volume', emptyDir: {} }, @@ -326,6 +361,7 @@ describe('DevModeManager', () => { { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts/0' }, { op: 'remove', path: '/spec/template/spec/volumes/0' }, + { op: 'remove', path: '/spec/template/spec/nodeSelector' }, ], undefined, undefined, @@ -399,12 +435,17 @@ describe('DevModeManager', () => { ], volumeMounts: [{ name: 'config-volume', mountPath: '/config' }], volumes: [{ name: 'config-volume', emptyDir: {} }], + nodeSelector: { 'app-long': 'deployments-m7i' }, }), }, }, spec: { template: { spec: { + nodeSelector: { + 'app-long': 'deployments-m7i', + 'kubernetes.io/hostname': 'agent-node-a', + }, containers: [ { name: 'grpc-echo', @@ -463,6 +504,11 @@ describe('DevModeManager', () => { path: '/spec/template/spec/volumes', value: [{ name: 'config-volume', emptyDir: {} }], }, + { + op: 'replace', + path: '/spec/template/spec/nodeSelector', + value: { 'app-long': 'deployments-m7i' }, + }, ], undefined, undefined, @@ -497,6 +543,7 @@ describe('DevModeManager', () => { ], }, ], + nodeSelector: { 'kubernetes.io/hostname': 'agent-node-a' }, volumes: [ { name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }, { name: 'config-volume', emptyDir: {} }, @@ -517,6 +564,7 @@ describe('DevModeManager', () => { { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts/0' }, { op: 'remove', path: '/spec/template/spec/volumes/0' }, + { op: 'remove', path: '/spec/template/spec/nodeSelector' }, ], undefined, undefined, @@ -549,6 +597,10 @@ describe('DevModeManager', () => { volumeMounts: [{ name: 'workspace', mountPath: '/workspace', subPath: 'repo' }], }, ], + nodeSelector: { + 'app-long': 'deployments-m7i', + 'kubernetes.io/hostname': 'agent-node-a', + }, volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: 'agent-pvc-abc' } }], }, }, @@ -574,6 +626,7 @@ describe('DevModeManager', () => { env: null, volumeMounts: null, volumes: null, + nodeSelector: { 'app-long': 'deployments-m7i' }, }, service: { serviceName: 'grpc-echo-service', @@ -595,6 +648,11 @@ describe('DevModeManager', () => { { op: 'remove', path: '/spec/template/spec/containers/0/env' }, { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts' }, { op: 'remove', path: '/spec/template/spec/volumes' }, + { + op: 'replace', + path: '/spec/template/spec/nodeSelector', + value: { 'app-long': 'deployments-m7i' }, + }, ], undefined, undefined, diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts index 0257082..fa59768 100644 --- a/src/server/lib/agentSession/devModeManager.ts +++ b/src/server/lib/agentSession/devModeManager.ts @@ -22,6 +22,7 @@ import { AGENT_WORKSPACE_ROOT, AGENT_WORKSPACE_SUBPATH } from './workspace'; const logger = getLogger(); const DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-deployment-snapshot'; const DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-service-snapshot'; +const SAME_NODE_SELECTOR_KEY = 'kubernetes.io/hostname'; export interface DevModeOptions { namespace: string; @@ -29,6 +30,7 @@ export interface DevModeOptions { serviceName: string; pvcName: string; devConfig: DevConfig; + requiredNodeName?: string; } export interface DevModeDeploymentSnapshot { @@ -40,6 +42,7 @@ export interface DevModeDeploymentSnapshot { env: k8s.V1EnvVar[] | null; volumeMounts: k8s.V1VolumeMount[] | null; volumes: k8s.V1Volume[] | null; + nodeSelector: Record | null; } export interface DevModeServiceSnapshot { @@ -56,6 +59,7 @@ interface AppliedDeploymentTemplate { spec?: { template?: { spec?: { + nodeSelector?: Record; containers?: Array<{ name?: string; command?: string[]; @@ -229,11 +233,17 @@ export class DevModeManager { } private async patchDeployment(opts: DevModeOptions, existing: k8s.V1Deployment): Promise { - const { namespace, pvcName, devConfig } = opts; + const { namespace, pvcName, devConfig, requiredNodeName } = opts; const deploymentName = existing.metadata?.name || opts.deploymentName; const workDir = devConfig.workDir || '/workspace'; const existingContainerName = existing.spec?.template?.spec?.containers?.[0]?.name || deploymentName; const deploymentSnapshot = this.buildDeploymentSnapshot(existing, existingContainerName); + const nodeSelector = requiredNodeName + ? { + ...(existing.spec?.template?.spec?.nodeSelector || {}), + [SAME_NODE_SELECTOR_KEY]: requiredNodeName, + } + : undefined; const patch = { metadata: { @@ -244,6 +254,7 @@ export class DevModeManager { spec: { template: { spec: { + ...(nodeSelector ? { nodeSelector } : {}), volumes: [{ name: 'workspace', persistentVolumeClaim: { claimName: pvcName } }], containers: [ { @@ -364,7 +375,7 @@ export class DevModeManager { desiredTemplate.spec?.template?.spec?.containers?.[liveContainerIndex]; const desiredVolumes = desiredTemplate.spec?.template?.spec?.volumes || []; - const patch: Array> = []; + const patch: Array> = []; if (liveContainer.command && !desiredContainer?.command) { patch.push({ op: 'remove', path: `/spec/template/spec/containers/${liveContainerIndex}/command` }); @@ -402,6 +413,13 @@ export class DevModeManager { } } + this.appendValuePatch( + patch, + '/spec/template/spec/nodeSelector', + liveSpec?.nodeSelector, + desiredTemplate.spec?.template?.spec?.nodeSelector + ); + if (patch.length === 0) { return; } @@ -505,6 +523,7 @@ export class DevModeManager { env: liveContainer?.env ? deepClone(liveContainer.env) : null, volumeMounts: liveContainer?.volumeMounts ? deepClone(liveContainer.volumeMounts) : null, volumes: liveSpec?.volumes ? deepClone(liveSpec.volumes) : null, + nodeSelector: liveSpec?.nodeSelector ? deepClone(liveSpec.nodeSelector) : null, }; } @@ -558,6 +577,7 @@ export class DevModeManager { env: this.cloneValue(container.env ?? null), volumeMounts: this.cloneValue(container.volumeMounts ?? null), volumes: this.cloneValue(deployment.spec?.template?.spec?.volumes ?? null), + nodeSelector: this.cloneValue(deployment.spec?.template?.spec?.nodeSelector ?? null), }, service: service ? { @@ -606,6 +626,12 @@ export class DevModeManager { existing.spec?.template?.spec?.volumes, snapshot.volumes ); + this.appendValuePatch( + patch, + '/spec/template/spec/nodeSelector', + existing.spec?.template?.spec?.nodeSelector, + snapshot.nodeSelector + ); if (patch.length === 0) { return; @@ -735,6 +761,18 @@ export class DevModeManager { } } + const liveNodeSelector = liveSpec?.nodeSelector || {}; + if (liveNodeSelector[SAME_NODE_SELECTOR_KEY]) { + if (Object.keys(liveNodeSelector).length === 1) { + patch.push({ op: 'remove', path: '/spec/template/spec/nodeSelector' }); + } else { + patch.push({ + op: 'remove', + path: `/spec/template/spec/nodeSelector/${SAME_NODE_SELECTOR_KEY.replace('/', '~1')}`, + }); + } + } + return patch; } } diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index 7dc9a30..5f4e7c4 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -144,6 +144,7 @@ const mockEnableDevMode = jest.fn().mockResolvedValue({ env: null, volumeMounts: null, volumes: null, + nodeSelector: null, }, service: null, }); @@ -156,7 +157,7 @@ const mockDisableDevMode = jest.fn().mockResolvedValue(undefined); (isGvisorAvailable as jest.Mock).mockResolvedValue(false); (createAgentPvc as jest.Mock).mockResolvedValue({}); (createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); -(createAgentPod as jest.Mock).mockResolvedValue({}); +(createAgentPod as jest.Mock).mockResolvedValue({ spec: { nodeName: 'agent-node-a' } }); (createAgentEditorService as jest.Mock).mockResolvedValue({}); (deleteAgentPod as jest.Mock).mockResolvedValue(undefined); (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); @@ -258,7 +259,7 @@ describe('AgentSessionService', () => { (isGvisorAvailable as jest.Mock).mockResolvedValue(false); (createAgentPvc as jest.Mock).mockResolvedValue({}); (createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); - (createAgentPod as jest.Mock).mockResolvedValue({}); + (createAgentPod as jest.Mock).mockResolvedValue({ spec: { nodeName: 'agent-node-a' } }); (createAgentEditorService as jest.Mock).mockResolvedValue({}); (deleteAgentPod as jest.Mock).mockResolvedValue(undefined); (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); @@ -490,6 +491,7 @@ describe('AgentSessionService', () => { deploymentName: 'web-build-uuid', serviceName: 'web-build-uuid', namespace: 'test-ns', + requiredNodeName: 'agent-node-a', }) ); expect(mockEnableDevMode).toHaveBeenCalledWith( @@ -497,6 +499,7 @@ describe('AgentSessionService', () => { deploymentName: 'api-build-uuid', serviceName: 'api-build-uuid', namespace: 'test-ns', + requiredNodeName: 'agent-node-a', }) ); expect(mockSessionQuery.patch).toHaveBeenCalledWith( @@ -677,7 +680,7 @@ describe('AgentSessionService', () => { await expect(AgentSessionService.endSession('sess-1')).rejects.toThrow('Session not found or already ended'); }); - it('ends session, reverts deploys, deletes pod and pvc, updates DB and Redis', async () => { + it('ends session, triggers deploy restore, deletes pod and pvc, updates DB and Redis', async () => { const activeSession = { id: 1, uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', @@ -744,16 +747,8 @@ describe('AgentSessionService', () => { expect(DeploymentManager).toHaveBeenCalledWith(devModeDeploys); expect(deployManagerDeploy).toHaveBeenCalled(); - expect(mockDisableDevMode).toHaveBeenCalledTimes(2); - expect(mockDisableDevMode).toHaveBeenNthCalledWith( - 1, - 'test-ns', - 'deploy-10', - 'deploy-10', - activeSession.devModeSnapshots['10'] - ); - expect(mockDisableDevMode).toHaveBeenNthCalledWith( - 2, + expect(mockDisableDevMode).toHaveBeenCalledTimes(1); + expect(mockDisableDevMode).toHaveBeenCalledWith( 'test-ns', 'deploy-10', 'deploy-10', @@ -773,7 +768,7 @@ describe('AgentSessionService', () => { expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); }); - it('starts runtime cleanup before deploy restore finishes and deletes PVC after restore', async () => { + it('returns after cleanup and restore trigger without waiting for redeploy to finish', async () => { const activeSession = { id: 1, uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', @@ -847,11 +842,14 @@ describe('AgentSessionService', () => { expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-sess1'); expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-sess1'); expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); - expect(deleteAgentPvc).not.toHaveBeenCalled(); + await expect(endPromise).resolves.toBeUndefined(); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-sess1'); + expect(deployManagerDeploy).toHaveBeenCalledTimes(1); + expect(mockDisableDevMode).toHaveBeenCalledTimes(1); releaseDeploy(); - await endPromise; - expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-sess1'); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockDisableDevMode).toHaveBeenCalledTimes(2); }); it('queues sandbox cleanup instead of waiting on synchronous build deletion', async () => { diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index b0534c5..e5f6678 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -136,6 +136,39 @@ async function restoreDevModeDeploys( await cleanupDevModePatches(namespace, snapshots, deploys); } +function triggerDevModeDeployRestore( + namespace: string, + snapshots: SessionSnapshotMap | null | undefined, + deploys: Deploy[] +): void { + if (deploys.length === 0) { + return; + } + + // Restore runs in the background after agent teardown so ending a session + // does not block on workload rollout/readiness. + void (async () => { + try { + await restoreDeploys(deploys); + await cleanupDevModePatches(namespace, snapshots, deploys); + logger.info( + `Background dev mode restore finished: namespace=${namespace} deploys=${deploys + .map((deploy) => deploy.uuid || deploy.deployable?.name || deploy.service?.name || deploy.id) + .join(',')}` + ); + } catch (error) { + logger.error( + { + error, + namespace, + deploys: deploys.map((deploy) => deploy.uuid || deploy.deployable?.name || deploy.service?.name || deploy.id), + }, + 'Background dev mode restore failed after session end' + ); + } + })(); +} + async function deleteAgentRuntimeResources( namespace: string, podName: string, @@ -344,7 +377,7 @@ export default class AgentSessionService { .filter((command): command is string => Boolean(command)); const combinedInstallCommand = installCommands.length > 0 ? installCommands.join('\n\n') : undefined; - await createAgentPod({ + const agentPod = await createAgentPod({ podName, namespace: opts.namespace, pvcName, @@ -369,6 +402,11 @@ export default class AgentSessionService { userIdentity: opts.userIdentity, nodeSelector: opts.nodeSelector, }); + const agentNodeName = agentPod.spec?.nodeName || null; + + if ((opts.services || []).length > 0 && !agentNodeName) { + throw new Error(`Agent pod ${podName} did not report a scheduled node`); + } const devModeManager = new DevModeManager(); for (const svc of opts.services || []) { @@ -379,6 +417,7 @@ export default class AgentSessionService { serviceName: resourceName, pvcName, devConfig: svc.devConfig, + requiredNodeName: agentNodeName || undefined, }); mutatedDeploys.push(svc.deployId); devModeSnapshots[String(svc.deployId)] = snapshot; @@ -543,17 +582,13 @@ export default class AgentSessionService { await Deploy.query().findById(deploy.id).patch({ devMode: false, devModeSessionId: null }); } - const restorePromise = - devModeDeploys.length > 0 - ? restoreDevModeDeploys(session.namespace, session.devModeSnapshots, devModeDeploys) - : Promise.resolve(); - await Promise.all([ - restorePromise, deleteAgentRuntimeResources(session.namespace, session.podName, apiKeySecretName), cleanupForwardedAgentEnvSecrets(session.namespace, session.uuid, session.forwardedAgentSecretProviders), ]); + await cleanupDevModePatches(session.namespace, session.devModeSnapshots, devModeDeploys); await deleteAgentPvc(session.namespace, session.pvcName); + triggerDevModeDeployRestore(session.namespace, session.devModeSnapshots, devModeDeploys); await AgentSession.query().findById(session.id).patch({ status: 'ended', From f3b399a67398c672f73a666e3bce364cdd7af08e Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 10:46:49 -0700 Subject: [PATCH 08/18] force dev app deploy to 1 replica, better errors handling on agent start --- .../__tests__/devModeManager.test.ts | 45 ++++- src/server/lib/agentSession/devModeManager.ts | 7 + .../lib/agentSession/startupFailureState.ts | 167 ++++++++++++++++++ .../services/__tests__/agentSession.test.ts | 71 ++++++++ src/server/services/agentSession.ts | 67 ++++++- src/shared/types/agentSession.ts | 7 +- ws-server.ts | 72 +++++++- 7 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 src/server/lib/agentSession/startupFailureState.ts diff --git a/src/server/lib/agentSession/__tests__/devModeManager.test.ts b/src/server/lib/agentSession/__tests__/devModeManager.test.ts index 337560d..169668e 100644 --- a/src/server/lib/agentSession/__tests__/devModeManager.test.ts +++ b/src/server/lib/agentSession/__tests__/devModeManager.test.ts @@ -113,7 +113,8 @@ describe('DevModeManager', () => { 'lifecycle.goodrx.com/dev-mode-deployment-snapshot': expect.any(String), }, }, - spec: { + spec: expect.objectContaining({ + replicas: 1, template: { spec: { nodeSelector: { 'kubernetes.io/hostname': 'agent-node-a' }, @@ -129,7 +130,7 @@ describe('DevModeManager', () => { ], }, }, - }, + }), }), undefined, undefined, @@ -303,6 +304,34 @@ describe('DevModeManager', () => { ); }); + it('captures the original replica count in the deployment snapshot', async () => { + mockReadDeployment.mockResolvedValue({ + body: { + metadata: { name: 'my-app' }, + spec: { + replicas: 3, + template: { + spec: { + containers: [{ name: 'web-app', image: 'registry.example/my-app:built' }], + }, + }, + }, + }, + }); + + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev' }, + }; + + const snapshot = await manager.enableDevMode(opts); + + expect(snapshot.deployment.replicas).toBe(3); + }); + it('removes dev-mode-only deployment fields not present in last-applied configuration', async () => { mockReadDeployment.mockResolvedValue({ body: { @@ -327,6 +356,7 @@ describe('DevModeManager', () => { }, }, spec: { + replicas: 1, template: { spec: { containers: [ @@ -361,6 +391,7 @@ describe('DevModeManager', () => { { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, { op: 'remove', path: '/spec/template/spec/containers/0/volumeMounts/0' }, { op: 'remove', path: '/spec/template/spec/volumes/0' }, + { op: 'remove', path: '/spec/replicas' }, { op: 'remove', path: '/spec/template/spec/nodeSelector' }, ], undefined, @@ -380,6 +411,7 @@ describe('DevModeManager', () => { annotations: { 'kubectl.kubernetes.io/last-applied-configuration': JSON.stringify({ spec: { + replicas: 1, template: { spec: { containers: [ @@ -398,6 +430,7 @@ describe('DevModeManager', () => { }, }, spec: { + replicas: 1, template: { spec: { containers: [ @@ -427,7 +460,9 @@ describe('DevModeManager', () => { name: 'grpc-echo-resolved', annotations: { 'lifecycle.goodrx.com/dev-mode-deployment-snapshot': JSON.stringify({ + deploymentName: 'grpc-echo-resolved', containerName: 'grpc-echo', + replicas: 3, image: 'registry.example/grpc-echo:built', env: [ { name: 'COMPONENT', value: 'app' }, @@ -440,6 +475,7 @@ describe('DevModeManager', () => { }, }, spec: { + replicas: 1, template: { spec: { nodeSelector: { @@ -484,6 +520,7 @@ describe('DevModeManager', () => { path: '/spec/template/spec/containers/0/image', value: 'registry.example/grpc-echo:built', }, + { op: 'replace', path: '/spec/replicas', value: 3 }, { op: 'remove', path: '/spec/template/spec/containers/0/command' }, { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, { @@ -529,6 +566,7 @@ describe('DevModeManager', () => { }, }, spec: { + replicas: 1, template: { spec: { containers: [ @@ -585,6 +623,7 @@ describe('DevModeManager', () => { }, }, spec: { + replicas: 1, template: { spec: { containers: [ @@ -620,6 +659,7 @@ describe('DevModeManager', () => { deployment: { deploymentName: 'grpc-echo-resolved', containerName: 'lc-apps', + replicas: 2, image: 'registry.example/grpc-echo:built', command: null, workingDir: null, @@ -643,6 +683,7 @@ describe('DevModeManager', () => { path: '/spec/template/spec/containers/0/image', value: 'registry.example/grpc-echo:built', }, + { op: 'replace', path: '/spec/replicas', value: 2 }, { op: 'remove', path: '/spec/template/spec/containers/0/command' }, { op: 'remove', path: '/spec/template/spec/containers/0/workingDir' }, { op: 'remove', path: '/spec/template/spec/containers/0/env' }, diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts index fa59768..ae9624e 100644 --- a/src/server/lib/agentSession/devModeManager.ts +++ b/src/server/lib/agentSession/devModeManager.ts @@ -36,6 +36,7 @@ export interface DevModeOptions { export interface DevModeDeploymentSnapshot { deploymentName: string; containerName: string; + replicas: number | null; image: string | null; command: string[] | null; workingDir: string | null; @@ -57,6 +58,7 @@ export interface DevModeResourceSnapshot { interface AppliedDeploymentTemplate { spec?: { + replicas?: number; template?: { spec?: { nodeSelector?: Record; @@ -252,6 +254,7 @@ export class DevModeManager { }, }, spec: { + replicas: 1, template: { spec: { ...(nodeSelector ? { nodeSelector } : {}), @@ -413,6 +416,7 @@ export class DevModeManager { } } + this.appendValuePatch(patch, '/spec/replicas', existing.spec?.replicas, desiredTemplate.spec?.replicas); this.appendValuePatch( patch, '/spec/template/spec/nodeSelector', @@ -517,6 +521,7 @@ export class DevModeManager { return { deploymentName: existing.metadata?.name || '', containerName, + replicas: existing.spec?.replicas ?? null, image: liveContainer?.image || null, command: liveContainer?.command ? deepClone(liveContainer.command) : null, workingDir: liveContainer?.workingDir || null, @@ -571,6 +576,7 @@ export class DevModeManager { deployment: { deploymentName: deployment.metadata?.name || '', containerName: container.name || deployment.metadata?.name || 'container', + replicas: this.cloneValue(deployment.spec?.replicas ?? null), image: container.image || null, command: this.cloneValue(container.command ?? null), workingDir: container.workingDir ?? null, @@ -616,6 +622,7 @@ export class DevModeManager { } this.appendValuePatch(patch, `${containerPath}/image`, liveContainer.image, snapshot.image); + this.appendValuePatch(patch, '/spec/replicas', existing.spec?.replicas, snapshot.replicas); this.appendValuePatch(patch, `${containerPath}/command`, liveContainer.command, snapshot.command); this.appendValuePatch(patch, `${containerPath}/workingDir`, liveContainer.workingDir, snapshot.workingDir); this.appendValuePatch(patch, `${containerPath}/env`, liveContainer.env, snapshot.env); diff --git a/src/server/lib/agentSession/startupFailureState.ts b/src/server/lib/agentSession/startupFailureState.ts new file mode 100644 index 0000000..a839de6 --- /dev/null +++ b/src/server/lib/agentSession/startupFailureState.ts @@ -0,0 +1,167 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Redis } from 'ioredis'; + +const AGENT_SESSION_STARTUP_FAILURE_REDIS_PREFIX = 'lifecycle:agent:session:startup-failure:'; +const AGENT_SESSION_STARTUP_FAILURE_TTL_SECONDS = 60 * 60; +const AGENT_SESSION_STARTUP_FAILURE_MESSAGE_MAX_LENGTH = 4000; + +export type AgentSessionStartupFailureStage = 'create_session' | 'connect_runtime'; + +export interface AgentSessionStartupFailureState { + sessionId: string; + stage: AgentSessionStartupFailureStage; + title: string; + message: string; + recordedAt: string; +} + +export type PublicAgentSessionStartupFailure = Omit; + +function agentSessionStartupFailureKey(sessionId: string): string { + return `${AGENT_SESSION_STARTUP_FAILURE_REDIS_PREFIX}${sessionId}`; +} + +function truncateMessage(message: string): string { + if (message.length <= AGENT_SESSION_STARTUP_FAILURE_MESSAGE_MAX_LENGTH) { + return message; + } + + return `${message.slice(0, AGENT_SESSION_STARTUP_FAILURE_MESSAGE_MAX_LENGTH - 3)}...`; +} + +function normalizeFailureMessage(error: unknown): string { + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Lifecycle could not start the agent runtime.'; + const message = rawMessage.trim() || 'Lifecycle could not start the agent runtime.'; + + return truncateMessage(message); +} + +function stripMessagePrefix(message: string, prefix: string): string { + if (!message.startsWith(prefix)) { + return message; + } + + const stripped = message.slice(prefix.length).trim(); + return stripped || message; +} + +function classifyFailure( + message: string, + stage: AgentSessionStartupFailureStage +): Pick { + if (/^Agent pod failed to start:/i.test(message)) { + return { + title: 'Agent pod failed to start', + message: stripMessagePrefix(message, 'Agent pod failed to start:'), + }; + } + + if (/^Agent pod did not become ready within/i.test(message)) { + return { + title: 'Agent runtime did not become ready', + message, + }; + } + + if (/ImagePullBackOff|ErrImagePull/i.test(message)) { + return { + title: 'Agent image could not be pulled', + message, + }; + } + + if (/init-workspace/i.test(message)) { + return { + title: 'Workspace initialization failed', + message, + }; + } + + if (/editor/i.test(message)) { + return { + title: 'Agent editor failed to start', + message, + }; + } + + return { + title: stage === 'create_session' ? 'Agent session failed to start' : 'Agent runtime connection failed', + message, + }; +} + +export function buildAgentSessionStartupFailure(params: { + sessionId: string; + error: unknown; + stage?: AgentSessionStartupFailureStage; +}): AgentSessionStartupFailureState { + const stage = params.stage || 'connect_runtime'; + const message = normalizeFailureMessage(params.error); + const classified = classifyFailure(message, stage); + + return { + sessionId: params.sessionId, + stage, + title: classified.title, + message: classified.message, + recordedAt: new Date().toISOString(), + }; +} + +export async function setAgentSessionStartupFailure( + redis: Redis, + failure: AgentSessionStartupFailureState +): Promise { + await redis.setex( + agentSessionStartupFailureKey(failure.sessionId), + AGENT_SESSION_STARTUP_FAILURE_TTL_SECONDS, + JSON.stringify(failure) + ); +} + +export async function getAgentSessionStartupFailure( + redis: Redis, + sessionId: string +): Promise { + const raw = await redis.get(agentSessionStartupFailureKey(sessionId)); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as AgentSessionStartupFailureState; + } catch { + return null; + } +} + +export async function clearAgentSessionStartupFailure(redis: Redis, sessionId: string): Promise { + await redis.del(agentSessionStartupFailureKey(sessionId)); +} + +export function toPublicAgentSessionStartupFailure( + failure: AgentSessionStartupFailureState +): PublicAgentSessionStartupFailure { + const { sessionId: _sessionId, ...publicFailure } = failure; + return publicFailure; +} diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index 5f4e7c4..a18f0d5 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -138,6 +138,7 @@ const mockEnableDevMode = jest.fn().mockResolvedValue({ deployment: { deploymentName: 'service', containerName: 'service', + replicas: null, image: 'node:20', command: null, workingDir: null, @@ -246,12 +247,14 @@ describe('AgentSessionService', () => { deployment: { deploymentName: 'service', containerName: 'service', + replicas: null, image: 'node:20', command: null, workingDir: null, env: null, volumeMounts: null, volumes: null, + nodeSelector: null, }, service: null, }); @@ -553,6 +556,11 @@ describe('AgentSessionService', () => { await expect(AgentSessionService.createSession(baseOpts)).rejects.toThrow('pod creation failed'); + expect(mockRedis.setex).toHaveBeenCalledWith( + 'lifecycle:agent:session:startup-failure:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 3600, + expect.any(String) + ); expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); @@ -694,12 +702,14 @@ describe('AgentSessionService', () => { deployment: { deploymentName: 'deploy-10', containerName: 'web', + replicas: null, image: 'node:20', command: null, workingDir: null, env: null, volumeMounts: null, volumes: null, + nodeSelector: null, }, service: null, }, @@ -781,12 +791,14 @@ describe('AgentSessionService', () => { deployment: { deploymentName: 'deploy-10', containerName: 'web', + replicas: null, image: 'node:20', command: null, workingDir: null, env: null, volumeMounts: null, volumes: null, + nodeSelector: null, }, service: null, }, @@ -923,6 +935,63 @@ describe('AgentSessionService', () => { }); }); + describe('session startup failures', () => { + it('returns the persisted runtime failure for a session', async () => { + mockRedis.get.mockResolvedValue( + JSON.stringify({ + sessionId: 'sess-1', + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }) + ); + + const result = await AgentSessionService.getSessionStartupFailure('sess-1'); + + expect(mockRedis.get).toHaveBeenCalledWith('lifecycle:agent:session:startup-failure:sess-1'); + expect(result).toEqual({ + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }); + }); + + it('persists a runtime failure in Redis and marks the session errored', async () => { + mockSessionQuery.findOne.mockResolvedValue({ + id: 123, + uuid: 'sess-1', + status: 'active', + }); + + const result = await AgentSessionService.markSessionRuntimeFailure( + 'sess-1', + new Error('Agent pod failed to start: init-workspace: ImagePullBackOff') + ); + + expect(mockRedis.setex).toHaveBeenCalledWith( + 'lifecycle:agent:session:startup-failure:sess-1', + 3600, + expect.any(String) + ); + expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:sess-1'); + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + endedAt: expect.any(String), + }) + ); + expect(result).toEqual( + expect.objectContaining({ + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + }) + ); + }); + }); + describe('getActiveSessions', () => { it('returns active sessions for user', async () => { await AgentSessionService.getActiveSessions('user-123'); @@ -956,12 +1025,14 @@ describe('AgentSessionService', () => { deployment: { deploymentName: 'api', containerName: 'api', + replicas: null, image: 'node:20', command: null, workingDir: null, env: null, volumeMounts: null, volumes: null, + nodeSelector: null, }, service: null, }, diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index e5f6678..e19d151 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -41,6 +41,15 @@ import { } from 'server/lib/agentSession/runtimeConfig'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; +import { + AgentSessionStartupFailureStage, + PublicAgentSessionStartupFailure, + buildAgentSessionStartupFailure, + clearAgentSessionStartupFailure, + getAgentSessionStartupFailure, + setAgentSessionStartupFailure, + toPublicAgentSessionStartupFailure, +} from 'server/lib/agentSession/startupFailureState'; const logger = getLogger(); const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; @@ -205,6 +214,44 @@ export interface CreateSessionOptions { } export default class AgentSessionService { + static async getSessionStartupFailure(sessionId: string): Promise { + const redis = RedisClient.getInstance().getRedis(); + const failure = await getAgentSessionStartupFailure(redis, sessionId); + + return failure ? toPublicAgentSessionStartupFailure(failure) : null; + } + + static async markSessionRuntimeFailure( + sessionId: string, + error: unknown, + stage: AgentSessionStartupFailureStage = 'connect_runtime' + ): Promise { + const redis = RedisClient.getInstance().getRedis(); + const failure = buildAgentSessionStartupFailure({ + sessionId, + error, + stage, + }); + + await setAgentSessionStartupFailure(redis, failure).catch(() => {}); + await redis.del(`${SESSION_REDIS_PREFIX}${sessionId}`).catch(() => {}); + + const session = await AgentSession.query() + .findOne({ uuid: sessionId }) + .catch(() => null); + if (session && (session.status === 'starting' || session.status === 'active')) { + await AgentSession.query() + .findById(session.id) + .patch({ + status: 'error', + endedAt: new Date().toISOString(), + } as unknown as Partial) + .catch(() => {}); + } + + return toPublicAgentSessionStartupFailure(failure); + } + static async enrichSessions(sessions: AgentSession[]): Promise { if (sessions.length === 0) { return []; @@ -332,6 +379,7 @@ export default class AgentSessionService { const model = opts.model || 'claude-sonnet-4-6'; const mutatedDeploys: number[] = []; const devModeSnapshots: SessionSnapshotMap = {}; + let failureStage: AgentSessionStartupFailureStage = 'create_session'; let sessionPersisted = false; let session: AgentSession | null = null; const redis = RedisClient.getInstance().getRedis(); @@ -377,6 +425,7 @@ export default class AgentSessionService { .filter((command): command is string => Boolean(command)); const combinedInstallCommand = installCommands.length > 0 ? installCommands.join('\n\n') : undefined; + failureStage = 'connect_runtime'; const agentPod = await createAgentPod({ podName, namespace: opts.namespace, @@ -458,8 +507,16 @@ export default class AgentSessionService { status: 'active', } as AgentSession; + await clearAgentSessionStartupFailure(redis, sessionUuid).catch(() => {}); + return session!; } catch (err) { + const startupFailure = buildAgentSessionStartupFailure({ + sessionId: sessionUuid, + error: err, + stage: failureStage, + }); + if ( buildKind === BuildKind.ENVIRONMENT && opts.buildUuid && @@ -473,6 +530,8 @@ export default class AgentSessionService { logger.error(`Session creation failed, rolling back: sessionId=${sessionUuid} err=${(err as Error).message}`); + await setAgentSessionStartupFailure(redis, startupFailure).catch(() => {}); + const revertPromise = mutatedDeploys.length > 0 ? (async () => { @@ -504,6 +563,7 @@ export default class AgentSessionService { .findById(session!.id) .patch({ status: 'error', + endedAt: new Date().toISOString(), devModeSnapshots: {}, } as unknown as Partial) .catch(() => {}); @@ -520,6 +580,7 @@ export default class AgentSessionService { status: 'error', buildUuid: opts.buildUuid || null, buildKind, + endedAt: new Date().toISOString(), devModeSnapshots: {}, forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, } as unknown as Partial) @@ -551,7 +612,10 @@ export default class AgentSessionService { endedAt: new Date().toISOString(), }); - await redis.del(`${SESSION_REDIS_PREFIX}${session.uuid}`); + await Promise.all([ + redis.del(`${SESSION_REDIS_PREFIX}${session.uuid}`), + clearAgentSessionStartupFailure(redis, session.uuid).catch(() => {}), + ]); const { default: BuildService } = await import('./build'); const buildService = new BuildService(); @@ -585,6 +649,7 @@ export default class AgentSessionService { await Promise.all([ deleteAgentRuntimeResources(session.namespace, session.podName, apiKeySecretName), cleanupForwardedAgentEnvSecrets(session.namespace, session.uuid, session.forwardedAgentSecretProviders), + clearAgentSessionStartupFailure(redis, session.uuid).catch(() => {}), ]); await cleanupDevModePatches(session.namespace, session.devModeSnapshots, devModeDeploys); await deleteAgentPvc(session.namespace, session.pvcName); diff --git a/src/shared/types/agentSession.ts b/src/shared/types/agentSession.ts index e2c9793..b2c8249 100644 --- a/src/shared/types/agentSession.ts +++ b/src/shared/types/agentSession.ts @@ -70,6 +70,11 @@ export type AgentWSServerMessage = } | { type: 'phase'; phase: AgentWSPhase; label: string; tool?: string } | { type: 'prompt'; message: string; promptId: string } - | { type: 'status'; status: 'connecting' | 'ready' | 'working' | 'idle' | 'error' | 'ended' } + | { + type: 'status'; + status: 'connecting' | 'ready' | 'working' | 'idle' | 'error' | 'ended'; + title?: string; + message?: string; + } | { type: 'heartbeat'; ts: number } | { type: 'dev_reload'; service: string; trigger: string }; diff --git a/ws-server.ts b/ws-server.ts index 272f21c..a704aeb 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -33,6 +33,12 @@ import next from 'next'; import { WebSocketServer, WebSocket } from 'ws'; import { rootLogger } from './src/server/lib/logger'; import { streamK8sLogs, AbortHandle } from './src/server/lib/k8sStreamer'; +import { + AgentSessionStartupFailureStage, + PublicAgentSessionStartupFailure, + buildAgentSessionStartupFailure, + toPublicAgentSessionStartupFailure, +} from './src/server/lib/agentSession/startupFailureState'; const dev = process.env.NODE_ENV !== 'production'; const hostname = process.env.HOSTNAME || 'localhost'; @@ -89,6 +95,44 @@ type AgentSessionRuntime = { const agentSessionRuntimes = new Map(); +function buildLocalAgentSessionFailure( + sessionId: string, + error: unknown, + stage: AgentSessionStartupFailureStage = 'connect_runtime' +): PublicAgentSessionStartupFailure { + return toPublicAgentSessionStartupFailure( + buildAgentSessionStartupFailure({ + sessionId, + error, + stage, + }) + ); +} + +async function persistAgentRuntimeFailure( + sessionId: string, + error: unknown, + stage: AgentSessionStartupFailureStage = 'connect_runtime' +): Promise { + const AgentSessionService = (await import('./src/server/services/agentSession')).default; + return AgentSessionService.markSessionRuntimeFailure(sessionId, error, stage); +} + +async function resolveAgentSessionFailureForClient( + sessionId: string | null, + error: unknown, + stage: AgentSessionStartupFailureStage = 'connect_runtime' +): Promise { + if (!sessionId) { + return buildLocalAgentSessionFailure('unknown-session', error, stage); + } + + const AgentSessionService = (await import('./src/server/services/agentSession')).default; + const persistedFailure = await AgentSessionService.getSessionStartupFailure(sessionId); + + return persistedFailure || buildLocalAgentSessionFailure(sessionId, error, stage); +} + function parseCookieHeader(cookieHeader: string | string[] | undefined): Record { if (!cookieHeader) { return {}; @@ -476,14 +520,26 @@ async function getOrCreateAgentRuntime( } logger.error({ ...agentLogCtx, err }, 'Agent exec error'); - broadcastAgentMessage(runtime!, { type: 'status', status: 'error' }); + const failure = buildLocalAgentSessionFailure(sessionId, err); + void persistAgentRuntimeFailure(sessionId, err).catch((persistError) => { + logger.warn({ ...agentLogCtx, err: persistError }, 'Failed to persist agent runtime failure'); + }); + broadcastAgentMessage(runtime!, { + type: 'status', + status: 'error', + title: failure.title, + message: failure.message, + }); closeAgentClients(runtime!, 1011, 'Agent exec error'); disposeAgentRuntime(sessionId, runtime!, false); }); broadcastAgentMessage(runtime!, { type: 'status', status: 'ready' }); })() - .catch((error) => { + .catch(async (error) => { + await persistAgentRuntimeFailure(sessionId, error).catch((persistError) => { + logger.warn({ ...agentLogCtx, err: persistError }, 'Failed to persist agent runtime failure'); + }); disposeAgentRuntime(sessionId, runtime!, true); throw error; }) @@ -981,9 +1037,17 @@ app.prepare().then(() => { runtime.clients.delete(ws); scheduleAgentRuntimeCleanup(sessionId, runtime, agentLogCtx); } + const failure = await resolveAgentSessionFailureForClient(sessionId, error); if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.send(JSON.stringify({ type: 'status', status: 'error' })); - closeSocket(ws, 1008, `Connection error: ${error.message}`); + ws.send( + JSON.stringify({ + type: 'status', + status: 'error', + title: failure.title, + message: failure.message, + }) + ); + closeSocket(ws, 1008, `${failure.title}: ${failure.message}`); } } }); From d81ae6826b3df347718e33b1fd264a7e10068fb4 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 12:02:37 -0700 Subject: [PATCH 09/18] Handle HPAs --- .../__tests__/devModeManager.test.ts | 138 ++++++++- src/server/lib/agentSession/devModeManager.ts | 280 +++++++++++++++--- 2 files changed, 383 insertions(+), 35 deletions(-) diff --git a/src/server/lib/agentSession/__tests__/devModeManager.test.ts b/src/server/lib/agentSession/__tests__/devModeManager.test.ts index 169668e..f69ecb6 100644 --- a/src/server/lib/agentSession/__tests__/devModeManager.test.ts +++ b/src/server/lib/agentSession/__tests__/devModeManager.test.ts @@ -22,6 +22,9 @@ const mockReadService = jest.fn(); const mockListServices = jest.fn(); const mockPatchDeployment = jest.fn(); const mockPatchService = jest.fn(); +const mockReadHorizontalPodAutoscaler = jest.fn(); +const mockListHorizontalPodAutoscalers = jest.fn(); +const mockPatchHorizontalPodAutoscaler = jest.fn(); jest.mock('@kubernetes/client-node', () => { const actual = jest.requireActual('@kubernetes/client-node'); @@ -44,6 +47,13 @@ jest.mock('@kubernetes/client-node', () => { patchNamespacedService: mockPatchService, }; } + if (apiClass === actual.AutoscalingV2Api) { + return { + readNamespacedHorizontalPodAutoscaler: mockReadHorizontalPodAutoscaler, + listNamespacedHorizontalPodAutoscaler: mockListHorizontalPodAutoscalers, + patchNamespacedHorizontalPodAutoscaler: mockPatchHorizontalPodAutoscaler, + }; + } return {}; }), })), @@ -87,8 +97,11 @@ describe('DevModeManager', () => { }, }); mockListServices.mockResolvedValue({ body: { items: [] } }); + mockReadHorizontalPodAutoscaler.mockRejectedValue(new k8s.HttpError({ statusCode: 404 } as any, 'not found', 404)); + mockListHorizontalPodAutoscalers.mockResolvedValue({ body: { items: [] } }); mockPatchDeployment.mockResolvedValue({}); mockPatchService.mockResolvedValue({}); + mockPatchHorizontalPodAutoscaler.mockResolvedValue({}); manager = new DevModeManager(); }); @@ -109,9 +122,9 @@ describe('DevModeManager', () => { 'test-ns', expect.objectContaining({ metadata: { - annotations: { + annotations: expect.objectContaining({ 'lifecycle.goodrx.com/dev-mode-deployment-snapshot': expect.any(String), - }, + }), }, spec: expect.objectContaining({ replicas: 1, @@ -332,6 +345,58 @@ describe('DevModeManager', () => { expect(snapshot.deployment.replicas).toBe(3); }); + it('pins an attached HorizontalPodAutoscaler to a single replica during dev mode', async () => { + mockListHorizontalPodAutoscalers.mockResolvedValue({ + body: { + items: [ + { + metadata: { name: 'my-app-hpa' }, + spec: { + minReplicas: 2, + maxReplicas: 5, + scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'my-app' }, + }, + }, + ], + }, + }); + + const opts: DevModeOptions = { + namespace: 'test-ns', + deploymentName: 'my-app', + serviceName: 'my-app', + pvcName: 'agent-pvc-abc', + devConfig: { image: 'node:20-slim', command: 'pnpm dev' }, + }; + + const snapshot = await manager.enableDevMode(opts); + + expect(snapshot.horizontalPodAutoscaler).toEqual({ + hpaName: 'my-app-hpa', + minReplicas: 2, + maxReplicas: 5, + }); + expect(mockPatchHorizontalPodAutoscaler).toHaveBeenCalledWith( + 'my-app-hpa', + 'test-ns', + { + spec: { + minReplicas: 1, + maxReplicas: 1, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + expect(mockPatchHorizontalPodAutoscaler.mock.invocationCallOrder[0]).toBeLessThan( + mockPatchDeployment.mock.invocationCallOrder[0] + ); + }); + it('removes dev-mode-only deployment fields not present in last-applied configuration', async () => { mockReadDeployment.mockResolvedValue({ body: { @@ -472,6 +537,11 @@ describe('DevModeManager', () => { volumes: [{ name: 'config-volume', emptyDir: {} }], nodeSelector: { 'app-long': 'deployments-m7i' }, }), + 'lifecycle.goodrx.com/dev-mode-hpa-snapshot': JSON.stringify({ + hpaName: 'grpc-echo-hpa', + minReplicas: 2, + maxReplicas: 5, + }), }, }, spec: { @@ -504,6 +574,16 @@ describe('DevModeManager', () => { }, }, }); + mockReadHorizontalPodAutoscaler.mockResolvedValue({ + body: { + metadata: { name: 'grpc-echo-hpa' }, + spec: { + minReplicas: 1, + maxReplicas: 1, + scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'grpc-echo-resolved' }, + }, + }, + }); await manager.disableDevMode('test-ns', 'grpc-echo'); @@ -515,6 +595,10 @@ describe('DevModeManager', () => { op: 'remove', path: '/metadata/annotations/lifecycle.goodrx.com~1dev-mode-deployment-snapshot', }, + { + op: 'remove', + path: '/metadata/annotations/lifecycle.goodrx.com~1dev-mode-hpa-snapshot', + }, { op: 'replace', path: '/spec/template/spec/containers/0/image', @@ -554,6 +638,22 @@ describe('DevModeManager', () => { undefined, { headers: { 'Content-Type': 'application/json-patch+json' } } ); + expect(mockPatchHorizontalPodAutoscaler).toHaveBeenCalledWith( + 'grpc-echo-hpa', + 'test-ns', + { + spec: { + minReplicas: 2, + maxReplicas: 5, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); }); it('falls back to removing dev-mode-only deployment fields when last-applied annotation is missing', async () => { @@ -654,6 +754,16 @@ describe('DevModeManager', () => { }, }, }); + mockReadHorizontalPodAutoscaler.mockResolvedValue({ + body: { + metadata: { name: 'grpc-echo-hpa' }, + spec: { + minReplicas: 1, + maxReplicas: 1, + scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'grpc-echo-resolved' }, + }, + }, + }); await manager.disableDevMode('test-ns', 'grpc-echo', 'grpc-echo', { deployment: { @@ -672,6 +782,11 @@ describe('DevModeManager', () => { serviceName: 'grpc-echo-service', ports: [{ name: 'tcp', port: 8080, targetPort: 8080, protocol: 'TCP' }], }, + horizontalPodAutoscaler: { + hpaName: 'grpc-echo-hpa', + minReplicas: 2, + maxReplicas: 4, + }, }); expect(mockPatchDeployment).toHaveBeenCalledWith( @@ -722,6 +837,25 @@ describe('DevModeManager', () => { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); + expect(mockPatchHorizontalPodAutoscaler).toHaveBeenCalledWith( + 'grpc-echo-hpa', + 'test-ns', + { + spec: { + minReplicas: 2, + maxReplicas: 4, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + expect(mockPatchDeployment.mock.invocationCallOrder[0]).toBeLessThan( + mockPatchHorizontalPodAutoscaler.mock.invocationCallOrder[0] + ); }); it('resolves build-specific deployment and service names when logical names do not exist directly', async () => { diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts index ae9624e..4ae7a9f 100644 --- a/src/server/lib/agentSession/devModeManager.ts +++ b/src/server/lib/agentSession/devModeManager.ts @@ -22,6 +22,7 @@ import { AGENT_WORKSPACE_ROOT, AGENT_WORKSPACE_SUBPATH } from './workspace'; const logger = getLogger(); const DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-deployment-snapshot'; const DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-service-snapshot'; +const DEV_MODE_HPA_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-hpa-snapshot'; const SAME_NODE_SELECTOR_KEY = 'kubernetes.io/hostname'; export interface DevModeOptions { @@ -51,9 +52,16 @@ export interface DevModeServiceSnapshot { ports: k8s.V1ServicePort[] | null; } +export interface DevModeHorizontalPodAutoscalerSnapshot { + hpaName: string; + minReplicas: number | null; + maxReplicas: number; +} + export interface DevModeResourceSnapshot { deployment: DevModeDeploymentSnapshot; service: DevModeServiceSnapshot | null; + horizontalPodAutoscaler?: DevModeHorizontalPodAutoscalerSnapshot | null; } interface AppliedDeploymentTemplate { @@ -102,12 +110,14 @@ export class DevModeManager { private kc: k8s.KubeConfig; private appsApi: k8s.AppsV1Api; private coreApi: k8s.CoreV1Api; + private autoscalingApi: k8s.AutoscalingV2Api; constructor() { this.kc = new k8s.KubeConfig(); this.kc.loadFromDefault(); this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api); + this.autoscalingApi = this.kc.makeApiClient(k8s.AutoscalingV2Api); } async enableDevMode(opts: DevModeOptions): Promise { @@ -116,12 +126,84 @@ export class DevModeManager { const service = opts.devConfig.ports?.length ? await this.resolveService(opts.namespace, opts.serviceName, resolvedDeploymentName, deployment) : null; - const snapshot = this.captureSnapshot(deployment, service); + const horizontalPodAutoscaler = await this.resolveHorizontalPodAutoscaler(opts.namespace, resolvedDeploymentName); + const snapshot = this.captureSnapshot(deployment, service, horizontalPodAutoscaler); + + let deploymentPatched = false; + let servicePatched = false; + let horizontalPodAutoscalerPatched = false; + + try { + if (horizontalPodAutoscaler && snapshot.horizontalPodAutoscaler) { + await this.patchHorizontalPodAutoscaler(opts.namespace, horizontalPodAutoscaler); + horizontalPodAutoscalerPatched = true; + } + + await this.patchDeployment(opts, deployment, snapshot.deployment, snapshot.horizontalPodAutoscaler ?? null); + deploymentPatched = true; - await this.patchDeployment(opts, deployment); + if (service) { + await this.patchService(opts, service); + servicePatched = true; + } + } catch (error) { + const cleanupTasks: Array> = []; + + if (servicePatched && snapshot.service) { + cleanupTasks.push( + (async () => { + const liveService = await this.resolveService( + opts.namespace, + opts.serviceName, + resolvedDeploymentName, + deployment + ).catch(() => null); + + if (liveService) { + await this.restoreServiceFromSnapshot(opts.namespace, liveService, snapshot.service!).catch(() => {}); + } + })() + ); + } + + if (deploymentPatched) { + cleanupTasks.push( + (async () => { + const liveDeployment = await this.resolveDeployment(opts.namespace, resolvedDeploymentName).catch( + () => null + ); + + if (liveDeployment) { + await this.restoreDeploymentFromSnapshot(opts.namespace, liveDeployment, snapshot.deployment).catch( + () => {} + ); + } + })() + ); + } - if (service) { - await this.patchService(opts, service); + if (horizontalPodAutoscalerPatched && snapshot.horizontalPodAutoscaler) { + cleanupTasks.push( + (async () => { + const liveHorizontalPodAutoscaler = await this.resolveHorizontalPodAutoscaler( + opts.namespace, + resolvedDeploymentName, + snapshot.horizontalPodAutoscaler?.hpaName + ).catch(() => null); + + if (liveHorizontalPodAutoscaler) { + await this.restoreHorizontalPodAutoscalerFromSnapshot( + opts.namespace, + liveHorizontalPodAutoscaler, + snapshot.horizontalPodAutoscaler! + ).catch(() => {}); + } + })() + ); + } + + await Promise.all(cleanupTasks); + throw error; } logger.info(`Enabled dev mode: deployment=${resolvedDeploymentName} namespace=${opts.namespace}`); @@ -136,6 +218,8 @@ export class DevModeManager { ): Promise { const deployment = await this.resolveDeployment(namespace, deploymentName); const resolvedDeploymentName = deployment.metadata?.name || deploymentName; + const horizontalPodAutoscalerSnapshot = + snapshot?.horizontalPodAutoscaler ?? this.getHorizontalPodAutoscalerSnapshot(deployment); if (snapshot?.deployment) { await this.restoreDeploymentFromSnapshot(namespace, deployment, snapshot.deployment); @@ -160,6 +244,30 @@ export class DevModeManager { } } + if (horizontalPodAutoscalerSnapshot) { + try { + const horizontalPodAutoscaler = await this.resolveHorizontalPodAutoscaler( + namespace, + resolvedDeploymentName, + horizontalPodAutoscalerSnapshot.hpaName + ); + + if (horizontalPodAutoscaler) { + await this.restoreHorizontalPodAutoscalerFromSnapshot( + namespace, + horizontalPodAutoscaler, + horizontalPodAutoscalerSnapshot + ); + } + } catch (error) { + logger.warn( + `Failed to restore HorizontalPodAutoscaler during dev mode cleanup: deployment=${resolvedDeploymentName} namespace=${namespace} err=${ + (error as Error).message + }` + ); + } + } + logger.info(`Disabled dev mode patch: deployment=${resolvedDeploymentName} namespace=${namespace}`); } @@ -234,12 +342,42 @@ export class DevModeManager { ); } - private async patchDeployment(opts: DevModeOptions, existing: k8s.V1Deployment): Promise { + private async resolveHorizontalPodAutoscaler( + namespace: string, + deploymentName: string, + preferredName?: string + ): Promise { + if (preferredName) { + try { + const response = await this.autoscalingApi.readNamespacedHorizontalPodAutoscaler(preferredName, namespace); + return response.body; + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + } + + const { body } = await this.autoscalingApi.listNamespacedHorizontalPodAutoscaler(namespace); + return ( + body.items.find( + (horizontalPodAutoscaler) => + horizontalPodAutoscaler.spec?.scaleTargetRef?.kind === 'Deployment' && + horizontalPodAutoscaler.spec?.scaleTargetRef?.name === deploymentName + ) || null + ); + } + + private async patchDeployment( + opts: DevModeOptions, + existing: k8s.V1Deployment, + deploymentSnapshot: DevModeDeploymentSnapshot, + horizontalPodAutoscalerSnapshot: DevModeHorizontalPodAutoscalerSnapshot | null + ): Promise { const { namespace, pvcName, devConfig, requiredNodeName } = opts; const deploymentName = existing.metadata?.name || opts.deploymentName; const workDir = devConfig.workDir || '/workspace'; const existingContainerName = existing.spec?.template?.spec?.containers?.[0]?.name || deploymentName; - const deploymentSnapshot = this.buildDeploymentSnapshot(existing, existingContainerName); const nodeSelector = requiredNodeName ? { ...(existing.spec?.template?.spec?.nodeSelector || {}), @@ -251,6 +389,9 @@ export class DevModeManager { metadata: { annotations: { [DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION]: JSON.stringify(deploymentSnapshot), + [DEV_MODE_HPA_SNAPSHOT_ANNOTATION]: horizontalPodAutoscalerSnapshot + ? JSON.stringify(horizontalPodAutoscalerSnapshot) + : null, }, }, spec: { @@ -290,6 +431,34 @@ export class DevModeManager { ); } + private async patchHorizontalPodAutoscaler( + namespace: string, + existing: k8s.V2HorizontalPodAutoscaler + ): Promise { + const hpaName = existing.metadata?.name; + if (!hpaName) { + return; + } + + await this.autoscalingApi.patchNamespacedHorizontalPodAutoscaler( + hpaName, + namespace, + { + spec: { + minReplicas: 1, + maxReplicas: 1, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + logger.info(`Pinned HorizontalPodAutoscaler to a single replica: hpa=${hpaName} namespace=${namespace}`); + } + private async patchService(opts: DevModeOptions, existing: k8s.V1Service): Promise { const { namespace, devConfig } = opts; const serviceName = existing.metadata?.name || opts.serviceName; @@ -539,58 +708,69 @@ export class DevModeManager { }; } - private getDeploymentSnapshot(existing: k8s.V1Deployment): DevModeDeploymentSnapshot | null { - const annotation = existing.metadata?.annotations?.[DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION]; + private buildHorizontalPodAutoscalerSnapshot( + existing: k8s.V2HorizontalPodAutoscaler + ): DevModeHorizontalPodAutoscalerSnapshot { + return { + hpaName: existing.metadata?.name || '', + minReplicas: existing.spec?.minReplicas ?? null, + maxReplicas: existing.spec?.maxReplicas ?? 1, + }; + } + + private parseAnnotationSnapshot(annotation: string | undefined): T | null { if (!annotation) { return null; } try { - return JSON.parse(annotation) as DevModeDeploymentSnapshot; + return JSON.parse(annotation) as T; } catch (error) { - logger.warn({ error }, 'Failed to parse dev mode deployment snapshot annotation'); + logger.warn({ error }, 'Failed to parse dev mode snapshot annotation'); return null; } } + private getDeploymentSnapshot(existing: k8s.V1Deployment): DevModeDeploymentSnapshot | null { + return this.parseAnnotationSnapshot( + existing.metadata?.annotations?.[DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION] + ); + } + private getServiceSnapshot(existing: k8s.V1Service): DevModeServiceSnapshot | null { - const annotation = existing.metadata?.annotations?.[DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION]; - if (!annotation) { - return null; - } + return this.parseAnnotationSnapshot( + existing.metadata?.annotations?.[DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION] + ); + } - try { - return JSON.parse(annotation) as DevModeServiceSnapshot; - } catch (error) { - logger.warn({ error }, 'Failed to parse dev mode service snapshot annotation'); - return null; - } + private getHorizontalPodAutoscalerSnapshot( + existing: k8s.V1Deployment + ): DevModeHorizontalPodAutoscalerSnapshot | null { + return this.parseAnnotationSnapshot( + existing.metadata?.annotations?.[DEV_MODE_HPA_SNAPSHOT_ANNOTATION] + ); } - private captureSnapshot(deployment: k8s.V1Deployment, service: k8s.V1Service | null): DevModeResourceSnapshot { + private captureSnapshot( + deployment: k8s.V1Deployment, + service: k8s.V1Service | null, + horizontalPodAutoscaler: k8s.V2HorizontalPodAutoscaler | null + ): DevModeResourceSnapshot { const container = deployment.spec?.template?.spec?.containers?.[0] || ({ name: deployment.metadata?.name || 'container' } as k8s.V1Container); return { - deployment: { - deploymentName: deployment.metadata?.name || '', - containerName: container.name || deployment.metadata?.name || 'container', - replicas: this.cloneValue(deployment.spec?.replicas ?? null), - image: container.image || null, - command: this.cloneValue(container.command ?? null), - workingDir: container.workingDir ?? null, - env: this.cloneValue(container.env ?? null), - volumeMounts: this.cloneValue(container.volumeMounts ?? null), - volumes: this.cloneValue(deployment.spec?.template?.spec?.volumes ?? null), - nodeSelector: this.cloneValue(deployment.spec?.template?.spec?.nodeSelector ?? null), - }, + deployment: this.buildDeploymentSnapshot(deployment, container.name || deployment.metadata?.name || 'container'), service: service ? { serviceName: service.metadata?.name || '', ports: this.cloneValue(service.spec?.ports ?? null), } : null, + horizontalPodAutoscaler: horizontalPodAutoscaler + ? this.buildHorizontalPodAutoscalerSnapshot(horizontalPodAutoscaler) + : null, }; } @@ -621,6 +801,13 @@ export class DevModeManager { }); } + if (existing.metadata?.annotations?.[DEV_MODE_HPA_SNAPSHOT_ANNOTATION]) { + patch.push({ + op: 'remove', + path: `/metadata/annotations/${DEV_MODE_HPA_SNAPSHOT_ANNOTATION.replace('/', '~1')}`, + }); + } + this.appendValuePatch(patch, `${containerPath}/image`, liveContainer.image, snapshot.image); this.appendValuePatch(patch, '/spec/replicas', existing.spec?.replicas, snapshot.replicas); this.appendValuePatch(patch, `${containerPath}/command`, liveContainer.command, snapshot.command); @@ -688,6 +875,33 @@ export class DevModeManager { logger.info(`Restored service ports from snapshot: service=${serviceName} namespace=${namespace}`); } + private async restoreHorizontalPodAutoscalerFromSnapshot( + namespace: string, + existing: k8s.V2HorizontalPodAutoscaler, + snapshot: DevModeHorizontalPodAutoscalerSnapshot + ): Promise { + const hpaName = existing.metadata?.name || snapshot.hpaName; + const patch = { + spec: { + ...(snapshot.minReplicas !== null ? { minReplicas: snapshot.minReplicas } : { minReplicas: null }), + maxReplicas: snapshot.maxReplicas, + }, + }; + + await this.autoscalingApi.patchNamespacedHorizontalPodAutoscaler( + hpaName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } + ); + logger.info(`Restored HorizontalPodAutoscaler from snapshot: hpa=${hpaName} namespace=${namespace}`); + } + private appendValuePatch( patch: Array>, path: string, From e80bc3c140e4c1ffcc1aac8caf42f23bf93bfaf7 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 13:51:18 -0700 Subject: [PATCH 10/18] make agent pod resources configurable --- docs/schema/yaml/1.0.0.yaml | 20 +++ .../api/v2/ai/agent/sandbox-sessions/route.ts | 1 + src/app/api/v2/ai/agent/sessions/route.ts | 96 +++++++++--- .../015_add_agent_sessions_and_api_keys.ts | 22 +++ src/server/jobs/agentSandboxSessionLaunch.ts | 2 + .../agentSession/__tests__/podFactory.test.ts | 69 ++++++++ .../__tests__/runtimeConfig.test.ts | 148 ++++++++++++++++++ src/server/lib/agentSession/podFactory.ts | 8 +- src/server/lib/agentSession/runtimeConfig.ts | 81 ++++++++++ src/server/lib/jsonschema/schemas/1.0.0.json | 48 ++++++ .../yamlSchemas/schema_1_0_0/schema_1_0_0.ts | 25 +++ src/server/models/yaml/YamlEnvironment.ts | 13 ++ .../models/yaml/tests/YamlService.test.ts | 34 ++++ src/server/models/yaml/types.ts | 12 ++ .../services/__tests__/agentSession.test.ts | 36 +++++ src/server/services/agentSandboxSession.ts | 6 +- src/server/services/agentSession.ts | 3 + src/server/services/types/globalConfig.ts | 16 +- 18 files changed, 614 insertions(+), 26 deletions(-) diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index f3589a8..4bec526 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -77,6 +77,26 @@ environment: # @param environment.webhooks.env (required) env: + # @param environment.agentSession + agentSession: + # @param environment.agentSession.resources + resources: + # @param environment.agentSession.resources.agent + agent: + # @param environment.agentSession.resources.agent.requests + requests: + + # @param environment.agentSession.resources.agent.limits + limits: + + # @param environment.agentSession.resources.editor + editor: + # @param environment.agentSession.resources.editor.requests + requests: + + # @param environment.agentSession.resources.editor.limits + limits: + # @section services services: # @param services[] diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts index 03e9fb6..715e2d6 100644 --- a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts +++ b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts @@ -278,6 +278,7 @@ const postHandler = async (req: NextRequest) => { agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, nodeSelector: runtimeConfig.nodeSelector, + resources: runtimeConfig.resources, } as SandboxSessionLaunchJob, { jobId: launchId, diff --git a/src/app/api/v2/ai/agent/sessions/route.ts b/src/app/api/v2/ai/agent/sessions/route.ts index 3ad2808..8b86680 100644 --- a/src/app/api/v2/ai/agent/sessions/route.ts +++ b/src/app/api/v2/ai/agent/sessions/route.ts @@ -21,14 +21,16 @@ import { getRequestUserIdentity } from 'server/lib/get-user'; import { resolveRequestGitHubToken } from 'server/lib/agentSession/githubToken'; import { AgentSessionRuntimeConfigError, + mergeAgentSessionResources, resolveAgentSessionRuntimeConfig, } from 'server/lib/agentSession/runtimeConfig'; import AgentSessionService, { ActiveEnvironmentSessionError } from 'server/services/agentSession'; import { - loadAgentSessionServiceCandidates, + resolveAgentSessionServiceCandidates, resolveRequestedAgentSessionServices, } from 'server/services/agentSessionCandidates'; import Build from 'server/models/Build'; +import { fetchLifecycleConfig, type LifecycleConfig } from 'server/models/yaml'; import type { DevConfig } from 'server/models/yaml/YamlService'; import { BuildKind } from 'shared/constants'; @@ -58,6 +60,27 @@ function repoNameFromRepoUrl(repoUrl?: string | null) { return normalized || null; } +async function resolveLifecycleConfigForSession({ + buildContext, + repoUrl, + branch, +}: { + buildContext: Awaited> | null; + repoUrl?: string | null; + branch?: string | null; +}): Promise { + if (buildContext?.pullRequest?.fullName && buildContext.pullRequest.branchName) { + return fetchLifecycleConfig(buildContext.pullRequest.fullName, buildContext.pullRequest.branchName); + } + + const repositoryName = repoNameFromRepoUrl(repoUrl); + if (!repositoryName || !branch) { + return null; + } + + return fetchLifecycleConfig(repositoryName, branch); +} + function serializeSessionSummary(session: T) { const sessionId = session.uuid || String(session.id); const { @@ -93,7 +116,10 @@ async function resolveBuildContext(buildUuid: string) { async function resolveRequestedServices( buildUuid: string | undefined, - requestedServices: unknown[] | undefined + requestedServices: unknown[] | undefined, + buildContext: Awaited> | null, + lifecycleConfig: LifecycleConfig | null, + lifecycleConfigError?: unknown ): Promise { if (!Array.isArray(requestedServices) || requestedServices.length === 0) { return []; @@ -107,19 +133,30 @@ async function resolveRequestedServices( throw new Error('buildUuid is required when services are specified'); } + if (!buildContext) { + throw new Error('Build not found'); + } + + if (!lifecycleConfig) { + throw lifecycleConfigError instanceof Error + ? lifecycleConfigError + : new Error('Lifecycle config not found for build'); + } + const requestedNames = requestedServices.filter((service): service is string => typeof service === 'string'); if (requestedNames.length !== requestedServices.length) { throw new Error('services must be an array of service names'); } - return resolveRequestedAgentSessionServices(await loadAgentSessionServiceCandidates(buildUuid), requestedNames).map( - ({ name, deployId, devConfig, baseDeploy }) => ({ - name, - deployId, - devConfig, - resourceName: baseDeploy.uuid || undefined, - }) - ); + return resolveRequestedAgentSessionServices( + resolveAgentSessionServiceCandidates(buildContext.deploys || [], lifecycleConfig), + requestedNames + ).map(({ name, deployId, devConfig, baseDeploy }) => ({ + name, + deployId, + devConfig, + resourceName: baseDeploy.uuid || undefined, + })); } /** @@ -391,23 +428,42 @@ const postHandler = async (req: NextRequest) => { let prNumber = body.prNumber; let namespace = body.namespace; let buildKind = BuildKind.ENVIRONMENT; + let buildContext: Awaited> | null = null; + let lifecycleConfig: LifecycleConfig | null = null; + let lifecycleConfigError: unknown; if (buildUuid) { - const build = await resolveBuildContext(buildUuid); - if (!build?.pullRequest) { + buildContext = await resolveBuildContext(buildUuid); + if (!buildContext?.pullRequest) { return errorResponse(new Error('Build not found'), { status: 404 }, req); } - buildKind = build.kind || BuildKind.ENVIRONMENT; - repoUrl = repoUrl || `https://github.com/${build.pullRequest.fullName}.git`; - branch = branch || build.pullRequest.branchName; - prNumber = prNumber ?? build.pullRequest.pullRequestNumber; - namespace = namespace || build.namespace; + buildKind = buildContext.kind || BuildKind.ENVIRONMENT; + repoUrl = repoUrl || `https://github.com/${buildContext.pullRequest.fullName}.git`; + branch = branch || buildContext.pullRequest.branchName; + prNumber = prNumber ?? buildContext.pullRequest.pullRequestNumber; + namespace = namespace || buildContext.namespace; + } + + try { + lifecycleConfig = await resolveLifecycleConfigForSession({ + buildContext, + repoUrl, + branch, + }); + } catch (error) { + lifecycleConfigError = error; } let resolvedServices: ResolvedSessionService[]; try { - resolvedServices = await resolveRequestedServices(buildUuid, services); + resolvedServices = await resolveRequestedServices( + buildUuid, + services, + buildContext, + lifecycleConfig, + lifecycleConfigError + ); } catch (err) { return errorResponse(err, { status: 400 }, req); } @@ -434,6 +490,10 @@ const postHandler = async (req: NextRequest) => { agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, nodeSelector: runtimeConfig.nodeSelector, + resources: mergeAgentSessionResources( + runtimeConfig.resources, + lifecycleConfig?.environment?.agentSession?.resources + ), }); return successResponse( diff --git a/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts index fd7f60f..64df3ff 100644 --- a/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts +++ b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts @@ -21,6 +21,28 @@ export async function up(knex: Knex): Promise { const defaultAgentSessionDefaults = { image: process.env.AGENT_IMAGE || null, editorImage: process.env.AGENT_EDITOR_IMAGE || null, + resources: { + agent: { + requests: { + cpu: process.env.AGENT_POD_CPU_REQUEST || '500m', + memory: process.env.AGENT_POD_MEMORY_REQUEST || '1Gi', + }, + limits: { + cpu: process.env.AGENT_POD_CPU_LIMIT || '2', + memory: process.env.AGENT_POD_MEMORY_LIMIT || '4Gi', + }, + }, + editor: { + requests: { + cpu: process.env.AGENT_EDITOR_CPU_REQUEST || '250m', + memory: process.env.AGENT_EDITOR_MEMORY_REQUEST || '512Mi', + }, + limits: { + cpu: process.env.AGENT_EDITOR_CPU_LIMIT || '1', + memory: process.env.AGENT_EDITOR_MEMORY_LIMIT || '1Gi', + }, + }, + }, claude: { permissions: { allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], diff --git a/src/server/jobs/agentSandboxSessionLaunch.ts b/src/server/jobs/agentSandboxSessionLaunch.ts index e878b43..5f6b603 100644 --- a/src/server/jobs/agentSandboxSessionLaunch.ts +++ b/src/server/jobs/agentSandboxSessionLaunch.ts @@ -48,6 +48,7 @@ export async function processAgentSandboxSessionLaunch(job: Job => { @@ -69,6 +70,7 @@ export async function processAgentSandboxSessionLaunch(job: Job { }); }); + it('prefers explicit agent-session resource overrides when provided', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + resources: { + agent: { + requests: { + cpu: '1', + memory: '2Gi', + }, + limits: { + cpu: '4', + memory: '8Gi', + }, + }, + editor: { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '2Gi', + }, + }, + }, + }); + + expect(getInitContainer(pod, 'prepare-workspace').resources).toEqual({ + requests: { + cpu: '1', + memory: '2Gi', + }, + limits: { + cpu: '4', + memory: '8Gi', + }, + }); + expect(getInitContainer(pod, 'init-workspace').resources).toEqual({ + requests: { + cpu: '1', + memory: '2Gi', + }, + limits: { + cpu: '4', + memory: '8Gi', + }, + }); + expect(pod.spec!.containers[0].resources).toEqual({ + requests: { + cpu: '1', + memory: '2Gi', + }, + limits: { + cpu: '4', + memory: '8Gi', + }, + }); + expect(pod.spec!.containers[1].resources).toEqual({ + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '2Gi', + }, + }); + }); + it('starts a code-server editor sidecar on the editor port', () => { const pod = buildAgentPodSpec(baseOpts); diff --git a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts index e0800c3..bd1f5b9 100644 --- a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts +++ b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts @@ -27,9 +27,11 @@ jest.mock('server/services/globalConfig', () => ({ import { AgentSessionRuntimeConfigError, + mergeAgentSessionResources, renderAgentSessionClaudeAttribution, resolveAgentSessionClaudeConfig, resolveAgentSessionClaudeConfigFromDefaults, + resolveAgentSessionResourcesFromDefaults, resolveAgentSessionRuntimeConfig, } from '../runtimeConfig'; @@ -49,6 +51,28 @@ describe('runtimeConfig', () => { await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ image: 'lifecycle-agent:sha-123', editorImage: 'codercom/code-server:4.98.2', + resources: { + agent: { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, + }, + editor: { + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, + }, + }, claude: { permissions: { allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], @@ -83,6 +107,92 @@ describe('runtimeConfig', () => { 'app-long': 'deployments-m7i', pool: 'agents', }, + resources: { + agent: { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, + }, + editor: { + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, + }, + }, + claude: { + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + }, + }); + }); + + it('returns configured agent and editor resources when present', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + resources: { + agent: { + requests: { + cpu: '900m', + }, + limits: { + memory: '6Gi', + }, + }, + editor: { + requests: { + memory: '768Mi', + }, + limits: { + cpu: '1500m', + }, + }, + }, + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + resources: { + agent: { + requests: { + cpu: '900m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '6Gi', + }, + }, + editor: { + requests: { + cpu: '250m', + memory: '768Mi', + }, + limits: { + cpu: '1500m', + memory: '1Gi', + }, + }, + }, claude: { permissions: { allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], @@ -140,6 +250,44 @@ describe('runtimeConfig', () => { }); }); + it('merges lifecycle resource overrides over runtime defaults', () => { + expect( + mergeAgentSessionResources(resolveAgentSessionResourcesFromDefaults(), { + agent: { + requests: { + cpu: '1200m', + }, + }, + editor: { + limits: { + memory: '2Gi', + }, + }, + }) + ).toEqual({ + agent: { + requests: { + cpu: '1200m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, + }, + editor: { + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '2Gi', + }, + }, + }); + }); + it('renders attribution from the app name placeholder', () => { expect(renderAgentSessionClaudeAttribution('Generated with ({appName})', 'sample-app')).toBe( 'Generated with (sample-app)' diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index 24ab505..2423c29 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -118,6 +118,10 @@ export interface AgentPodOpts { buildUuid?: string; userIdentity?: RequestUserIdentity; nodeSelector?: Record; + resources?: { + agent?: k8s.V1ResourceRequirements; + editor?: k8s.V1ResourceRequirements; + }; } function buildAgentResources(): k8s.V1ResourceRequirements { @@ -246,8 +250,8 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { }; const initScript = generateInitScript(initScriptOpts); - const resources = buildAgentResources(); - const editorResources = buildEditorResources(); + const resources = opts.resources?.agent || buildAgentResources(); + const editorResources = opts.resources?.editor || buildEditorResources(); const userEnv = buildUserIdentityEnv(userIdentity); const githubTokenEnv = buildGitHubTokenEnv(apiKeySecretName, hasGitHubToken); const workspaceVolumeMount = buildWorkspaceVolumeMount(workspacePath); diff --git a/src/server/lib/agentSession/runtimeConfig.ts b/src/server/lib/agentSession/runtimeConfig.ts index e8b94ef..d06e161 100644 --- a/src/server/lib/agentSession/runtimeConfig.ts +++ b/src/server/lib/agentSession/runtimeConfig.ts @@ -19,16 +19,29 @@ import type { AgentSessionClaudeAttribution, AgentSessionClaudeConfig, AgentSessionClaudePermissions, + AgentSessionResourcesConfig, AgentSessionSchedulingConfig, + ResourceRequirements, } from 'server/services/types/globalConfig'; export interface AgentSessionRuntimeConfig { image: string; editorImage: string; nodeSelector?: Record; + resources: ResolvedAgentSessionResources; claude: ResolvedAgentSessionClaudeConfig; } +export interface ResolvedAgentSessionResourceRequirements { + requests: Record; + limits: Record; +} + +export interface ResolvedAgentSessionResources { + agent: ResolvedAgentSessionResourceRequirements; + editor: ResolvedAgentSessionResourceRequirements; +} + export interface ResolvedAgentSessionClaudePermissions { allow: string[]; deny: string[]; @@ -49,6 +62,26 @@ const DEFAULT_CLAUDE_PERMISSION_ALLOW = ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit const DEFAULT_CLAUDE_PERMISSION_DENY: string[] = []; const DEFAULT_CLAUDE_COMMIT_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; const DEFAULT_CLAUDE_PR_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; +const DEFAULT_AGENT_RESOURCES: ResolvedAgentSessionResourceRequirements = { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, +}; +const DEFAULT_EDITOR_RESOURCES: ResolvedAgentSessionResourceRequirements = { + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, +}; function normalizeStringArray(values: unknown, fallback: string[]): string[] { if (!Array.isArray(values)) { @@ -71,6 +104,34 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +function normalizeResourceQuantityMap(values: unknown): Record { + if (!values || typeof values !== 'object' || Array.isArray(values)) { + return {}; + } + + return Object.fromEntries( + Object.entries(values) + .filter(([key, value]) => typeof key === 'string' && key.trim() && typeof value === 'string' && value.trim()) + .map(([key, value]) => [key.trim(), value.trim()]) + ); +} + +function mergeResourceRequirements( + fallback: ResolvedAgentSessionResourceRequirements, + overrides?: ResourceRequirements | null +): ResolvedAgentSessionResourceRequirements { + return { + requests: { + ...fallback.requests, + ...normalizeResourceQuantityMap(overrides?.requests), + }, + limits: { + ...fallback.limits, + ...normalizeResourceQuantityMap(overrides?.limits), + }, + }; +} + function normalizeNodeSelector(scheduling?: AgentSessionSchedulingConfig | null): Record | undefined { const nodeSelector = scheduling?.nodeSelector; @@ -87,6 +148,25 @@ function normalizeNodeSelector(scheduling?: AgentSessionSchedulingConfig | null) return Object.keys(normalized).length > 0 ? normalized : undefined; } +export function resolveAgentSessionResourcesFromDefaults( + resourceDefaults?: AgentSessionResourcesConfig | null +): ResolvedAgentSessionResources { + return { + agent: mergeResourceRequirements(DEFAULT_AGENT_RESOURCES, resourceDefaults?.agent), + editor: mergeResourceRequirements(DEFAULT_EDITOR_RESOURCES, resourceDefaults?.editor), + }; +} + +export function mergeAgentSessionResources( + baseResources: ResolvedAgentSessionResources, + overrides?: AgentSessionResourcesConfig | null +): ResolvedAgentSessionResources { + return { + agent: mergeResourceRequirements(baseResources.agent, overrides?.agent), + editor: mergeResourceRequirements(baseResources.editor, overrides?.editor), + }; +} + export function resolveAgentSessionClaudeConfigFromDefaults( claudeDefaults?: AgentSessionClaudeConfig | null ): ResolvedAgentSessionClaudeConfig { @@ -164,6 +244,7 @@ export async function resolveAgentSessionRuntimeConfig(): Promise; + readonly limits?: Record; +} + +export interface AgentSessionEnvironmentConfig { + readonly resources?: { + readonly agent?: AgentSessionEnvironmentResourceRequirements; + readonly editor?: AgentSessionEnvironmentResourceRequirements; + }; +} + export interface Environment { readonly defaultServices?: YamlService.DependencyService[]; readonly optionalServices?: YamlService.DependencyService[]; readonly webhooks?: YamlService.Webhook[]; + readonly agentSession?: AgentSessionEnvironmentConfig; } diff --git a/src/server/models/yaml/tests/YamlService.test.ts b/src/server/models/yaml/tests/YamlService.test.ts index a159648..e66ac66 100644 --- a/src/server/models/yaml/tests/YamlService.test.ts +++ b/src/server/models/yaml/tests/YamlService.test.ts @@ -105,6 +105,40 @@ describe('Yaml Service', () => { `; describe('validation', () => { + test('accepts environment-level agent session resource overrides', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +environment: + agentSession: + resources: + agent: + requests: + cpu: '1200m' + limits: + memory: '6Gi' + editor: + requests: + memory: '768Mi' + limits: + cpu: '1500m' +services: + - name: 'agent-app' + dev: + image: 'node:20-slim' + command: 'pnpm dev' + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'apps/agent-app/Dockerfile' +`); + + expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); + }); + test('accepts forwardEnvVarsToAgent in dev config', () => { const parser = new YamlConfigParser(); const config = parser.parseYamlConfigFromString(`--- diff --git a/src/server/models/yaml/types.ts b/src/server/models/yaml/types.ts index 63ecfb2..ebfc729 100644 --- a/src/server/models/yaml/types.ts +++ b/src/server/models/yaml/types.ts @@ -21,6 +21,18 @@ export type LifecycleYamlConfigEnvironment = { optionalServices?: YamlService[]; webhooks?: YamlWebhook[]; enabledFeatures?: string[]; + agentSession?: { + resources?: { + agent?: { + requests?: Record; + limits?: Record; + }; + editor?: { + requests?: Record; + limits?: Record; + }; + }; + }; }; export type LifecycleYamlConfigOptions = { diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index a18f0d5..4e89484 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -377,6 +377,42 @@ describe('AgentSessionService', () => { expect(session.status).toBe('active'); }); + it('passes resolved agent-session resources through to pod creation when provided', async () => { + const optsWithResources: CreateSessionOptions = { + ...baseOpts, + resources: { + agent: { + requests: { + cpu: '900m', + memory: '2Gi', + }, + limits: { + cpu: '3', + memory: '6Gi', + }, + }, + editor: { + requests: { + cpu: '400m', + memory: '768Mi', + }, + limits: { + cpu: '1500m', + memory: '2Gi', + }, + }, + }, + }; + + await AgentSessionService.createSession(optsWithResources); + + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + resources: optsWithResources.resources, + }) + ); + }); + it('passes forwarded service env through to the agent pod when configured', async () => { (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ env: { PRIVATE_REGISTRY_TOKEN: 'plain-token' }, diff --git a/src/server/services/agentSandboxSession.ts b/src/server/services/agentSandboxSession.ts index 0f603f0..2abbce9 100644 --- a/src/server/services/agentSandboxSession.ts +++ b/src/server/services/agentSandboxSession.ts @@ -19,6 +19,7 @@ import { customAlphabet, nanoid } from 'nanoid'; import BaseService from './_service'; import BuildService from './build'; import AgentSessionService from './agentSession'; +import { mergeAgentSessionResources, type ResolvedAgentSessionResources } from 'server/lib/agentSession/runtimeConfig'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; import { getLogger } from 'server/lib/logger'; import { Build, Deploy, Deployable } from 'server/models'; @@ -70,6 +71,7 @@ export interface LaunchSandboxSessionOptions { agentImage: string; editorImage: string; nodeSelector?: Record; + resources: ResolvedAgentSessionResources; onProgress?: (stage: SandboxLaunchStage, message: string) => Promise | void; } @@ -92,7 +94,7 @@ export default class AgentSandboxSessionService extends BaseService { private readonly deployService = new DeployService(this.db, this.redis, this.redlock, this.queueManager); async launch(opts: LaunchSandboxSessionOptions): Promise { - const { baseBuild, environmentSource, candidates } = await this.loadBaseBuildAndCandidates(opts); + const { baseBuild, environmentSource, lifecycleConfig, candidates } = await this.loadBaseBuildAndCandidates(opts); if (candidates.length === 0) { throw new Error( `No dev-mode sandboxable services were found in ${environmentSource.repo}:${environmentSource.branch}` @@ -172,6 +174,7 @@ export default class AgentSandboxSessionService extends BaseService { agentImage: opts.agentImage, editorImage: opts.editorImage, nodeSelector: opts.nodeSelector, + resources: mergeAgentSessionResources(opts.resources, lifecycleConfig.environment?.agentSession?.resources), userIdentity: opts.userIdentity, }); @@ -238,6 +241,7 @@ export default class AgentSandboxSessionService extends BaseService { return { baseBuild, environmentSource, + lifecycleConfig, candidates: await this.resolveCandidateServices(baseBuild, lifecycleConfig, environmentSource), }; } diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index e19d151..2a50246 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -36,6 +36,7 @@ import { extractContextForQueue, getLogger } from 'server/lib/logger'; import { BuildKind } from 'shared/constants'; import type { RequestUserIdentity } from 'server/lib/get-user'; import { + type ResolvedAgentSessionResources, resolveAgentSessionClaudeConfig, renderAgentSessionClaudeAttribution, } from 'server/lib/agentSession/runtimeConfig'; @@ -211,6 +212,7 @@ export interface CreateSessionOptions { agentImage: string; editorImage: string; nodeSelector?: Record; + resources?: ResolvedAgentSessionResources; } export default class AgentSessionService { @@ -450,6 +452,7 @@ export default class AgentSessionService { buildUuid: opts.buildUuid, userIdentity: opts.userIdentity, nodeSelector: opts.nodeSelector, + resources: opts.resources, }); const agentNodeName = agentPod.spec?.nodeName || null; diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index 6d49c0f..e4f927b 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -73,10 +73,21 @@ export type AgentSessionSchedulingConfig = { nodeSelector?: Record | null; }; +export type ResourceRequirements = { + requests?: Record; + limits?: Record; +}; + +export type AgentSessionResourcesConfig = { + agent?: ResourceRequirements | null; + editor?: ResourceRequirements | null; +}; + export type AgentSessionDefaults = { image?: string | null; editorImage?: string | null; scheduling?: AgentSessionSchedulingConfig; + resources?: AgentSessionResourcesConfig; claude?: AgentSessionClaudeConfig; }; @@ -169,11 +180,6 @@ export type BuildDefaults = { }; }; -export type ResourceRequirements = { - requests?: Record; - limits?: Record; -}; - export type CommentToggleConfig = { enabled: boolean; overrides?: Record; From dcb3ad15b2d66f73f7a1511df5d83ca612ce5801 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 20:59:43 -0700 Subject: [PATCH 11/18] agent-sa with only read, improve error handling on agent boot --- .../v2/ai/agent/sessions/[sessionId]/route.ts | 20 ++ src/app/api/v2/ai/agent/sessions/route.ts | 41 +++++ .../lib/__tests__/secretEnvBuilder.test.ts | 63 +++++++ .../__tests__/apiKeySecretFactory.test.ts | 16 ++ .../__tests__/configSeeder.test.ts | 15 +- .../agentSession/__tests__/podFactory.test.ts | 29 ++- .../lib/agentSession/apiKeySecretFactory.ts | 4 +- src/server/lib/agentSession/configSeeder.ts | 13 +- src/server/lib/agentSession/podFactory.ts | 5 +- .../lib/agentSession/serviceAccountFactory.ts | 24 +++ src/server/lib/kubernetes/rbac.ts | 65 ++++++- src/server/lib/secretEnvBuilder.ts | 13 +- .../services/__tests__/agentSession.test.ts | 173 ++++++++++++++++-- src/server/services/agentSession.ts | 121 ++++++++---- 14 files changed, 542 insertions(+), 60 deletions(-) create mode 100644 src/server/lib/agentSession/serviceAccountFactory.ts diff --git a/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts index b9dce5d..4fee16e 100644 --- a/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts +++ b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts @@ -81,6 +81,7 @@ function serializeSessionSummary { repo: repoNameFromRepoUrl(repoUrl), branch, services: resolvedServices.map((service) => service.name), + startupFailure: null, }), { status: 201 }, req diff --git a/src/server/lib/__tests__/secretEnvBuilder.test.ts b/src/server/lib/__tests__/secretEnvBuilder.test.ts index 803c11a..6225e87 100644 --- a/src/server/lib/__tests__/secretEnvBuilder.test.ts +++ b/src/server/lib/__tests__/secretEnvBuilder.test.ts @@ -33,6 +33,36 @@ describe('secretEnvBuilder', () => { ]); }); + it('can source regular env vars from a default secret', () => { + const env = { + APP_ENV: 'production', + SERVICE_URL: 'https://example.com', + }; + + const result = buildPodEnvWithSecrets(env, [], 'service', 'agent-secret-abc123'); + + expect(result).toEqual([ + { + name: 'APP_ENV', + valueFrom: { + secretKeyRef: { + name: 'agent-secret-abc123', + key: 'APP_ENV', + }, + }, + }, + { + name: 'SERVICE_URL', + valueFrom: { + secretKeyRef: { + name: 'agent-secret-abc123', + key: 'SERVICE_URL', + }, + }, + }, + ]); + }); + it('returns secret refs as secretKeyRef', () => { const env = { DB_PASSWORD: '{{aws:myapp/db:password}}', @@ -85,6 +115,39 @@ describe('secretEnvBuilder', () => { expect(apiKey.valueFrom?.secretKeyRef?.name).toBe('api-server-aws-secrets'); }); + it('sources plain env vars from the default secret while leaving secret refs on provider secrets', () => { + const env = { + APP_ENV: 'production', + DB_PASSWORD: '{{aws:myapp/db:password}}', + }; + const secretRefs: SecretRefWithEnvKey[] = [ + { envKey: 'DB_PASSWORD', provider: 'aws', path: 'myapp/db', key: 'password' }, + ]; + + const result = buildPodEnvWithSecrets(env, secretRefs, 'api-server', 'agent-secret-abc123'); + + expect(result).toEqual([ + { + name: 'APP_ENV', + valueFrom: { + secretKeyRef: { + name: 'agent-secret-abc123', + key: 'APP_ENV', + }, + }, + }, + { + name: 'DB_PASSWORD', + valueFrom: { + secretKeyRef: { + name: 'api-server-aws-secrets', + key: 'DB_PASSWORD', + }, + }, + }, + ]); + }); + it('handles multiple providers', () => { const env = { AWS_SECRET: '{{aws:path:key}}', diff --git a/src/server/lib/agentSession/__tests__/apiKeySecretFactory.test.ts b/src/server/lib/agentSession/__tests__/apiKeySecretFactory.test.ts index 8479da6..4d331f7 100644 --- a/src/server/lib/agentSession/__tests__/apiKeySecretFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/apiKeySecretFactory.test.ts @@ -63,6 +63,22 @@ describe('apiKeySecretFactory', () => { ); }); + it('stores forwarded plain env values in the session secret', async () => { + await createAgentApiKeySecret('test-ns', 'agent-secret-abc123', 'sk-ant-test-key', null, undefined, { + PRIVATE_REGISTRY_TOKEN: 'plain-token', + }); + + expect(mockCreateSecret).toHaveBeenCalledWith( + 'test-ns', + expect.objectContaining({ + stringData: { + ANTHROPIC_API_KEY: 'sk-ant-test-key', + PRIVATE_REGISTRY_TOKEN: 'plain-token', + }, + }) + ); + }); + it('omits the GitHub token keys when no token is provided', async () => { await createAgentApiKeySecret('test-ns', 'agent-secret-abc123', 'sk-ant-test-key'); diff --git a/src/server/lib/agentSession/__tests__/configSeeder.test.ts b/src/server/lib/agentSession/__tests__/configSeeder.test.ts index 9f6e2b2..bb06a76 100644 --- a/src/server/lib/agentSession/__tests__/configSeeder.test.ts +++ b/src/server/lib/agentSession/__tests__/configSeeder.test.ts @@ -24,13 +24,24 @@ describe('configSeeder', () => { workspacePath: '/workspace', }; - it('contains git clone with branch', () => { + it('contains git clone with progress, shallow depth, and branch', () => { const script = generateInitScript(baseOpts); - expect(script).toContain('git clone --branch "feature/test"'); + expect(script).toContain('git clone --progress --depth 50 --branch "feature/test"'); expect(script).toContain('"https://github.com/org/repo.git"'); expect(script).toContain('"/workspace"'); }); + it('unshallows the branch before checkout when a revision is requested', () => { + const script = generateInitScript({ + ...baseOpts, + revision: 'abc123def456', + }); + + expect(script).toContain('git rev-parse --verify --quiet "abc123def456^{commit}" >/dev/null'); + expect(script).toContain('git fetch --unshallow origin "feature/test" || git fetch origin "feature/test"'); + expect(script).toContain('git checkout "abc123def456"'); + }); + it('marks the workspace as a safe git directory before checkout steps', () => { const script = generateInitScript(baseOpts); expect(script).toContain('mkdir -p "/workspace"'); diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts index 451a24c..005ef21 100644 --- a/src/server/lib/agentSession/__tests__/podFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -209,6 +209,15 @@ describe('podFactory', () => { }); }); + it('applies a configured service account when provided', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + serviceAccountName: 'agent-sa', + }); + + expect(pod.spec!.serviceAccountName).toBe('agent-sa'); + }); + it('sets ANTHROPIC_API_KEY from a secret and CLAUDE_MODEL as an env var', () => { const pod = buildAgentPodSpec(baseOpts); const envVars = pod.spec!.containers[0].env; @@ -308,7 +317,15 @@ describe('podFactory', () => { expect(getInitContainer(pod, 'init-workspace').env).toEqual( expect.arrayContaining([ - { name: 'PACKAGE_REGISTRY_TOKEN', value: 'plain-token' }, + { + name: 'PACKAGE_REGISTRY_TOKEN', + valueFrom: { + secretKeyRef: { + name: 'agent-secret-abc123', + key: 'PACKAGE_REGISTRY_TOKEN', + }, + }, + }, { name: 'PRIVATE_REGISTRY_TOKEN', valueFrom: { @@ -322,7 +339,15 @@ describe('podFactory', () => { ); expect(pod.spec!.containers[0].env).toEqual( expect.arrayContaining([ - { name: 'PACKAGE_REGISTRY_TOKEN', value: 'plain-token' }, + { + name: 'PACKAGE_REGISTRY_TOKEN', + valueFrom: { + secretKeyRef: { + name: 'agent-secret-abc123', + key: 'PACKAGE_REGISTRY_TOKEN', + }, + }, + }, { name: 'PRIVATE_REGISTRY_TOKEN', valueFrom: { diff --git a/src/server/lib/agentSession/apiKeySecretFactory.ts b/src/server/lib/agentSession/apiKeySecretFactory.ts index 938aa50..1562870 100644 --- a/src/server/lib/agentSession/apiKeySecretFactory.ts +++ b/src/server/lib/agentSession/apiKeySecretFactory.ts @@ -29,7 +29,8 @@ export async function createAgentApiKeySecret( secretName: string, apiKey: string, githubToken?: string | null, - buildUuid?: string + buildUuid?: string, + forwardedEnv?: Record ): Promise { const logger = getLogger(); const coreApi = getCoreApi(); @@ -53,6 +54,7 @@ export async function createAgentApiKeySecret( GITHUB_TOKEN: githubToken, } : {}), + ...(forwardedEnv || {}), }, }; diff --git a/src/server/lib/agentSession/configSeeder.ts b/src/server/lib/agentSession/configSeeder.ts index 85b1ca1..c5a7cb4 100644 --- a/src/server/lib/agentSession/configSeeder.ts +++ b/src/server/lib/agentSession/configSeeder.ts @@ -100,13 +100,20 @@ export function generateInitScript(opts: InitScriptOpts): string { } lines.push( - `git clone --branch "${escapeDoubleQuotedShell(branch)}" --single-branch "${escapeDoubleQuotedShell( - repoUrl - )}" "${escapeDoubleQuotedShell(workspacePath)}"`, + `git clone --progress --depth 50 --branch "${escapeDoubleQuotedShell( + branch + )}" --single-branch "${escapeDoubleQuotedShell(repoUrl)}" "${escapeDoubleQuotedShell(workspacePath)}"`, `cd "${escapeDoubleQuotedShell(workspacePath)}"` ); if (revision) { + lines.push( + `if ! git rev-parse --verify --quiet "${escapeDoubleQuotedShell(revision)}^{commit}" >/dev/null; then`, + ` git fetch --unshallow origin "${escapeDoubleQuotedShell( + branch + )}" || git fetch origin "${escapeDoubleQuotedShell(branch)}"`, + 'fi' + ); lines.push(`git checkout "${escapeDoubleQuotedShell(revision)}"`); } diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index 2423c29..f4b3e09 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -118,6 +118,7 @@ export interface AgentPodOpts { buildUuid?: string; userIdentity?: RequestUserIdentity; nodeSelector?: Record; + serviceAccountName?: string; resources?: { agent?: k8s.V1ResourceRequirements; editor?: k8s.V1ResourceRequirements; @@ -259,7 +260,8 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { const forwardedAgentSecretEnv = buildPodEnvWithSecrets( forwardedAgentEnv, opts.forwardedAgentSecretRefs || [], - opts.forwardedAgentSecretServiceName || podName + opts.forwardedAgentSecretServiceName || podName, + apiKeySecretName ); const securityContext: k8s.V1SecurityContext = { @@ -288,6 +290,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { spec: { ...(useGvisor ? { runtimeClassName: 'gvisor' } : {}), ...(opts.nodeSelector ? { nodeSelector: opts.nodeSelector } : {}), + ...(opts.serviceAccountName ? { serviceAccountName: opts.serviceAccountName } : {}), securityContext: { runAsUser: 1000, runAsGroup: 1000, diff --git a/src/server/lib/agentSession/serviceAccountFactory.ts b/src/server/lib/agentSession/serviceAccountFactory.ts new file mode 100644 index 0000000..dd954bc --- /dev/null +++ b/src/server/lib/agentSession/serviceAccountFactory.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupReadOnlyServiceAccountInNamespace } from 'server/lib/kubernetes/rbac'; + +export const AGENT_SESSION_SERVICE_ACCOUNT_NAME = 'agent-sa'; + +export async function ensureAgentSessionServiceAccount(namespace: string): Promise { + await setupReadOnlyServiceAccountInNamespace(namespace, AGENT_SESSION_SERVICE_ACCOUNT_NAME); + return AGENT_SESSION_SERVICE_ACCOUNT_NAME; +} diff --git a/src/server/lib/kubernetes/rbac.ts b/src/server/lib/kubernetes/rbac.ts index f79d11c..207fd24 100644 --- a/src/server/lib/kubernetes/rbac.ts +++ b/src/server/lib/kubernetes/rbac.ts @@ -22,7 +22,7 @@ export interface RBACConfig { namespace: string; serviceAccountName: string; awsRoleArn?: string; - permissions: 'build' | 'deploy' | 'full'; + permissions: 'build' | 'deploy' | 'full' | 'read'; } const PERMISSION_RULES = { @@ -38,6 +38,58 @@ const PERMISSION_RULES = { verbs: ['get', 'list', 'watch'], }, ], + read: [ + { + apiGroups: [''], + resources: [ + 'configmaps', + 'endpoints', + 'events', + 'persistentvolumeclaims', + 'pods', + 'pods/log', + 'replicationcontrollers', + 'resourcequotas', + 'services', + ], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['apps'], + resources: ['controllerrevisions', 'daemonsets', 'deployments', 'replicasets', 'statefulsets'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['batch'], + resources: ['cronjobs', 'jobs'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['networking.k8s.io'], + resources: ['ingresses', 'networkpolicies'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['autoscaling'], + resources: ['horizontalpodautoscalers'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['discovery.k8s.io'], + resources: ['endpointslices'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['events.k8s.io'], + resources: ['events'], + verbs: ['get', 'list', 'watch'], + }, + { + apiGroups: ['policy'], + resources: ['poddisruptionbudgets'], + verbs: ['get', 'list', 'watch'], + }, + ], deploy: [ { apiGroups: ['*'], @@ -184,6 +236,17 @@ export async function setupBuildServiceAccountInNamespace( }); } +export async function setupReadOnlyServiceAccountInNamespace( + namespace: string, + serviceAccountName: string = 'agent-sa' +): Promise { + await setupServiceAccountWithRBAC({ + namespace, + serviceAccountName, + permissions: 'read', + }); +} + export async function setupDeployServiceAccountInNamespace( namespace: string, serviceAccountName: string = 'default', diff --git a/src/server/lib/secretEnvBuilder.ts b/src/server/lib/secretEnvBuilder.ts index ba3c2c9..36b9f64 100644 --- a/src/server/lib/secretEnvBuilder.ts +++ b/src/server/lib/secretEnvBuilder.ts @@ -34,7 +34,8 @@ export interface PodEnvEntry { export function buildPodEnvWithSecrets( env: Record | null | undefined, secretRefs: SecretRefWithEnvKey[], - serviceName: string + serviceName: string, + defaultSecretName?: string ): PodEnvEntry[] { if (!env) { return []; @@ -61,6 +62,16 @@ export function buildPodEnvWithSecrets( }, }, }); + } else if (defaultSecretName) { + entries.push({ + name, + valueFrom: { + secretKeyRef: { + name: defaultSecretName, + key: name, + }, + }, + }); } else { entries.push({ name, diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index 4e89484..cd6eb63 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -26,6 +26,7 @@ jest.mock('server/lib/agentSession/pvcFactory'); jest.mock('server/lib/agentSession/apiKeySecretFactory'); jest.mock('server/lib/agentSession/podFactory'); jest.mock('server/lib/agentSession/editorServiceFactory'); +jest.mock('server/lib/agentSession/serviceAccountFactory'); jest.mock('server/lib/agentSession/gvisorCheck'); jest.mock('server/lib/agentSession/configSeeder'); jest.mock('server/lib/agentSession/devModeManager'); @@ -107,6 +108,7 @@ import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFacto import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; import { createAgentPod, deleteAgentPod } from 'server/lib/agentSession/podFactory'; import { createAgentEditorService, deleteAgentEditorService } from 'server/lib/agentSession/editorServiceFactory'; +import { ensureAgentSessionServiceAccount } from 'server/lib/agentSession/serviceAccountFactory'; import { isGvisorAvailable } from 'server/lib/agentSession/gvisorCheck'; import { DevModeManager } from 'server/lib/agentSession/devModeManager'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; @@ -160,6 +162,7 @@ const mockDisableDevMode = jest.fn().mockResolvedValue(undefined); (createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); (createAgentPod as jest.Mock).mockResolvedValue({ spec: { nodeName: 'agent-node-a' } }); (createAgentEditorService as jest.Mock).mockResolvedValue({}); +(ensureAgentSessionServiceAccount as jest.Mock).mockResolvedValue('agent-sa'); (deleteAgentPod as jest.Mock).mockResolvedValue(undefined); (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); (deleteAgentApiKeySecret as jest.Mock).mockResolvedValue(undefined); @@ -264,6 +267,7 @@ describe('AgentSessionService', () => { (createAgentApiKeySecret as jest.Mock).mockResolvedValue({}); (createAgentPod as jest.Mock).mockResolvedValue({ spec: { nodeName: 'agent-node-a' } }); (createAgentEditorService as jest.Mock).mockResolvedValue({}); + (ensureAgentSessionServiceAccount as jest.Mock).mockResolvedValue('agent-sa'); (deleteAgentPod as jest.Mock).mockResolvedValue(undefined); (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); (deleteAgentApiKeySecret as jest.Mock).mockResolvedValue(undefined); @@ -329,12 +333,14 @@ describe('AgentSessionService', () => { const session = await AgentSessionService.createSession(baseOpts); expect(createAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa', '10Gi', undefined); + expect(ensureAgentSessionServiceAccount).toHaveBeenCalledWith('test-ns'); expect(createAgentApiKeySecret).toHaveBeenCalledWith( 'test-ns', 'agent-secret-aaaaaaaa', 'sk-ant-test-key', undefined, - undefined + undefined, + {} ); expect(createAgentPod).toHaveBeenCalledWith( expect.objectContaining({ @@ -343,6 +349,7 @@ describe('AgentSessionService', () => { pvcName: 'agent-pvc-aaaaaaaa', image: 'lifecycle-agent:latest', apiKeySecretName: 'agent-secret-aaaaaaaa', + serviceAccountName: 'agent-sa', hasGitHubToken: false, model: 'claude-sonnet-4-6', claudePermissions: { @@ -438,6 +445,16 @@ describe('AgentSessionService', () => { await AgentSessionService.createSession(optsWithServices); + expect(createAgentApiKeySecret).toHaveBeenCalledWith( + 'test-ns', + 'agent-secret-aaaaaaaa', + 'sk-ant-test-key', + undefined, + undefined, + { + PRIVATE_REGISTRY_TOKEN: 'plain-token', + } + ); expect(createAgentPod).toHaveBeenCalledWith( expect.objectContaining({ forwardedAgentEnv: { PRIVATE_REGISTRY_TOKEN: 'plain-token' }, @@ -494,7 +511,8 @@ describe('AgentSessionService', () => { 'agent-secret-aaaaaaaa', 'sk-ant-test-key', 'gho_test_token', - undefined + undefined, + {} ); expect(createAgentPod).toHaveBeenCalledWith( expect.objectContaining({ @@ -597,15 +615,11 @@ describe('AgentSessionService', () => { 3600, expect.any(String) ); - expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); - expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); - expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); - expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); - expect(cleanupForwardedAgentEnvSecrets).toHaveBeenCalledWith( - 'test-ns', - 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', - [] - ); + expect(deleteAgentEditorService).not.toHaveBeenCalled(); + expect(deleteAgentPod).not.toHaveBeenCalled(); + expect(deleteAgentPvc).not.toHaveBeenCalled(); + expect(deleteAgentApiKeySecret).not.toHaveBeenCalled(); + expect(cleanupForwardedAgentEnvSecrets).not.toHaveBeenCalled(); expect(mockSessionQuery.patch).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' })); }); @@ -664,7 +678,7 @@ describe('AgentSessionService', () => { ); }); - it('starts agent runtime cleanup before deploy restore finishes during rollback', async () => { + it('retains failed agent resources while deploy restore finishes during rollback', async () => { const optsWithServices: CreateSessionOptions = { ...baseOpts, services: [{ name: 'web', deployId: 1, devConfig: { image: 'node:20', command: 'pnpm dev' } }], @@ -696,14 +710,17 @@ describe('AgentSessionService', () => { const rollbackPromise = AgentSessionService.createSession(optsWithServices); await new Promise((resolve) => setImmediate(resolve)); - expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); - expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); - expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(deleteAgentPod).not.toHaveBeenCalled(); + expect(deleteAgentEditorService).not.toHaveBeenCalled(); + expect(deleteAgentApiKeySecret).not.toHaveBeenCalled(); expect(deleteAgentPvc).not.toHaveBeenCalled(); releaseDeploy(); await expect(rollbackPromise).rejects.toThrow('snapshot persist failed'); - expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); + expect(deleteAgentPod).not.toHaveBeenCalled(); + expect(deleteAgentEditorService).not.toHaveBeenCalled(); + expect(deleteAgentApiKeySecret).not.toHaveBeenCalled(); + expect(deleteAgentPvc).not.toHaveBeenCalled(); }); }); @@ -814,6 +831,46 @@ describe('AgentSessionService', () => { expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); }); + it('cleans up a failed session when explicitly ended', async () => { + const failedSession = { + id: 1, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + status: 'error', + namespace: 'test-ns', + podName: 'agent-sess1', + pvcName: 'agent-pvc-sess1', + forwardedAgentSecretProviders: ['aws'], + devModeSnapshots: {}, + buildUuid: null, + }; + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce({ + findOne: jest.fn().mockResolvedValue(failedSession), + }) + .mockReturnValueOnce({ + findById: jest.fn().mockReturnValue({ + patch: mockSessionQuery.patch, + }), + }); + + await AgentSessionService.endSession('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(cleanupForwardedAgentEnvSecrets).toHaveBeenCalledWith('test-ns', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', [ + 'aws', + ]); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-sess1'); + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'ended', + endedAt: expect.any(String), + }) + ); + }); + it('returns after cleanup and restore trigger without waiting for redeploy to finish', async () => { const activeSession = { id: 1, @@ -966,6 +1023,41 @@ describe('AgentSessionService', () => { expect.objectContaining({ id: 'sess-1', status: 'active', + startupFailure: null, + }) + ); + }); + + it('attaches persisted startup failure details for errored sessions', async () => { + mockSessionQuery.findOne.mockResolvedValue({ + id: 1, + uuid: 'sess-1', + status: 'error', + buildUuid: null, + devModeSnapshots: {}, + }); + mockRedis.get.mockResolvedValueOnce( + JSON.stringify({ + sessionId: 'sess-1', + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }) + ); + + const result = await AgentSessionService.getSession('sess-1'); + + expect(result).toEqual( + expect.objectContaining({ + id: 'sess-1', + status: 'error', + startupFailure: { + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }, }) ); }); @@ -1152,12 +1244,61 @@ describe('AgentSessionService', () => { repo: 'example-org/example-repo', branch: 'feature/live', services: ['grpc-echo'], + startupFailure: null, }), expect.objectContaining({ id: 'sess-ended', repo: 'example-org/example-repo', branch: 'feature/sandbox', services: ['lc-test-gh-type'], + startupFailure: null, + }), + ]); + }); + + it('attaches persisted startup failures to errored sessions in the list response', async () => { + const sessions = [ + { + id: 101, + uuid: 'sess-error', + userId: 'user-123', + buildUuid: null, + status: 'error', + devModeSnapshots: {}, + }, + ]; + + const sessionsQuery = { + where: jest.fn().mockReturnThis(), + orderBy: jest + .fn() + .mockImplementationOnce(() => sessionsQuery) + .mockImplementationOnce(() => Promise.resolve(sessions)), + }; + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue(sessionsQuery); + + mockRedis.get.mockResolvedValueOnce( + JSON.stringify({ + sessionId: 'sess-error', + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }) + ); + + const result = await AgentSessionService.getSessions('user-123', { includeEnded: true }); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'sess-error', + status: 'error', + startupFailure: { + stage: 'connect_runtime', + title: 'Agent pod failed to start', + message: 'init-workspace: ImagePullBackOff', + recordedAt: '2026-03-25T10:00:00.000Z', + }, }), ]); }); diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 2a50246..6945720 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -24,6 +24,7 @@ import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFacto import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; import { createAgentPod, deleteAgentPod } from 'server/lib/agentSession/podFactory'; import { createAgentEditorService, deleteAgentEditorService } from 'server/lib/agentSession/editorServiceFactory'; +import { ensureAgentSessionServiceAccount } from 'server/lib/agentSession/serviceAccountFactory'; import { isGvisorAvailable } from 'server/lib/agentSession/gvisorCheck'; import { DevModeManager } from 'server/lib/agentSession/devModeManager'; import type { DevModeResourceSnapshot } from 'server/lib/agentSession/devModeManager'; @@ -59,7 +60,7 @@ const ACTIVE_ENVIRONMENT_SESSION_UNIQUE_INDEX = 'agent_sessions_active_environme const DEV_MODE_REDEPLOY_GRAPH = '[deployable.[repository], repository, service, build.[pullRequest.[repository]]]'; const SESSION_DEPLOY_GRAPH = '[deployable, repository, service]'; -type AgentSessionSummaryRecord = AgentSession & { +type AgentSessionSummaryRecordBase = AgentSession & { id: string; uuid: string; baseBuildUuid: string | null; @@ -68,6 +69,10 @@ type AgentSessionSummaryRecord = AgentSession & { services: string[]; }; +type AgentSessionSummaryRecord = AgentSessionSummaryRecordBase & { + startupFailure: PublicAgentSessionStartupFailure | null; +}; + type ActiveEnvironmentSessionSummary = { id: string | null; status: AgentSession['status']; @@ -98,6 +103,36 @@ async function restoreDeploys(deploys: Deploy[]): Promise { await new DeploymentManager(deploys).deploy(); } +async function attachStartupFailures( + sessions: T[] +): Promise> { + if (sessions.length === 0) { + return []; + } + + const errorSessions = sessions.filter((session) => session.status === 'error'); + if (errorSessions.length === 0) { + return sessions.map((session) => ({ + ...session, + startupFailure: null, + })); + } + + const redis = RedisClient.getInstance().getRedis(); + const failures = await Promise.all( + errorSessions.map(async (session) => { + const failure = await getAgentSessionStartupFailure(redis, session.uuid).catch(() => null); + return [session.uuid, failure ? toPublicAgentSessionStartupFailure(failure) : null] as const; + }) + ); + const failureBySessionId = new Map(failures); + + return sessions.map((session) => ({ + ...session, + startupFailure: failureBySessionId.get(session.uuid) ?? null, + })); +} + type SessionSnapshotMap = Record; function getSessionSnapshot( @@ -303,7 +338,7 @@ export default class AgentSessionService { const snapshotDeployById = new Map(snapshotDeploys.map((deploy) => [deploy.id, deploy])); - return sessions.map((session) => { + const enrichedSessions = sessions.map((session) => { const build = session.buildUuid ? buildByUuid.get(session.buildUuid) : null; const sessionDeploys = liveDeploysBySessionId.get(session.id) || @@ -337,8 +372,10 @@ export default class AgentSessionService { build?.baseBuild?.pullRequest?.branchName || null, services, - } as AgentSessionSummaryRecord; + } as AgentSessionSummaryRecordBase; }); + + return attachStartupFailures(enrichedSessions); } static async getEnvironmentActiveSession( @@ -398,6 +435,11 @@ export default class AgentSessionService { sessionUuid, opts.buildUuid ); + const forwardedPlainAgentEnv = Object.fromEntries( + Object.entries(forwardedAgentEnv.env).filter( + ([envKey]) => !forwardedAgentEnv.secretRefs.some((secretRef) => secretRef.envKey === envKey) + ) + ); try { session = await AgentSession.query().insertAndFetch({ @@ -416,9 +458,17 @@ export default class AgentSessionService { } as unknown as Partial); sessionPersisted = true; - await Promise.all([ + const [, , agentServiceAccountName] = await Promise.all([ createAgentPvc(opts.namespace, pvcName, '10Gi', opts.buildUuid), - createAgentApiKeySecret(opts.namespace, apiKeySecretName, apiKey, opts.githubToken, opts.buildUuid), + createAgentApiKeySecret( + opts.namespace, + apiKeySecretName, + apiKey, + opts.githubToken, + opts.buildUuid, + forwardedPlainAgentEnv + ), + ensureAgentSessionServiceAccount(opts.namespace), ]); const useGvisor = await isGvisorAvailable(); @@ -452,6 +502,7 @@ export default class AgentSessionService { buildUuid: opts.buildUuid, userIdentity: opts.userIdentity, nodeSelector: opts.nodeSelector, + serviceAccountName: agentServiceAccountName, resources: opts.resources, }); const agentNodeName = agentPod.spec?.nodeName || null; @@ -534,6 +585,35 @@ export default class AgentSessionService { logger.error(`Session creation failed, rolling back: sessionId=${sessionUuid} err=${(err as Error).message}`); await setAgentSessionStartupFailure(redis, startupFailure).catch(() => {}); + const endedAt = new Date().toISOString(); + + if (sessionPersisted) { + await AgentSession.query() + .findById(session!.id) + .patch({ + status: 'error', + endedAt, + } as unknown as Partial) + .catch(() => {}); + } else { + await AgentSession.query() + .insert({ + uuid: sessionUuid, + userId: opts.userId, + ownerGithubUsername: opts.userIdentity?.githubUsername || null, + podName, + namespace: opts.namespace, + pvcName, + model, + status: 'error', + buildUuid: opts.buildUuid || null, + buildKind, + endedAt, + devModeSnapshots: {}, + forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, + } as unknown as Partial) + .catch(() => {}); + } const revertPromise = mutatedDeploys.length > 0 @@ -554,40 +634,15 @@ export default class AgentSessionService { })() : Promise.resolve(); - await Promise.all([ - revertPromise, - deleteAgentRuntimeResources(opts.namespace, podName, apiKeySecretName).catch(() => {}), - cleanupForwardedAgentEnvSecrets(opts.namespace, sessionUuid, forwardedAgentEnv.secretProviders).catch(() => {}), - ]); - await deleteAgentPvc(opts.namespace, pvcName).catch(() => {}); + await revertPromise; - if (sessionPersisted) { + if (sessionPersisted && Object.keys(devModeSnapshots).length > 0) { await AgentSession.query() .findById(session!.id) .patch({ - status: 'error', - endedAt: new Date().toISOString(), devModeSnapshots: {}, } as unknown as Partial) .catch(() => {}); - } else { - await AgentSession.query() - .insert({ - uuid: sessionUuid, - userId: opts.userId, - ownerGithubUsername: opts.userIdentity?.githubUsername || null, - podName, - namespace: opts.namespace, - pvcName, - model, - status: 'error', - buildUuid: opts.buildUuid || null, - buildKind, - endedAt: new Date().toISOString(), - devModeSnapshots: {}, - forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, - } as unknown as Partial) - .catch(() => {}); } throw err; @@ -596,7 +651,7 @@ export default class AgentSessionService { static async endSession(sessionId: string): Promise { const session = await AgentSession.query().findOne({ uuid: sessionId }); - if (!session || (session.status !== 'active' && session.status !== 'starting')) { + if (!session || (session.status !== 'active' && session.status !== 'starting' && session.status !== 'error')) { throw new Error('Session not found or already ended'); } From 11fef6472fa3b1dfaba687f1619094cde850ab70 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Wed, 25 Mar 2026 21:42:51 -0700 Subject: [PATCH 12/18] make agent timeout configurable --- docs/schema/yaml/1.0.0.yaml | 10 +- .../api/v2/ai/agent/sandbox-sessions/route.ts | 1 + src/app/api/v2/ai/agent/sessions/route.ts | 5 + .../015_add_agent_sessions_and_api_keys.ts | 4 + src/server/jobs/agentSandboxSessionLaunch.ts | 2 + .../agentSession/__tests__/podFactory.test.ts | 59 ++++++++++++ .../__tests__/runtimeConfig.test.ts | 92 +++++++++++++++++++ src/server/lib/agentSession/podFactory.ts | 38 +++++++- src/server/lib/agentSession/runtimeConfig.ts | 70 ++++++++++++++ src/server/lib/jsonschema/schemas/1.0.0.json | 20 ++++ .../yamlSchemas/schema_1_0_0/schema_1_0_0.ts | 16 ++++ src/server/models/yaml/YamlService.ts | 6 ++ .../models/yaml/tests/YamlService.test.ts | 25 +++++ .../services/__tests__/agentSession.test.ts | 18 ++++ src/server/services/agentSandboxSession.ts | 11 ++- src/server/services/agentSession.ts | 3 + src/server/services/types/globalConfig.ts | 6 ++ 17 files changed, 380 insertions(+), 6 deletions(-) diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index 4bec526..5e5e19e 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -745,4 +745,12 @@ services: # @param services.dev.forwardEnvVarsToAgent forwardEnvVarsToAgent: # @param services.dev.forwardEnvVarsToAgent[] - - '' \ No newline at end of file + - '' + # @param services.dev.agentSession + agentSession: + # @param services.dev.agentSession.readiness + readiness: + # @param services.dev.agentSession.readiness.timeoutMs + timeoutMs: 0 + # @param services.dev.agentSession.readiness.pollMs + pollMs: 0 \ No newline at end of file diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts index 715e2d6..4c7a595 100644 --- a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts +++ b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts @@ -278,6 +278,7 @@ const postHandler = async (req: NextRequest) => { agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, nodeSelector: runtimeConfig.nodeSelector, + readiness: runtimeConfig.readiness, resources: runtimeConfig.resources, } as SandboxSessionLaunchJob, { diff --git a/src/app/api/v2/ai/agent/sessions/route.ts b/src/app/api/v2/ai/agent/sessions/route.ts index 663b2e1..53a00d8 100644 --- a/src/app/api/v2/ai/agent/sessions/route.ts +++ b/src/app/api/v2/ai/agent/sessions/route.ts @@ -21,6 +21,7 @@ import { getRequestUserIdentity } from 'server/lib/get-user'; import { resolveRequestGitHubToken } from 'server/lib/agentSession/githubToken'; import { AgentSessionRuntimeConfigError, + mergeAgentSessionReadinessForServices, mergeAgentSessionResources, resolveAgentSessionRuntimeConfig, } from 'server/lib/agentSession/runtimeConfig'; @@ -530,6 +531,10 @@ const postHandler = async (req: NextRequest) => { agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, nodeSelector: runtimeConfig.nodeSelector, + readiness: mergeAgentSessionReadinessForServices( + runtimeConfig.readiness, + resolvedServices.map((service) => service.devConfig.agentSession?.readiness) + ), resources: mergeAgentSessionResources( runtimeConfig.resources, lifecycleConfig?.environment?.agentSession?.resources diff --git a/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts index 64df3ff..c6f9995 100644 --- a/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts +++ b/src/server/db/migrations/015_add_agent_sessions_and_api_keys.ts @@ -21,6 +21,10 @@ export async function up(knex: Knex): Promise { const defaultAgentSessionDefaults = { image: process.env.AGENT_IMAGE || null, editorImage: process.env.AGENT_EDITOR_IMAGE || null, + readiness: { + timeoutMs: parseInt(process.env.AGENT_POD_READY_TIMEOUT_MS || '60000', 10), + pollMs: parseInt(process.env.AGENT_POD_READY_POLL_MS || '2000', 10), + }, resources: { agent: { requests: { diff --git a/src/server/jobs/agentSandboxSessionLaunch.ts b/src/server/jobs/agentSandboxSessionLaunch.ts index 5f6b603..ebd16db 100644 --- a/src/server/jobs/agentSandboxSessionLaunch.ts +++ b/src/server/jobs/agentSandboxSessionLaunch.ts @@ -48,6 +48,7 @@ export async function processAgentSandboxSessionLaunch(job: Job { 200 ); }); + + it('prefers explicit readiness overrides over process env defaults', async () => { + mockCreatePod.mockResolvedValue({ body: { metadata: { name: 'agent-abc123' } } }); + mockReadPod.mockResolvedValue({ + body: { + status: { + phase: 'Running', + initContainerStatuses: [ + { + name: 'prepare-workspace', + state: { + terminated: { + reason: 'Completed', + exitCode: 0, + }, + }, + }, + { + name: 'init-workspace', + state: { + terminated: { + reason: 'Completed', + exitCode: 0, + }, + }, + }, + ], + containerStatuses: [ + { + name: 'agent', + state: { + running: { + startedAt: '2026-03-26T00:00:00Z', + }, + }, + }, + { + name: 'editor', + state: { + running: { + startedAt: '2026-03-26T00:00:01Z', + }, + }, + }, + ], + }, + }, + }); + + await expect( + createAgentPod({ + ...baseOpts, + readiness: { + timeoutMs: 1, + pollMs: 0, + }, + }) + ).rejects.toThrow('Agent pod did not become ready within 1ms'); + }); }); describe('deleteAgentPod', () => { diff --git a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts index bd1f5b9..80c74d3 100644 --- a/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts +++ b/src/server/lib/agentSession/__tests__/runtimeConfig.test.ts @@ -27,10 +27,13 @@ jest.mock('server/services/globalConfig', () => ({ import { AgentSessionRuntimeConfigError, + mergeAgentSessionReadiness, + mergeAgentSessionReadinessForServices, mergeAgentSessionResources, renderAgentSessionClaudeAttribution, resolveAgentSessionClaudeConfig, resolveAgentSessionClaudeConfigFromDefaults, + resolveAgentSessionReadinessFromDefaults, resolveAgentSessionResourcesFromDefaults, resolveAgentSessionRuntimeConfig, } from '../runtimeConfig'; @@ -51,6 +54,10 @@ describe('runtimeConfig', () => { await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ image: 'lifecycle-agent:sha-123', editorImage: 'codercom/code-server:4.98.2', + readiness: { + timeoutMs: 60000, + pollMs: 2000, + }, resources: { agent: { requests: { @@ -107,6 +114,10 @@ describe('runtimeConfig', () => { 'app-long': 'deployments-m7i', pool: 'agents', }, + readiness: { + timeoutMs: 60000, + pollMs: 2000, + }, resources: { agent: { requests: { @@ -171,6 +182,10 @@ describe('runtimeConfig', () => { await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ image: 'lifecycle-agent:sha-123', editorImage: 'codercom/code-server:4.98.2', + readiness: { + timeoutMs: 60000, + pollMs: 2000, + }, resources: { agent: { requests: { @@ -206,6 +221,60 @@ describe('runtimeConfig', () => { }); }); + it('returns configured readiness settings when present', async () => { + getAllConfigs.mockResolvedValue({ + agentSessionDefaults: { + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + readiness: { + timeoutMs: 120000, + pollMs: 500, + }, + }, + }); + + await expect(resolveAgentSessionRuntimeConfig()).resolves.toEqual({ + image: 'lifecycle-agent:sha-123', + editorImage: 'codercom/code-server:4.98.2', + readiness: { + timeoutMs: 120000, + pollMs: 500, + }, + resources: { + agent: { + requests: { + cpu: '500m', + memory: '1Gi', + }, + limits: { + cpu: '2', + memory: '4Gi', + }, + }, + editor: { + requests: { + cpu: '250m', + memory: '512Mi', + }, + limits: { + cpu: '1', + memory: '1Gi', + }, + }, + }, + claude: { + permissions: { + allow: ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)'], + deny: [], + }, + attribution: { + commitTemplate: 'Generated with ({appName})', + prTemplate: 'Generated with ({appName})', + }, + }, + }); + }); + it('returns the configured Claude settings when present', async () => { getAllConfigs.mockResolvedValue({ agentSessionDefaults: { @@ -288,6 +357,29 @@ describe('runtimeConfig', () => { }); }); + it('merges direct readiness overrides over runtime defaults', () => { + expect( + mergeAgentSessionReadiness(resolveAgentSessionReadinessFromDefaults({ timeoutMs: 60000, pollMs: 2000 }), { + timeoutMs: 120000, + }) + ).toEqual({ + timeoutMs: 120000, + pollMs: 2000, + }); + }); + + it('merges service readiness overrides over runtime defaults', () => { + expect( + mergeAgentSessionReadinessForServices( + resolveAgentSessionReadinessFromDefaults({ timeoutMs: 60000, pollMs: 2000 }), + [{ timeoutMs: 120000 }, { timeoutMs: 90000, pollMs: 500 }, undefined, { pollMs: 1000 }] + ) + ).toEqual({ + timeoutMs: 120000, + pollMs: 500, + }); + }); + it('renders attribution from the app name placeholder', () => { expect(renderAgentSessionClaudeAttribution('Generated with ({appName})', 'sample-app')).toBe( 'Generated with (sample-app)' diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index f4b3e09..ce36fbc 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -32,6 +32,21 @@ function sleep(ms: number): Promise { }); } +function normalizeNonNegativeInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.trunc(value); + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + + return undefined; +} + function getPodStartupFailure(pod: k8s.V1Pod): string | null { const statuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])]; @@ -119,6 +134,10 @@ export interface AgentPodOpts { userIdentity?: RequestUserIdentity; nodeSelector?: Record; serviceAccountName?: string; + readiness?: { + timeoutMs: number; + pollMs: number; + }; resources?: { agent?: k8s.V1ResourceRequirements; editor?: k8s.V1ResourceRequirements; @@ -518,9 +537,20 @@ function summarizeLogLine(logs: string | null): string | null { return firstLine || null; } -async function waitForAgentPodReady(coreApi: k8s.CoreV1Api, namespace: string, podName: string): Promise { - const readyTimeoutMs = parseInt(process.env.AGENT_POD_READY_TIMEOUT_MS || '60000', 10); - const readyPollMs = parseInt(process.env.AGENT_POD_READY_POLL_MS || '2000', 10); +async function waitForAgentPodReady( + coreApi: k8s.CoreV1Api, + namespace: string, + podName: string, + readiness?: AgentPodOpts['readiness'] +): Promise { + const readyTimeoutMs = + normalizeNonNegativeInteger(readiness?.timeoutMs) ?? + normalizeNonNegativeInteger(process.env.AGENT_POD_READY_TIMEOUT_MS) ?? + 60000; + const readyPollMs = + normalizeNonNegativeInteger(readiness?.pollMs) ?? + normalizeNonNegativeInteger(process.env.AGENT_POD_READY_POLL_MS) ?? + 2000; const deadline = Date.now() + readyTimeoutMs; let lastObservedState = 'pending'; @@ -592,7 +622,7 @@ export async function createAgentPod(opts: AgentPodOpts): Promise { const pod = buildAgentPodSpec(opts); await coreApi.createNamespacedPod(opts.namespace, pod); - const result = await waitForAgentPodReady(coreApi, opts.namespace, opts.podName); + const result = await waitForAgentPodReady(coreApi, opts.namespace, opts.podName, opts.readiness); logger.info(`podFactory: created pod name=${opts.podName} namespace=${opts.namespace}`); return result; } diff --git a/src/server/lib/agentSession/runtimeConfig.ts b/src/server/lib/agentSession/runtimeConfig.ts index d06e161..3f7876f 100644 --- a/src/server/lib/agentSession/runtimeConfig.ts +++ b/src/server/lib/agentSession/runtimeConfig.ts @@ -19,6 +19,7 @@ import type { AgentSessionClaudeAttribution, AgentSessionClaudeConfig, AgentSessionClaudePermissions, + AgentSessionReadinessConfig, AgentSessionResourcesConfig, AgentSessionSchedulingConfig, ResourceRequirements, @@ -28,10 +29,16 @@ export interface AgentSessionRuntimeConfig { image: string; editorImage: string; nodeSelector?: Record; + readiness: ResolvedAgentSessionReadinessConfig; resources: ResolvedAgentSessionResources; claude: ResolvedAgentSessionClaudeConfig; } +export interface ResolvedAgentSessionReadinessConfig { + timeoutMs: number; + pollMs: number; +} + export interface ResolvedAgentSessionResourceRequirements { requests: Record; limits: Record; @@ -62,6 +69,8 @@ const DEFAULT_CLAUDE_PERMISSION_ALLOW = ['Bash(*)', 'Read(*)', 'Write(*)', 'Edit const DEFAULT_CLAUDE_PERMISSION_DENY: string[] = []; const DEFAULT_CLAUDE_COMMIT_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; const DEFAULT_CLAUDE_PR_ATTRIBUTION_TEMPLATE = 'Generated with ({appName})'; +const DEFAULT_AGENT_READY_TIMEOUT_MS = 60000; +const DEFAULT_AGENT_READY_POLL_MS = 2000; const DEFAULT_AGENT_RESOURCES: ResolvedAgentSessionResourceRequirements = { requests: { cpu: '500m', @@ -104,6 +113,21 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +function normalizeNonNegativeInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.trunc(value); + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + + return undefined; +} + function normalizeResourceQuantityMap(values: unknown): Record { if (!values || typeof values !== 'object' || Array.isArray(values)) { return {}; @@ -148,6 +172,51 @@ function normalizeNodeSelector(scheduling?: AgentSessionSchedulingConfig | null) return Object.keys(normalized).length > 0 ? normalized : undefined; } +function getDefaultReadinessConfig(): ResolvedAgentSessionReadinessConfig { + return { + timeoutMs: normalizeNonNegativeInteger(process.env.AGENT_POD_READY_TIMEOUT_MS) ?? DEFAULT_AGENT_READY_TIMEOUT_MS, + pollMs: normalizeNonNegativeInteger(process.env.AGENT_POD_READY_POLL_MS) ?? DEFAULT_AGENT_READY_POLL_MS, + }; +} + +export function resolveAgentSessionReadinessFromDefaults( + readinessDefaults?: AgentSessionReadinessConfig | null +): ResolvedAgentSessionReadinessConfig { + const defaults = getDefaultReadinessConfig(); + + return { + timeoutMs: normalizeNonNegativeInteger(readinessDefaults?.timeoutMs) ?? defaults.timeoutMs, + pollMs: normalizeNonNegativeInteger(readinessDefaults?.pollMs) ?? defaults.pollMs, + }; +} + +export function mergeAgentSessionReadiness( + baseReadiness: ResolvedAgentSessionReadinessConfig, + overrides?: AgentSessionReadinessConfig | null +): ResolvedAgentSessionReadinessConfig { + return { + timeoutMs: normalizeNonNegativeInteger(overrides?.timeoutMs) ?? baseReadiness.timeoutMs, + pollMs: normalizeNonNegativeInteger(overrides?.pollMs) ?? baseReadiness.pollMs, + }; +} + +export function mergeAgentSessionReadinessForServices( + baseReadiness: ResolvedAgentSessionReadinessConfig, + overrides: Array +): ResolvedAgentSessionReadinessConfig { + const timeoutOverrides = overrides + .map((override) => normalizeNonNegativeInteger(override?.timeoutMs)) + .filter((value): value is number => value !== undefined); + const pollOverrides = overrides + .map((override) => normalizeNonNegativeInteger(override?.pollMs)) + .filter((value): value is number => value !== undefined); + + return { + timeoutMs: timeoutOverrides.length > 0 ? Math.max(...timeoutOverrides) : baseReadiness.timeoutMs, + pollMs: pollOverrides.length > 0 ? Math.min(...pollOverrides) : baseReadiness.pollMs, + }; +} + export function resolveAgentSessionResourcesFromDefaults( resourceDefaults?: AgentSessionResourcesConfig | null ): ResolvedAgentSessionResources { @@ -244,6 +313,7 @@ export async function resolveAgentSessionRuntimeConfig(): Promise; forwardEnvVarsToAgent?: string[]; + agentSession?: { + readiness?: { + timeoutMs?: number; + pollMs?: number; + }; + }; } export interface Service { diff --git a/src/server/models/yaml/tests/YamlService.test.ts b/src/server/models/yaml/tests/YamlService.test.ts index e66ac66..fb122dd 100644 --- a/src/server/models/yaml/tests/YamlService.test.ts +++ b/src/server/models/yaml/tests/YamlService.test.ts @@ -139,6 +139,31 @@ services: expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); }); + test('accepts service-level agent session readiness overrides in dev config', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +services: + - name: 'agent-app' + dev: + image: 'node:20-slim' + command: 'pnpm dev' + agentSession: + readiness: + timeoutMs: 120000 + pollMs: 500 + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'apps/agent-app/Dockerfile' +`); + + expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); + }); + test('accepts forwardEnvVarsToAgent in dev config', () => { const parser = new YamlConfigParser(); const config = parser.parseYamlConfigFromString(`--- diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index cd6eb63..3c043f6 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -420,6 +420,24 @@ describe('AgentSessionService', () => { ); }); + it('passes resolved agent-session readiness through to pod creation when provided', async () => { + const optsWithReadiness: CreateSessionOptions = { + ...baseOpts, + readiness: { + timeoutMs: 120000, + pollMs: 500, + }, + }; + + await AgentSessionService.createSession(optsWithReadiness); + + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + readiness: optsWithReadiness.readiness, + }) + ); + }); + it('passes forwarded service env through to the agent pod when configured', async () => { (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ env: { PRIVATE_REGISTRY_TOKEN: 'plain-token' }, diff --git a/src/server/services/agentSandboxSession.ts b/src/server/services/agentSandboxSession.ts index 2abbce9..9bd3609 100644 --- a/src/server/services/agentSandboxSession.ts +++ b/src/server/services/agentSandboxSession.ts @@ -19,7 +19,12 @@ import { customAlphabet, nanoid } from 'nanoid'; import BaseService from './_service'; import BuildService from './build'; import AgentSessionService from './agentSession'; -import { mergeAgentSessionResources, type ResolvedAgentSessionResources } from 'server/lib/agentSession/runtimeConfig'; +import { + mergeAgentSessionReadinessForServices, + mergeAgentSessionResources, + type ResolvedAgentSessionReadinessConfig, + type ResolvedAgentSessionResources, +} from 'server/lib/agentSession/runtimeConfig'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; import { getLogger } from 'server/lib/logger'; import { Build, Deploy, Deployable } from 'server/models'; @@ -71,6 +76,7 @@ export interface LaunchSandboxSessionOptions { agentImage: string; editorImage: string; nodeSelector?: Record; + readiness: ResolvedAgentSessionReadinessConfig; resources: ResolvedAgentSessionResources; onProgress?: (stage: SandboxLaunchStage, message: string) => Promise | void; } @@ -174,6 +180,9 @@ export default class AgentSandboxSessionService extends BaseService { agentImage: opts.agentImage, editorImage: opts.editorImage, nodeSelector: opts.nodeSelector, + readiness: mergeAgentSessionReadinessForServices(opts.readiness, [ + selectedService.devConfig.agentSession?.readiness, + ]), resources: mergeAgentSessionResources(opts.resources, lifecycleConfig.environment?.agentSession?.resources), userIdentity: opts.userIdentity, }); diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 6945720..dff7102 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -37,6 +37,7 @@ import { extractContextForQueue, getLogger } from 'server/lib/logger'; import { BuildKind } from 'shared/constants'; import type { RequestUserIdentity } from 'server/lib/get-user'; import { + type ResolvedAgentSessionReadinessConfig, type ResolvedAgentSessionResources, resolveAgentSessionClaudeConfig, renderAgentSessionClaudeAttribution, @@ -247,6 +248,7 @@ export interface CreateSessionOptions { agentImage: string; editorImage: string; nodeSelector?: Record; + readiness?: ResolvedAgentSessionReadinessConfig; resources?: ResolvedAgentSessionResources; } @@ -502,6 +504,7 @@ export default class AgentSessionService { buildUuid: opts.buildUuid, userIdentity: opts.userIdentity, nodeSelector: opts.nodeSelector, + readiness: opts.readiness, serviceAccountName: agentServiceAccountName, resources: opts.resources, }); diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index e4f927b..14bfb50 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -73,6 +73,11 @@ export type AgentSessionSchedulingConfig = { nodeSelector?: Record | null; }; +export type AgentSessionReadinessConfig = { + timeoutMs?: number | string | null; + pollMs?: number | string | null; +}; + export type ResourceRequirements = { requests?: Record; limits?: Record; @@ -87,6 +92,7 @@ export type AgentSessionDefaults = { image?: string | null; editorImage?: string | null; scheduling?: AgentSessionSchedulingConfig; + readiness?: AgentSessionReadinessConfig; resources?: AgentSessionResourcesConfig; claude?: AgentSessionClaudeConfig; }; From b03acd04f74a24d78787cdc4419ee163107dd46e Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Thu, 26 Mar 2026 00:26:26 -0700 Subject: [PATCH 13/18] inject env metadata to agent context --- .../__tests__/systemPrompt.test.ts | 131 ++++++++++++++ src/server/lib/agentSession/execProxy.ts | 7 +- src/server/lib/agentSession/systemPrompt.ts | 160 ++++++++++++++++++ src/server/services/agentSession.ts | 30 ++++ ws-server.ts | 15 +- 5 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 src/server/lib/agentSession/__tests__/systemPrompt.test.ts create mode 100644 src/server/lib/agentSession/systemPrompt.ts diff --git a/src/server/lib/agentSession/__tests__/systemPrompt.test.ts b/src/server/lib/agentSession/__tests__/systemPrompt.test.ts new file mode 100644 index 0000000..1ea1158 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/systemPrompt.test.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/models/Build'); +jest.mock('server/models/Deploy'); +jest.mock('server/models/yaml', () => ({ + fetchLifecycleConfig: jest.fn(), + getDeployingServicesByName: jest.fn(), +})); + +import Build from 'server/models/Build'; +import Deploy from 'server/models/Deploy'; +import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml'; +import { + buildAgentSessionDynamicSystemPrompt, + combineAgentSessionAppendSystemPrompt, + resolveAgentSessionPromptContext, +} from '../systemPrompt'; + +describe('agent session system prompt', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('builds a compact dynamic session context prompt', () => { + expect( + buildAgentSessionDynamicSystemPrompt({ + namespace: 'env-sample-123456', + buildUuid: 'sample-123456', + services: [ + { + name: 'next-web', + publicUrl: 'https://next-web-sample.lifecycle.dev.example.com', + workDir: '/workspace/apps/next-web', + }, + ], + }) + ).toBe( + [ + 'Session context:', + '- namespace: env-sample-123456', + '- buildUuid: sample-123456', + '- selected services:', + ' - next-web: publicUrl=https://next-web-sample.lifecycle.dev.example.com, workDir=/workspace/apps/next-web', + ].join('\n') + ); + }); + + it('combines the configured and dynamic prompts with spacing', () => { + expect( + combineAgentSessionAppendSystemPrompt('Use concise responses.', 'Session context:\n- namespace: env-sample') + ).toBe('Use concise responses.\n\nSession context:\n- namespace: env-sample'); + }); + + it('resolves selected service public URLs and workdirs from deploy and lifecycle config metadata', async () => { + const buildGraphQuery = { + withGraphFetched: jest.fn().mockResolvedValue({ + pullRequest: { + fullName: 'example-org/example-repo', + branchName: 'feature/sample', + }, + }), + }; + (Build.query as jest.Mock) = jest.fn().mockReturnValue({ + findOne: jest.fn().mockReturnValue(buildGraphQuery), + }); + + const deployGraphQuery = { + withGraphFetched: jest.fn().mockResolvedValue([ + { + uuid: 'next-web-sample-123456', + branchName: 'feature/sample', + publicUrl: 'next-web-sample.lifecycle.dev.example.com', + deployable: { name: 'next-web' }, + repository: { fullName: 'example-org/example-repo' }, + service: null, + }, + ]), + }; + (Deploy.query as jest.Mock) = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue(deployGraphQuery), + }); + + (fetchLifecycleConfig as jest.Mock).mockResolvedValue({ + services: [{ name: 'next-web', dev: { workDir: '/workspace/apps/next-web' } }], + }); + (getDeployingServicesByName as jest.Mock).mockReturnValue({ + name: 'next-web', + dev: { workDir: '/workspace/apps/next-web' }, + }); + + await expect( + resolveAgentSessionPromptContext({ + sessionDbId: 123, + namespace: 'env-sample-123456', + buildUuid: 'sample-123456', + }) + ).resolves.toEqual({ + namespace: 'env-sample-123456', + buildUuid: 'sample-123456', + services: [ + { + name: 'next-web', + publicUrl: 'https://next-web-sample.lifecycle.dev.example.com', + workDir: '/workspace/apps/next-web', + }, + ], + }); + + expect(fetchLifecycleConfig).toHaveBeenCalledWith('example-org/example-repo', 'feature/sample'); + expect(getDeployingServicesByName).toHaveBeenCalledWith( + expect.objectContaining({ + services: expect.any(Array), + }), + 'next-web' + ); + }); +}); diff --git a/src/server/lib/agentSession/execProxy.ts b/src/server/lib/agentSession/execProxy.ts index 7ef01bc..802701a 100644 --- a/src/server/lib/agentSession/execProxy.ts +++ b/src/server/lib/agentSession/execProxy.ts @@ -92,7 +92,8 @@ export async function attachToAgentPod( namespace: string, podName: string, model: string, - container = 'agent' + container = 'agent', + appendSystemPrompt?: string ): Promise { const kc = new k8s.KubeConfig(); kc.loadFromDefault(); @@ -121,14 +122,14 @@ export async function attachToAgentPod( const stdin = new Readable({ read() {} }); let closed = false; - const claudeConfig = await resolveAgentSessionClaudeConfig(); + const resolvedAppendSystemPrompt = appendSystemPrompt ?? (await resolveAgentSessionClaudeConfig()).appendSystemPrompt; try { const ws = await exec.exec( namespace, podName, container, - ['sh', '-lc', buildClaudeExecScript(model, claudeConfig.appendSystemPrompt)], + ['sh', '-lc', buildClaudeExecScript(model, resolvedAppendSystemPrompt)], stdout, stderr, stdin, diff --git a/src/server/lib/agentSession/systemPrompt.ts b/src/server/lib/agentSession/systemPrompt.ts new file mode 100644 index 0000000..042b215 --- /dev/null +++ b/src/server/lib/agentSession/systemPrompt.ts @@ -0,0 +1,160 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Build from 'server/models/Build'; +import Deploy from 'server/models/Deploy'; +import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml'; +import type { LifecycleConfig } from 'server/models/yaml'; + +export interface AgentSessionPromptServiceContext { + name: string; + publicUrl?: string; + workDir?: string; +} + +export interface AgentSessionPromptContext { + namespace: string; + buildUuid?: string | null; + services: AgentSessionPromptServiceContext[]; +} + +type SessionPromptLookupContext = { + sessionDbId: number; + namespace: string; + buildUuid?: string | null; +}; + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function formatPublicUrl(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + + return /^https?:\/\//.test(normalized) ? normalized : `https://${normalized}`; +} + +export function buildAgentSessionDynamicSystemPrompt(context: AgentSessionPromptContext): string { + const lines = ['Session context:', `- namespace: ${context.namespace}`]; + + if (context.buildUuid) { + lines.push(`- buildUuid: ${context.buildUuid}`); + } + + if (context.services.length > 0) { + lines.push('- selected services:'); + + const services = [...context.services].sort((left, right) => left.name.localeCompare(right.name)); + for (const service of services) { + const details = [ + service.publicUrl ? `publicUrl=${service.publicUrl}` : null, + service.workDir ? `workDir=${service.workDir}` : null, + ].filter((value): value is string => Boolean(value)); + + lines.push(` - ${service.name}${details.length > 0 ? `: ${details.join(', ')}` : ''}`); + } + } + + return lines.join('\n'); +} + +export function combineAgentSessionAppendSystemPrompt( + configuredPrompt?: string, + dynamicPrompt?: string +): string | undefined { + const parts = [normalizeOptionalString(configuredPrompt), normalizeOptionalString(dynamicPrompt)].filter( + (value): value is string => Boolean(value) + ); + + return parts.length > 0 ? parts.join('\n\n') : undefined; +} + +async function fetchCachedLifecycleConfig( + repositoryName: string, + branchName: string, + cache: Map> +): Promise { + const key = `${repositoryName}::${branchName}`; + let promise = cache.get(key); + + if (!promise) { + promise = fetchLifecycleConfig(repositoryName, branchName).catch(() => null); + cache.set(key, promise); + } + + return promise; +} + +async function resolveBuildSource(buildUuid?: string | null): Promise<{ repo?: string; branch?: string }> { + const normalizedBuildUuid = normalizeOptionalString(buildUuid); + if (!normalizedBuildUuid) { + return {}; + } + + const build = await Build.query().findOne({ uuid: normalizedBuildUuid }).withGraphFetched('[pullRequest]'); + return { + repo: normalizeOptionalString(build?.pullRequest?.fullName), + branch: normalizeOptionalString(build?.pullRequest?.branchName), + }; +} + +export async function resolveAgentSessionPromptContext( + lookup: SessionPromptLookupContext +): Promise { + const deploys = await Deploy.query() + .where({ devModeSessionId: lookup.sessionDbId }) + .withGraphFetched('[deployable, repository, service]'); + const buildSource = await resolveBuildSource(lookup.buildUuid); + const lifecycleConfigCache = new Map>(); + + const services = await Promise.all( + deploys.map(async (deploy): Promise => { + const serviceName = + normalizeOptionalString(deploy.deployable?.name) || + normalizeOptionalString(deploy.service?.name) || + normalizeOptionalString(deploy.uuid); + + if (!serviceName) { + return null; + } + + const repositoryName = normalizeOptionalString(deploy.repository?.fullName) || buildSource.repo; + const branchName = normalizeOptionalString(deploy.branchName) || buildSource.branch; + + let workDir: string | undefined; + if (repositoryName && branchName) { + const lifecycleConfig = await fetchCachedLifecycleConfig(repositoryName, branchName, lifecycleConfigCache); + const yamlService = lifecycleConfig ? getDeployingServicesByName(lifecycleConfig, serviceName) : undefined; + workDir = normalizeOptionalString(yamlService?.dev?.workDir); + } + + return { + name: serviceName, + publicUrl: formatPublicUrl(deploy.publicUrl), + workDir, + }; + }) + ); + + return { + namespace: lookup.namespace, + buildUuid: lookup.buildUuid, + services: services.filter((service): service is AgentSessionPromptServiceContext => Boolean(service)), + }; +} diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index dff7102..4025521 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -44,6 +44,11 @@ import { } from 'server/lib/agentSession/runtimeConfig'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; +import { + buildAgentSessionDynamicSystemPrompt, + combineAgentSessionAppendSystemPrompt, + resolveAgentSessionPromptContext, +} from 'server/lib/agentSession/systemPrompt'; import { AgentSessionStartupFailureStage, PublicAgentSessionStartupFailure, @@ -737,6 +742,31 @@ export default class AgentSessionService { return enrichedSession || null; } + static async getSessionAppendSystemPrompt(sessionId: string): Promise { + const [session, claudeConfig] = await Promise.all([ + AgentSession.query().findOne({ uuid: sessionId }).select('id', 'namespace', 'buildUuid'), + resolveAgentSessionClaudeConfig(), + ]); + const configuredPrompt = claudeConfig.appendSystemPrompt; + + if (!session) { + return configuredPrompt; + } + + try { + const context = await resolveAgentSessionPromptContext({ + sessionDbId: session.id, + namespace: session.namespace, + buildUuid: session.buildUuid, + }); + + return combineAgentSessionAppendSystemPrompt(configuredPrompt, buildAgentSessionDynamicSystemPrompt(context)); + } catch (error) { + logger.warn({ err: error, sessionId }, 'Failed to resolve dynamic agent session prompt context'); + return configuredPrompt; + } + } + static async getActiveSessions(userId: string) { return AgentSession.query() .where({ userId }) diff --git a/ws-server.ts b/ws-server.ts index a704aeb..c2b78ad 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -447,6 +447,7 @@ async function getOrCreateAgentRuntime( namespace: string, podName: string, model: string, + appendSystemPrompt: string | undefined, agentLogCtx: Record ): Promise { let runtime = agentSessionRuntimes.get(sessionId); @@ -488,6 +489,7 @@ async function getOrCreateAgentRuntime( namespace, podName, model, + appendSystemPrompt, agentLogCtx )) as AgentExecConnection; runtime!.execConn = execConn; @@ -555,17 +557,24 @@ async function getOrCreateAgentRuntime( } async function attachToAgentPodWithRetry( - attachToAgentPod: (namespace: string, podName: string, model: string) => Promise, + attachToAgentPod: ( + namespace: string, + podName: string, + model: string, + container?: string, + appendSystemPrompt?: string + ) => Promise, namespace: string, podName: string, model: string, + appendSystemPrompt: string | undefined, agentLogCtx: Record ): Promise { let lastError: Error | null = null; for (let attempt = 1; attempt <= AGENT_EXEC_ATTACH_MAX_ATTEMPTS; attempt++) { try { - return await attachToAgentPod(namespace, podName, model); + return await attachToAgentPod(namespace, podName, model, undefined, appendSystemPrompt); } catch (error: any) { lastError = error; @@ -954,6 +963,7 @@ app.prepare().then(() => { activeSessionId, typeof tokenFromQuery === 'string' ? tokenFromQuery : null ); + const appendSystemPrompt = await AgentSessionService.getSessionAppendSystemPrompt(activeSessionId); agentLogCtx.podName = session.podName; agentLogCtx.namespace = session.namespace; @@ -964,6 +974,7 @@ app.prepare().then(() => { session.namespace, session.podName, session.model, + appendSystemPrompt, agentLogCtx ); From 69a4fb6719296e1843460e31c8bbcaf561eba55f Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Thu, 26 Mar 2026 01:01:58 -0700 Subject: [PATCH 14/18] resolve env vars in dev block --- .../services/__tests__/agentSession.test.ts | 77 ++++++++++++++++++- src/server/services/agentSession.ts | 68 ++++++++++++++-- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index 3c043f6..fe28d73 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -93,7 +93,12 @@ jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: { getInstance: jest.fn(() => ({ - getAllConfigs: jest.fn().mockResolvedValue({}), + getAllConfigs: jest.fn().mockResolvedValue({ + lifecycleDefaults: { + defaultUUID: 'sample-env-0', + defaultPublicUrl: 'sample-env.example.test', + }, + }), getOrgChartName: jest.fn().mockResolvedValue('org-chart'), getGithubAppName: jest.fn().mockResolvedValue('sample-lifecycle-app'), })), @@ -587,6 +592,76 @@ describe('AgentSessionService', () => { ); }); + it('renders dev env templates with the shared build env renderer before enabling dev mode', async () => { + const buildContext = { + uuid: 'sample-build-123', + namespace: 'sample-ns', + enableFullYaml: true, + enabledFeatures: [], + pullRequest: { + pullRequestNumber: 42, + branchName: 'feature/sample-change', + fullName: 'sample-org/sample-repo', + }, + deploys: [ + { + active: true, + publicUrl: 'sample-service-sample-env.example.test', + deployable: { + name: 'sample-service', + type: 'github', + buildUUID: 'sample-build-123', + }, + }, + ], + $fetchGraph: jest.fn().mockResolvedValue(undefined), + }; + + (Build.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce({ + findOne: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue(buildContext), + }), + }) + .mockReturnValueOnce({ + findOne: jest.fn().mockReturnValue({ + select: jest.fn().mockResolvedValue({ namespace: 'static-env-ns' }), + }), + }); + + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + buildUuid: 'sample-build-123', + services: [ + { + name: 'sample-service', + deployId: 1, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + env: { + ASSET_PREFIX: 'https://{{sample-service_publicUrl}}', + }, + }, + }, + ], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(buildContext.$fetchGraph).toHaveBeenCalledWith('[deploys.[service, deployable], pullRequest]'); + expect(mockEnableDevMode).toHaveBeenCalledWith( + expect.objectContaining({ + devConfig: expect.objectContaining({ + env: { + ASSET_PREFIX: 'https://sample-service-sample-env.example.test', + }, + }), + }) + ); + }); + it('runs install commands for all selected services during workspace init', async () => { const optsWithServices: CreateSessionOptions = { ...baseOpts, diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 4025521..cd308cc 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -17,8 +17,10 @@ import 'server/lib/dependencies'; import * as k8s from '@kubernetes/client-node'; import { v4 as uuid } from 'uuid'; +import type Database from 'server/database'; import AgentSession from 'server/models/AgentSession'; import Build from 'server/models/Build'; +import Configuration from 'server/models/Configuration'; import Deploy from 'server/models/Deploy'; import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFactory'; import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; @@ -34,7 +36,7 @@ import GlobalConfigService from 'server/services/globalConfig'; import { DevConfig } from 'server/models/yaml/YamlService'; import RedisClient from 'server/lib/redisClient'; import { extractContextForQueue, getLogger } from 'server/lib/logger'; -import { BuildKind } from 'shared/constants'; +import { BuildKind, FeatureFlags } from 'shared/constants'; import type { RequestUserIdentity } from 'server/lib/get-user'; import { type ResolvedAgentSessionReadinessConfig, @@ -58,6 +60,7 @@ import { setAgentSessionStartupFailure, toPublicAgentSessionStartupFailure, } from 'server/lib/agentSession/startupFailureState'; +import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; const logger = getLogger(); const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; @@ -140,6 +143,7 @@ async function attachStartupFailures; +type SessionService = NonNullable[number]; function getSessionSnapshot( snapshots: SessionSnapshotMap | null | undefined, @@ -149,6 +153,59 @@ function getSessionSnapshot( return snapshot ?? null; } +async function resolveTemplatedDevConfigEnvs( + buildUuid: string | undefined, + namespace: string, + services: CreateSessionOptions['services'] +): Promise { + if (!buildUuid || !services?.length) { + return services; + } + + const hasTemplatedEnv = services.some((service) => + Object.values(service.devConfig.env || {}).some((value) => typeof value === 'string' && value.includes('{{')) + ); + if (!hasTemplatedEnv) { + return services; + } + + const build = await Build.query() + .findOne({ uuid: buildUuid }) + .withGraphFetched('[deploys.[service, deployable], pullRequest]'); + if (!build) { + throw new Error('Build not found'); + } + + const envResolver = new BuildEnvironmentVariables({ + models: { + Build, + Configuration, + }, + } as unknown as Database); + const availableEnv = envResolver.cleanup(await envResolver.availableEnvironmentVariablesForBuild(build)); + const useDefaultUUID = + !Array.isArray(build.enabledFeatures) || !build.enabledFeatures.includes(FeatureFlags.NO_DEFAULT_ENV_RESOLVE); + const resolvedNamespace = build.namespace || namespace; + + return Promise.all( + services.map(async (service): Promise => { + if (!service.devConfig.env) { + return service; + } + + return { + ...service, + devConfig: { + ...service.devConfig, + env: envResolver.parseTemplateData( + await envResolver.compileEnv(service.devConfig.env, availableEnv, useDefaultUUID, resolvedNamespace) + ), + }, + }; + }) + ); +} + async function cleanupDevModePatches( namespace: string, snapshots: SessionSnapshotMap | null | undefined, @@ -436,8 +493,9 @@ export default class AgentSessionService { githubAppName ); const claudePrAttribution = renderAgentSessionClaudeAttribution(claudeConfig.attribution.prTemplate, githubAppName); + const resolvedServices = await resolveTemplatedDevConfigEnvs(opts.buildUuid, opts.namespace, opts.services); const forwardedAgentEnv = await resolveForwardedAgentEnv( - opts.services, + resolvedServices, opts.namespace, sessionUuid, opts.buildUuid @@ -479,7 +537,7 @@ export default class AgentSessionService { ]); const useGvisor = await isGvisorAvailable(); - const installCommands = (opts.services || []) + const installCommands = (resolvedServices || []) .map((service) => service.devConfig.installCommand) .filter((command): command is string => Boolean(command)); const combinedInstallCommand = installCommands.length > 0 ? installCommands.join('\n\n') : undefined; @@ -515,12 +573,12 @@ export default class AgentSessionService { }); const agentNodeName = agentPod.spec?.nodeName || null; - if ((opts.services || []).length > 0 && !agentNodeName) { + if ((resolvedServices || []).length > 0 && !agentNodeName) { throw new Error(`Agent pod ${podName} did not report a scheduled node`); } const devModeManager = new DevModeManager(); - for (const svc of opts.services || []) { + for (const svc of resolvedServices || []) { const resourceName = svc.resourceName || svc.name; const snapshot = await devModeManager.enableDevMode({ namespace: opts.namespace, From 2ad45e4b2aeab00fe61f593edb810e1597416363 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Fri, 27 Mar 2026 13:10:20 -0700 Subject: [PATCH 15/18] add prewarm for agent session --- docs/schema/yaml/1.0.0.yaml | 6 + .../db/migrations/016_add_agent_prewarms.ts | 43 ++ src/server/jobs/agentSessionPrewarm.ts | 29 ++ src/server/jobs/index.ts | 6 + src/server/lib/agentSession/podFactory.ts | 124 ++--- .../lib/agentSession/prewarmJobFactory.ts | 271 +++++++++++ src/server/lib/jsonschema/schemas/1.0.0.json | 12 + .../__tests__/getDeploymentPods.test.ts | 212 +++++++++ .../lib/kubernetes/getDeploymentPods.ts | 46 +- .../yamlSchemas/schema_1_0_0/schema_1_0_0.ts | 12 + src/server/models/AgentPrewarm.ts | 65 +++ src/server/models/index.ts | 3 + src/server/models/yaml/YamlEnvironment.ts | 3 + .../models/yaml/tests/YamlService.test.ts | 38 ++ src/server/models/yaml/types.ts | 3 + .../services/__tests__/agentPrewarm.test.ts | 296 ++++++++++++ .../services/__tests__/agentSession.test.ts | 149 +++++- src/server/services/agentPrewarm.ts | 430 ++++++++++++++++++ src/server/services/agentSession.ts | 43 +- src/server/services/build.ts | 11 + src/shared/config.ts | 1 + 21 files changed, 1716 insertions(+), 87 deletions(-) create mode 100644 src/server/db/migrations/016_add_agent_prewarms.ts create mode 100644 src/server/jobs/agentSessionPrewarm.ts create mode 100644 src/server/lib/agentSession/prewarmJobFactory.ts create mode 100644 src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts create mode 100644 src/server/models/AgentPrewarm.ts create mode 100644 src/server/services/__tests__/agentPrewarm.test.ts create mode 100644 src/server/services/agentPrewarm.ts diff --git a/docs/schema/yaml/1.0.0.yaml b/docs/schema/yaml/1.0.0.yaml index 5e5e19e..00554e0 100644 --- a/docs/schema/yaml/1.0.0.yaml +++ b/docs/schema/yaml/1.0.0.yaml @@ -97,6 +97,12 @@ environment: # @param environment.agentSession.resources.editor.limits limits: + # @param environment.agentSession.prewarm + prewarm: + # @param environment.agentSession.prewarm.services + services: + # @param environment.agentSession.prewarm.services[] + - '' # @section services services: # @param services[] diff --git a/src/server/db/migrations/016_add_agent_prewarms.ts b/src/server/db/migrations/016_add_agent_prewarms.ts new file mode 100644 index 0000000..f9c925c --- /dev/null +++ b/src/server/db/migrations/016_add_agent_prewarms.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('agent_prewarms', (table) => { + table.increments('id').primary(); + table.uuid('uuid').notNullable().unique().defaultTo(knex.raw('gen_random_uuid()')); + table.string('buildUuid').notNullable().index(); + table.string('namespace').notNullable(); + table.string('repo').nullable(); + table.string('branch').nullable(); + table.string('revision').nullable().index(); + table.string('pvcName').notNullable(); + table.string('jobName').notNullable(); + table.string('status').notNullable().defaultTo('queued').index(); + table.jsonb('services').notNullable().defaultTo('[]'); + table.text('errorMessage').nullable(); + table.timestamp('completedAt').nullable(); + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); + table.unique(['buildUuid', 'revision', 'pvcName']); + table.index(['buildUuid', 'status', 'updatedAt']); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('agent_prewarms'); +} diff --git a/src/server/jobs/agentSessionPrewarm.ts b/src/server/jobs/agentSessionPrewarm.ts new file mode 100644 index 0000000..6ec35f4 --- /dev/null +++ b/src/server/jobs/agentSessionPrewarm.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Job } from 'bullmq'; +import { getLogger } from 'server/lib/logger'; +import AgentPrewarmService, { AgentPrewarmQueueJob } from 'server/services/agentPrewarm'; + +const logger = getLogger(); + +export async function processAgentSessionPrewarm(job: Job): Promise { + const { buildUuid } = job.data; + await new AgentPrewarmService().prepareBuildPrewarm(buildUuid).catch((error) => { + logger.error({ error, buildUuid }, 'Agent prewarm job failed'); + throw error; + }); +} diff --git a/src/server/jobs/index.ts b/src/server/jobs/index.ts index dc78d57..1cfabb4 100644 --- a/src/server/jobs/index.ts +++ b/src/server/jobs/index.ts @@ -21,6 +21,7 @@ import RedisClient from 'server/lib/redisClient'; import QueueManager from 'server/lib/queueManager'; import { MAX_GITHUB_API_REQUEST, GITHUB_API_REQUEST_INTERVAL, QUEUE_NAMES } from 'shared/config'; import { processAgentSessionCleanup } from './agentSessionCleanup'; +import { processAgentSessionPrewarm } from './agentSessionPrewarm'; import { processAgentSandboxSessionLaunch } from './agentSandboxSessionLaunch'; let isBootstrapped = false; @@ -110,6 +111,11 @@ export default function bootstrapJobs(services: IServices) { concurrency: 1, }); + queueManager.registerWorker(QUEUE_NAMES.AGENT_SESSION_PREWARM, processAgentSessionPrewarm, { + connection: redisClient.getConnection(), + concurrency: 2, + }); + queueManager.registerWorker(QUEUE_NAMES.AGENT_SANDBOX_SESSION_LAUNCH, processAgentSandboxSessionLaunch, { connection: redisClient.getConnection(), concurrency: 5, diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index ce36fbc..1aa4c79 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -138,6 +138,7 @@ export interface AgentPodOpts { timeoutMs: number; pollMs: number; }; + skipWorkspaceBootstrap?: boolean; resources?: { agent?: k8s.V1ResourceRequirements; editor?: k8s.V1ResourceRequirements; @@ -251,6 +252,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { claudePrAttribution, useGvisor, userIdentity, + skipWorkspaceBootstrap, } = opts; const initScriptOpts: InitScriptOpts = { @@ -319,65 +321,69 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { type: 'RuntimeDefault', }, }, - initContainers: [ - { - name: 'prepare-workspace', - image, - imagePullPolicy: 'IfNotPresent', - command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], - resources, - securityContext: { - ...securityContext, - readOnlyRootFilesystem: false, - }, - volumeMounts: [ - { - name: 'workspace', - mountPath: AGENT_WORKSPACE_VOLUME_ROOT, - }, - { - name: 'tmp', - mountPath: '/tmp', - }, - ], - env: [ - { name: 'TMPDIR', value: '/tmp' }, - { name: 'TMP', value: '/tmp' }, - { name: 'TEMP', value: '/tmp' }, - ], - }, - { - name: 'init-workspace', - image, - imagePullPolicy: 'IfNotPresent', - command: ['sh', '-c', initScript], - resources, - securityContext: { - ...securityContext, - readOnlyRootFilesystem: false, - }, - volumeMounts: [ - workspaceVolumeMount, - { - name: 'claude-config', - mountPath: '/home/claude/.claude', - }, - { - name: 'tmp', - mountPath: '/tmp', - }, - ], - env: [ - { name: 'HOME', value: '/home/claude/.claude' }, - { name: 'TMPDIR', value: '/tmp' }, - { name: 'TMP', value: '/tmp' }, - { name: 'TEMP', value: '/tmp' }, - ...forwardedAgentSecretEnv, - ...githubTokenEnv, - ...userEnv, - ], - }, - ], + ...(skipWorkspaceBootstrap + ? {} + : { + initContainers: [ + { + name: 'prepare-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + { + name: 'workspace', + mountPath: AGENT_WORKSPACE_VOLUME_ROOT, + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + }, + { + name: 'init-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', initScript], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + workspaceVolumeMount, + { + name: 'claude-config', + mountPath: '/home/claude/.claude', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ...forwardedAgentSecretEnv, + ...githubTokenEnv, + ...userEnv, + ], + }, + ], + }), containers: [ { name: 'agent', diff --git a/src/server/lib/agentSession/prewarmJobFactory.ts b/src/server/lib/agentSession/prewarmJobFactory.ts new file mode 100644 index 0000000..8594b50 --- /dev/null +++ b/src/server/lib/agentSession/prewarmJobFactory.ts @@ -0,0 +1,271 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as k8s from '@kubernetes/client-node'; +import { getLogger } from 'server/lib/logger'; +import { generateInitScript, InitScriptOpts } from './configSeeder'; +import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; +import { buildPodEnvWithSecrets } from 'server/lib/secretEnvBuilder'; +import type { SecretRefWithEnvKey } from 'server/lib/secretRefs'; +import { AGENT_WORKSPACE_SUBPATH } from './workspace'; +import { JobMonitor } from 'server/lib/kubernetes/JobMonitor'; + +const AGENT_WORKSPACE_VOLUME_ROOT = '/workspace-volume'; +const DEFAULT_PREWARM_TIMEOUT_SECONDS = 30 * 60; + +export interface AgentPrewarmJobOpts { + jobName: string; + namespace: string; + pvcName: string; + image: string; + apiKeySecretName: string; + hasGitHubToken?: boolean; + repoUrl: string; + branch: string; + revision?: string; + workspacePath: string; + installCommand?: string; + forwardedAgentEnv?: Record; + forwardedAgentSecretRefs?: SecretRefWithEnvKey[]; + forwardedAgentSecretServiceName?: string; + buildUuid?: string; + nodeSelector?: Record; + serviceAccountName?: string; + resources?: k8s.V1ResourceRequirements; + timeoutSeconds?: number; +} + +function buildWorkspaceVolumeMount(workspacePath: string): k8s.V1VolumeMount { + return { + name: 'workspace', + mountPath: workspacePath, + subPath: AGENT_WORKSPACE_SUBPATH, + }; +} + +function getBatchApi(): k8s.BatchV1Api { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + return kc.makeApiClient(k8s.BatchV1Api); +} + +export function buildAgentPrewarmJobSpec(opts: AgentPrewarmJobOpts): k8s.V1Job { + const initScriptOpts: InitScriptOpts = { + repoUrl: opts.repoUrl, + branch: opts.branch, + revision: opts.revision, + workspacePath: opts.workspacePath, + installCommand: opts.installCommand, + useGitHubToken: opts.hasGitHubToken, + }; + + const workspaceVolumeMount = buildWorkspaceVolumeMount(opts.workspacePath); + const forwardedAgentEnv = opts.forwardedAgentEnv || {}; + const forwardedAgentSecretEnv = buildPodEnvWithSecrets( + forwardedAgentEnv, + opts.forwardedAgentSecretRefs || [], + opts.forwardedAgentSecretServiceName || opts.jobName, + opts.apiKeySecretName + ); + const githubTokenEnv: k8s.V1EnvVar[] = opts.hasGitHubToken + ? [ + { + name: 'GITHUB_TOKEN', + valueFrom: { + secretKeyRef: { + name: opts.apiKeySecretName, + key: 'GITHUB_TOKEN', + }, + }, + }, + { + name: 'GH_TOKEN', + valueFrom: { + secretKeyRef: { + name: opts.apiKeySecretName, + key: 'GITHUB_TOKEN', + }, + }, + }, + ] + : []; + + const securityContext: k8s.V1SecurityContext = { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + readOnlyRootFilesystem: true, + allowPrivilegeEscalation: false, + capabilities: { + drop: ['ALL'], + }, + }; + + return { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { + name: opts.jobName, + namespace: opts.namespace, + labels: { + ...buildLifecycleLabels({ buildUuid: opts.buildUuid }), + 'app.kubernetes.io/component': 'agent-session-prewarm', + 'app.kubernetes.io/name': opts.jobName, + }, + }, + spec: { + backoffLimit: 0, + ttlSecondsAfterFinished: 24 * 60 * 60, + activeDeadlineSeconds: opts.timeoutSeconds || DEFAULT_PREWARM_TIMEOUT_SECONDS, + template: { + metadata: { + labels: { + ...buildLifecycleLabels({ buildUuid: opts.buildUuid }), + 'app.kubernetes.io/component': 'agent-session-prewarm', + 'job-name': opts.jobName, + }, + }, + spec: { + ...(opts.nodeSelector ? { nodeSelector: opts.nodeSelector } : {}), + ...(opts.serviceAccountName ? { serviceAccountName: opts.serviceAccountName } : {}), + restartPolicy: 'Never', + securityContext: { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + fsGroup: 1000, + seccompProfile: { + type: 'RuntimeDefault', + }, + }, + initContainers: [ + { + name: 'prepare-workspace', + image: opts.image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], + resources: opts.resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + { + name: 'workspace', + mountPath: AGENT_WORKSPACE_VOLUME_ROOT, + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + }, + { + name: 'init-workspace', + image: opts.image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', generateInitScript(initScriptOpts)], + resources: opts.resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + workspaceVolumeMount, + { + name: 'claude-config', + mountPath: '/home/claude/.claude', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ...forwardedAgentSecretEnv, + ...githubTokenEnv, + ], + }, + ], + containers: [ + { + name: 'complete', + image: opts.image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', 'echo "agent prewarm complete"'], + resources: opts.resources, + securityContext, + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + volumeMounts: [ + workspaceVolumeMount, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + }, + ], + volumes: [ + { + name: 'workspace', + persistentVolumeClaim: { + claimName: opts.pvcName, + }, + }, + { + name: 'claude-config', + emptyDir: {}, + }, + { + name: 'tmp', + emptyDir: {}, + }, + ], + }, + }, + }, + }; +} + +export async function createAgentPrewarmJob(opts: AgentPrewarmJobOpts): Promise { + const batchApi = getBatchApi(); + const job = buildAgentPrewarmJobSpec(opts); + const { body } = await batchApi.createNamespacedJob(opts.namespace, job); + getLogger().info(`agentPrewarmJobFactory: created Job name=${opts.jobName} namespace=${opts.namespace}`); + return body; +} + +export async function monitorAgentPrewarmJob(jobName: string, namespace: string, timeoutSeconds?: number) { + const monitor = new JobMonitor(jobName, namespace); + return monitor.waitForCompletion({ + timeoutSeconds: timeoutSeconds || DEFAULT_PREWARM_TIMEOUT_SECONDS, + containerFilters: ['complete'], + logPrefix: 'agent-prewarm', + }); +} diff --git a/src/server/lib/jsonschema/schemas/1.0.0.json b/src/server/lib/jsonschema/schemas/1.0.0.json index 055eade..ce94433 100644 --- a/src/server/lib/jsonschema/schemas/1.0.0.json +++ b/src/server/lib/jsonschema/schemas/1.0.0.json @@ -203,6 +203,18 @@ } } } + }, + "prewarm": { + "type": "object", + "additionalProperties": false, + "properties": { + "services": { + "type": "array", + "items": { + "type": "string" + } + } + } } } } diff --git a/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts b/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts new file mode 100644 index 0000000..1ab14fb --- /dev/null +++ b/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts @@ -0,0 +1,212 @@ +/** + * Copyright 2025 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var mockListNamespacedDeployment: jest.Mock; +var mockListNamespacedStatefulSet: jest.Mock; +var mockListNamespacedPod: jest.Mock; + +jest.mock('@kubernetes/client-node', () => { + const actual = jest.requireActual('@kubernetes/client-node'); + mockListNamespacedDeployment = jest.fn(); + mockListNamespacedStatefulSet = jest.fn(); + mockListNamespacedPod = jest.fn(); + + const appsClient = { + listNamespacedDeployment: mockListNamespacedDeployment, + listNamespacedStatefulSet: mockListNamespacedStatefulSet, + }; + const coreClient = { + listNamespacedPod: mockListNamespacedPod, + }; + + return { + ...actual, + KubeConfig: jest.fn().mockImplementation(() => ({ + loadFromCluster: jest.fn(), + loadFromDefault: jest.fn(), + makeApiClient: jest.fn().mockImplementation((client: unknown) => { + if (client === actual.AppsV1Api) { + return appsClient; + } + + if (client === actual.CoreV1Api) { + return coreClient; + } + + return {}; + }), + })), + }; +}); + +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +import { getDeploymentPods } from '../getDeploymentPods'; + +function buildPod({ + name, + createdAt, + phase = 'Running', + deletionTimestamp, + containerStatuses = [ + { + name: 'app', + ready: true, + restartCount: 0, + state: { running: {} }, + }, + ], +}: { + name: string; + createdAt: string; + phase?: string; + deletionTimestamp?: string; + containerStatuses?: Array>; +}) { + return { + metadata: { + name, + creationTimestamp: createdAt, + deletionTimestamp, + }, + spec: { + containers: [ + { + name: 'app', + image: 'sample-image', + }, + ], + }, + status: { + phase, + containerStatuses, + }, + }; +} + +describe('getDeploymentPods', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockListNamespacedDeployment.mockResolvedValue({ + body: { + items: [ + { + spec: { + selector: { + matchLabels: { + app: 'sample-service', + }, + }, + }, + }, + ], + }, + }); + mockListNamespacedStatefulSet.mockResolvedValue({ + body: { items: [] }, + }); + }); + + it('filters terminated pods and keeps newest active pods first', async () => { + mockListNamespacedPod.mockResolvedValue({ + body: { + items: [ + buildPod({ + name: 'active-new', + createdAt: '2026-03-27T19:00:00.000Z', + }), + buildPod({ + name: 'active-old', + createdAt: '2026-03-27T18:00:00.000Z', + }), + buildPod({ + name: 'failed-phase', + createdAt: '2026-03-27T17:00:00.000Z', + phase: 'Failed', + containerStatuses: [ + { + name: 'app', + ready: false, + restartCount: 1, + state: { terminated: { reason: 'Error' } }, + }, + ], + }), + buildPod({ + name: 'deleting', + createdAt: '2026-03-27T16:00:00.000Z', + deletionTimestamp: '2026-03-27T19:01:00.000Z', + }), + buildPod({ + name: 'all-terminated', + createdAt: '2026-03-27T15:00:00.000Z', + containerStatuses: [ + { + name: 'app', + ready: false, + restartCount: 0, + state: { terminated: { reason: 'Completed' } }, + }, + ], + }), + ], + }, + }); + + const pods = await getDeploymentPods('sample-service', 'sample-env'); + + expect(pods.map((pod) => pod.podName)).toEqual(['active-new', 'active-old']); + expect(pods[0]?.ready).toBe('1/1'); + expect(mockListNamespacedPod).toHaveBeenCalledWith( + 'env-sample-env', + undefined, + undefined, + undefined, + undefined, + 'app=sample-service' + ); + }); + + it('returns an empty list when every pod is terminal', async () => { + mockListNamespacedPod.mockResolvedValue({ + body: { + items: [ + buildPod({ + name: 'terminated', + createdAt: '2026-03-27T17:00:00.000Z', + phase: 'Succeeded', + containerStatuses: [ + { + name: 'app', + ready: false, + restartCount: 0, + state: { terminated: { reason: 'Completed' } }, + }, + ], + }), + ], + }, + }); + + await expect(getDeploymentPods('sample-service', 'sample-env')).resolves.toEqual([]); + }); +}); diff --git a/src/server/lib/kubernetes/getDeploymentPods.ts b/src/server/lib/kubernetes/getDeploymentPods.ts index 94c2d04..ce73c12 100644 --- a/src/server/lib/kubernetes/getDeploymentPods.ts +++ b/src/server/lib/kubernetes/getDeploymentPods.ts @@ -95,6 +95,20 @@ function podAgeSeconds(pod: k8s.V1Pod): number { return Math.max(0, Math.floor((Date.now() - new Date(created).getTime()) / 1000)); } +function isTerminalPod(pod: k8s.V1Pod): boolean { + if (pod.metadata?.deletionTimestamp) { + return true; + } + + const phase = pod.status?.phase; + if (phase === 'Succeeded' || phase === 'Failed') { + return true; + } + + const appContainerStatuses = pod.status?.containerStatuses ?? []; + return appContainerStatuses.length > 0 && appContainerStatuses.every((status) => Boolean(status.state?.terminated)); +} + function containerState(cs?: k8s.V1ContainerStatus): { state: ContainerState; reason?: string } { if (!cs) return { state: 'Unknown' }; @@ -207,26 +221,28 @@ export async function getDeploymentPods(deploymentName: string, uuid: string): P labelSelector ); - const pods = podResp.body.items ?? []; + const pods = (podResp.body.items ?? []).filter((pod) => !isTerminalPod(pod)); if (pods.length === 0) { return []; } - return pods.map((pod) => { - const ageSeconds = podAgeSeconds(pod); - const containers = extractContainers(pod); - - return { - podName: pod.metadata?.name ?? '', - status: podStatus(pod), - restarts: podRestarts(pod), - ageSeconds, - age: formatAge(ageSeconds), - ready: podReady(pod), - containers, - }; - }); + return pods + .map((pod) => { + const ageSeconds = podAgeSeconds(pod); + const containers = extractContainers(pod); + + return { + podName: pod.metadata?.name ?? '', + status: podStatus(pod), + restarts: podRestarts(pod), + ageSeconds, + age: formatAge(ageSeconds), + ready: podReady(pod), + containers, + }; + }) + .sort((left, right) => left.ageSeconds - right.ageSeconds); } catch (error) { getLogger().error({ error }, `K8s: failed to list workload pods service=${deploymentName}`); throw error; diff --git a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts index 5b3cc47..c93f1dc 100644 --- a/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts +++ b/src/server/lib/yamlSchemas/schema_1_0_0/schema_1_0_0.ts @@ -37,6 +37,17 @@ const agentSessionResources = { }, }; +const agentSessionPrewarm = { + type: 'object', + additionalProperties: false, + properties: { + services: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; + const agentSessionReadiness = { type: 'object', additionalProperties: false, @@ -65,6 +76,7 @@ const schema_1_0_0 = { additionalProperties: false, properties: { resources: agentSessionResources, + prewarm: agentSessionPrewarm, }, }, defaultServices: { diff --git a/src/server/models/AgentPrewarm.ts b/src/server/models/AgentPrewarm.ts new file mode 100644 index 0000000..2ad201b --- /dev/null +++ b/src/server/models/AgentPrewarm.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Model from './_Model'; + +export type AgentPrewarmStatus = 'queued' | 'running' | 'ready' | 'error'; + +export default class AgentPrewarm extends Model { + uuid!: string; + buildUuid!: string; + namespace!: string; + repo!: string | null; + branch!: string | null; + revision!: string | null; + pvcName!: string; + jobName!: string; + status!: AgentPrewarmStatus; + services!: string[]; + errorMessage!: string | null; + completedAt!: string | null; + + static tableName = 'agent_prewarms'; + static timestamps = true; + static idColumn = 'id'; + + static jsonSchema = { + type: 'object', + required: ['uuid', 'buildUuid', 'namespace', 'pvcName', 'jobName', 'status'], + properties: { + id: { type: 'integer' }, + uuid: { + type: 'string', + pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + }, + buildUuid: { type: 'string' }, + namespace: { type: 'string' }, + repo: { type: ['string', 'null'] }, + branch: { type: ['string', 'null'] }, + revision: { type: ['string', 'null'] }, + pvcName: { type: 'string' }, + jobName: { type: 'string' }, + status: { type: 'string', enum: ['queued', 'running', 'ready', 'error'], default: 'queued' }, + services: { type: 'array', items: { type: 'string' }, default: [] }, + errorMessage: { type: ['string', 'null'] }, + completedAt: { type: ['string', 'null'] }, + }, + }; + + static get jsonAttributes() { + return ['services']; + } +} diff --git a/src/server/models/index.ts b/src/server/models/index.ts index 6d00684..483a7aa 100644 --- a/src/server/models/index.ts +++ b/src/server/models/index.ts @@ -33,6 +33,7 @@ import ConversationMessage from './ConversationMessage'; import MessageFeedback from './MessageFeedback'; import ConversationFeedback from './ConversationFeedback'; import AgentSession from './AgentSession'; +import AgentPrewarm from './AgentPrewarm'; import UserApiKey from './UserApiKey'; export interface IModels { @@ -55,6 +56,7 @@ export interface IModels { MessageFeedback: typeof MessageFeedback; ConversationFeedback: typeof ConversationFeedback; AgentSession: typeof AgentSession; + AgentPrewarm: typeof AgentPrewarm; UserApiKey: typeof UserApiKey; } @@ -78,5 +80,6 @@ export { MessageFeedback, ConversationFeedback, AgentSession, + AgentPrewarm, UserApiKey, }; diff --git a/src/server/models/yaml/YamlEnvironment.ts b/src/server/models/yaml/YamlEnvironment.ts index 3e30f34..3f98fbe 100644 --- a/src/server/models/yaml/YamlEnvironment.ts +++ b/src/server/models/yaml/YamlEnvironment.ts @@ -26,6 +26,9 @@ export interface AgentSessionEnvironmentConfig { readonly agent?: AgentSessionEnvironmentResourceRequirements; readonly editor?: AgentSessionEnvironmentResourceRequirements; }; + readonly prewarm?: { + readonly services?: string[]; + }; } export interface Environment { diff --git a/src/server/models/yaml/tests/YamlService.test.ts b/src/server/models/yaml/tests/YamlService.test.ts index fb122dd..387e14f 100644 --- a/src/server/models/yaml/tests/YamlService.test.ts +++ b/src/server/models/yaml/tests/YamlService.test.ts @@ -139,6 +139,44 @@ services: expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); }); + test('accepts environment-level agent session prewarm service lists', () => { + const parser = new YamlConfigParser(); + const config = parser.parseYamlConfigFromString(`--- +version: '1.0.0' +environment: + agentSession: + prewarm: + services: + - agent-app + - api +services: + - name: 'agent-app' + dev: + image: 'node:20-slim' + command: 'pnpm dev' + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'apps/agent-app/Dockerfile' + - name: 'api' + dev: + image: 'node:20-slim' + command: 'pnpm dev:api' + github: + repository: 'org/example' + branchName: 'main' + docker: + defaultTag: 'main' + app: + dockerfilePath: 'apps/api/Dockerfile' +`); + + expect(() => new YamlConfigValidator().validate_1_0_0(config)).not.toThrow(); + }); + test('accepts service-level agent session readiness overrides in dev config', () => { const parser = new YamlConfigParser(); const config = parser.parseYamlConfigFromString(`--- diff --git a/src/server/models/yaml/types.ts b/src/server/models/yaml/types.ts index ebfc729..1946958 100644 --- a/src/server/models/yaml/types.ts +++ b/src/server/models/yaml/types.ts @@ -32,6 +32,9 @@ export type LifecycleYamlConfigEnvironment = { limits?: Record; }; }; + prewarm?: { + services?: string[]; + }; }; }; diff --git a/src/server/services/__tests__/agentPrewarm.test.ts b/src/server/services/__tests__/agentPrewarm.test.ts new file mode 100644 index 0000000..3e8cdb6 --- /dev/null +++ b/src/server/services/__tests__/agentPrewarm.test.ts @@ -0,0 +1,296 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('server/lib/dependencies', () => ({})); +jest.mock('server/models/AgentPrewarm'); +jest.mock('server/models/AgentSession'); +jest.mock('server/models/Build'); +jest.mock('server/models/yaml', () => ({ + fetchLifecycleConfig: jest.fn(), +})); +jest.mock('server/lib/agentSession/pvcFactory', () => ({ + createAgentPvc: jest.fn(), + deleteAgentPvc: jest.fn(), +})); +jest.mock('../agentSessionCandidates', () => ({ + resolveAgentSessionServiceCandidates: jest.fn(), + resolveRequestedAgentSessionServices: jest.fn(), +})); +jest.mock('server/lib/logger', () => ({ + getLogger: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + })), + extractContextForQueue: jest.fn(() => ({ requestId: 'req-123' })), +})); + +import AgentPrewarm from 'server/models/AgentPrewarm'; +import AgentSession from 'server/models/AgentSession'; +import Build from 'server/models/Build'; +import { fetchLifecycleConfig } from 'server/models/yaml'; +import AgentPrewarmService from 'server/services/agentPrewarm'; +import { deleteAgentPvc } from 'server/lib/agentSession/pvcFactory'; +import { resolveAgentSessionServiceCandidates, resolveRequestedAgentSessionServices } from '../agentSessionCandidates'; + +describe('AgentPrewarmService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (AgentPrewarm.query as jest.Mock) = jest.fn(); + (AgentSession.query as jest.Mock) = jest.fn(); + (deleteAgentPvc as jest.Mock).mockResolvedValue(undefined); + }); + + describe('canReusePrewarm', () => { + it('allows requested services that are a subset of the prewarm service list', () => { + expect(AgentPrewarmService.canReusePrewarm(['web', 'api'], ['web'])).toBe(true); + }); + + it('rejects requested services that are not fully covered by the prewarm service list', () => { + expect(AgentPrewarmService.canReusePrewarm(['web'], ['web', 'api'])).toBe(false); + }); + }); + + describe('queueBuildPrewarm', () => { + it('does not enqueue when a matching active prewarm already exists', async () => { + const queueAdd = jest.fn(); + const queueManager = { + registerQueue: jest.fn().mockReturnValue({ add: queueAdd }), + }; + const service = new AgentPrewarmService({} as any, {} as any, {} as any, queueManager as any); + jest.spyOn(service as any, 'resolveBuildPrewarmPlan').mockResolvedValue({ + buildUuid: 'build-123', + namespace: 'env-sample', + repo: 'example-org/example-repo', + repoUrl: 'https://github.com/example-org/example-repo.git', + branch: 'sample-branch', + revision: 'sha-123', + configuredServiceNames: ['api', 'web'], + services: [], + }); + (AgentPrewarm.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue([ + { + revision: 'sha-123', + services: ['web', 'api'], + status: 'ready', + }, + ]), + }); + + await expect(service.queueBuildPrewarm('build-123')).resolves.toBe(false); + expect(queueAdd).not.toHaveBeenCalled(); + }); + + it('enqueues when no active prewarm matches the configured services', async () => { + const queueAdd = jest.fn().mockResolvedValue(undefined); + const queueManager = { + registerQueue: jest.fn().mockReturnValue({ add: queueAdd }), + }; + const service = new AgentPrewarmService({} as any, {} as any, {} as any, queueManager as any); + jest.spyOn(service as any, 'resolveBuildPrewarmPlan').mockResolvedValue({ + buildUuid: 'build-123', + namespace: 'env-sample', + repo: 'example-org/example-repo', + repoUrl: 'https://github.com/example-org/example-repo.git', + branch: 'sample-branch', + revision: 'sha-123', + configuredServiceNames: ['api', 'web'], + services: [], + }); + (AgentPrewarm.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue([]), + }); + + await expect(service.queueBuildPrewarm('build-123')).resolves.toBe(true); + expect(queueAdd).toHaveBeenCalledWith( + 'prewarm', + expect.objectContaining({ + buildUuid: 'build-123', + requestId: 'req-123', + }), + { + jobId: 'agent-prewarm:build-123:sha-123:api,web', + } + ); + }); + }); + + describe('resolveBuildPrewarmPlan', () => { + it('uses pullRequest.latestCommit instead of the synthetic build sha for revision pinning', async () => { + const queueManager = { + registerQueue: jest.fn().mockReturnValue({ add: jest.fn() }), + }; + const service = new AgentPrewarmService({} as any, {} as any, {} as any, queueManager as any); + const build = { + kind: 'environment', + namespace: 'env-sample', + sha: '1b9337', + pullRequest: { + fullName: 'example-org/example-repo', + branchName: 'sample-branch', + latestCommit: '0123456789abcdef0123456789abcdef01234567', + }, + deploys: [], + }; + + (Build.query as jest.Mock).mockReturnValue({ + findOne: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue(build), + }), + }); + (fetchLifecycleConfig as jest.Mock).mockResolvedValue({ + environment: { + agentSession: { + prewarm: { + services: ['web'], + }, + }, + }, + }); + (resolveAgentSessionServiceCandidates as jest.Mock).mockReturnValue([ + { name: 'web', deployId: 1, devConfig: { command: 'pnpm dev' } }, + ]); + (resolveRequestedAgentSessionServices as jest.Mock).mockReturnValue([ + { name: 'web', deployId: 1, devConfig: { command: 'pnpm dev' } }, + ]); + + const plan = await (service as any).resolveBuildPrewarmPlan('build-123'); + + expect(plan?.revision).toBe('0123456789abcdef0123456789abcdef01234567'); + expect(plan?.revision).not.toBe('1b9337'); + }); + }); + + describe('getReadyPrewarmByPvc', () => { + it('only preserves the latest ready prewarm pvc for a build', async () => { + (AgentPrewarm.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue([ + { + uuid: 'prewarm-new', + pvcName: 'agent-prewarm-pvc-new', + status: 'ready', + }, + { + uuid: 'prewarm-old', + pvcName: 'agent-prewarm-pvc-old', + status: 'ready', + }, + ]), + }); + const service = new AgentPrewarmService( + {} as any, + {} as any, + {} as any, + { + registerQueue: jest.fn().mockReturnValue({ add: jest.fn() }), + } as any + ); + + await expect( + service.getReadyPrewarmByPvc({ buildUuid: 'build-123', pvcName: 'agent-prewarm-pvc-old' }) + ).resolves.toBeNull(); + await expect( + service.getReadyPrewarmByPvc({ buildUuid: 'build-123', pvcName: 'agent-prewarm-pvc-new' }) + ).resolves.toEqual( + expect.objectContaining({ + uuid: 'prewarm-new', + pvcName: 'agent-prewarm-pvc-new', + }) + ); + }); + }); + + describe('cleanupSupersededPrewarms', () => { + it('deletes older prewarm pvcs that are not in use by active sessions', async () => { + const deleteById = jest.fn().mockResolvedValue(1); + (AgentPrewarm.query as jest.Mock) + .mockReturnValueOnce({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue([ + { id: 20, uuid: 'prewarm-new', pvcName: 'agent-prewarm-pvc-new', status: 'ready' }, + { id: 10, uuid: 'prewarm-old', pvcName: 'agent-prewarm-pvc-old', status: 'ready' }, + ]), + }) + .mockReturnValueOnce({ + deleteById, + }); + (AgentSession.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockResolvedValue([]), + }); + const service = new AgentPrewarmService( + {} as any, + {} as any, + {} as any, + { + registerQueue: jest.fn().mockReturnValue({ add: jest.fn() }), + } as any + ); + + await (service as any).cleanupSupersededPrewarms( + { buildUuid: 'build-123', namespace: 'env-sample' }, + { id: 20, uuid: 'prewarm-new', pvcName: 'agent-prewarm-pvc-new' } + ); + + expect(deleteAgentPvc).toHaveBeenCalledWith('env-sample', 'agent-prewarm-pvc-old'); + expect(deleteById).toHaveBeenCalledWith(10); + }); + + it('keeps older prewarm pvcs that are still in use by active sessions', async () => { + (AgentPrewarm.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue([ + { id: 20, uuid: 'prewarm-new', pvcName: 'agent-prewarm-pvc-new', status: 'ready' }, + { id: 10, uuid: 'prewarm-old', pvcName: 'agent-prewarm-pvc-old', status: 'ready' }, + ]), + }); + (AgentSession.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnThis(), + whereIn: jest.fn().mockResolvedValue([ + { + pvcName: 'agent-prewarm-pvc-old', + status: 'active', + }, + ]), + }); + const service = new AgentPrewarmService( + {} as any, + {} as any, + {} as any, + { + registerQueue: jest.fn().mockReturnValue({ add: jest.fn() }), + } as any + ); + + await (service as any).cleanupSupersededPrewarms( + { buildUuid: 'build-123', namespace: 'env-sample' }, + { id: 20, uuid: 'prewarm-new', pvcName: 'agent-prewarm-pvc-new' } + ); + + expect(deleteAgentPvc).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index fe28d73..f827204 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -18,6 +18,9 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); +const mockGetCompatibleReadyPrewarm = jest.fn(); +const mockGetReadyPrewarmByPvc = jest.fn(); + jest.mock('server/models/AgentSession'); jest.mock('server/models/Build'); jest.mock('server/models/Deploy'); @@ -33,6 +36,13 @@ jest.mock('server/lib/agentSession/devModeManager'); jest.mock('server/lib/agentSession/forwardedEnv'); jest.mock('server/lib/kubernetes/networkPolicyFactory'); jest.mock('server/services/userApiKey'); +jest.mock('server/services/agentPrewarm', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + getCompatibleReadyPrewarm: mockGetCompatibleReadyPrewarm, + getReadyPrewarmByPvc: mockGetReadyPrewarmByPvc, + })), +})); jest.mock('server/lib/nativeHelm/helm', () => ({ deployHelm: jest.fn().mockResolvedValue(undefined), })); @@ -283,6 +293,8 @@ describe('AgentSessionService', () => { })); mockedBuildServiceModule.deleteQueueAdd.mockResolvedValue(undefined); mockedBuildServiceModule.deleteBuild.mockResolvedValue(undefined); + mockGetCompatibleReadyPrewarm.mockResolvedValue(null); + mockGetReadyPrewarmByPvc.mockResolvedValue(null); (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ env: {}, secretRefs: [], @@ -389,6 +401,64 @@ describe('AgentSessionService', () => { expect(session.status).toBe('active'); }); + it('reuses a compatible ready prewarm PVC and skips workspace bootstrap', async () => { + mockGetCompatibleReadyPrewarm.mockResolvedValue({ + uuid: 'prewarm-1', + pvcName: 'agent-prewarm-pvc-1234', + services: ['web'], + status: 'ready', + }); + + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + buildUuid: 'build-123', + services: [{ name: 'web', deployId: 1, devConfig: { image: 'node:20', command: 'pnpm dev' } }], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(mockGetCompatibleReadyPrewarm).toHaveBeenCalledWith({ + buildUuid: 'build-123', + requestedServices: ['web'], + revision: undefined, + }); + expect(createAgentPvc).not.toHaveBeenCalled(); + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + pvcName: 'agent-prewarm-pvc-1234', + skipWorkspaceBootstrap: true, + }) + ); + expect(mockSessionQuery.insertAndFetch).toHaveBeenCalledWith( + expect.objectContaining({ + pvcName: 'agent-prewarm-pvc-1234', + }) + ); + }); + + it('falls back to the cold path when no compatible prewarm is available', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + buildUuid: 'build-123', + services: [{ name: 'api', deployId: 2, devConfig: { image: 'node:20', command: 'pnpm dev' } }], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(mockGetCompatibleReadyPrewarm).toHaveBeenCalledWith({ + buildUuid: 'build-123', + requestedServices: ['api'], + revision: undefined, + }); + expect(createAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa', '10Gi', 'build-123'); + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + pvcName: 'agent-pvc-aaaaaaaa', + skipWorkspaceBootstrap: false, + }) + ); + }); + it('passes resolved agent-session resources through to pod creation when provided', async () => { const optsWithResources: CreateSessionOptions = { ...baseOpts, @@ -708,11 +778,15 @@ describe('AgentSessionService', () => { 3600, expect.any(String) ); - expect(deleteAgentEditorService).not.toHaveBeenCalled(); - expect(deleteAgentPod).not.toHaveBeenCalled(); - expect(deleteAgentPvc).not.toHaveBeenCalled(); - expect(deleteAgentApiKeySecret).not.toHaveBeenCalled(); - expect(cleanupForwardedAgentEnvSecrets).not.toHaveBeenCalled(); + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(cleanupForwardedAgentEnvSecrets).toHaveBeenCalledWith( + 'test-ns', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + [] + ); expect(mockSessionQuery.patch).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' })); }); @@ -810,10 +884,10 @@ describe('AgentSessionService', () => { releaseDeploy(); await expect(rollbackPromise).rejects.toThrow('snapshot persist failed'); - expect(deleteAgentPod).not.toHaveBeenCalled(); - expect(deleteAgentEditorService).not.toHaveBeenCalled(); - expect(deleteAgentApiKeySecret).not.toHaveBeenCalled(); - expect(deleteAgentPvc).not.toHaveBeenCalled(); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentEditorService).toHaveBeenCalledWith('test-ns', 'agent-aaaaaaaa'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(deleteAgentPvc).toHaveBeenCalledWith('test-ns', 'agent-pvc-aaaaaaaa'); }); }); @@ -839,6 +913,7 @@ describe('AgentSessionService', () => { id: 1, uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', status: 'active', + buildUuid: null, namespace: 'test-ns', podName: 'agent-sess1', pvcName: 'agent-pvc-sess1', @@ -924,17 +999,71 @@ describe('AgentSessionService', () => { expect(mockRedis.del).toHaveBeenCalledWith('lifecycle:agent:session:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); }); + it('preserves a reused prewarm PVC when ending the session', async () => { + mockGetReadyPrewarmByPvc.mockResolvedValue({ + uuid: 'prewarm-1', + pvcName: 'agent-prewarm-pvc-1234', + status: 'ready', + }); + + const activeSession = { + id: 1, + uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + status: 'active', + buildUuid: 'build-123', + namespace: 'test-ns', + podName: 'agent-sess1', + pvcName: 'agent-prewarm-pvc-1234', + forwardedAgentSecretProviders: [], + devModeSnapshots: {}, + }; + + const patchMock = jest.fn().mockResolvedValue(1); + + let agentQueryCount = 0; + (AgentSession.query as jest.Mock) = jest.fn().mockImplementation(() => { + agentQueryCount++; + if (agentQueryCount === 1) { + return { findOne: jest.fn().mockResolvedValue(activeSession) }; + } + return { findById: jest.fn().mockReturnValue({ patch: patchMock }) }; + }); + + (Build.query as jest.Mock) = jest.fn().mockReturnValue({ + findOne: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue({ kind: 'environment' }), + }), + }); + + (Deploy.query as jest.Mock) = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + withGraphFetched: jest.fn().mockResolvedValue([]), + }), + }); + + await AgentSessionService.endSession('sess-1'); + + expect(mockGetReadyPrewarmByPvc).toHaveBeenCalledWith({ + buildUuid: 'build-123', + pvcName: 'agent-prewarm-pvc-1234', + }); + expect(deleteAgentPvc).not.toHaveBeenCalled(); + expect(deleteAgentPod).toHaveBeenCalledWith('test-ns', 'agent-sess1'); + expect(deleteAgentApiKeySecret).toHaveBeenCalledWith('test-ns', 'agent-secret-aaaaaaaa'); + expect(patchMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'ended', devModeSnapshots: {} })); + }); + it('cleans up a failed session when explicitly ended', async () => { const failedSession = { id: 1, uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', status: 'error', + buildUuid: null, namespace: 'test-ns', podName: 'agent-sess1', pvcName: 'agent-pvc-sess1', forwardedAgentSecretProviders: ['aws'], devModeSnapshots: {}, - buildUuid: null, }; (AgentSession.query as jest.Mock) = jest .fn() diff --git a/src/server/services/agentPrewarm.ts b/src/server/services/agentPrewarm.ts new file mode 100644 index 0000000..582c171 --- /dev/null +++ b/src/server/services/agentPrewarm.ts @@ -0,0 +1,430 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'server/lib/dependencies'; +import * as k8s from '@kubernetes/client-node'; +import { v4 as uuid } from 'uuid'; +import BaseService from './_service'; +import AgentPrewarm from 'server/models/AgentPrewarm'; +import AgentSession from 'server/models/AgentSession'; +import Build from 'server/models/Build'; +import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFactory'; +import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; +import { ensureAgentSessionServiceAccount } from 'server/lib/agentSession/serviceAccountFactory'; +import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; +import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; +import { createAgentPrewarmJob, monitorAgentPrewarmJob } from 'server/lib/agentSession/prewarmJobFactory'; +import { + resolveAgentSessionRuntimeConfig, + type AgentSessionRuntimeConfig, +} from 'server/lib/agentSession/runtimeConfig'; +import { extractContextForQueue, getLogger } from 'server/lib/logger'; +import type { LifecycleConfig } from 'server/models/yaml'; +import { fetchLifecycleConfig } from 'server/models/yaml'; +import type { DevConfig } from 'server/models/yaml/YamlService'; +import { BuildKind } from 'shared/constants'; +import { QUEUE_NAMES } from 'shared/config'; +import GlobalConfigService from './globalConfig'; +import { resolveAgentSessionServiceCandidates, resolveRequestedAgentSessionServices } from './agentSessionCandidates'; + +const logger = getLogger(); +const AGENT_PREWARM_ERROR_MESSAGE_MAX_LENGTH = 4000; + +type ResolvedPrewarmService = { + name: string; + deployId: number; + devConfig: DevConfig; +}; + +type ResolvedBuildPrewarmPlan = { + buildUuid: string; + namespace: string; + repo: string; + repoUrl: string; + branch: string; + revision?: string; + configuredServiceNames: string[]; + services: ResolvedPrewarmService[]; +}; + +export interface AgentPrewarmQueueJob { + buildUuid: string; +} + +function normalizeServiceNames(serviceNames: Array): string[] { + return [...new Set(serviceNames.map((serviceName) => serviceName?.trim()).filter(Boolean) as string[])].sort( + (left, right) => left.localeCompare(right) + ); +} + +function hasExactServiceMatch(prewarmServices: string[], requestedServices: string[]): boolean { + const normalizedPrewarm = normalizeServiceNames(prewarmServices); + const normalizedRequested = normalizeServiceNames(requestedServices); + return JSON.stringify(normalizedPrewarm) === JSON.stringify(normalizedRequested); +} + +export function canReusePrewarm(prewarmServices: string[], requestedServices: string[]): boolean { + const requested = normalizeServiceNames(requestedServices); + if (requested.length === 0) { + return true; + } + + const available = new Set(normalizeServiceNames(prewarmServices)); + return requested.every((serviceName) => available.has(serviceName)); +} + +function truncateErrorMessage(message: string): string { + if (message.length <= AGENT_PREWARM_ERROR_MESSAGE_MAX_LENGTH) { + return message; + } + + return `${message.slice(0, AGENT_PREWARM_ERROR_MESSAGE_MAX_LENGTH - 3)}...`; +} + +function buildCombinedInstallCommand(services: ResolvedPrewarmService[]): string | undefined { + const installCommands = services + .map((service) => service.devConfig.installCommand) + .filter((command): command is string => Boolean(command?.trim())); + + return installCommands.length > 0 ? installCommands.join('\n\n') : undefined; +} + +export default class AgentPrewarmService extends BaseService { + prewarmQueue = this.queueManager.registerQueue(QUEUE_NAMES.AGENT_SESSION_PREWARM, { + connection: this.redis, + defaultJobOptions: { + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, + }); + + static normalizeServiceNames(serviceNames: string[]): string[] { + return normalizeServiceNames(serviceNames); + } + + static canReusePrewarm(prewarmServices: string[], requestedServices: string[]): boolean { + return canReusePrewarm(prewarmServices, requestedServices); + } + + async getCompatibleReadyPrewarm(params: { + buildUuid: string; + requestedServices: string[]; + revision?: string; + }): Promise { + const prewarms = await AgentPrewarm.query() + .where({ + buildUuid: params.buildUuid, + status: 'ready', + }) + .orderBy('updatedAt', 'desc'); + + return ( + prewarms.find((prewarm) => { + if (params.revision && prewarm.revision && prewarm.revision !== params.revision) { + return false; + } + + return canReusePrewarm(prewarm.services || [], params.requestedServices); + }) || null + ); + } + + async getReadyPrewarmByPvc(params: { buildUuid: string; pvcName: string }): Promise { + const prewarms = await AgentPrewarm.query() + .where({ + buildUuid: params.buildUuid, + status: 'ready', + }) + .orderBy('updatedAt', 'desc'); + const matchingPrewarm = prewarms.find((prewarm) => prewarm.pvcName === params.pvcName) || null; + if (!matchingPrewarm) { + return null; + } + + return prewarms[0]?.uuid === matchingPrewarm.uuid ? matchingPrewarm : null; + } + + async queueBuildPrewarm(buildUuid: string): Promise { + const plan = await this.resolveBuildPrewarmPlan(buildUuid); + if (!plan) { + return false; + } + + const activePrewarms = await AgentPrewarm.query() + .where({ buildUuid: plan.buildUuid }) + .whereIn('status', ['queued', 'running', 'ready']) + .orderBy('updatedAt', 'desc'); + const matchingPrewarm = activePrewarms.find((prewarm) => { + const sameRevision = !plan.revision || !prewarm.revision || prewarm.revision === plan.revision; + return sameRevision && hasExactServiceMatch(prewarm.services || [], plan.configuredServiceNames); + }); + + if (matchingPrewarm) { + return false; + } + + await this.prewarmQueue.add( + 'prewarm', + { + buildUuid: plan.buildUuid, + ...extractContextForQueue(), + }, + { + jobId: `agent-prewarm:${plan.buildUuid}:${plan.revision || 'head'}:${plan.configuredServiceNames.join(',')}`, + } + ); + + return true; + } + + async prepareBuildPrewarm(buildUuid: string): Promise { + const plan = await this.resolveBuildPrewarmPlan(buildUuid); + if (!plan) { + return null; + } + + const existingPrewarm = await AgentPrewarm.query() + .where({ buildUuid: plan.buildUuid }) + .whereIn('status', ['running', 'ready']) + .orderBy('updatedAt', 'desc'); + const matchingPrewarm = existingPrewarm.find((prewarm) => { + const sameRevision = !plan.revision || !prewarm.revision || prewarm.revision === plan.revision; + return sameRevision && hasExactServiceMatch(prewarm.services || [], plan.configuredServiceNames); + }); + if (matchingPrewarm) { + return matchingPrewarm; + } + + const prewarmUuid = uuid(); + const pvcName = `agent-prewarm-pvc-${prewarmUuid.slice(0, 8)}`; + const jobName = `agent-prewarm-${prewarmUuid.slice(0, 8)}`; + const secretName = `agent-prewarm-secret-${prewarmUuid.slice(0, 8)}`; + const runtimeConfig = await resolveAgentSessionRuntimeConfig(); + const githubToken = await GlobalConfigService.getInstance() + .getGithubClientToken() + .catch((error) => { + logger.warn({ error, buildUuid }, 'Agent prewarm could not resolve lifecycle GitHub app token'); + return null; + }); + const forwardedAgentEnv = await resolveForwardedAgentEnv(plan.services, plan.namespace, prewarmUuid, buildUuid); + const forwardedPlainAgentEnv = Object.fromEntries( + Object.entries(forwardedAgentEnv.env).filter( + ([envKey]) => !forwardedAgentEnv.secretRefs.some((secretRef) => secretRef.envKey === envKey) + ) + ); + const serviceAccountName = await ensureAgentSessionServiceAccount(plan.namespace); + const installCommand = buildCombinedInstallCommand(plan.services); + + let prewarm = await AgentPrewarm.query().insertAndFetch({ + uuid: prewarmUuid, + buildUuid: plan.buildUuid, + namespace: plan.namespace, + repo: plan.repo, + branch: plan.branch, + revision: plan.revision || null, + pvcName, + jobName, + status: 'running', + services: plan.configuredServiceNames, + errorMessage: null, + } as unknown as Partial); + + try { + await createAgentPvc(plan.namespace, pvcName, '10Gi', plan.buildUuid).catch((error: unknown) => { + const httpError = error as k8s.HttpError; + if (httpError?.statusCode === 409 || httpError?.response?.statusCode === 409) { + return null; + } + throw error; + }); + + await createAgentApiKeySecret( + plan.namespace, + secretName, + '', + githubToken, + plan.buildUuid, + forwardedPlainAgentEnv + ); + + await createAgentPrewarmJob({ + jobName, + namespace: plan.namespace, + pvcName, + image: runtimeConfig.image, + apiKeySecretName: secretName, + hasGitHubToken: Boolean(githubToken), + repoUrl: plan.repoUrl, + branch: plan.branch, + revision: plan.revision, + workspacePath: AGENT_WORKSPACE_ROOT, + installCommand, + forwardedAgentEnv: forwardedAgentEnv.env, + forwardedAgentSecretRefs: forwardedAgentEnv.secretRefs, + forwardedAgentSecretServiceName: forwardedAgentEnv.secretServiceName, + buildUuid: plan.buildUuid, + nodeSelector: runtimeConfig.nodeSelector, + serviceAccountName, + resources: runtimeConfig.resources.agent as unknown as AgentSessionRuntimeConfig['resources']['agent'] & + k8s.V1ResourceRequirements, + }); + + const result = await monitorAgentPrewarmJob(jobName, plan.namespace); + if (!result.success) { + const failureMessage = result.logs?.trim() || 'Agent prewarm job failed'; + throw new Error(failureMessage); + } + + await AgentPrewarm.query() + .findById(prewarm.id) + .patch({ + status: 'ready', + completedAt: new Date().toISOString(), + errorMessage: null, + } as unknown as Partial); + + prewarm = { + ...prewarm, + status: 'ready', + completedAt: new Date().toISOString(), + errorMessage: null, + } as AgentPrewarm; + + await this.cleanupSupersededPrewarms(plan, prewarm); + + return prewarm; + } catch (error) { + const errorMessage = truncateErrorMessage(error instanceof Error ? error.message : String(error)); + await AgentPrewarm.query() + .findById(prewarm.id) + .patch({ + status: 'error', + errorMessage, + } as unknown as Partial) + .catch(() => {}); + throw error; + } finally { + await deleteAgentApiKeySecret(plan.namespace, secretName).catch((error) => { + logger.warn({ error, buildUuid, secretName }, 'Agent prewarm secret cleanup failed'); + }); + await cleanupForwardedAgentEnvSecrets(plan.namespace, prewarmUuid, forwardedAgentEnv.secretProviders).catch( + (error) => { + logger.warn({ error, buildUuid, prewarmUuid }, 'Agent prewarm forwarded env cleanup failed'); + } + ); + } + } + + private async resolveBuildPrewarmPlan(buildUuid: string): Promise { + const build = await Build.query() + .findOne({ uuid: buildUuid }) + .withGraphFetched('[pullRequest, deploys.[deployable]]'); + if ( + !build || + build.kind !== BuildKind.ENVIRONMENT || + !build.pullRequest?.fullName || + !build.pullRequest?.branchName + ) { + return null; + } + + const lifecycleConfig = await fetchLifecycleConfig(build.pullRequest.fullName, build.pullRequest.branchName); + return this.resolvePrewarmPlanFromConfig( + buildUuid, + build.namespace, + build.pullRequest.fullName, + build.pullRequest.latestCommit || undefined, + lifecycleConfig, + build + ); + } + + private resolvePrewarmPlanFromConfig( + buildUuid: string, + namespace: string, + repositoryFullName: string, + revision: string | undefined, + lifecycleConfig: LifecycleConfig | null, + build: Build + ): ResolvedBuildPrewarmPlan | null { + const configuredServiceNames = normalizeServiceNames( + lifecycleConfig?.environment?.agentSession?.prewarm?.services || [] + ); + if (configuredServiceNames.length === 0) { + return null; + } + + const candidates = resolveAgentSessionServiceCandidates(build.deploys || [], lifecycleConfig as LifecycleConfig); + const services = resolveRequestedAgentSessionServices(candidates, configuredServiceNames).map((service) => ({ + name: service.name, + deployId: service.deployId, + devConfig: service.devConfig, + })); + + return { + buildUuid, + namespace, + repo: repositoryFullName, + repoUrl: `https://github.com/${repositoryFullName}.git`, + branch: build.pullRequest.branchName, + revision: revision || undefined, + configuredServiceNames, + services, + }; + } + + private async cleanupSupersededPrewarms( + plan: Pick, + latestPrewarm: Pick + ): Promise { + const prewarms = await AgentPrewarm.query() + .where({ buildUuid: plan.buildUuid }) + .whereIn('status', ['ready', 'error']) + .orderBy('updatedAt', 'desc'); + const supersededPrewarms = prewarms.filter((prewarm) => prewarm.id !== latestPrewarm.id); + if (supersededPrewarms.length === 0) { + return; + } + + const activeSessions = await AgentSession.query() + .where({ buildUuid: plan.buildUuid }) + .whereIn('status', ['starting', 'active']); + const inUsePvcs = new Set(activeSessions.map((session) => session.pvcName).filter(Boolean)); + + for (const prewarm of supersededPrewarms) { + if (!prewarm.pvcName || inUsePvcs.has(prewarm.pvcName)) { + continue; + } + + await deleteAgentPvc(plan.namespace, prewarm.pvcName).catch((error) => { + logger.warn( + { error, buildUuid: plan.buildUuid, pvcName: prewarm.pvcName, prewarmUuid: prewarm.uuid }, + 'Superseded agent prewarm PVC cleanup failed' + ); + }); + + await AgentPrewarm.query() + .deleteById(prewarm.id) + .catch((error) => { + logger.warn( + { error, buildUuid: plan.buildUuid, pvcName: prewarm.pvcName, prewarmUuid: prewarm.uuid }, + 'Superseded agent prewarm record cleanup failed' + ); + }); + } + } +} diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index cd308cc..1d2222e 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -61,6 +61,7 @@ import { toPublicAgentSessionStartupFailure, } from 'server/lib/agentSession/startupFailureState'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; +import AgentPrewarmService from './agentPrewarm'; const logger = getLogger(); const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; @@ -289,6 +290,29 @@ async function deleteAgentRuntimeResources( ]); } +async function resolveCompatiblePrewarm(buildUuid: string | undefined, requestedServices: string[], revision?: string) { + if (!buildUuid) { + return null; + } + + return new AgentPrewarmService().getCompatibleReadyPrewarm({ + buildUuid, + requestedServices, + revision, + }); +} + +async function resolveSessionPrewarmByPvc(buildUuid: string | null, pvcName: string) { + if (!buildUuid) { + return null; + } + + return new AgentPrewarmService().getReadyPrewarmByPvc({ + buildUuid, + pvcName, + }); +} + function isUniqueConstraintError(error: unknown, constraintName: string): boolean { const knexError = error as { code?: string; constraint?: string }; return knexError?.code === '23505' && knexError?.constraint === constraintName; @@ -477,7 +501,6 @@ export default class AgentSessionService { const sessionUuid = uuid(); const buildKind = opts.buildKind || BuildKind.ENVIRONMENT; const podName = `agent-${sessionUuid.slice(0, 8)}`; - const pvcName = `agent-pvc-${sessionUuid.slice(0, 8)}`; const apiKeySecretName = `agent-secret-${sessionUuid.slice(0, 8)}`; const model = opts.model || 'claude-sonnet-4-6'; const mutatedDeploys: number[] = []; @@ -494,6 +517,9 @@ export default class AgentSessionService { ); const claudePrAttribution = renderAgentSessionClaudeAttribution(claudeConfig.attribution.prTemplate, githubAppName); const resolvedServices = await resolveTemplatedDevConfigEnvs(opts.buildUuid, opts.namespace, opts.services); + const resolvedServiceNames = (resolvedServices || []).map((service) => service.name); + const compatiblePrewarm = await resolveCompatiblePrewarm(opts.buildUuid, resolvedServiceNames, opts.revision); + const pvcName = compatiblePrewarm?.pvcName || `agent-pvc-${sessionUuid.slice(0, 8)}`; const forwardedAgentEnv = await resolveForwardedAgentEnv( resolvedServices, opts.namespace, @@ -524,7 +550,7 @@ export default class AgentSessionService { sessionPersisted = true; const [, , agentServiceAccountName] = await Promise.all([ - createAgentPvc(opts.namespace, pvcName, '10Gi', opts.buildUuid), + compatiblePrewarm ? Promise.resolve(null) : createAgentPvc(opts.namespace, pvcName, '10Gi', opts.buildUuid), createAgentApiKeySecret( opts.namespace, apiKeySecretName, @@ -568,6 +594,7 @@ export default class AgentSessionService { userIdentity: opts.userIdentity, nodeSelector: opts.nodeSelector, readiness: opts.readiness, + skipWorkspaceBootstrap: Boolean(compatiblePrewarm), serviceAccountName: agentServiceAccountName, resources: opts.resources, }); @@ -702,6 +729,12 @@ export default class AgentSessionService { await revertPromise; + await Promise.all([ + deleteAgentRuntimeResources(opts.namespace, podName, apiKeySecretName).catch(() => {}), + cleanupForwardedAgentEnvSecrets(opts.namespace, sessionUuid, forwardedAgentEnv.secretProviders).catch(() => {}), + compatiblePrewarm ? Promise.resolve() : deleteAgentPvc(opts.namespace, pvcName).catch(() => {}), + ]); + if (sessionPersisted && Object.keys(devModeSnapshots).length > 0) { await AgentSession.query() .findById(session!.id) @@ -770,13 +803,17 @@ export default class AgentSessionService { await Deploy.query().findById(deploy.id).patch({ devMode: false, devModeSessionId: null }); } + const reusablePrewarm = await resolveSessionPrewarmByPvc(session.buildUuid, session.pvcName); + await Promise.all([ deleteAgentRuntimeResources(session.namespace, session.podName, apiKeySecretName), cleanupForwardedAgentEnvSecrets(session.namespace, session.uuid, session.forwardedAgentSecretProviders), clearAgentSessionStartupFailure(redis, session.uuid).catch(() => {}), ]); await cleanupDevModePatches(session.namespace, session.devModeSnapshots, devModeDeploys); - await deleteAgentPvc(session.namespace, session.pvcName); + if (!reusablePrewarm) { + await deleteAgentPvc(session.namespace, session.pvcName); + } triggerDevModeDeployRestore(session.namespace, session.devModeSnapshots, devModeDeploys); await AgentSession.query().findById(session.id).patch({ diff --git a/src/server/services/build.ts b/src/server/services/build.ts index 76c4a62..fb7273c 100644 --- a/src/server/services/build.ts +++ b/src/server/services/build.ts @@ -44,6 +44,7 @@ import { redisClient } from 'server/lib/dependencies'; import { generateGraph } from 'server/lib/dependencyGraph'; import GlobalConfigService from './globalConfig'; import IngressService from './ingress'; +import AgentPrewarmService from './agentPrewarm'; import { paginate, PaginationMetadata, PaginationParams } from 'server/lib/paginate'; import { getYamlFileContentFromBranch } from 'server/lib/github'; import WebhookService from './webhook'; @@ -1237,6 +1238,16 @@ export default class BuildService extends BaseService { namespace: build.namespace, role: serviceAccount?.role, }); + if (build.kind === BuildKind.ENVIRONMENT && build.uuid) { + await new AgentPrewarmService(this.db, this.redis, this.redlock, this.queueManager) + .queueBuildPrewarm(build.uuid) + .catch((error) => { + getLogger().warn( + { error, buildUuid: build.uuid, namespace: build.namespace }, + 'Agent prewarm queueing failed before deployment rollout' + ); + }); + } const allDeploys = await Deploy.query() .where({ diff --git a/src/shared/config.ts b/src/shared/config.ts index 0d1e6b4..8681fa7 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -117,6 +117,7 @@ export const QUEUE_NAMES = { GITHUB_DEPLOYMENT: `github_deployment_${JOB_VERSION}`, LABEL: `label_${JOB_VERSION}`, AGENT_SESSION_CLEANUP: 'agent_session_cleanup', + AGENT_SESSION_PREWARM: 'agent_session_prewarm', AGENT_SANDBOX_SESSION_LAUNCH: 'agent_sandbox_session_launch', } as const; From 5d22900a73fe64af1ba56957ab1b2e9905a8b6e9 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Fri, 27 Mar 2026 23:00:06 -0700 Subject: [PATCH 16/18] multi repo support --- .../admin/feedback/[id]/conversation/route.ts | 46 ++ src/app/api/v2/ai/admin/feedback/route.ts | 85 ++++ .../api/v2/ai/agent/sandbox-sessions/route.ts | 142 +++++- .../v2/ai/agent/session-candidates/route.ts | 15 +- .../v2/ai/agent/sessions/[sessionId]/route.ts | 63 +++ .../sessions/[sessionId]/services/route.ts | 318 ++++++++++++ src/app/api/v2/ai/agent/sessions/route.ts | 183 +++++-- src/app/api/v2/ai/chat/[buildUuid]/route.ts | 13 + .../017_add_agent_workspace_metadata.ts | 41 ++ src/server/jobs/agentSandboxSessionLaunch.ts | 18 +- src/server/jobs/agentSessionCleanup.ts | 8 +- src/server/jobs/agentSessionPrewarm.ts | 4 +- .../__tests__/configSeeder.test.ts | 30 +- .../agentSession/__tests__/podFactory.test.ts | 50 +- .../__tests__/servicePlan.test.ts | 152 ++++++ .../__tests__/systemPrompt.test.ts | 7 + .../lib/agentSession/apiKeySecretFactory.ts | 10 +- src/server/lib/agentSession/configSeeder.ts | 118 +++-- src/server/lib/agentSession/devModeManager.ts | 42 +- .../lib/agentSession/editorServiceFactory.ts | 10 +- src/server/lib/agentSession/execProxy.ts | 12 +- src/server/lib/agentSession/forwardedEnv.ts | 7 +- src/server/lib/agentSession/githubToken.ts | 14 +- src/server/lib/agentSession/gvisorCheck.ts | 2 +- src/server/lib/agentSession/podFactory.ts | 225 ++++++--- .../lib/agentSession/prewarmJobFactory.ts | 10 +- src/server/lib/agentSession/pvcFactory.ts | 6 +- src/server/lib/agentSession/servicePlan.ts | 281 +++++++++++ src/server/lib/agentSession/systemPrompt.ts | 90 ++-- src/server/lib/agentSession/workspace.ts | 106 ++++ .../__tests__/getDeploymentPods.test.ts | 52 ++ .../lib/kubernetes/getDeploymentPods.ts | 11 +- src/server/lib/response.ts | 6 +- src/server/models/AgentPrewarm.ts | 7 +- src/server/models/AgentSession.ts | 7 +- .../__tests__/agentSandboxSession.test.ts | 125 ++++- .../services/__tests__/agentSession.test.ts | 333 ++++++++++++ .../__tests__/agentSessionCandidates.test.ts | 35 +- src/server/services/agentPrewarm.ts | 100 +++- src/server/services/agentSandboxSession.ts | 344 +++++++++---- src/server/services/agentSession.ts | 473 ++++++++++++++++-- src/server/services/agentSessionCandidates.ts | 172 ++++++- src/shared/openApiSpec.ts | 150 ++++++ ws-server.ts | 74 ++- 44 files changed, 3534 insertions(+), 463 deletions(-) create mode 100644 src/app/api/v2/ai/agent/sessions/[sessionId]/services/route.ts create mode 100644 src/server/db/migrations/017_add_agent_workspace_metadata.ts create mode 100644 src/server/lib/agentSession/__tests__/servicePlan.test.ts create mode 100644 src/server/lib/agentSession/servicePlan.ts diff --git a/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.ts b/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.ts index e0a9288..c41c38c 100644 --- a/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.ts +++ b/src/app/api/v2/ai/admin/feedback/[id]/conversation/route.ts @@ -21,6 +21,52 @@ import MessageFeedback from 'server/models/MessageFeedback'; import ConversationFeedback from 'server/models/ConversationFeedback'; import Conversation from 'server/models/Conversation'; +/** + * @openapi + * /api/v2/ai/admin/feedback/{id}/conversation: + * get: + * summary: Get AI feedback conversation replay + * description: > + * Returns the full persisted conversation for a message or conversation + * feedback record so admins can replay the exchange that led to the rating. + * tags: + * - AI Feedback Admin + * operationId: getAdminFeedbackConversation + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: > + * Composite feedback identifier in the form `message-123` or + * `conversation-456`. + * responses: + * '200': + * description: Feedback conversation replay. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/GetAdminFeedbackConversationSuccessResponse' + * '400': + * description: Invalid feedback identifier. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Feedback record or conversation not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '500': + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ const getHandler = async (req: NextRequest, { params }: { params: { id: string } }) => { const { id } = params; diff --git a/src/app/api/v2/ai/admin/feedback/route.ts b/src/app/api/v2/ai/admin/feedback/route.ts index 68ec192..346d15d 100644 --- a/src/app/api/v2/ai/admin/feedback/route.ts +++ b/src/app/api/v2/ai/admin/feedback/route.ts @@ -74,6 +74,91 @@ interface ConversationMessageCostRow { metadata: unknown; } +/** + * @openapi + * /api/v2/ai/admin/feedback: + * get: + * summary: List AI feedback records + * description: > + * Returns paginated message-level and conversation-level AI feedback for + * admin review. Results can be filtered by repository, rating, type, and + * date range, and are ordered by creation time. + * tags: + * - AI Feedback Admin + * operationId: getAdminFeedbackList + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * description: Page number for pagination. + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * minimum: 1 + * description: Number of feedback records per page. + * - in: query + * name: repo + * schema: + * type: string + * description: Case-insensitive repository or build UUID search term. + * - in: query + * name: rating + * schema: + * type: string + * enum: [up, down] + * description: Filter by user rating. + * - in: query + * name: type + * schema: + * type: string + * enum: [message, conversation] + * description: Filter by feedback record type. + * - in: query + * name: from + * schema: + * type: string + * format: date-time + * description: Include feedback created on or after this timestamp. + * - in: query + * name: to + * schema: + * type: string + * format: date-time + * description: Include feedback created on or before this timestamp. + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [createdAt] + * default: createdAt + * description: Sort field. Only `createdAt` is currently supported. + * - in: query + * name: sortDirection + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * description: Sort direction. + * responses: + * '200': + * description: Paginated feedback records. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/GetAdminFeedbackListSuccessResponse' + * '500': + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ + function toSingleLine(value: string): string { return value.replace(/\s+/g, ' ').trim(); } diff --git a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts index 4c7a595..7781ed4 100644 --- a/src/app/api/v2/ai/agent/sandbox-sessions/route.ts +++ b/src/app/api/v2/ai/agent/sandbox-sessions/route.ts @@ -29,15 +29,74 @@ import { redisClient } from 'server/lib/dependencies'; import QueueManager from 'server/lib/queueManager'; import { QUEUE_NAMES } from 'shared/config'; import { setSandboxLaunchState, toPublicSandboxLaunchState } from 'server/lib/agentSession/sandboxLaunchState'; -import AgentSandboxSessionService from 'server/services/agentSandboxSession'; +import AgentSandboxSessionService, { + formatRequestedSandboxServicesLabel, + summarizeRequestedSandboxServices, + type RequestedSandboxService, + type RequestedSandboxServices, +} from 'server/services/agentSandboxSession'; import type { SandboxSessionLaunchJob } from 'server/jobs/agentSandboxSessionLaunch'; +import type { RequestedAgentSessionServiceRef } from 'server/services/agentSessionCandidates'; interface CreateSandboxSessionBody { baseBuildUuid?: string; - service?: string; + service?: unknown; + services?: unknown; model?: string; } +function isRequestedSandboxServiceRef(value: unknown): value is RequestedAgentSessionServiceRef { + return ( + value != null && + typeof value === 'object' && + typeof (value as RequestedAgentSessionServiceRef).name === 'string' && + ((value as RequestedAgentSessionServiceRef).repo == null || + typeof (value as RequestedAgentSessionServiceRef).repo === 'string') && + ((value as RequestedAgentSessionServiceRef).branch == null || + typeof (value as RequestedAgentSessionServiceRef).branch === 'string') + ); +} + +function parseRequestedSandboxService(value: unknown): RequestedSandboxService { + if (typeof value === 'string') { + return value; + } + + if (isRequestedSandboxServiceRef(value)) { + return value; + } + + throw new Error('service must be a service name or repo-qualified service reference'); +} + +function parseRequestedSandboxServices(value: unknown): RequestedSandboxServices { + if (!Array.isArray(value)) { + throw new Error('services must be an array of service names or repo-qualified service references'); + } + + if (value.length === 0) { + throw new Error('services must contain at least one service'); + } + + return value.map(parseRequestedSandboxService); +} + +function parseRequestedSandboxServicesFromBody(body: CreateSandboxSessionBody): RequestedSandboxServices { + if (body.services != null) { + if (body.service != null) { + throw new Error('Provide either service or services, not both'); + } + + return parseRequestedSandboxServices(body.services); + } + + if (body.service != null) { + return [parseRequestedSandboxService(body.service)]; + } + + throw new Error('service or services is required'); +} + const sandboxLaunchQueue = QueueManager.getInstance().registerQueue(QUEUE_NAMES.AGENT_SANDBOX_SESSION_LAUNCH, { connection: redisClient.getConnection(), defaultJobOptions: { @@ -93,6 +152,10 @@ const sandboxLaunchQueue = QueueManager.getInstance().registerQueue(QUEUE_NAMES. * type: string * type: * type: string + * repo: + * type: string + * branch: + * type: string * error: * nullable: true * '400': @@ -111,16 +174,54 @@ const sandboxLaunchQueue = QueueManager.getInstance().registerQueue(QUEUE_NAMES. * content: * application/json: * schema: - * type: object - * required: - * - baseBuildUuid - * properties: - * baseBuildUuid: - * type: string - * service: - * type: string - * model: - * type: string + * oneOf: + * - type: object + * required: + * - baseBuildUuid + * - service + * properties: + * baseBuildUuid: + * type: string + * service: + * oneOf: + * - type: string + * - type: object + * required: + * - name + * properties: + * name: + * type: string + * repo: + * type: string + * branch: + * type: string + * model: + * type: string + * - type: object + * required: + * - baseBuildUuid + * - services + * properties: + * baseBuildUuid: + * type: string + * services: + * type: array + * minItems: 1 + * items: + * oneOf: + * - type: string + * - type: object + * required: + * - name + * properties: + * name: + * type: string + * repo: + * type: string + * branch: + * type: string + * model: + * type: string * responses: * '200': * description: Service selection required or sandbox session launch queued @@ -245,10 +346,9 @@ const postHandler = async (req: NextRequest) => { } try { - if (!body.service) { - return errorResponse(new Error('service is required'), { status: 400 }, req); - } - + const requestedServices = parseRequestedSandboxServicesFromBody(body); + const requestedServiceSummary = summarizeRequestedSandboxServices(requestedServices); + const requestedServiceLabel = formatRequestedSandboxServicesLabel(requestedServices); const runtimeConfig = await resolveAgentSessionRuntimeConfig(); const githubToken = await resolveRequestGitHubToken(req); const launchId = uuid(); @@ -258,11 +358,11 @@ const postHandler = async (req: NextRequest) => { userId: userIdentity.userId, status: 'queued', stage: 'queued', - message: `Queued sandbox launch for ${body.service}`, + message: `Queued sandbox launch for ${requestedServiceLabel}`, createdAt: now, updatedAt: now, baseBuildUuid: body.baseBuildUuid, - service: body.service, + service: requestedServiceSummary, }); await sandboxLaunchQueue.add( @@ -273,7 +373,7 @@ const postHandler = async (req: NextRequest) => { userIdentity, encryptedGithubToken: githubToken ? encrypt(githubToken) : null, baseBuildUuid: body.baseBuildUuid, - service: body.service, + services: requestedServices, model: body.model, agentImage: runtimeConfig.image, editorImage: runtimeConfig.editorImage, @@ -292,11 +392,11 @@ const postHandler = async (req: NextRequest) => { userId: userIdentity.userId, status: 'queued', stage: 'queued', - message: `Queued sandbox launch for ${body.service}`, + message: `Queued sandbox launch for ${requestedServiceLabel}`, createdAt: now, updatedAt: now, baseBuildUuid: body.baseBuildUuid, - service: body.service, + service: requestedServiceSummary, }), { status: 200 }, req diff --git a/src/app/api/v2/ai/agent/session-candidates/route.ts b/src/app/api/v2/ai/agent/session-candidates/route.ts index 1f2a3ed..13e97f4 100644 --- a/src/app/api/v2/ai/agent/session-candidates/route.ts +++ b/src/app/api/v2/ai/agent/session-candidates/route.ts @@ -66,6 +66,13 @@ import { loadAgentSessionServiceCandidates } from 'server/services/agentSessionC * type: string * detail: * type: string + * repo: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true * activeSession: * type: object * nullable: true @@ -114,8 +121,12 @@ const getHandler = async (req: NextRequest) => { return successResponse( { services: services - .map(({ name, type, detail }) => ({ name, type, detail })) - .sort((a, b) => a.name.localeCompare(b.name)), + .map(({ name, type, detail, repo, branch, revision }) => ({ name, type, detail, repo, branch, revision })) + .sort((a, b) => + a.name === b.name + ? `${a.repo}:${a.branch}`.localeCompare(`${b.repo}:${b.branch}`) + : a.name.localeCompare(b.name) + ), activeSession, }, { status: 200 }, diff --git a/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts index 4fee16e..552b50d 100644 --- a/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts +++ b/src/app/api/v2/ai/agent/sessions/[sessionId]/route.ts @@ -77,6 +77,9 @@ function serializeSessionSummary(session: T) { + const sessionId = session.uuid || String(session.id); + const { + id: _internalId, + uuid: _uuid, + ...serialized + } = session as T & { + uuid?: string | null; + id: string | number; + }; + + return { + ...serialized, + id: sessionId, + websocketUrl: `/api/agent/session?sessionId=${sessionId}`, + editorUrl: `/api/agent/editor/${sessionId}/`, + }; +} + +function isRequestedSessionServiceRef(value: unknown): value is RequestedAgentSessionServiceRef { + return ( + value != null && + typeof value === 'object' && + typeof (value as RequestedAgentSessionServiceRef).name === 'string' && + ((value as RequestedAgentSessionServiceRef).repo == null || + typeof (value as RequestedAgentSessionServiceRef).repo === 'string') && + ((value as RequestedAgentSessionServiceRef).branch == null || + typeof (value as RequestedAgentSessionServiceRef).branch === 'string') + ); +} + +/** + * @openapi + * /api/v2/ai/agent/sessions/{sessionId}/services: + * post: + * summary: Connect services to an active agent session + * tags: + * - Agent Sessions + * operationId: connectAgentSessionServices + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [services] + * properties: + * services: + * type: array + * items: + * oneOf: + * - type: string + * - type: object + * required: [name] + * properties: + * name: + * type: string + * repo: + * type: string + * branch: + * type: string + * responses: + * '200': + * description: Updated agent session + * content: + * application/json: + * schema: + * type: object + * required: [request_id, data, error] + * properties: + * request_id: + * type: string + * data: + * type: object + * required: + * - id + * - buildUuid + * - baseBuildUuid + * - buildKind + * - userId + * - ownerGithubUsername + * - podName + * - namespace + * - model + * - status + * - repo + * - branch + * - services + * - websocketUrl + * - editorUrl + * - lastActivity + * - createdAt + * - updatedAt + * - endedAt + * - startupFailure + * properties: + * id: + * type: string + * buildUuid: + * type: string + * nullable: true + * baseBuildUuid: + * type: string + * nullable: true + * buildKind: + * $ref: '#/components/schemas/BuildKind' + * userId: + * type: string + * ownerGithubUsername: + * type: string + * nullable: true + * podName: + * type: string + * namespace: + * type: string + * model: + * type: string + * status: + * type: string + * enum: [starting, active, ended, error] + * repo: + * type: string + * nullable: true + * branch: + * type: string + * nullable: true + * primaryRepo: + * type: string + * nullable: true + * primaryBranch: + * type: string + * nullable: true + * workspaceRepos: + * type: array + * items: + * type: object + * required: [repo, repoUrl, branch, mountPath] + * properties: + * repo: + * type: string + * repoUrl: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * mountPath: + * type: string + * primary: + * type: boolean + * selectedServices: + * type: array + * items: + * type: object + * required: [name, deployId, repo, branch, workspacePath] + * properties: + * name: + * type: string + * deployId: + * type: integer + * repo: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * resourceName: + * type: string + * nullable: true + * workspacePath: + * type: string + * workDir: + * type: string + * nullable: true + * services: + * type: array + * items: + * type: string + * websocketUrl: + * type: string + * editorUrl: + * type: string + * lastActivity: + * type: string + * format: date-time + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * endedAt: + * type: string + * nullable: true + * format: date-time + * startupFailure: + * type: object + * nullable: true + * required: + * - stage + * - title + * - message + * - recordedAt + * properties: + * stage: + * type: string + * enum: [create_session, connect_runtime] + * title: + * type: string + * message: + * type: string + * recordedAt: + * type: string + * format: date-time + * error: + * nullable: true + * '400': + * description: Invalid request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '401': + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Session not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const postHandler = async (req: NextRequest, { params }: { params: Promise<{ sessionId: string }> }) => { + const userIdentity = getRequestUserIdentity(req); + if (!userIdentity) return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + + const body = (await req.json()) as { + services?: unknown[]; + }; + if (!Array.isArray(body.services) || body.services.length === 0) { + return errorResponse(new Error('services is required'), { status: 400 }, req); + } + + const requestedServices = body.services.map((service) => { + if (typeof service === 'string') { + return service; + } + + if (isRequestedSessionServiceRef(service)) { + return service; + } + + throw new Error('services must be an array of service names or repo-qualified service references'); + }); + + const { sessionId } = await params; + const session = await AgentSessionService.getSession(sessionId); + if (!session) { + return errorResponse(new Error('Session not found'), { status: 404 }, req); + } + + if (session.userId !== userIdentity.userId) { + return errorResponse(new Error('Forbidden: you do not own this session'), { status: 401 }, req); + } + + try { + await AgentSessionService.attachServices(sessionId, requestedServices); + const updatedSession = await AgentSessionService.getSession(sessionId); + + if (!updatedSession) { + return errorResponse(new Error('Session not found'), { status: 404 }, req); + } + + return successResponse(serializeSessionSummary(updatedSession), { status: 200 }, req); + } catch (error) { + if (error instanceof Error && /session not found/i.test(error.message)) { + return errorResponse(error, { status: 404 }, req); + } + + return errorResponse(error, { status: 400 }, req); + } +}; + +export const POST = createApiHandler(postHandler); diff --git a/src/app/api/v2/ai/agent/sessions/route.ts b/src/app/api/v2/ai/agent/sessions/route.ts index 53a00d8..914779d 100644 --- a/src/app/api/v2/ai/agent/sessions/route.ts +++ b/src/app/api/v2/ai/agent/sessions/route.ts @@ -27,8 +27,9 @@ import { } from 'server/lib/agentSession/runtimeConfig'; import AgentSessionService, { ActiveEnvironmentSessionError } from 'server/services/agentSession'; import { - resolveAgentSessionServiceCandidates, + resolveAgentSessionServiceCandidatesForBuild, resolveRequestedAgentSessionServices, + type RequestedAgentSessionServiceRef, } from 'server/services/agentSessionCandidates'; import Build from 'server/models/Build'; import { fetchLifecycleConfig, type LifecycleConfig } from 'server/models/yaml'; @@ -40,6 +41,11 @@ interface ResolvedSessionService { deployId: number; devConfig: DevConfig; resourceName?: string; + repo?: string | null; + branch?: string | null; + revision?: string | null; + workspacePath?: string; + workDir?: string | null; } interface CreateSessionBody { @@ -111,16 +117,28 @@ function isResolvedSessionService(value: unknown): value is ResolvedSessionServi ); } +function isRequestedSessionServiceRef(value: unknown): value is RequestedAgentSessionServiceRef { + return ( + value != null && + typeof value === 'object' && + typeof (value as RequestedAgentSessionServiceRef).name === 'string' && + ((value as RequestedAgentSessionServiceRef).repo == null || + typeof (value as RequestedAgentSessionServiceRef).repo === 'string') && + ((value as RequestedAgentSessionServiceRef).branch == null || + typeof (value as RequestedAgentSessionServiceRef).branch === 'string') + ); +} + async function resolveBuildContext(buildUuid: string) { - return Build.query().findOne({ uuid: buildUuid }).withGraphFetched('[pullRequest, deploys.[deployable]]'); + return Build.query() + .findOne({ uuid: buildUuid }) + .withGraphFetched('[pullRequest, deploys.[deployable, repository, service]]'); } async function resolveRequestedServices( buildUuid: string | undefined, requestedServices: unknown[] | undefined, - buildContext: Awaited> | null, - lifecycleConfig: LifecycleConfig | null, - lifecycleConfigError?: unknown + buildContext: Awaited> | null ): Promise { if (!Array.isArray(requestedServices) || requestedServices.length === 0) { return []; @@ -138,25 +156,29 @@ async function resolveRequestedServices( throw new Error('Build not found'); } - if (!lifecycleConfig) { - throw lifecycleConfigError instanceof Error - ? lifecycleConfigError - : new Error('Lifecycle config not found for build'); - } + const requestedRefs = requestedServices.map((service) => { + if (typeof service === 'string') { + return service; + } - const requestedNames = requestedServices.filter((service): service is string => typeof service === 'string'); - if (requestedNames.length !== requestedServices.length) { - throw new Error('services must be an array of service names'); - } + if (isRequestedSessionServiceRef(service)) { + return service; + } + + throw new Error('services must be an array of service names or repo-qualified service references'); + }); return resolveRequestedAgentSessionServices( - resolveAgentSessionServiceCandidates(buildContext.deploys || [], lifecycleConfig), - requestedNames - ).map(({ name, deployId, devConfig, baseDeploy }) => ({ + await resolveAgentSessionServiceCandidatesForBuild(buildContext), + requestedRefs + ).map(({ name, deployId, devConfig, baseDeploy, repo, branch, revision }) => ({ name, deployId, devConfig, resourceName: baseDeploy.uuid || undefined, + repo, + branch, + revision: revision || null, })); } @@ -241,6 +263,56 @@ async function resolveRequestedServices( * branch: * type: string * nullable: true + * primaryRepo: + * type: string + * nullable: true + * primaryBranch: + * type: string + * nullable: true + * workspaceRepos: + * type: array + * items: + * type: object + * required: [repo, repoUrl, branch, mountPath] + * properties: + * repo: + * type: string + * repoUrl: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * mountPath: + * type: string + * primary: + * type: boolean + * selectedServices: + * type: array + * items: + * type: object + * required: [name, deployId, repo, branch, workspacePath] + * properties: + * name: + * type: string + * deployId: + * type: integer + * repo: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * resourceName: + * type: string + * nullable: true + * workspacePath: + * type: string + * workDir: + * type: string + * nullable: true * services: * type: array * items: @@ -307,9 +379,19 @@ async function resolveRequestedServices( * type: string * services: * type: array - * description: Optional service names to enable dev mode for. + * description: Optional service names or repo-qualified service references to enable dev mode for. * items: - * type: string + * oneOf: + * - type: string + * - type: object + * required: [name] + * properties: + * name: + * type: string + * repo: + * type: string + * branch: + * type: string * model: * type: string * responses: @@ -377,6 +459,56 @@ async function resolveRequestedServices( * branch: * type: string * nullable: true + * primaryRepo: + * type: string + * nullable: true + * primaryBranch: + * type: string + * nullable: true + * workspaceRepos: + * type: array + * items: + * type: object + * required: [repo, repoUrl, branch, mountPath] + * properties: + * repo: + * type: string + * repoUrl: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * mountPath: + * type: string + * primary: + * type: boolean + * selectedServices: + * type: array + * items: + * type: object + * required: [name, deployId, repo, branch, workspacePath] + * properties: + * name: + * type: string + * deployId: + * type: integer + * repo: + * type: string + * branch: + * type: string + * revision: + * type: string + * nullable: true + * resourceName: + * type: string + * nullable: true + * workspacePath: + * type: string + * workDir: + * type: string + * nullable: true * services: * type: array * items: @@ -471,7 +603,6 @@ const postHandler = async (req: NextRequest) => { let buildKind = BuildKind.ENVIRONMENT; let buildContext: Awaited> | null = null; let lifecycleConfig: LifecycleConfig | null = null; - let lifecycleConfigError: unknown; if (buildUuid) { buildContext = await resolveBuildContext(buildUuid); @@ -492,19 +623,13 @@ const postHandler = async (req: NextRequest) => { repoUrl, branch, }); - } catch (error) { - lifecycleConfigError = error; + } catch { + lifecycleConfig = null; } let resolvedServices: ResolvedSessionService[]; try { - resolvedServices = await resolveRequestedServices( - buildUuid, - services, - buildContext, - lifecycleConfig, - lifecycleConfigError - ); + resolvedServices = await resolveRequestedServices(buildUuid, services, buildContext); } catch (err) { return errorResponse(err, { status: 400 }, req); } diff --git a/src/app/api/v2/ai/chat/[buildUuid]/route.ts b/src/app/api/v2/ai/chat/[buildUuid]/route.ts index c946fca..2f92b77 100644 --- a/src/app/api/v2/ai/chat/[buildUuid]/route.ts +++ b/src/app/api/v2/ai/chat/[buildUuid]/route.ts @@ -158,6 +158,9 @@ export const dynamic = 'force-dynamic'; * description: > * Optional service-scoped fix target. When provided in fix mode, * mutating tool calls are constrained to this selected issue. + * The additional action hint fields are used by fix-target + * authorization to distinguish file edits from PR label + * updates and Kubernetes patch actions. * properties: * serviceName: * type: string @@ -172,6 +175,16 @@ export const dynamic = 'force-dynamic'; * properties: * path: * type: string + * autoFixAction: + * type: string + * actionType: + * type: string + * fixType: + * type: string + * tool: + * type: string + * toolName: + * type: string * responses: * '200': * description: > diff --git a/src/server/db/migrations/017_add_agent_workspace_metadata.ts b/src/server/db/migrations/017_add_agent_workspace_metadata.ts new file mode 100644 index 0000000..437f6ee --- /dev/null +++ b/src/server/db/migrations/017_add_agent_workspace_metadata.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('agent_sessions', (table) => { + table.jsonb('workspaceRepos').notNullable().defaultTo('[]'); + table.jsonb('selectedServices').notNullable().defaultTo('[]'); + }); + + await knex.schema.alterTable('agent_prewarms', (table) => { + table.jsonb('workspaceRepos').notNullable().defaultTo('[]'); + table.jsonb('serviceRefs').notNullable().defaultTo('[]'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('agent_prewarms', (table) => { + table.dropColumn('serviceRefs'); + table.dropColumn('workspaceRepos'); + }); + + await knex.schema.alterTable('agent_sessions', (table) => { + table.dropColumn('selectedServices'); + table.dropColumn('workspaceRepos'); + }); +} diff --git a/src/server/jobs/agentSandboxSessionLaunch.ts b/src/server/jobs/agentSandboxSessionLaunch.ts index ebd16db..401a9bd 100644 --- a/src/server/jobs/agentSandboxSessionLaunch.ts +++ b/src/server/jobs/agentSandboxSessionLaunch.ts @@ -26,9 +26,12 @@ import { SandboxLaunchStage, setSandboxLaunchState, } from 'server/lib/agentSession/sandboxLaunchState'; -import AgentSandboxSessionService, { LaunchSandboxSessionOptions } from 'server/services/agentSandboxSession'; +import AgentSandboxSessionService, { + formatRequestedSandboxServicesLabel, + LaunchSandboxSessionOptions, +} from 'server/services/agentSandboxSession'; -const logger = getLogger(); +const logger = () => getLogger(); export interface SandboxSessionLaunchJob extends Omit { launchId: string; @@ -43,7 +46,7 @@ export async function processAgentSandboxSessionLaunch(job: Job getLogger(); const IDLE_TIMEOUT_MS = 30 * 60 * 1000; const STARTING_TIMEOUT_MS = 15 * 60 * 1000; @@ -33,12 +33,12 @@ export async function processAgentSessionCleanup(): Promise { for (const session of staleSessions) { const sessionId = session.uuid || String(session.id); try { - logger.info( - `Cleaning up stale agent session: sessionId=${sessionId} status=${session.status} lastActivity=${session.lastActivity}` + logger().info( + `Session: cleanup starting sessionId=${sessionId} status=${session.status} lastActivity=${session.lastActivity}` ); await AgentSessionService.endSession(sessionId); } catch (err) { - logger.error(`Failed to clean up session: sessionId=${sessionId} err=${(err as Error).message}`); + logger().error({ error: err, sessionId }, `Session: cleanup failed sessionId=${sessionId}`); } } } diff --git a/src/server/jobs/agentSessionPrewarm.ts b/src/server/jobs/agentSessionPrewarm.ts index 6ec35f4..d501366 100644 --- a/src/server/jobs/agentSessionPrewarm.ts +++ b/src/server/jobs/agentSessionPrewarm.ts @@ -18,12 +18,12 @@ import { Job } from 'bullmq'; import { getLogger } from 'server/lib/logger'; import AgentPrewarmService, { AgentPrewarmQueueJob } from 'server/services/agentPrewarm'; -const logger = getLogger(); +const logger = () => getLogger(); export async function processAgentSessionPrewarm(job: Job): Promise { const { buildUuid } = job.data; await new AgentPrewarmService().prepareBuildPrewarm(buildUuid).catch((error) => { - logger.error({ error, buildUuid }, 'Agent prewarm job failed'); + logger().error({ error, buildUuid }, `Prewarm: job failed buildUuid=${buildUuid}`); throw error; }); } diff --git a/src/server/lib/agentSession/__tests__/configSeeder.test.ts b/src/server/lib/agentSession/__tests__/configSeeder.test.ts index bb06a76..1be4303 100644 --- a/src/server/lib/agentSession/__tests__/configSeeder.test.ts +++ b/src/server/lib/agentSession/__tests__/configSeeder.test.ts @@ -108,7 +108,35 @@ describe('configSeeder', () => { expect(script).toContain('Pushing to $branch_name is not allowed'); expect(script).toContain('main'); expect(script).toContain('master'); - expect(script).toContain('chmod +x .git/hooks/pre-push'); + expect(script).toContain('chmod +x "/workspace/.git/hooks/pre-push"'); + }); + + it('clones additional repositories into repo-specific workspace paths', () => { + const script = generateInitScript({ + workspaceRepos: [ + { + repo: 'org/ui', + repoUrl: 'https://github.com/org/ui.git', + branch: 'feature/ui', + revision: 'abc123', + mountPath: '/workspace', + primary: true, + }, + { + repo: 'org/api', + repoUrl: 'https://github.com/org/api.git', + branch: 'feature/api', + revision: null, + mountPath: '/workspace/repos/org/api', + primary: false, + }, + ], + }); + + expect(script).toContain('git clone --progress --depth 50 --branch "feature/ui" --single-branch'); + expect(script).toContain('git clone --progress --depth 50 --branch "feature/api" --single-branch'); + expect(script).toContain('"/workspace/repos/org"'); + expect(script).toContain('"/workspace/repos/org/api"'); }); it('starts with shebang', () => { diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts index be28886..d20d0e2 100644 --- a/src/server/lib/agentSession/__tests__/podFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -15,6 +15,7 @@ */ import * as k8s from '@kubernetes/client-node'; +import { AGENT_EDITOR_WORKSPACE_FILE } from '../workspace'; const mockCreatePod = jest.fn(); const mockReadPod = jest.fn(); @@ -130,11 +131,12 @@ describe('podFactory', () => { describe('buildAgentPodSpec', () => { it('creates a pod with init and main containers', () => { const pod = buildAgentPodSpec(baseOpts); - expect(pod.spec!.initContainers).toHaveLength(2); + expect(pod.spec!.initContainers).toHaveLength(3); expect(pod.spec!.containers).toHaveLength(2); expect(pod.spec!.initContainers!.map((container) => container.name)).toEqual([ 'prepare-workspace', 'init-workspace', + 'prepare-editor-workspace', ]); expect(pod.spec!.containers[0].name).toBe('agent'); expect(pod.spec!.containers[1].name).toBe('editor'); @@ -530,7 +532,7 @@ describe('podFactory', () => { name: 'editor', image: 'codercom/code-server:4.98.2', args: [ - '/workspace', + AGENT_EDITOR_WORKSPACE_FILE, '--auth', 'none', '--bind-addr', @@ -571,6 +573,50 @@ describe('podFactory', () => { }); }); + it('writes a multi-root editor workspace file before code-server starts', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + workspaceRepos: [ + { + repo: 'org/repo', + repoUrl: 'https://github.com/org/repo.git', + branch: 'feature/test', + revision: null, + mountPath: '/workspace', + primary: true, + }, + { + repo: 'org/api', + repoUrl: 'https://github.com/org/api.git', + branch: 'feature/api', + revision: null, + mountPath: '/workspace/repos/org/api', + primary: false, + }, + ], + }); + + expect(getInitContainer(pod, 'prepare-editor-workspace')).toEqual( + expect.objectContaining({ + command: ['sh', '-c', expect.stringContaining(`cat > '${AGENT_EDITOR_WORKSPACE_FILE}' << 'WORKSPACE_EOF'`)], + volumeMounts: [{ name: 'tmp', mountPath: '/tmp' }], + }) + ); + expect(getInitContainer(pod, 'prepare-editor-workspace').command?.[2]).toContain('"name": "org/repo"'); + expect(getInitContainer(pod, 'prepare-editor-workspace').command?.[2]).toContain( + '"path": "/workspace/repos/org/api"' + ); + }); + + it('still prepares the editor workspace when workspace bootstrap is skipped', () => { + const pod = buildAgentPodSpec({ + ...baseOpts, + skipWorkspaceBootstrap: true, + }); + + expect(pod.spec!.initContainers?.map((container) => container.name)).toEqual(['prepare-editor-workspace']); + }); + it('does not set runtimeClassName when gVisor not requested', () => { const pod = buildAgentPodSpec(baseOpts); expect(pod.spec!.runtimeClassName).toBeUndefined(); diff --git a/src/server/lib/agentSession/__tests__/servicePlan.test.ts b/src/server/lib/agentSession/__tests__/servicePlan.test.ts new file mode 100644 index 0000000..75dd8b3 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/servicePlan.test.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildCombinedInstallCommand, resolveAgentSessionServicePlan } from '../servicePlan'; +import { AGENT_WORKSPACE_ROOT } from '../workspace'; + +describe('servicePlan', () => { + it('rewrites secondary repo service config against the mounted workspace path', () => { + const plan = resolveAgentSessionServicePlan({}, [ + { + name: 'api', + deployId: 1, + repo: 'example-org/api', + branch: 'feature/api', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'pnpm install', + }, + }, + { + name: 'web', + deployId: 2, + repo: 'example-org/web', + branch: 'feature/web', + devConfig: { + image: 'node:20', + workDir: '/workspace/apps/web', + command: 'pnpm --dir /workspace/apps/web dev', + installCommand: 'pnpm install', + env: { + CONFIG_PATH: '/workspace/config', + }, + }, + }, + ]); + + expect(plan.workspaceRepos).toEqual([ + expect.objectContaining({ + repo: 'example-org/api', + mountPath: AGENT_WORKSPACE_ROOT, + primary: true, + }), + expect.objectContaining({ + repo: 'example-org/web', + mountPath: '/workspace/repos/example-org/web', + primary: false, + }), + ]); + + expect(plan.services).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'web', + workspacePath: '/workspace/repos/example-org/web', + workDir: '/workspace/repos/example-org/web/apps/web', + devConfig: expect.objectContaining({ + workDir: '/workspace/repos/example-org/web/apps/web', + command: 'pnpm --dir /workspace/repos/example-org/web/apps/web dev', + installCommand: 'pnpm install', + env: { + CONFIG_PATH: '/workspace/repos/example-org/web/config', + }, + }), + }), + ]) + ); + + expect(plan.selectedServices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'web', + repo: 'example-org/web', + branch: 'feature/web', + workspacePath: '/workspace/repos/example-org/web', + workDir: '/workspace/repos/example-org/web/apps/web', + }), + ]) + ); + }); + + it('builds repo-aware install commands without duplicating primary repo cd steps', () => { + const plan = resolveAgentSessionServicePlan({}, [ + { + name: 'api', + deployId: 1, + repo: 'example-org/api', + branch: 'feature/api', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'pnpm install', + }, + }, + { + name: 'web', + deployId: 2, + repo: 'example-org/web', + branch: 'feature/web', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'pnpm install', + }, + }, + ]); + + expect(buildCombinedInstallCommand(plan.services)).toBe( + 'pnpm install\n\ncd "/workspace/repos/example-org/web"\npnpm install' + ); + }); + + it('rejects conflicting branches for the same repository', () => { + expect(() => + resolveAgentSessionServicePlan({}, [ + { + name: 'api', + deployId: 1, + repo: 'example-org/api', + branch: 'feature/one', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + }, + }, + { + name: 'worker', + deployId: 2, + repo: 'example-org/api', + branch: 'feature/two', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + }, + }, + ]) + ).toThrow('Selected services require conflicting branches for example-org/api'); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/systemPrompt.test.ts b/src/server/lib/agentSession/__tests__/systemPrompt.test.ts index 1ea1158..c40a837 100644 --- a/src/server/lib/agentSession/__tests__/systemPrompt.test.ts +++ b/src/server/lib/agentSession/__tests__/systemPrompt.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +jest.mock('server/models/AgentSession'); jest.mock('server/models/Build'); jest.mock('server/models/Deploy'); jest.mock('server/models/yaml', () => ({ @@ -21,6 +22,7 @@ jest.mock('server/models/yaml', () => ({ getDeployingServicesByName: jest.fn(), })); +import AgentSession from 'server/models/AgentSession'; import Build from 'server/models/Build'; import Deploy from 'server/models/Deploy'; import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml'; @@ -33,6 +35,9 @@ import { describe('agent session system prompt', () => { beforeEach(() => { jest.clearAllMocks(); + (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue({ + findById: jest.fn().mockResolvedValue(null), + }); }); it('builds a compact dynamic session context prompt', () => { @@ -115,6 +120,8 @@ describe('agent session system prompt', () => { { name: 'next-web', publicUrl: 'https://next-web-sample.lifecycle.dev.example.com', + repo: 'example-org/example-repo', + branch: 'feature/sample', workDir: '/workspace/apps/next-web', }, ], diff --git a/src/server/lib/agentSession/apiKeySecretFactory.ts b/src/server/lib/agentSession/apiKeySecretFactory.ts index 1562870..df7d5b0 100644 --- a/src/server/lib/agentSession/apiKeySecretFactory.ts +++ b/src/server/lib/agentSession/apiKeySecretFactory.ts @@ -59,7 +59,7 @@ export async function createAgentApiKeySecret( }; const { body: result } = await coreApi.createNamespacedSecret(namespace, secret); - logger.info(`apiKeySecretFactory: created Secret name=${secretName} namespace=${namespace}`); + logger.info(`AgentRuntime: credentials prepared kind=api_key_secret secretName=${secretName} namespace=${namespace}`); return result; } @@ -69,10 +69,14 @@ export async function deleteAgentApiKeySecret(namespace: string, secretName: str try { await coreApi.deleteNamespacedSecret(secretName, namespace); - logger.info(`apiKeySecretFactory: deleted Secret name=${secretName} namespace=${namespace}`); + logger.info( + `AgentRuntime: credentials cleaned kind=api_key_secret secretName=${secretName} namespace=${namespace}` + ); } catch (error: any) { if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { - logger.info(`apiKeySecretFactory: Secret not found (already deleted) name=${secretName} namespace=${namespace}`); + logger.info( + `AgentRuntime: credentials cleanup skipped reason=not_found kind=api_key_secret secretName=${secretName} namespace=${namespace}` + ); return; } diff --git a/src/server/lib/agentSession/configSeeder.ts b/src/server/lib/agentSession/configSeeder.ts index c5a7cb4..b4f9cf9 100644 --- a/src/server/lib/agentSession/configSeeder.ts +++ b/src/server/lib/agentSession/configSeeder.ts @@ -14,11 +14,15 @@ * limitations under the License. */ +import { posix as pathPosix } from 'path'; +import type { AgentSessionWorkspaceRepo } from './workspace'; + export interface InitScriptOpts { - repoUrl: string; - branch: string; + repoUrl?: string; + branch?: string; revision?: string; - workspacePath: string; + workspacePath?: string; + workspaceRepos?: AgentSessionWorkspaceRepo[]; installCommand?: string; claudeMdContent?: string; claudePermissions?: { @@ -37,12 +41,48 @@ function escapeDoubleQuotedShell(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`'); } +function resolveInitWorkspaceRepos(opts: InitScriptOpts): AgentSessionWorkspaceRepo[] { + if (opts.workspaceRepos && opts.workspaceRepos.length > 0) { + return opts.workspaceRepos; + } + + if (!opts.repoUrl || !opts.branch || !opts.workspacePath) { + throw new Error('repoUrl, branch, and workspacePath are required when workspaceRepos is not provided'); + } + + return [ + { + repo: opts.repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, ''), + repoUrl: opts.repoUrl, + branch: opts.branch, + revision: opts.revision || null, + mountPath: opts.workspacePath, + primary: true, + }, + ]; +} + +function appendPrePushHook(lines: string[], repoPath: string) { + lines.push( + `mkdir -p "${escapeDoubleQuotedShell(repoPath)}/.git/hooks"`, + `cat > "${escapeDoubleQuotedShell(repoPath)}/.git/hooks/pre-push" << 'HOOK_EOF'`, + '#!/bin/sh', + 'remote="$1"', + 'while read local_ref local_sha remote_ref remote_sha; do', + ` branch_name="\${remote_ref##refs/heads/}"`, + ` if [ "$branch_name" = "main" ] || [ "$branch_name" = "master" ]; then`, + ' echo "ERROR: Pushing to $branch_name is not allowed"', + ' exit 1', + ' fi', + 'done', + 'exit 0', + 'HOOK_EOF', + `chmod +x "${escapeDoubleQuotedShell(repoPath)}/.git/hooks/pre-push"` + ); +} + export function generateInitScript(opts: InitScriptOpts): string { const { - repoUrl, - branch, - revision, - workspacePath, installCommand, claudeMdContent, claudePermissions, @@ -53,6 +93,8 @@ export function generateInitScript(opts: InitScriptOpts): string { githubUsername, useGitHubToken, } = opts; + const workspaceRepos = resolveInitWorkspaceRepos(opts); + const primaryRepo = workspaceRepos.find((repo) => repo.primary) || workspaceRepos[0]; const settings = { permissions: { @@ -71,13 +113,7 @@ export function generateInitScript(opts: InitScriptOpts): string { const settingsJson = JSON.stringify(settings, null, 2); - const lines = [ - '#!/bin/sh', - 'set -e', - '', - `mkdir -p "${escapeDoubleQuotedShell(workspacePath)}"`, - `git config --global --add safe.directory "${escapeDoubleQuotedShell(workspacePath)}"`, - ]; + const lines = ['#!/bin/sh', 'set -e']; if (gitUserName) { lines.push(`git config --global user.name "${escapeDoubleQuotedShell(gitUserName)}"`); @@ -99,26 +135,35 @@ export function generateInitScript(opts: InitScriptOpts): string { ); } - lines.push( - `git clone --progress --depth 50 --branch "${escapeDoubleQuotedShell( - branch - )}" --single-branch "${escapeDoubleQuotedShell(repoUrl)}" "${escapeDoubleQuotedShell(workspacePath)}"`, - `cd "${escapeDoubleQuotedShell(workspacePath)}"` - ); + for (const repo of workspaceRepos) { + const parentDir = pathPosix.dirname(repo.mountPath); + const cloneRoot = parentDir === '/' ? repo.mountPath : parentDir; + lines.push( + '', + `mkdir -p "${escapeDoubleQuotedShell(cloneRoot)}"`, + `git config --global --add safe.directory "${escapeDoubleQuotedShell(repo.mountPath)}"`, + `git clone --progress --depth 50 --branch "${escapeDoubleQuotedShell( + repo.branch + )}" --single-branch "${escapeDoubleQuotedShell(repo.repoUrl)}" "${escapeDoubleQuotedShell(repo.mountPath)}"`, + `cd "${escapeDoubleQuotedShell(repo.mountPath)}"` + ); + + if (!repo.revision) { + continue; + } - if (revision) { lines.push( - `if ! git rev-parse --verify --quiet "${escapeDoubleQuotedShell(revision)}^{commit}" >/dev/null; then`, + `if ! git rev-parse --verify --quiet "${escapeDoubleQuotedShell(repo.revision)}^{commit}" >/dev/null; then`, ` git fetch --unshallow origin "${escapeDoubleQuotedShell( - branch - )}" || git fetch origin "${escapeDoubleQuotedShell(branch)}"`, + repo.branch + )}" || git fetch origin "${escapeDoubleQuotedShell(repo.branch)}"`, 'fi' ); - lines.push(`git checkout "${escapeDoubleQuotedShell(revision)}"`); + lines.push(`git checkout "${escapeDoubleQuotedShell(repo.revision)}"`); } if (installCommand) { - lines.push('', installCommand); + lines.push('', `cd "${escapeDoubleQuotedShell(primaryRepo.mountPath)}"`, installCommand); } lines.push('', 'mkdir -p ~/.claude', ''); @@ -133,24 +178,11 @@ export function generateInitScript(opts: InitScriptOpts): string { lines.push(`cat > ~/.claude/settings.json << 'SETTINGS_EOF'`); lines.push(settingsJson); lines.push('SETTINGS_EOF'); - lines.push(''); - lines.push( - 'mkdir -p .git/hooks', - `cat > .git/hooks/pre-push << 'HOOK_EOF'`, - '#!/bin/sh', - 'remote="$1"', - 'while read local_ref local_sha remote_ref remote_sha; do', - ` branch_name="\${remote_ref##refs/heads/}"`, - ` if [ "$branch_name" = "main" ] || [ "$branch_name" = "master" ]; then`, - ' echo "ERROR: Pushing to $branch_name is not allowed"', - ' exit 1', - ' fi', - 'done', - 'exit 0', - 'HOOK_EOF', - 'chmod +x .git/hooks/pre-push' - ); + for (const repo of workspaceRepos) { + lines.push(''); + appendPrePushHook(lines, repo.mountPath); + } return lines.join('\n') + '\n'; } diff --git a/src/server/lib/agentSession/devModeManager.ts b/src/server/lib/agentSession/devModeManager.ts index 4ae7a9f..b8e246d 100644 --- a/src/server/lib/agentSession/devModeManager.ts +++ b/src/server/lib/agentSession/devModeManager.ts @@ -19,7 +19,7 @@ import { DevConfig } from 'server/models/yaml/YamlService'; import { getLogger } from 'server/lib/logger'; import { AGENT_WORKSPACE_ROOT, AGENT_WORKSPACE_SUBPATH } from './workspace'; -const logger = getLogger(); +const logger = () => getLogger(); const DEV_MODE_DEPLOYMENT_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-deployment-snapshot'; const DEV_MODE_SERVICE_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-service-snapshot'; const DEV_MODE_HPA_SNAPSHOT_ANNOTATION = 'lifecycle.goodrx.com/dev-mode-hpa-snapshot'; @@ -206,7 +206,7 @@ export class DevModeManager { throw error; } - logger.info(`Enabled dev mode: deployment=${resolvedDeploymentName} namespace=${opts.namespace}`); + logger().info(`DevMode: enabled deployment=${resolvedDeploymentName} namespace=${opts.namespace}`); return snapshot; } @@ -236,10 +236,9 @@ export class DevModeManager { await this.cleanupServicePatch(namespace, service); } } catch (error) { - logger.warn( - `Failed to revert service ports during dev mode cleanup: service=${serviceName} namespace=${namespace} err=${ - (error as Error).message - }` + logger().warn( + { error, serviceName, namespace }, + `DevMode: service revert failed service=${serviceName} namespace=${namespace}` ); } } @@ -260,15 +259,14 @@ export class DevModeManager { ); } } catch (error) { - logger.warn( - `Failed to restore HorizontalPodAutoscaler during dev mode cleanup: deployment=${resolvedDeploymentName} namespace=${namespace} err=${ - (error as Error).message - }` + logger().warn( + { error, deploymentName: resolvedDeploymentName, namespace }, + `DevMode: hpa restore failed deployment=${resolvedDeploymentName} namespace=${namespace}` ); } } - logger.info(`Disabled dev mode patch: deployment=${resolvedDeploymentName} namespace=${namespace}`); + logger().info(`DevMode: disabled deployment=${resolvedDeploymentName} namespace=${namespace}`); } private async resolveDeployment(namespace: string, deploymentName: string): Promise { @@ -456,7 +454,7 @@ export class DevModeManager { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); - logger.info(`Pinned HorizontalPodAutoscaler to a single replica: hpa=${hpaName} namespace=${namespace}`); + logger().info(`DevMode: hpa pinned hpa=${hpaName} namespace=${namespace} replicas=1`); } private async patchService(opts: DevModeOptions, existing: k8s.V1Service): Promise { @@ -497,7 +495,7 @@ export class DevModeManager { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); - logger.info(`Patched service targetPort: service=${serviceName} port=${devPort}`); + logger().info(`DevMode: service patched service=${serviceName} port=${devPort}`); } private async cleanupDeploymentPatch( @@ -515,11 +513,11 @@ export class DevModeManager { if (!desiredTemplate) { const fallbackPatch = this.buildFallbackCleanupPatch(existing); if (fallbackPatch.length === 0) { - logger.warn(`Skipping dev mode cleanup; last-applied annotation missing deployment=${deploymentName}`); + logger().warn(`DevMode: cleanup skipped reason=last_applied_missing deployment=${deploymentName}`); return; } - logger.warn(`Using fallback dev mode cleanup; last-applied annotation missing deployment=${deploymentName}`); + logger().warn(`DevMode: cleanup fallback deployment=${deploymentName}`); await this.appsApi.patchNamespacedDeployment( deploymentName, namespace, @@ -624,7 +622,7 @@ export class DevModeManager { const annotation = existing.metadata?.annotations?.['kubectl.kubernetes.io/last-applied-configuration']; if (!annotation) { - logger.warn(`Skipping service port revert; last-applied annotation missing service=${serviceName}`); + logger().warn(`DevMode: service revert skipped reason=last_applied_missing service=${serviceName}`); return; } @@ -634,7 +632,7 @@ export class DevModeManager { try { originalSpec = JSON.parse(annotation); } catch { - logger.warn(`Failed to parse last-applied service annotation: service=${serviceName}`); + logger().warn(`DevMode: service annotation parse failed service=${serviceName}`); return; } @@ -665,7 +663,7 @@ export class DevModeManager { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); - logger.info(`Reverted service ports: service=${serviceName} namespace=${namespace}`); + logger().info(`DevMode: service reverted service=${serviceName} namespace=${namespace}`); } private getLastAppliedTemplate(existing: k8s.V1Deployment): AppliedDeploymentTemplate | null { @@ -677,7 +675,7 @@ export class DevModeManager { try { return JSON.parse(annotation) as AppliedDeploymentTemplate; } catch (error) { - logger.warn({ error }, 'Failed to parse last-applied deployment annotation during dev mode cleanup'); + logger().warn({ error }, 'DevMode: deployment annotation parse failed'); return null; } } @@ -726,7 +724,7 @@ export class DevModeManager { try { return JSON.parse(annotation) as T; } catch (error) { - logger.warn({ error }, 'Failed to parse dev mode snapshot annotation'); + logger().warn({ error }, 'DevMode: snapshot annotation parse failed'); return null; } } @@ -872,7 +870,7 @@ export class DevModeManager { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); - logger.info(`Restored service ports from snapshot: service=${serviceName} namespace=${namespace}`); + logger().info(`DevMode: service restored service=${serviceName} namespace=${namespace}`); } private async restoreHorizontalPodAutoscalerFromSnapshot( @@ -899,7 +897,7 @@ export class DevModeManager { undefined, { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } } ); - logger.info(`Restored HorizontalPodAutoscaler from snapshot: hpa=${hpaName} namespace=${namespace}`); + logger().info(`DevMode: hpa restored hpa=${hpaName} namespace=${namespace}`); } private appendValuePatch( diff --git a/src/server/lib/agentSession/editorServiceFactory.ts b/src/server/lib/agentSession/editorServiceFactory.ts index 2b7a752..9d0b125 100644 --- a/src/server/lib/agentSession/editorServiceFactory.ts +++ b/src/server/lib/agentSession/editorServiceFactory.ts @@ -59,9 +59,7 @@ export async function createAgentEditorService( }; const { body: result } = await coreApi.createNamespacedService(namespace, service); - logger.info( - `editorServiceFactory: created Service name=${serviceName} namespace=${namespace} port=${AGENT_EDITOR_PORT}` - ); + logger.info(`AgentEditor: ready serviceName=${serviceName} namespace=${namespace} port=${AGENT_EDITOR_PORT}`); return result; } @@ -71,12 +69,10 @@ export async function deleteAgentEditorService(namespace: string, serviceName: s try { await coreApi.deleteNamespacedService(serviceName, namespace); - logger.info(`editorServiceFactory: deleted Service name=${serviceName} namespace=${namespace}`); + logger.info(`AgentEditor: cleaned serviceName=${serviceName} namespace=${namespace}`); } catch (error: any) { if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { - logger.info( - `editorServiceFactory: Service not found (already deleted) name=${serviceName} namespace=${namespace}` - ); + logger.info(`AgentEditor: cleanup skipped reason=not_found serviceName=${serviceName} namespace=${namespace}`); return; } diff --git a/src/server/lib/agentSession/execProxy.ts b/src/server/lib/agentSession/execProxy.ts index 802701a..7badb24 100644 --- a/src/server/lib/agentSession/execProxy.ts +++ b/src/server/lib/agentSession/execProxy.ts @@ -19,7 +19,7 @@ import { Writable, Readable } from 'stream'; import { getLogger } from 'server/lib/logger'; import { resolveAgentSessionClaudeConfig } from './runtimeConfig'; -const logger = getLogger(); +const logger = () => getLogger(); const CLAUDE_HOME = '/home/claude/.claude'; function shellEscape(value: string): string { @@ -84,7 +84,10 @@ async function sendSignalToClaudeProcess( false ); } catch (err: any) { - logger.warn(`Failed to send ${signal} to claude process: pod=${podName} err=${err?.message}`); + logger().warn( + { error: err, namespace, podName, signal }, + `AgentExec: signal failed process=claude signal=${signal} podName=${podName} namespace=${namespace}` + ); } } @@ -147,7 +150,10 @@ export async function attachToAgentPod( }); } } catch (err: any) { - logger.error(`Failed to exec into agent pod: name=${podName} err=${err?.message}`); + logger().error( + { error: err, namespace, podName }, + `AgentExec: attach failed podName=${podName} namespace=${namespace}` + ); throw err; } diff --git a/src/server/lib/agentSession/forwardedEnv.ts b/src/server/lib/agentSession/forwardedEnv.ts index 6a7a128..b993564 100644 --- a/src/server/lib/agentSession/forwardedEnv.ts +++ b/src/server/lib/agentSession/forwardedEnv.ts @@ -23,7 +23,7 @@ import { parseSecretRefsFromEnv, SecretRefWithEnvKey } from 'server/lib/secretRe import { SecretProcessor } from 'server/services/secretProcessor'; import GlobalConfigService from 'server/services/globalConfig'; -const logger = getLogger(); +const logger = () => getLogger(); export interface ForwardedAgentEnvService { name: string; @@ -191,7 +191,10 @@ export async function cleanupForwardedAgentEnvSecrets( continue; } - logger.warn({ error, namespace, secretName }, 'Agent forwarded env secret cleanup failed'); + logger().warn( + { error, namespace, secretName }, + `Secret: cleanup failed type=forwarded_agent_env name=${secretName} namespace=${namespace}` + ); } } } diff --git a/src/server/lib/agentSession/githubToken.ts b/src/server/lib/agentSession/githubToken.ts index 6772e84..6a07016 100644 --- a/src/server/lib/agentSession/githubToken.ts +++ b/src/server/lib/agentSession/githubToken.ts @@ -18,7 +18,7 @@ import type { NextRequest } from 'next/server'; import { getLogger } from 'server/lib/logger'; import GlobalConfigService from 'server/services/globalConfig'; -const logger = getLogger(); +const logger = () => getLogger(); function getBearerToken(req: NextRequest): string | null { const authHeader = req.headers.get('authorization') || req.headers.get('Authorization'); @@ -55,9 +55,7 @@ function parseBrokerTokenResponse(body: string): string | null { export async function fetchGitHubBrokerToken(keycloakAccessToken: string): Promise { const issuer = process.env.KEYCLOAK_ISSUER_INTERNAL?.trim() || process.env.KEYCLOAK_ISSUER?.trim(); if (!issuer) { - logger.warn( - 'GitHub broker token lookup skipped because neither KEYCLOAK_ISSUER_INTERNAL nor KEYCLOAK_ISSUER is configured' - ); + logger().warn('GitHub: broker token skipped reason=issuer_missing'); return null; } @@ -69,11 +67,11 @@ export async function fetchGitHubBrokerToken(keycloakAccessToken: string): Promi }); if (!response.ok) { - logger.warn( + logger().warn( { status: response.status, }, - 'GitHub broker token lookup failed' + `GitHub: broker token failed status=${response.status}` ); return null; } @@ -86,7 +84,7 @@ export async function resolveRequestGitHubToken(req: NextRequest): Promise { return false; } const logger = getLogger(); - logger.warn(`gvisorCheck: failed to check RuntimeClass error=${error?.message || error}`); + logger.warn({ error }, 'Session: runtime check failed name=gvisor'); cachedResult = false; cacheTimestamp = now; return false; diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index 1aa4c79..38c4678 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -21,7 +21,14 @@ import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; import type { RequestUserIdentity } from 'server/lib/get-user'; import { buildPodEnvWithSecrets } from 'server/lib/secretEnvBuilder'; import type { SecretRefWithEnvKey } from 'server/lib/secretRefs'; -import { AGENT_WORKSPACE_SUBPATH } from './workspace'; +import { + AGENT_EDITOR_WORKSPACE_FILE, + AGENT_WORKSPACE_SUBPATH, + buildAgentEditorWorkspaceContents, + normalizeAgentWorkspaceRepo, + repoNameFromRepoUrl, + type AgentSessionWorkspaceRepo, +} from './workspace'; export const AGENT_EDITOR_PORT = parseInt(process.env.AGENT_EDITOR_PORT || '13337', 10); const AGENT_WORKSPACE_VOLUME_ROOT = '/workspace-volume'; @@ -114,10 +121,11 @@ export interface AgentPodOpts { apiKeySecretName: string; hasGitHubToken?: boolean; model: string; - repoUrl: string; - branch: string; + repoUrl?: string; + branch?: string; revision?: string; workspacePath: string; + workspaceRepos?: AgentSessionWorkspaceRepo[]; installCommand?: string; claudeMdContent?: string; claudePermissions?: { @@ -231,6 +239,49 @@ function buildWorkspaceVolumeMount(workspacePath: string): k8s.V1VolumeMount { }; } +function escapeSingleQuotedShell(value: string): string { + return value.replace(/'/g, `'"'"'`); +} + +function resolveEditorWorkspaceRepos(opts: AgentPodOpts): AgentSessionWorkspaceRepo[] { + if (opts.workspaceRepos?.length) { + return opts.workspaceRepos; + } + + const repo = repoNameFromRepoUrl(opts.repoUrl); + if (!repo || !opts.branch) { + return []; + } + + return [ + { + ...normalizeAgentWorkspaceRepo( + { + repo, + repoUrl: opts.repoUrl!, + branch: opts.branch, + revision: opts.revision || null, + }, + true + ), + mountPath: opts.workspacePath, + }, + ]; +} + +function generateEditorWorkspaceInitScript(workspaceRepos: AgentSessionWorkspaceRepo[]): string { + const workspaceJson = buildAgentEditorWorkspaceContents(workspaceRepos); + + return [ + '#!/bin/sh', + 'set -e', + `cat > '${escapeSingleQuotedShell(AGENT_EDITOR_WORKSPACE_FILE)}' << 'WORKSPACE_EOF'`, + workspaceJson, + 'WORKSPACE_EOF', + '', + ].join('\n'); +} + export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { const { podName, @@ -260,6 +311,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { branch, revision, workspacePath, + workspaceRepos: opts.workspaceRepos, installCommand, claudeMdContent, claudePermissions, @@ -277,6 +329,8 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { const userEnv = buildUserIdentityEnv(userIdentity); const githubTokenEnv = buildGitHubTokenEnv(apiKeySecretName, hasGitHubToken); const workspaceVolumeMount = buildWorkspaceVolumeMount(workspacePath); + const editorWorkspaceRepos = resolveEditorWorkspaceRepos(opts); + const editorWorkspaceInitScript = generateEditorWorkspaceInitScript(editorWorkspaceRepos); const forwardedAgentEnv = opts.forwardedAgentEnv || {}; const forwardedAgentSecretEnv = buildPodEnvWithSecrets( forwardedAgentEnv, @@ -296,6 +350,93 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { }, }; + const initContainers: k8s.V1Container[] = []; + + if (!skipWorkspaceBootstrap) { + initContainers.push( + { + name: 'prepare-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + { + name: 'workspace', + mountPath: AGENT_WORKSPACE_VOLUME_ROOT, + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + }, + { + name: 'init-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', initScript], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + workspaceVolumeMount, + { + name: 'claude-config', + mountPath: '/home/claude/.claude', + }, + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'HOME', value: '/home/claude/.claude' }, + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ...forwardedAgentSecretEnv, + ...githubTokenEnv, + ...userEnv, + ], + } + ); + } + + initContainers.push({ + name: 'prepare-editor-workspace', + image, + imagePullPolicy: 'IfNotPresent', + command: ['sh', '-c', editorWorkspaceInitScript], + resources, + securityContext: { + ...securityContext, + readOnlyRootFilesystem: false, + }, + volumeMounts: [ + { + name: 'tmp', + mountPath: '/tmp', + }, + ], + env: [ + { name: 'TMPDIR', value: '/tmp' }, + { name: 'TMP', value: '/tmp' }, + { name: 'TEMP', value: '/tmp' }, + ], + }); + const pod: k8s.V1Pod = { apiVersion: 'v1', kind: 'Pod', @@ -321,69 +462,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { type: 'RuntimeDefault', }, }, - ...(skipWorkspaceBootstrap - ? {} - : { - initContainers: [ - { - name: 'prepare-workspace', - image, - imagePullPolicy: 'IfNotPresent', - command: ['sh', '-c', `mkdir -p "${AGENT_WORKSPACE_VOLUME_ROOT}/${AGENT_WORKSPACE_SUBPATH}"`], - resources, - securityContext: { - ...securityContext, - readOnlyRootFilesystem: false, - }, - volumeMounts: [ - { - name: 'workspace', - mountPath: AGENT_WORKSPACE_VOLUME_ROOT, - }, - { - name: 'tmp', - mountPath: '/tmp', - }, - ], - env: [ - { name: 'TMPDIR', value: '/tmp' }, - { name: 'TMP', value: '/tmp' }, - { name: 'TEMP', value: '/tmp' }, - ], - }, - { - name: 'init-workspace', - image, - imagePullPolicy: 'IfNotPresent', - command: ['sh', '-c', initScript], - resources, - securityContext: { - ...securityContext, - readOnlyRootFilesystem: false, - }, - volumeMounts: [ - workspaceVolumeMount, - { - name: 'claude-config', - mountPath: '/home/claude/.claude', - }, - { - name: 'tmp', - mountPath: '/tmp', - }, - ], - env: [ - { name: 'HOME', value: '/home/claude/.claude' }, - { name: 'TMPDIR', value: '/tmp' }, - { name: 'TMP', value: '/tmp' }, - { name: 'TEMP', value: '/tmp' }, - ...forwardedAgentSecretEnv, - ...githubTokenEnv, - ...userEnv, - ], - }, - ], - }), + initContainers, containers: [ { name: 'agent', @@ -429,7 +508,7 @@ export function buildAgentPodSpec(opts: AgentPodOpts): k8s.V1Pod { image: editorImage, imagePullPolicy: 'IfNotPresent', args: [ - workspacePath, + AGENT_EDITOR_WORKSPACE_FILE, '--auth', 'none', '--bind-addr', @@ -524,7 +603,7 @@ async function getContainerLogs( } catch (error) { getLogger().debug( { error, namespace, podName, containerName }, - `podFactory: unable to fetch logs for container name=${containerName} namespace=${namespace}` + `Session: logs fetch failed containerName=${containerName} namespace=${namespace} podName=${podName}` ); return null; } @@ -590,7 +669,7 @@ async function waitForAgentPodReady( if (containerLogs) { getLogger().error( { namespace, podName, containerName: failingContainer, logs: containerLogs }, - `podFactory: startup logs for failing container name=${failingContainer} namespace=${namespace}` + `Session: startup logs captured containerName=${failingContainer} namespace=${namespace} podName=${podName}` ); } @@ -610,7 +689,7 @@ async function waitForAgentPodReady( if (timeoutLogs) { getLogger().error( { namespace, podName, containerName: 'init-workspace', logs: timeoutLogs }, - `podFactory: init-workspace logs after startup timeout namespace=${namespace}` + `Session: timeout logs captured containerName=init-workspace namespace=${namespace} podName=${podName}` ); } @@ -629,7 +708,7 @@ export async function createAgentPod(opts: AgentPodOpts): Promise { await coreApi.createNamespacedPod(opts.namespace, pod); const result = await waitForAgentPodReady(coreApi, opts.namespace, opts.podName, opts.readiness); - logger.info(`podFactory: created pod name=${opts.podName} namespace=${opts.namespace}`); + logger.info(`Session: pod ready podName=${opts.podName} namespace=${opts.namespace}`); return result; } @@ -639,10 +718,10 @@ export async function deleteAgentPod(namespace: string, podName: string): Promis try { await coreApi.deleteNamespacedPod(podName, namespace); - logger.info(`podFactory: deleted pod name=${podName} namespace=${namespace}`); + logger.info(`Session: pod cleaned podName=${podName} namespace=${namespace}`); } catch (error: any) { if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { - logger.info(`podFactory: pod not found (already deleted) name=${podName} namespace=${namespace}`); + logger.info(`Session: pod cleanup skipped reason=not_found podName=${podName} namespace=${namespace}`); return; } throw error; diff --git a/src/server/lib/agentSession/prewarmJobFactory.ts b/src/server/lib/agentSession/prewarmJobFactory.ts index 8594b50..bf9c50b 100644 --- a/src/server/lib/agentSession/prewarmJobFactory.ts +++ b/src/server/lib/agentSession/prewarmJobFactory.ts @@ -20,7 +20,7 @@ import { generateInitScript, InitScriptOpts } from './configSeeder'; import { buildLifecycleLabels } from 'server/lib/kubernetes/labels'; import { buildPodEnvWithSecrets } from 'server/lib/secretEnvBuilder'; import type { SecretRefWithEnvKey } from 'server/lib/secretRefs'; -import { AGENT_WORKSPACE_SUBPATH } from './workspace'; +import { AGENT_WORKSPACE_SUBPATH, type AgentSessionWorkspaceRepo } from './workspace'; import { JobMonitor } from 'server/lib/kubernetes/JobMonitor'; const AGENT_WORKSPACE_VOLUME_ROOT = '/workspace-volume'; @@ -33,10 +33,11 @@ export interface AgentPrewarmJobOpts { image: string; apiKeySecretName: string; hasGitHubToken?: boolean; - repoUrl: string; - branch: string; + repoUrl?: string; + branch?: string; revision?: string; workspacePath: string; + workspaceRepos?: AgentSessionWorkspaceRepo[]; installCommand?: string; forwardedAgentEnv?: Record; forwardedAgentSecretRefs?: SecretRefWithEnvKey[]; @@ -68,6 +69,7 @@ export function buildAgentPrewarmJobSpec(opts: AgentPrewarmJobOpts): k8s.V1Job { branch: opts.branch, revision: opts.revision, workspacePath: opts.workspacePath, + workspaceRepos: opts.workspaceRepos, installCommand: opts.installCommand, useGitHubToken: opts.hasGitHubToken, }; @@ -257,7 +259,7 @@ export async function createAgentPrewarmJob(opts: AgentPrewarmJobOpts): Promise< const batchApi = getBatchApi(); const job = buildAgentPrewarmJobSpec(opts); const { body } = await batchApi.createNamespacedJob(opts.namespace, job); - getLogger().info(`agentPrewarmJobFactory: created Job name=${opts.jobName} namespace=${opts.namespace}`); + getLogger().info(`Prewarm: job started jobName=${opts.jobName} namespace=${opts.namespace}`); return body; } diff --git a/src/server/lib/agentSession/pvcFactory.ts b/src/server/lib/agentSession/pvcFactory.ts index 872c7e9..b9a7add 100644 --- a/src/server/lib/agentSession/pvcFactory.ts +++ b/src/server/lib/agentSession/pvcFactory.ts @@ -66,7 +66,7 @@ export async function createAgentPvc( const { body: result } = await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc); logger.info( - `pvcFactory: created PVC name=${pvcName} namespace=${namespace} size=${storageSize} accessMode=${accessMode}` + `AgentRuntime: workspace prepared pvcName=${pvcName} namespace=${namespace} size=${storageSize} accessMode=${accessMode}` ); return result; } @@ -77,10 +77,10 @@ export async function deleteAgentPvc(namespace: string, pvcName: string): Promis try { await coreApi.deleteNamespacedPersistentVolumeClaim(pvcName, namespace); - logger.info(`pvcFactory: deleted PVC name=${pvcName} namespace=${namespace}`); + logger.info(`AgentRuntime: workspace cleaned pvcName=${pvcName} namespace=${namespace}`); } catch (error: any) { if (error instanceof k8s.HttpError && error.response?.statusCode === 404) { - logger.info(`pvcFactory: PVC not found (already deleted) name=${pvcName} namespace=${namespace}`); + logger.info(`AgentRuntime: workspace cleanup skipped reason=not_found pvcName=${pvcName} namespace=${namespace}`); return; } throw error; diff --git a/src/server/lib/agentSession/servicePlan.ts b/src/server/lib/agentSession/servicePlan.ts new file mode 100644 index 0000000..5d9855f --- /dev/null +++ b/src/server/lib/agentSession/servicePlan.ts @@ -0,0 +1,281 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DevConfig } from 'server/models/yaml/YamlService'; +import { + AGENT_WORKSPACE_ROOT, + normalizeAgentWorkspaceRepo, + repoNameFromRepoUrl, + rewriteWorkspacePathForRepo, + rewriteWorkspaceScriptForRepo, + type AgentSessionSelectedService, + type AgentSessionWorkspaceRepo, +} from './workspace'; + +type WorkspaceRepoInput = Pick & { + primary?: boolean; +}; + +export interface AgentSessionWorkspaceRepoResolutionOpts { + repoUrl?: string | null; + branch?: string | null; + revision?: string | null; + workspaceRepos?: AgentSessionWorkspaceRepo[] | null; +} + +export interface AgentSessionServiceInput { + name: string; + deployId: number; + devConfig: DevConfig; + resourceName?: string | null; + repo?: string | null; + branch?: string | null; + revision?: string | null; + workspacePath?: string; + workDir?: string | null; +} + +export type ResolvedAgentSessionService = Omit< + T, + 'repo' | 'branch' | 'revision' | 'workspacePath' | 'workDir' | 'devConfig' +> & { + devConfig: DevConfig; + repo: string; + branch: string; + revision: string | null; + workspacePath: string; + workDir: string; +}; + +export function workspaceRepoKey(repo: string): string { + return repo.trim().toLowerCase(); +} + +function rewriteWorkspaceEnvForRepo( + env: Record | undefined, + repoRoot: string +): Record | undefined { + if (!env) { + return env; + } + + return Object.fromEntries( + Object.entries(env).map(([envKey, envValue]) => [ + envKey, + typeof envValue === 'string' ? rewriteWorkspaceScriptForRepo(envValue, repoRoot) : envValue, + ]) + ); +} + +export function rewriteDevConfigForWorkspaceRepo(devConfig: DevConfig, repoRoot: string): DevConfig { + return { + ...devConfig, + ...(devConfig.workDir + ? { workDir: rewriteWorkspacePathForRepo(devConfig.workDir, repoRoot) } + : { workDir: repoRoot }), + ...(devConfig.command ? { command: rewriteWorkspaceScriptForRepo(devConfig.command, repoRoot) } : {}), + ...(devConfig.installCommand + ? { installCommand: rewriteWorkspaceScriptForRepo(devConfig.installCommand, repoRoot) } + : {}), + ...(devConfig.env ? { env: rewriteWorkspaceEnvForRepo(devConfig.env, repoRoot) } : {}), + }; +} + +export function resolveAgentSessionWorkspaceRepos( + opts: AgentSessionWorkspaceRepoResolutionOpts, + services: ReadonlyArray> | undefined +): AgentSessionWorkspaceRepo[] { + const inputs: WorkspaceRepoInput[] = []; + + if (opts.workspaceRepos?.length) { + inputs.push(...opts.workspaceRepos); + } else if (opts.repoUrl && opts.branch) { + const repo = repoNameFromRepoUrl(opts.repoUrl); + if (!repo) { + throw new Error('Unable to resolve repository name from repoUrl'); + } + + inputs.push({ + repo, + repoUrl: opts.repoUrl, + branch: opts.branch, + revision: opts.revision || null, + primary: true, + }); + } + + for (const service of services || []) { + if (!service.repo || !service.branch) { + continue; + } + + inputs.push({ + repo: service.repo, + repoUrl: `https://github.com/${service.repo}.git`, + branch: service.branch, + revision: service.revision || null, + }); + } + + if (inputs.length === 0) { + throw new Error('At least one workspace repository is required'); + } + + const orderedKeys: string[] = []; + const reposByKey = new Map(); + let primaryKey: string | null = null; + + for (const input of inputs) { + const key = workspaceRepoKey(input.repo); + const existing = reposByKey.get(key); + + if (!existing) { + reposByKey.set(key, { ...input }); + orderedKeys.push(key); + } else { + if (existing.branch !== input.branch) { + throw new Error( + `Selected services require conflicting branches for ${input.repo}: ${existing.branch} and ${input.branch}` + ); + } + + if (existing.revision && input.revision && existing.revision !== input.revision) { + throw new Error( + `Selected services require conflicting revisions for ${input.repo}: ${existing.revision} and ${input.revision}` + ); + } + + reposByKey.set(key, { + ...existing, + repoUrl: existing.repoUrl || input.repoUrl, + revision: existing.revision || input.revision || null, + primary: existing.primary || input.primary, + }); + } + + if (input.primary) { + if (primaryKey && primaryKey !== key) { + throw new Error( + `Multiple primary repositories were requested: ${reposByKey.get(primaryKey)?.repo} and ${input.repo}` + ); + } + + primaryKey = key; + } + } + + const resolvedPrimaryKey = primaryKey || orderedKeys[0]; + + return orderedKeys.map((key) => { + const repo = reposByKey.get(key)!; + return normalizeAgentWorkspaceRepo(repo, key === resolvedPrimaryKey); + }); +} + +export function applyWorkspaceReposToServices( + services: ReadonlyArray | undefined, + workspaceRepos: AgentSessionWorkspaceRepo[] +): { + services: Array> | undefined; + selectedServices: AgentSessionSelectedService[]; +} { + if (!services?.length) { + return { services: services ? [] : undefined, selectedServices: [] }; + } + + const primaryRepo = workspaceRepos.find((repo) => repo.primary) || workspaceRepos[0]; + const reposByKey = new Map(workspaceRepos.map((repo) => [workspaceRepoKey(repo.repo), repo])); + + const adjustedServices = services.map((service) => { + const serviceRepo = service.repo || primaryRepo.repo; + const repo = reposByKey.get(workspaceRepoKey(serviceRepo)); + if (!repo) { + throw new Error(`Workspace repository missing for selected service ${service.name} in ${serviceRepo}`); + } + + const effectiveBranch = service.branch || repo.branch; + const effectiveRevision = service.revision || repo.revision || null; + const effectiveDevConfig = rewriteDevConfigForWorkspaceRepo(service.devConfig, repo.mountPath); + const effectiveWorkDir = effectiveDevConfig.workDir || repo.mountPath; + + return { + ...service, + repo: repo.repo, + branch: effectiveBranch, + revision: effectiveRevision, + workspacePath: repo.mountPath, + workDir: effectiveWorkDir, + devConfig: effectiveDevConfig, + } satisfies ResolvedAgentSessionService; + }); + + return { + services: adjustedServices, + selectedServices: adjustedServices.map((service) => ({ + name: service.name, + deployId: service.deployId, + repo: service.repo, + branch: service.branch, + revision: service.revision || null, + resourceName: service.resourceName || null, + workspacePath: service.workspacePath, + workDir: service.workDir || null, + })), + }; +} + +export function resolveAgentSessionServicePlan( + opts: AgentSessionWorkspaceRepoResolutionOpts, + services: ReadonlyArray | undefined +): { + workspaceRepos: AgentSessionWorkspaceRepo[]; + services: Array> | undefined; + selectedServices: AgentSessionSelectedService[]; +} { + const workspaceRepos = resolveAgentSessionWorkspaceRepos(opts, services); + const resolvedServices = applyWorkspaceReposToServices(services, workspaceRepos); + + return { + workspaceRepos, + ...resolvedServices, + }; +} + +export function buildCombinedInstallCommand>( + services: ReadonlyArray | undefined +): string | undefined { + const installCommands = (services || []) + .map((service) => { + const installCommand = service.devConfig.installCommand?.trim(); + const workspacePath = service.workspacePath?.trim(); + if (!installCommand || !workspacePath) { + return null; + } + + if ( + workspacePath === AGENT_WORKSPACE_ROOT || + installCommand.includes(AGENT_WORKSPACE_ROOT) || + /(^|\n)\s*cd\s+/.test(installCommand) + ) { + return installCommand; + } + + return `cd "${workspacePath}"\n${installCommand}`; + }) + .filter((command): command is string => Boolean(command)); + + return installCommands.length > 0 ? installCommands.join('\n\n') : undefined; +} diff --git a/src/server/lib/agentSession/systemPrompt.ts b/src/server/lib/agentSession/systemPrompt.ts index 042b215..1c08a32 100644 --- a/src/server/lib/agentSession/systemPrompt.ts +++ b/src/server/lib/agentSession/systemPrompt.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import AgentSession from 'server/models/AgentSession'; import Build from 'server/models/Build'; import Deploy from 'server/models/Deploy'; import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml'; @@ -22,6 +23,9 @@ import type { LifecycleConfig } from 'server/models/yaml'; export interface AgentSessionPromptServiceContext { name: string; publicUrl?: string; + repo?: string; + branch?: string; + workspacePath?: string; workDir?: string; } @@ -63,7 +67,10 @@ export function buildAgentSessionDynamicSystemPrompt(context: AgentSessionPrompt const services = [...context.services].sort((left, right) => left.name.localeCompare(right.name)); for (const service of services) { const details = [ + service.repo ? `repo=${service.repo}` : null, + service.branch ? `branch=${service.branch}` : null, service.publicUrl ? `publicUrl=${service.publicUrl}` : null, + service.workspacePath ? `workspacePath=${service.workspacePath}` : null, service.workDir ? `workDir=${service.workDir}` : null, ].filter((value): value is string => Boolean(value)); @@ -117,44 +124,69 @@ async function resolveBuildSource(buildUuid?: string | null): Promise<{ repo?: s export async function resolveAgentSessionPromptContext( lookup: SessionPromptLookupContext ): Promise { - const deploys = await Deploy.query() - .where({ devModeSessionId: lookup.sessionDbId }) - .withGraphFetched('[deployable, repository, service]'); - const buildSource = await resolveBuildSource(lookup.buildUuid); + const [session, deploys, buildSource] = await Promise.all([ + AgentSession.query().findById(lookup.sessionDbId), + Deploy.query() + .where({ devModeSessionId: lookup.sessionDbId }) + .withGraphFetched('[deployable, repository, service]'), + resolveBuildSource(lookup.buildUuid), + ]); const lifecycleConfigCache = new Map>(); + const deployById = new Map(deploys.filter((deploy) => deploy.id != null).map((deploy) => [deploy.id, deploy])); - const services = await Promise.all( - deploys.map(async (deploy): Promise => { - const serviceName = - normalizeOptionalString(deploy.deployable?.name) || - normalizeOptionalString(deploy.service?.name) || - normalizeOptionalString(deploy.uuid); + let services: AgentSessionPromptServiceContext[]; - if (!serviceName) { - return null; - } - - const repositoryName = normalizeOptionalString(deploy.repository?.fullName) || buildSource.repo; - const branchName = normalizeOptionalString(deploy.branchName) || buildSource.branch; - - let workDir: string | undefined; - if (repositoryName && branchName) { - const lifecycleConfig = await fetchCachedLifecycleConfig(repositoryName, branchName, lifecycleConfigCache); - const yamlService = lifecycleConfig ? getDeployingServicesByName(lifecycleConfig, serviceName) : undefined; - workDir = normalizeOptionalString(yamlService?.dev?.workDir); - } + if (session?.selectedServices?.length) { + services = session.selectedServices.map((service) => { + const deploy = deployById.get(service.deployId); return { - name: serviceName, - publicUrl: formatPublicUrl(deploy.publicUrl), - workDir, + name: service.name, + publicUrl: formatPublicUrl(deploy?.publicUrl), + repo: normalizeOptionalString(service.repo), + branch: normalizeOptionalString(service.branch), + workspacePath: normalizeOptionalString(service.workspacePath), + workDir: normalizeOptionalString(service.workDir) || normalizeOptionalString(service.workspacePath), }; - }) - ); + }); + } else { + services = ( + await Promise.all( + deploys.map(async (deploy): Promise => { + const serviceName = + normalizeOptionalString(deploy.deployable?.name) || + normalizeOptionalString(deploy.service?.name) || + normalizeOptionalString(deploy.uuid); + + if (!serviceName) { + return null; + } + + const repositoryName = normalizeOptionalString(deploy.repository?.fullName) || buildSource.repo; + const branchName = normalizeOptionalString(deploy.branchName) || buildSource.branch; + + let workDir: string | undefined; + if (repositoryName && branchName) { + const lifecycleConfig = await fetchCachedLifecycleConfig(repositoryName, branchName, lifecycleConfigCache); + const yamlService = lifecycleConfig ? getDeployingServicesByName(lifecycleConfig, serviceName) : undefined; + workDir = normalizeOptionalString(yamlService?.dev?.workDir); + } + + return { + name: serviceName, + publicUrl: formatPublicUrl(deploy.publicUrl), + repo: repositoryName, + branch: branchName, + workDir, + }; + }) + ) + ).filter((service): service is AgentSessionPromptServiceContext => Boolean(service)); + } return { namespace: lookup.namespace, buildUuid: lookup.buildUuid, - services: services.filter((service): service is AgentSessionPromptServiceContext => Boolean(service)), + services, }; } diff --git a/src/server/lib/agentSession/workspace.ts b/src/server/lib/agentSession/workspace.ts index d522e17..7ab69a7 100644 --- a/src/server/lib/agentSession/workspace.ts +++ b/src/server/lib/agentSession/workspace.ts @@ -14,5 +14,111 @@ * limitations under the License. */ +import { posix as pathPosix } from 'path'; + export const AGENT_WORKSPACE_ROOT = '/workspace'; export const AGENT_WORKSPACE_SUBPATH = 'repo'; +export const AGENT_EDITOR_WORKSPACE_FILE = '/tmp/agent-session.code-workspace'; +const AGENT_ADDITIONAL_REPOS_ROOT = `${AGENT_WORKSPACE_ROOT}/repos`; + +export interface AgentSessionWorkspaceRepo { + repo: string; + repoUrl: string; + branch: string; + revision?: string | null; + mountPath: string; + primary?: boolean; +} + +export interface AgentSessionSelectedService { + name: string; + deployId: number; + repo: string; + branch: string; + revision?: string | null; + resourceName?: string | null; + workspacePath: string; + workDir?: string | null; +} + +function splitRepoFullName(repo: string): { owner: string; name: string } { + const [owner, name] = repo.split('/'); + if (!owner || !name) { + throw new Error(`Invalid repository full name: ${repo}`); + } + + return { owner, name }; +} + +export function repoNameFromRepoUrl(repoUrl?: string | null): string | null { + if (!repoUrl) { + return null; + } + + const normalized = repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, ''); + return normalized || null; +} + +export function buildAgentWorkspaceRepoMountPath(repo: string, primary = false): string { + if (primary) { + return AGENT_WORKSPACE_ROOT; + } + + const { owner, name } = splitRepoFullName(repo); + return pathPosix.join(AGENT_ADDITIONAL_REPOS_ROOT, owner, name); +} + +export function normalizeAgentWorkspaceRepo( + repo: Pick, + primary = false +): AgentSessionWorkspaceRepo { + return { + repo: repo.repo, + repoUrl: repo.repoUrl, + branch: repo.branch, + revision: repo.revision || null, + mountPath: buildAgentWorkspaceRepoMountPath(repo.repo, primary), + primary, + }; +} + +export function rewriteWorkspacePathForRepo(value: string, repoRoot: string): string { + if (!value.trim()) { + return value; + } + + if (value === AGENT_WORKSPACE_ROOT) { + return repoRoot; + } + + if (value.startsWith(`${AGENT_WORKSPACE_ROOT}/`)) { + return `${repoRoot}${value.slice(AGENT_WORKSPACE_ROOT.length)}`; + } + + if (value.startsWith('/')) { + return value; + } + + return pathPosix.join(repoRoot, value); +} + +export function rewriteWorkspaceScriptForRepo(value: string, repoRoot: string): string { + if (!value.trim() || repoRoot === AGENT_WORKSPACE_ROOT) { + return value; + } + + return value.split(AGENT_WORKSPACE_ROOT).join(repoRoot); +} + +export function buildAgentEditorWorkspaceContents(workspaceRepos: AgentSessionWorkspaceRepo[]): string { + return JSON.stringify( + { + folders: workspaceRepos.map((repo) => ({ + name: repo.repo, + path: repo.mountPath, + })), + }, + null, + 2 + ); +} diff --git a/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts b/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts index 1ab14fb..e2a0b3f 100644 --- a/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts +++ b/src/server/lib/kubernetes/__tests__/getDeploymentPods.test.ts @@ -17,6 +17,7 @@ var mockListNamespacedDeployment: jest.Mock; var mockListNamespacedStatefulSet: jest.Mock; var mockListNamespacedPod: jest.Mock; +var mockBuildFindOne: jest.Mock; jest.mock('@kubernetes/client-node', () => { const actual = jest.requireActual('@kubernetes/client-node'); @@ -60,6 +61,15 @@ jest.mock('server/lib/logger', () => ({ }), })); +jest.mock('server/models/Build', () => ({ + __esModule: true, + default: { + query: jest.fn(() => ({ + findOne: (...args: unknown[]) => mockBuildFindOne(...args), + })), + }, +})); + import { getDeploymentPods } from '../getDeploymentPods'; function buildPod({ @@ -106,6 +116,11 @@ function buildPod({ describe('getDeploymentPods', () => { beforeEach(() => { jest.clearAllMocks(); + mockBuildFindOne = jest.fn().mockReturnValue({ + select: jest.fn().mockResolvedValue({ + namespace: 'env-sample-env', + }), + }); mockListNamespacedDeployment.mockResolvedValue({ body: { items: [ @@ -186,6 +201,43 @@ describe('getDeploymentPods', () => { ); }); + it('uses the build namespace for sandbox builds', async () => { + mockBuildFindOne.mockReturnValue({ + select: jest.fn().mockResolvedValue({ + namespace: 'sbx-sample-env', + }), + }); + mockListNamespacedPod.mockResolvedValue({ + body: { + items: [ + buildPod({ + name: 'sandbox-active', + createdAt: '2026-03-27T19:00:00.000Z', + }), + ], + }, + }); + + await getDeploymentPods('sample-service', 'sample-env'); + + expect(mockListNamespacedDeployment).toHaveBeenCalledWith( + 'sbx-sample-env', + undefined, + undefined, + undefined, + undefined, + 'app.kubernetes.io/instance=sample-service-sample-env' + ); + expect(mockListNamespacedPod).toHaveBeenCalledWith( + 'sbx-sample-env', + undefined, + undefined, + undefined, + undefined, + 'app=sample-service' + ); + }); + it('returns an empty list when every pod is terminal', async () => { mockListNamespacedPod.mockResolvedValue({ body: { diff --git a/src/server/lib/kubernetes/getDeploymentPods.ts b/src/server/lib/kubernetes/getDeploymentPods.ts index ce73c12..3a9dc15 100644 --- a/src/server/lib/kubernetes/getDeploymentPods.ts +++ b/src/server/lib/kubernetes/getDeploymentPods.ts @@ -17,6 +17,7 @@ import * as k8s from '@kubernetes/client-node'; import { getLogger } from 'server/lib/logger'; +import Build from 'server/models/Build'; type ContainerState = 'Running' | 'Waiting' | 'Terminated' | 'Unknown'; @@ -40,6 +41,14 @@ export interface PodInfo { containers: ContainerInfo[]; } +async function resolveBuildNamespace(uuid: string): Promise { + const build = await Build.query() + .findOne({ uuid }) + .select('namespace') + .catch(() => null); + return build?.namespace || `env-${uuid}`; +} + function loadKubeConfig(): k8s.KubeConfig { const kc = new k8s.KubeConfig(); try { @@ -171,7 +180,7 @@ export async function getDeploymentPods(deploymentName: string, uuid: string): P const coreV1 = kc.makeApiClient(k8s.CoreV1Api); try { - const namespace = `env-${uuid}`; + const namespace = await resolveBuildNamespace(uuid); const fullDeploymentName = `${deploymentName}-${uuid}`; const workloadSelector = `app.kubernetes.io/instance=${fullDeploymentName}`; diff --git a/src/server/lib/response.ts b/src/server/lib/response.ts index 57c7e6b..7a22441 100644 --- a/src/server/lib/response.ts +++ b/src/server/lib/response.ts @@ -24,7 +24,7 @@ interface Metadata { type SuccessStatusCode = 200 | 201; -type ErrorStatusCode = 400 | 401 | 404 | 409 | 500 | 502; +type ErrorStatusCode = 400 | 401 | 404 | 409 | 500 | 502 | 503; interface SuccessResponse { request_id: string; @@ -54,7 +54,7 @@ export function successResponse(data: T, options: SuccessResponseOptions, req const { status, metadata } = options; const body: SuccessResponse = { - request_id: req.headers.get('x-request-id'), + request_id: req.headers.get('x-request-id') || '', data, error: null, }; @@ -80,7 +80,7 @@ export function errorResponse(error: unknown, options: ErrorResponseOptions, req const { status } = options; const body: ErrorResponse = { - request_id: req.headers.get('x-request-id'), + request_id: req.headers.get('x-request-id') || '', data: null, error: { message: error instanceof Error ? error.message : 'An unknown error occurred.', diff --git a/src/server/models/AgentPrewarm.ts b/src/server/models/AgentPrewarm.ts index 2ad201b..9854820 100644 --- a/src/server/models/AgentPrewarm.ts +++ b/src/server/models/AgentPrewarm.ts @@ -15,6 +15,7 @@ */ import Model from './_Model'; +import type { AgentSessionSelectedService, AgentSessionWorkspaceRepo } from 'server/lib/agentSession/workspace'; export type AgentPrewarmStatus = 'queued' | 'running' | 'ready' | 'error'; @@ -29,6 +30,8 @@ export default class AgentPrewarm extends Model { jobName!: string; status!: AgentPrewarmStatus; services!: string[]; + workspaceRepos!: AgentSessionWorkspaceRepo[]; + serviceRefs!: AgentSessionSelectedService[]; errorMessage!: string | null; completedAt!: string | null; @@ -54,12 +57,14 @@ export default class AgentPrewarm extends Model { jobName: { type: 'string' }, status: { type: 'string', enum: ['queued', 'running', 'ready', 'error'], default: 'queued' }, services: { type: 'array', items: { type: 'string' }, default: [] }, + workspaceRepos: { type: 'array', items: { type: 'object' }, default: [] }, + serviceRefs: { type: 'array', items: { type: 'object' }, default: [] }, errorMessage: { type: ['string', 'null'] }, completedAt: { type: ['string', 'null'] }, }, }; static get jsonAttributes() { - return ['services']; + return ['services', 'workspaceRepos', 'serviceRefs']; } } diff --git a/src/server/models/AgentSession.ts b/src/server/models/AgentSession.ts index 77fb2fc..41e23cc 100644 --- a/src/server/models/AgentSession.ts +++ b/src/server/models/AgentSession.ts @@ -16,6 +16,7 @@ import Model from './_Model'; import type { DevModeResourceSnapshot } from 'server/lib/agentSession/devModeManager'; +import type { AgentSessionSelectedService, AgentSessionWorkspaceRepo } from 'server/lib/agentSession/workspace'; import { BuildKind } from 'shared/constants'; export default class AgentSession extends Model { @@ -33,6 +34,8 @@ export default class AgentSession extends Model { endedAt!: string | null; devModeSnapshots!: Record; forwardedAgentSecretProviders!: string[]; + workspaceRepos!: AgentSessionWorkspaceRepo[]; + selectedServices!: AgentSessionSelectedService[]; static tableName = 'agent_sessions'; static timestamps = true; @@ -60,11 +63,13 @@ export default class AgentSession extends Model { endedAt: { type: ['string', 'null'] }, devModeSnapshots: { type: 'object', default: {} }, forwardedAgentSecretProviders: { type: 'array', items: { type: 'string' }, default: [] }, + workspaceRepos: { type: 'array', items: { type: 'object' }, default: [] }, + selectedServices: { type: 'array', items: { type: 'object' }, default: [] }, }, }; static get jsonAttributes() { - return ['devModeSnapshots', 'forwardedAgentSecretProviders']; + return ['devModeSnapshots', 'forwardedAgentSecretProviders', 'workspaceRepos', 'selectedServices']; } static get relationMappings() { diff --git a/src/server/services/__tests__/agentSandboxSession.test.ts b/src/server/services/__tests__/agentSandboxSession.test.ts index 3d9ae13..395ba19 100644 --- a/src/server/services/__tests__/agentSandboxSession.test.ts +++ b/src/server/services/__tests__/agentSandboxSession.test.ts @@ -102,13 +102,68 @@ describe('agentSandboxSession', () => { requires: [], }); - const includedKeys = await (service as any).resolveDependencyClosure(baseBuild, selectedService, { + const includedDeployIds = await (service as any).resolveDependencyClosure(baseBuild, [selectedService], { repo: 'env/static-environments', branch: 'main', }); - expect([...includedKeys]).toEqual(expect.arrayContaining(['org/frontend::frontend', 'org/api-b::shared-api'])); - expect(includedKeys.has('org/api-a::shared-api')).toBe(false); + expect([...includedDeployIds]).toEqual(expect.arrayContaining([1, 3])); + expect(includedDeployIds.has(2)).toBe(false); + }); + + it('keeps branch identity when resolving duplicate dependency names from the same repo', async () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + const baseBuild = { + uuid: 'base-build', + deploys: [ + { + id: 1, + active: true, + branchName: 'main', + deployable: { name: 'frontend' }, + repository: { fullName: 'org/frontend' }, + }, + { + id: 2, + active: true, + branchName: 'main', + deployable: { name: 'shared-api' }, + repository: { fullName: 'org/api' }, + }, + { + id: 3, + active: true, + branchName: 'release', + deployable: { name: 'shared-api' }, + repository: { fullName: 'org/api' }, + }, + ], + } as any; + const selectedService = { + name: 'frontend', + devConfig: { image: 'node:20', command: 'pnpm dev' }, + baseDeploy: baseBuild.deploys[0], + serviceRepo: 'org/frontend', + serviceBranch: 'main', + yamlService: { + name: 'frontend', + requires: [{ name: 'shared-api', repository: 'org/api', branch: 'release' }], + }, + } as any; + + (fetchLifecycleConfig as jest.Mock).mockResolvedValue({}); + (getDeployingServicesByName as jest.Mock).mockReturnValue({ + name: 'shared-api', + requires: [], + }); + + const includedDeployIds = await (service as any).resolveDependencyClosure(baseBuild, [selectedService], { + repo: 'env/static-environments', + branch: 'main', + }); + + expect([...includedDeployIds]).toEqual(expect.arrayContaining([1, 3])); + expect(includedDeployIds.has(2)).toBe(false); }); it('fails closed when multiple top-level sandbox candidates share the same name', () => { @@ -121,4 +176,68 @@ describe('agentSandboxSession', () => { ]) ).toThrow('Multiple sandbox services matched shared-api'); }); + + it('resolves a repo-qualified sandbox service when names collide', () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + + const selected = (service as any).resolveSelectedService( + { name: 'shared-api', repo: 'org/api-b', branch: 'main' }, + [ + { name: 'shared-api', serviceRepo: 'org/api-a', serviceBranch: 'main' }, + { name: 'shared-api', serviceRepo: 'org/api-b', serviceBranch: 'main' }, + ] + ); + + expect(selected.serviceRepo).toBe('org/api-b'); + expect(selected.serviceBranch).toBe('main'); + }); + + it('resolves multiple selected sandbox services without duplicating matches', () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + + const selected = (service as any).resolveSelectedServices( + [ + { name: 'frontend', repo: 'org/frontend', branch: 'main' }, + { name: 'worker', repo: 'org/worker', branch: 'main' }, + { name: 'frontend', repo: 'org/frontend', branch: 'main' }, + ], + [ + { name: 'frontend', serviceRepo: 'org/frontend', serviceBranch: 'main' }, + { name: 'worker', serviceRepo: 'org/worker', serviceBranch: 'main' }, + ] + ); + + expect(selected).toHaveLength(2); + expect(selected.map((item: any) => item.name)).toEqual(['frontend', 'worker']); + }); + + it('maps selected services to cloned sandbox deploys by base deploy id', () => { + const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any); + const selectedService = { + name: 'lc-test-3', + serviceRepo: 'org/lc-test-3', + baseDeploy: { + id: 42, + repository: { fullName: 'org/lc-test-3' }, + }, + } as any; + const sandboxDeploy = { + id: 7, + uuid: 'lc-test-3-sandbox', + deployable: { name: 'lc-test-3' }, + repository: { fullName: 'org/other-repo' }, + } as any; + + const mapped = (service as any).resolveSelectedSandboxDeploys( + [selectedService], + new Map([[selectedService.baseDeploy.id, sandboxDeploy]]) + ); + + expect(mapped).toEqual([ + { + selectedService, + sandboxDeploy, + }, + ]); + }); }); diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index f827204..5fe80b3 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -20,6 +20,7 @@ mockRedisClient(); const mockGetCompatibleReadyPrewarm = jest.fn(); const mockGetReadyPrewarmByPvc = jest.fn(); +const mockExecInPod = jest.fn(); jest.mock('server/models/AgentSession'); jest.mock('server/models/Build'); @@ -36,6 +37,14 @@ jest.mock('server/lib/agentSession/devModeManager'); jest.mock('server/lib/agentSession/forwardedEnv'); jest.mock('server/lib/kubernetes/networkPolicyFactory'); jest.mock('server/services/userApiKey'); +jest.mock('server/services/agentSessionCandidates', () => { + const actual = jest.requireActual('server/services/agentSessionCandidates'); + return { + __esModule: true, + ...actual, + loadAgentSessionServiceCandidates: jest.fn(), + }; +}); jest.mock('server/services/agentPrewarm', () => ({ __esModule: true, default: jest.fn().mockImplementation(() => ({ @@ -57,10 +66,20 @@ jest.mock('@kubernetes/client-node', () => { const actual = jest.requireActual('@kubernetes/client-node'); return { ...actual, + Exec: jest.fn().mockImplementation(() => ({ + exec: mockExecInPod, + })), KubeConfig: jest.fn().mockImplementation(() => ({ loadFromDefault: jest.fn(), makeApiClient: jest.fn().mockReturnValue({ createNamespacedNetworkPolicy: jest.fn().mockResolvedValue({}), + readNamespacedPod: jest.fn().mockResolvedValue({ + body: { + spec: { + nodeName: 'agent-node-a', + }, + }, + }), }), })), }; @@ -132,6 +151,7 @@ import RedisClient from 'server/lib/redisClient'; import { deployHelm } from 'server/lib/nativeHelm/helm'; import { DeploymentManager } from 'server/lib/deploymentManager/deploymentManager'; import BuildServiceModule from 'server/services/build'; +import { loadAgentSessionServiceCandidates } from 'server/services/agentSessionCandidates'; const mockRedis = { setex: jest.fn().mockResolvedValue('OK'), @@ -295,6 +315,25 @@ describe('AgentSessionService', () => { mockedBuildServiceModule.deleteBuild.mockResolvedValue(undefined); mockGetCompatibleReadyPrewarm.mockResolvedValue(null); mockGetReadyPrewarmByPvc.mockResolvedValue(null); + mockExecInPod.mockImplementation( + async ( + _namespace: string, + _podName: string, + _containerName: string, + _command: string[], + _stdout: unknown, + _stderr: unknown, + _stdin: unknown, + _tty: boolean, + statusCallback?: (status: Record) => void + ) => { + statusCallback?.({ status: 'Success' }); + return { + on: jest.fn(), + }; + } + ); + (loadAgentSessionServiceCandidates as jest.Mock).mockResolvedValue([]); (resolveForwardedAgentEnv as jest.Mock).mockResolvedValue({ env: {}, secretRefs: [], @@ -768,6 +807,107 @@ describe('AgentSessionService', () => { ); }); + it('rewrites dev-mode workspace paths when selected services span multiple repositories', async () => { + const optsWithServices: CreateSessionOptions = { + ...baseOpts, + repoUrl: 'https://github.com/org/ui.git', + branch: 'feature/ui', + services: [ + { + name: 'web', + deployId: 1, + resourceName: 'web-build-uuid', + repo: 'org/ui', + branch: 'feature/ui', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + workDir: 'apps/web', + installCommand: 'pnpm install', + }, + }, + { + name: 'api', + deployId: 2, + resourceName: 'api-build-uuid', + repo: 'org/api', + branch: 'feature/api', + devConfig: { + image: 'node:20', + command: 'pnpm start', + workDir: 'services/api', + installCommand: 'pnpm install', + }, + }, + ], + }; + + await AgentSessionService.createSession(optsWithServices); + + expect(createAgentPod).toHaveBeenCalledWith( + expect.objectContaining({ + repoUrl: 'https://github.com/org/ui.git', + branch: 'feature/ui', + workspaceRepos: [ + expect.objectContaining({ + repo: 'org/ui', + branch: 'feature/ui', + mountPath: '/workspace', + primary: true, + }), + expect.objectContaining({ + repo: 'org/api', + branch: 'feature/api', + mountPath: '/workspace/repos/org/api', + primary: false, + }), + ], + installCommand: 'pnpm install\n\ncd "/workspace/repos/org/api"\npnpm install', + }) + ); + expect(mockEnableDevMode).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + devConfig: expect.objectContaining({ + workDir: '/workspace/apps/web', + }), + }) + ); + expect(mockEnableDevMode).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + devConfig: expect.objectContaining({ + workDir: '/workspace/repos/org/api/services/api', + }), + }) + ); + expect(mockSessionQuery.insertAndFetch).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceRepos: [ + expect.objectContaining({ repo: 'org/ui', primary: true }), + expect.objectContaining({ repo: 'org/api', primary: false }), + ], + selectedServices: [ + expect.objectContaining({ + name: 'web', + repo: 'org/ui', + branch: 'feature/ui', + workspacePath: '/workspace', + workDir: '/workspace/apps/web', + }), + expect.objectContaining({ + name: 'api', + repo: 'org/api', + branch: 'feature/api', + workspacePath: '/workspace/repos/org/api', + workDir: '/workspace/repos/org/api/services/api', + }), + ], + }) + ); + expect(mockGetCompatibleReadyPrewarm).not.toHaveBeenCalled(); + }); + it('rolls back on pod creation failure', async () => { (createAgentPod as jest.Mock).mockRejectedValue(new Error('pod creation failed')); @@ -891,6 +1031,199 @@ describe('AgentSessionService', () => { }); }); + describe('attachServices', () => { + it('connects a same-repo service to an active single-repo session', async () => { + mockSessionQuery.findOne.mockResolvedValue({ + id: 321, + uuid: 'sess-1', + status: 'active', + buildUuid: 'build-123', + buildKind: 'environment', + namespace: 'test-ns', + podName: 'agent-aaaaaaaa', + pvcName: 'agent-pvc-aaaaaaaa', + workspaceRepos: [ + { + repo: 'example-org/example-repo', + repoUrl: 'https://github.com/example-org/example-repo.git', + branch: 'feature/current', + mountPath: '/workspace', + primary: true, + }, + ], + selectedServices: [], + devModeSnapshots: {}, + }); + (loadAgentSessionServiceCandidates as jest.Mock).mockResolvedValue([ + { + name: 'web', + type: 'github', + deployId: 11, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'cd /workspace/apps/web && pnpm install', + workDir: '/workspace/apps/web', + }, + repo: 'example-org/example-repo', + branch: 'feature/current', + revision: '0123456789abcdef0123456789abcdef01234567', + baseDeploy: { + id: 11, + uuid: 'web-build-uuid', + }, + }, + ]); + + await AgentSessionService.attachServices('sess-1', ['web']); + + expect(loadAgentSessionServiceCandidates).toHaveBeenCalledWith('build-123'); + expect(mockExecInPod).toHaveBeenCalledWith( + 'test-ns', + 'agent-aaaaaaaa', + 'agent', + ['sh', '-lc', 'cd /workspace/apps/web && pnpm install'], + expect.anything(), + expect.anything(), + null, + false, + expect.any(Function) + ); + expect(mockEnableDevMode).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'test-ns', + deploymentName: 'web-build-uuid', + serviceName: 'web-build-uuid', + pvcName: 'agent-pvc-aaaaaaaa', + requiredNodeName: 'agent-node-a', + devConfig: expect.objectContaining({ + workDir: '/workspace/apps/web', + }), + }) + ); + expect(mockDeployQuery.patch).toHaveBeenCalledWith({ + devMode: true, + devModeSessionId: 321, + }); + expect(mockSessionQuery.patch).toHaveBeenCalledWith( + expect.objectContaining({ + selectedServices: [ + expect.objectContaining({ + name: 'web', + deployId: 11, + repo: 'example-org/example-repo', + branch: 'feature/current', + workspacePath: '/workspace', + workDir: '/workspace/apps/web', + }), + ], + devModeSnapshots: expect.objectContaining({ + '11': expect.any(Object), + }), + }) + ); + }); + + it('rejects services outside the current repo checkout', async () => { + mockSessionQuery.findOne.mockResolvedValue({ + id: 321, + uuid: 'sess-1', + status: 'active', + buildUuid: 'build-123', + buildKind: 'environment', + namespace: 'test-ns', + podName: 'agent-aaaaaaaa', + pvcName: 'agent-pvc-aaaaaaaa', + workspaceRepos: [ + { + repo: 'example-org/example-repo', + repoUrl: 'https://github.com/example-org/example-repo.git', + branch: 'feature/current', + mountPath: '/workspace', + primary: true, + }, + ], + selectedServices: [], + devModeSnapshots: {}, + }); + (loadAgentSessionServiceCandidates as jest.Mock).mockResolvedValue([ + { + name: 'api', + type: 'github', + deployId: 22, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + }, + repo: 'example-org/other-repo', + branch: 'feature/current', + revision: null, + baseDeploy: { + id: 22, + uuid: 'api-build-uuid', + }, + }, + ]); + + await expect(AgentSessionService.attachServices('sess-1', ['api'])).rejects.toThrow( + 'Only services from example-org/example-repo:feature/current can be connected after the session starts.' + ); + + expect(mockEnableDevMode).not.toHaveBeenCalled(); + expect(mockSessionQuery.patch).not.toHaveBeenCalled(); + }); + + it('rejects services that require forwarding env vars into the already-running agent', async () => { + mockSessionQuery.findOne.mockResolvedValue({ + id: 321, + uuid: 'sess-1', + status: 'active', + buildUuid: 'build-123', + buildKind: 'environment', + namespace: 'test-ns', + podName: 'agent-aaaaaaaa', + pvcName: 'agent-pvc-aaaaaaaa', + workspaceRepos: [ + { + repo: 'example-org/example-repo', + repoUrl: 'https://github.com/example-org/example-repo.git', + branch: 'feature/current', + mountPath: '/workspace', + primary: true, + }, + ], + selectedServices: [], + devModeSnapshots: {}, + }); + (loadAgentSessionServiceCandidates as jest.Mock).mockResolvedValue([ + { + name: 'worker', + type: 'github', + deployId: 33, + devConfig: { + image: 'node:20', + command: 'pnpm dev', + forwardEnvVarsToAgent: ['PRIVATE_TOKEN'], + }, + repo: 'example-org/example-repo', + branch: 'feature/current', + revision: null, + baseDeploy: { + id: 33, + uuid: 'worker-build-uuid', + }, + }, + ]); + + await expect(AgentSessionService.attachServices('sess-1', ['worker'])).rejects.toThrow( + 'Services that forward env vars to the agent must be selected when the session starts: worker' + ); + + expect(mockEnableDevMode).not.toHaveBeenCalled(); + expect(mockSessionQuery.patch).not.toHaveBeenCalled(); + }); + }); + describe('endSession', () => { it('throws if session not found', async () => { (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue({ diff --git a/src/server/services/__tests__/agentSessionCandidates.test.ts b/src/server/services/__tests__/agentSessionCandidates.test.ts index 55741cd..4ae7b9d 100644 --- a/src/server/services/__tests__/agentSessionCandidates.test.ts +++ b/src/server/services/__tests__/agentSessionCandidates.test.ts @@ -16,7 +16,7 @@ import { YamlConfigParser } from 'server/lib/yamlConfigParser'; import type { Deploy } from 'server/models'; -import { resolveAgentSessionServiceCandidates } from '../agentSessionCandidates'; +import { resolveAgentSessionServiceCandidates, resolveRequestedAgentSessionServices } from '../agentSessionCandidates'; import { DeployStatus, DeployTypes } from 'shared/constants'; describe('agentSessionCandidates', () => { @@ -120,4 +120,37 @@ services: const names = resolveAgentSessionServiceCandidates(deploys, lifecycleConfig).map((candidate) => candidate.name); expect(names).toEqual(['github-app', 'helm-app']); }); + + test('requires repo-qualified service references when names collide across repositories', () => { + const candidates = [ + { + name: 'web', + type: DeployTypes.GITHUB, + deployId: 11, + devConfig: { image: 'node:20', command: 'pnpm dev' }, + repo: 'example-org/ui', + branch: 'feature/ui', + revision: 'abc123', + baseDeploy: { id: 11 } as unknown as Deploy, + }, + { + name: 'web', + type: DeployTypes.GITHUB, + deployId: 12, + devConfig: { image: 'node:20', command: 'pnpm dev' }, + repo: 'example-org/marketing-site', + branch: 'feature/web-refresh', + revision: 'def456', + baseDeploy: { id: 12 } as unknown as Deploy, + }, + ]; + + expect(() => resolveRequestedAgentSessionServices(candidates, ['web'])).toThrow( + 'Multiple services matched the request; specify repo to disambiguate' + ); + + expect(resolveRequestedAgentSessionServices(candidates, [{ name: 'web', repo: 'example-org/ui' }])).toEqual([ + candidates[0], + ]); + }); }); diff --git a/src/server/services/agentPrewarm.ts b/src/server/services/agentPrewarm.ts index 582c171..637422a 100644 --- a/src/server/services/agentPrewarm.ts +++ b/src/server/services/agentPrewarm.ts @@ -25,7 +25,16 @@ import { createAgentPvc, deleteAgentPvc } from 'server/lib/agentSession/pvcFacto import { createAgentApiKeySecret, deleteAgentApiKeySecret } from 'server/lib/agentSession/apiKeySecretFactory'; import { ensureAgentSessionServiceAccount } from 'server/lib/agentSession/serviceAccountFactory'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; -import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; +import { + AGENT_WORKSPACE_ROOT, + type AgentSessionSelectedService, + type AgentSessionWorkspaceRepo, +} from 'server/lib/agentSession/workspace'; +import { + buildCombinedInstallCommand, + resolveAgentSessionServicePlan, + type ResolvedAgentSessionService, +} from 'server/lib/agentSession/servicePlan'; import { createAgentPrewarmJob, monitorAgentPrewarmJob } from 'server/lib/agentSession/prewarmJobFactory'; import { resolveAgentSessionRuntimeConfig, @@ -40,15 +49,20 @@ import { QUEUE_NAMES } from 'shared/config'; import GlobalConfigService from './globalConfig'; import { resolveAgentSessionServiceCandidates, resolveRequestedAgentSessionServices } from './agentSessionCandidates'; -const logger = getLogger(); +const logger = () => getLogger(); const AGENT_PREWARM_ERROR_MESSAGE_MAX_LENGTH = 4000; -type ResolvedPrewarmService = { +type PrewarmServiceInput = { name: string; deployId: number; devConfig: DevConfig; + repo: string; + branch: string; + revision?: string | null; }; +type ResolvedPrewarmService = ResolvedAgentSessionService; + type ResolvedBuildPrewarmPlan = { buildUuid: string; namespace: string; @@ -58,6 +72,8 @@ type ResolvedBuildPrewarmPlan = { revision?: string; configuredServiceNames: string[]; services: ResolvedPrewarmService[]; + workspaceRepos: AgentSessionWorkspaceRepo[]; + serviceRefs: AgentSessionSelectedService[]; }; export interface AgentPrewarmQueueJob { @@ -94,14 +110,6 @@ function truncateErrorMessage(message: string): string { return `${message.slice(0, AGENT_PREWARM_ERROR_MESSAGE_MAX_LENGTH - 3)}...`; } -function buildCombinedInstallCommand(services: ResolvedPrewarmService[]): string | undefined { - const installCommands = services - .map((service) => service.devConfig.installCommand) - .filter((command): command is string => Boolean(command?.trim())); - - return installCommands.length > 0 ? installCommands.join('\n\n') : undefined; -} - export default class AgentPrewarmService extends BaseService { prewarmQueue = this.queueManager.registerQueue(QUEUE_NAMES.AGENT_SESSION_PREWARM, { connection: this.redis, @@ -188,6 +196,12 @@ export default class AgentPrewarmService extends BaseService { } ); + logger().info( + `Prewarm: queued buildUuid=${plan.buildUuid} services=${plan.configuredServiceNames.join(',')} revision=${ + plan.revision || 'head' + }` + ); + return true; } @@ -217,7 +231,10 @@ export default class AgentPrewarmService extends BaseService { const githubToken = await GlobalConfigService.getInstance() .getGithubClientToken() .catch((error) => { - logger.warn({ error, buildUuid }, 'Agent prewarm could not resolve lifecycle GitHub app token'); + logger().warn( + { error, buildUuid }, + `Prewarm: github token lookup failed source=lifecycle_app buildUuid=${buildUuid}` + ); return null; }); const forwardedAgentEnv = await resolveForwardedAgentEnv(plan.services, plan.namespace, prewarmUuid, buildUuid); @@ -229,6 +246,12 @@ export default class AgentPrewarmService extends BaseService { const serviceAccountName = await ensureAgentSessionServiceAccount(plan.namespace); const installCommand = buildCombinedInstallCommand(plan.services); + logger().info( + `Prewarm: starting buildUuid=${plan.buildUuid} services=${plan.configuredServiceNames.join(',')} revision=${ + plan.revision || 'head' + }` + ); + let prewarm = await AgentPrewarm.query().insertAndFetch({ uuid: prewarmUuid, buildUuid: plan.buildUuid, @@ -240,6 +263,8 @@ export default class AgentPrewarmService extends BaseService { jobName, status: 'running', services: plan.configuredServiceNames, + workspaceRepos: plan.workspaceRepos, + serviceRefs: plan.serviceRefs, errorMessage: null, } as unknown as Partial); @@ -272,6 +297,7 @@ export default class AgentPrewarmService extends BaseService { branch: plan.branch, revision: plan.revision, workspacePath: AGENT_WORKSPACE_ROOT, + workspaceRepos: plan.workspaceRepos, installCommand, forwardedAgentEnv: forwardedAgentEnv.env, forwardedAgentSecretRefs: forwardedAgentEnv.secretRefs, @@ -304,6 +330,12 @@ export default class AgentPrewarmService extends BaseService { errorMessage: null, } as AgentPrewarm; + logger().info( + `Prewarm: ready buildUuid=${plan.buildUuid} prewarmUuid=${ + prewarm.uuid + } services=${plan.configuredServiceNames.join(',')}` + ); + await this.cleanupSupersededPrewarms(plan, prewarm); return prewarm; @@ -319,11 +351,17 @@ export default class AgentPrewarmService extends BaseService { throw error; } finally { await deleteAgentApiKeySecret(plan.namespace, secretName).catch((error) => { - logger.warn({ error, buildUuid, secretName }, 'Agent prewarm secret cleanup failed'); + logger().warn( + { error, buildUuid, secretName }, + `Prewarm: secret cleanup failed buildUuid=${buildUuid} secretName=${secretName}` + ); }); await cleanupForwardedAgentEnvSecrets(plan.namespace, prewarmUuid, forwardedAgentEnv.secretProviders).catch( (error) => { - logger.warn({ error, buildUuid, prewarmUuid }, 'Agent prewarm forwarded env cleanup failed'); + logger().warn( + { error, buildUuid, prewarmUuid }, + `Prewarm: forwarded_env cleanup failed buildUuid=${buildUuid} prewarmUuid=${prewarmUuid}` + ); } ); } @@ -373,17 +411,33 @@ export default class AgentPrewarmService extends BaseService { name: service.name, deployId: service.deployId, devConfig: service.devConfig, + repo: service.repo || repositoryFullName, + branch: service.branch || build.pullRequest?.branchName || null, + revision: service.revision || revision || null, })); + const { + workspaceRepos, + services: resolvedServices, + selectedServices, + } = resolveAgentSessionServicePlan({}, services); + if (workspaceRepos.length !== 1) { + return null; + } + + const [workspaceRepo] = workspaceRepos; + return { buildUuid, namespace, - repo: repositoryFullName, - repoUrl: `https://github.com/${repositoryFullName}.git`, - branch: build.pullRequest.branchName, - revision: revision || undefined, + repo: workspaceRepo.repo, + repoUrl: workspaceRepo.repoUrl, + branch: workspaceRepo.branch, + revision: workspaceRepo.revision || undefined, configuredServiceNames, - services, + services: resolvedServices || [], + workspaceRepos, + serviceRefs: selectedServices, }; } @@ -411,18 +465,18 @@ export default class AgentPrewarmService extends BaseService { } await deleteAgentPvc(plan.namespace, prewarm.pvcName).catch((error) => { - logger.warn( + logger().warn( { error, buildUuid: plan.buildUuid, pvcName: prewarm.pvcName, prewarmUuid: prewarm.uuid }, - 'Superseded agent prewarm PVC cleanup failed' + `Prewarm: pvc cleanup failed reason=superseded buildUuid=${plan.buildUuid} prewarmUuid=${prewarm.uuid} pvcName=${prewarm.pvcName}` ); }); await AgentPrewarm.query() .deleteById(prewarm.id) .catch((error) => { - logger.warn( + logger().warn( { error, buildUuid: plan.buildUuid, pvcName: prewarm.pvcName, prewarmUuid: prewarm.uuid }, - 'Superseded agent prewarm record cleanup failed' + `Prewarm: record cleanup failed reason=superseded buildUuid=${plan.buildUuid} prewarmUuid=${prewarm.uuid} pvcName=${prewarm.pvcName}` ); }); } diff --git a/src/server/services/agentSandboxSession.ts b/src/server/services/agentSandboxSession.ts index 9bd3609..b38f655 100644 --- a/src/server/services/agentSandboxSession.ts +++ b/src/server/services/agentSandboxSession.ts @@ -39,12 +39,15 @@ import { BuildKind, BuildStatus, DeployStatus, DeployTypes } from 'shared/consta import DeployService from './deploy'; import type { SandboxLaunchStage } from 'server/lib/agentSession/sandboxLaunchState'; import type { RequestUserIdentity } from 'server/lib/get-user'; +import type { RequestedAgentSessionServiceRef } from './agentSessionCandidates'; const randomSha = customAlphabet('1234567890abcdef', 6); export interface SandboxServiceCandidate { name: string; type: DeployTypes; + repo: string; + branch: string; } interface ResolvedSandboxService { @@ -61,9 +64,65 @@ interface EnvironmentSource { branch: string; } -interface SandboxServiceIdentity { - name: string; - repo: string; +interface CreatedSandboxBuild { + build: Build; + sandboxDeploysByBaseDeployId: Map; +} + +export type RequestedSandboxService = string | RequestedAgentSessionServiceRef; +export type RequestedSandboxServices = RequestedSandboxService[]; + +function normalizeOptionalString(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRepoKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function formatRequestedSandboxServiceLabel(service?: RequestedSandboxService | null): string { + if (!service) { + return 'unknown service'; + } + + if (typeof service === 'string') { + return service; + } + + const repo = normalizeOptionalString(service.repo); + const branch = normalizeOptionalString(service.branch); + + if (!repo && !branch) { + return service.name; + } + + return `${service.name} (${repo ?? 'unknown-repo'}${branch ? `:${branch}` : ''})`; +} + +export function summarizeRequestedSandboxServices(services?: RequestedSandboxServices | null): string { + if (!services || services.length === 0) { + return 'unknown service'; + } + + if (services.length === 1) { + return typeof services[0] === 'string' ? services[0] : services[0].name; + } + + return `${services.length} services`; +} + +export function formatRequestedSandboxServicesLabel(services?: RequestedSandboxServices | null): string { + if (!services || services.length === 0) { + return 'unknown service'; + } + + const labels = services.map((service) => formatRequestedSandboxServiceLabel(service)); + if (labels.length <= 2) { + return labels.join(', '); + } + + return `${labels.slice(0, 2).join(', ')} +${labels.length - 2} more`; } export interface LaunchSandboxSessionOptions { @@ -71,7 +130,7 @@ export interface LaunchSandboxSessionOptions { userIdentity?: RequestUserIdentity; githubToken?: string | null; baseBuildUuid: string; - service?: string; + services?: RequestedSandboxServices; model?: string; agentImage: string; editorImage: string; @@ -107,22 +166,45 @@ export default class AgentSandboxSessionService extends BaseService { ); } - const selectedService = opts.service != null ? this.resolveSelectedService(opts.service, candidates) : null; + const selectedServices = opts.services != null ? this.resolveSelectedServices(opts.services, candidates) : []; - if (!selectedService) { + if (selectedServices.length === 0) { return { status: 'needs_service_selection', services: candidates - .map((candidate) => ({ name: candidate.name, type: getDeployTypeFromBaseDeploy(candidate.baseDeploy) })) - .sort((a, b) => a.name.localeCompare(b.name)), + .map((candidate) => ({ + name: candidate.name, + type: getDeployTypeFromBaseDeploy(candidate.baseDeploy), + repo: candidate.serviceRepo, + branch: candidate.serviceBranch, + })) + .sort((a, b) => + a.name === b.name + ? `${a.repo}:${a.branch}`.localeCompare(`${b.repo}:${b.branch}`) + : a.name.localeCompare(b.name) + ), }; } - await opts.onProgress?.('creating_sandbox_build', `Creating sandbox build for ${selectedService.name}`); - const sandboxBuild = await this.createSandboxBuild({ + const selectedServiceSummary = summarizeRequestedSandboxServices( + selectedServices.map((service) => ({ + name: service.name, + repo: service.serviceRepo, + branch: service.serviceBranch, + })) + ); + + getLogger().info( + `Sandbox: starting baseBuildUuid=${opts.baseBuildUuid} services=${selectedServices + .map((service) => `${service.name}@${service.serviceRepo}:${service.serviceBranch}`) + .join(',')}` + ); + + await opts.onProgress?.('creating_sandbox_build', `Creating sandbox build for ${selectedServiceSummary}`); + const { build: sandboxBuild, sandboxDeploysByBaseDeployId } = await this.createSandboxBuild({ baseBuild, environmentSource, - selectedService, + selectedServices, }); try { @@ -130,14 +212,14 @@ export default class AgentSandboxSessionService extends BaseService { await sandboxBuild.$query().patch({ runUUID, status: BuildStatus.QUEUED }); await sandboxBuild.$fetchGraph('[environment, pullRequest.[repository], deploys.[deployable, repository]]'); - await opts.onProgress?.('resolving_environment', `Resolving environment variables for ${selectedService.name}`); + await opts.onProgress?.('resolving_environment', `Resolving environment variables for ${selectedServiceSummary}`); await new BuildEnvironmentVariables(this.db).resolve(sandboxBuild); await this.buildService.updateStatusAndComment(sandboxBuild, BuildStatus.DEPLOYING, runUUID, false, false); - await opts.onProgress?.('deploying_resources', `Deploying sandbox resources for ${selectedService.name}`); + await opts.onProgress?.('deploying_resources', `Deploying sandbox resources for ${selectedServiceSummary}`); const deployed = await this.buildService.generateAndApplyManifests({ build: sandboxBuild, - githubRepositoryId: '', + githubRepositoryId: null, namespace: sandboxBuild.namespace, }); @@ -153,51 +235,59 @@ export default class AgentSandboxSessionService extends BaseService { throw new Error(`Sandbox deployment failed for ${sandboxBuild.uuid}`); } - const sandboxDeploy = sandboxBuild.deploys?.find((deploy) => deploy.deployable?.name === selectedService.name); - if (!sandboxDeploy?.id) { - throw new Error(`Sandbox deploy not found for ${selectedService.name}`); - } + const selectedSandboxServices = this.resolveSelectedSandboxDeploys( + selectedServices, + sandboxDeploysByBaseDeployId + ); - await opts.onProgress?.('creating_agent_session', `Starting agent session for ${selectedService.name}`); + await opts.onProgress?.('creating_agent_session', `Starting agent session for ${selectedServiceSummary}`); const session = await AgentSessionService.createSession({ userId: opts.userId, buildUuid: sandboxBuild.uuid, githubToken: opts.githubToken, - services: [ - { - name: selectedService.name, - deployId: sandboxDeploy.id, - devConfig: selectedService.devConfig, - resourceName: sandboxDeploy.uuid || undefined, - }, - ], + services: selectedSandboxServices.map(({ selectedService, sandboxDeploy }) => ({ + name: selectedService.name, + deployId: sandboxDeploy.id, + devConfig: selectedService.devConfig, + resourceName: sandboxDeploy.uuid || undefined, + repo: selectedService.serviceRepo, + branch: selectedService.baseDeploy.branchName || selectedService.serviceBranch, + revision: selectedService.baseDeploy.sha || undefined, + })), model: opts.model, - repoUrl: `https://github.com/${selectedService.serviceRepo}.git`, - branch: selectedService.baseDeploy.branchName || selectedService.serviceBranch, - revision: selectedService.baseDeploy.sha || undefined, prNumber: baseBuild.pullRequest?.pullRequestNumber, namespace: sandboxBuild.namespace, agentImage: opts.agentImage, editorImage: opts.editorImage, nodeSelector: opts.nodeSelector, - readiness: mergeAgentSessionReadinessForServices(opts.readiness, [ - selectedService.devConfig.agentSession?.readiness, - ]), + readiness: mergeAgentSessionReadinessForServices( + opts.readiness, + selectedServices.map((service) => service.devConfig.agentSession?.readiness) + ), resources: mergeAgentSessionResources(opts.resources, lifecycleConfig.environment?.agentSession?.resources), userIdentity: opts.userIdentity, }); + getLogger().info( + `Sandbox: ready baseBuildUuid=${opts.baseBuildUuid} buildUuid=${sandboxBuild.uuid} sessionId=${ + session.uuid + } services=${selectedServices.map((service) => service.name).join(',')}` + ); + return { status: 'created', - service: selectedService.name, + service: selectedServiceSummary, buildUuid: sandboxBuild.uuid, namespace: sandboxBuild.namespace, session, - services: [selectedService.name], + services: selectedServices.map((service) => service.name), }; } catch (error) { await this.buildService.deleteBuild(sandboxBuild).catch((cleanupError) => { - getLogger().warn({ error: cleanupError, buildUuid: sandboxBuild.uuid }, 'Sandbox cleanup failed after launch'); + getLogger().warn( + { error: cleanupError, buildUuid: sandboxBuild.uuid }, + `Sandbox: cleanup failed action=launch_rollback buildUuid=${sandboxBuild.uuid}` + ); }); throw error; } @@ -216,8 +306,14 @@ export default class AgentSandboxSessionService extends BaseService { .map((candidate) => ({ name: candidate.name, type: getDeployTypeFromBaseDeploy(candidate.baseDeploy), + repo: candidate.serviceRepo, + branch: candidate.serviceBranch, })) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => + a.name === b.name + ? `${a.repo}:${a.branch}`.localeCompare(`${b.repo}:${b.branch}`) + : a.name.localeCompare(b.name) + ); } private async loadBaseBuildAndCandidates({ @@ -297,7 +393,7 @@ export default class AgentSandboxSessionService extends BaseService { }); } catch (error) { getLogger({ buildUuid: baseBuild.uuid, serviceName, error }).warn( - 'Sandbox candidate resolution skipped service due to config error' + `Sandbox: candidate skipped service=${serviceName} buildUuid=${baseBuild.uuid} reason=config_error` ); } } @@ -305,14 +401,60 @@ export default class AgentSandboxSessionService extends BaseService { return resolvedCandidates; } - private resolveSelectedService(serviceName: string, candidates: ResolvedSandboxService[]): ResolvedSandboxService { - const matches = candidates.filter((candidate) => candidate.name === serviceName); + private resolveSelectedServices( + requestedServices: RequestedSandboxServices, + candidates: ResolvedSandboxService[] + ): ResolvedSandboxService[] { + const selectedServices: ResolvedSandboxService[] = []; + const seenServiceKeys = new Set(); + + for (const requestedService of requestedServices) { + const selectedService = this.resolveSelectedService(requestedService, candidates); + const serviceKey = this.getResolvedSandboxServiceKey(selectedService); + + if (seenServiceKeys.has(serviceKey)) { + continue; + } + + seenServiceKeys.add(serviceKey); + selectedServices.push(selectedService); + } + + return selectedServices; + } + + private resolveSelectedService( + requestedService: RequestedSandboxService, + candidates: ResolvedSandboxService[] + ): ResolvedSandboxService { + const serviceName = typeof requestedService === 'string' ? requestedService : requestedService.name; + const requestedRepo = + typeof requestedService === 'string' ? undefined : normalizeOptionalString(requestedService.repo); + const requestedBranch = + typeof requestedService === 'string' ? undefined : normalizeOptionalString(requestedService.branch); + + const matches = candidates.filter((candidate) => { + if (candidate.name !== serviceName) { + return false; + } + + if (requestedRepo && normalizeRepoKey(candidate.serviceRepo) !== normalizeRepoKey(requestedRepo)) { + return false; + } + + if (requestedBranch && candidate.serviceBranch !== requestedBranch) { + return false; + } + + return true; + }); + if (matches.length === 0) { - throw new Error(`Unknown sandbox service: ${serviceName}`); + throw new Error(`Unknown sandbox service: ${formatRequestedSandboxServiceLabel(requestedService)}`); } if (matches.length > 1) { - throw new Error(`Multiple sandbox services matched ${serviceName}`); + throw new Error(`Multiple sandbox services matched ${formatRequestedSandboxServiceLabel(requestedService)}`); } return matches[0]; @@ -321,22 +463,23 @@ export default class AgentSandboxSessionService extends BaseService { private async createSandboxBuild({ baseBuild, environmentSource, - selectedService, + selectedServices, }: { baseBuild: Build; environmentSource: EnvironmentSource; - selectedService: ResolvedSandboxService; - }): Promise { - const includedKeys = await this.resolveDependencyClosure(baseBuild, selectedService, environmentSource); + selectedServices: ResolvedSandboxService[]; + }): Promise { + const includedDeployIds = await this.resolveDependencyClosure(baseBuild, selectedServices, environmentSource); const baseDeploys = (baseBuild.deploys || []).filter( - (deploy) => - deploy.active && this.getDeployServiceKey(deploy) && includedKeys.has(this.getDeployServiceKey(deploy)!) + (deploy) => deploy.active && Boolean(deploy.id) && includedDeployIds.has(deploy.id) ); - if (baseDeploys.length !== includedKeys.size) { - const foundKeys = new Set(baseDeploys.map((deploy) => this.getDeployServiceKey(deploy)).filter(Boolean)); - const missing = [...includedKeys].filter((key) => !foundKeys.has(key)); - throw new Error(`Base build is missing active deploys for sandbox dependencies: ${missing.join(', ')}`); + if (baseDeploys.length !== includedDeployIds.size) { + const foundDeployIds = new Set( + baseDeploys.map((deploy) => deploy.id).filter((deployId): deployId is number => Boolean(deployId)) + ); + const missingDeployIds = [...includedDeployIds].filter((deployId) => !foundDeployIds.has(deployId)); + throw new Error(`Base build is missing active deploys for sandbox dependencies: ${missingDeployIds.join(', ')}`); } const haikunator = new Haikunator({ @@ -348,6 +491,7 @@ export default class AgentSandboxSessionService extends BaseService { const sandboxNamespace = `sbx-${sandboxUuid}`; return Build.transaction(async (trx) => { + const sandboxDeploysByBaseDeployId = new Map(); const baseBuildJson = baseBuild.$toJson() as Record; const { id: _baseBuildId, @@ -457,6 +601,7 @@ export default class AgentSandboxSessionService extends BaseService { devMode: false, devModeSessionId: null, } as unknown as Partial); + sandboxDeploysByBaseDeployId.set(baseDeploy.id, sandboxDeploy); const sandboxDeployable = await Deployable.query(trx).findById(sandboxDeployableId); if (!sandboxDeployable) { @@ -486,37 +631,60 @@ export default class AgentSandboxSessionService extends BaseService { } await sandboxBuild.$fetchGraph('[pullRequest.[repository], environment, deploys.[deployable, repository]]'); - return sandboxBuild; + return { + build: sandboxBuild, + sandboxDeploysByBaseDeployId, + }; + }); + } + + private resolveSelectedSandboxDeploys( + selectedServices: ResolvedSandboxService[], + sandboxDeploysByBaseDeployId: Map + ): Array<{ + selectedService: ResolvedSandboxService; + sandboxDeploy: Deploy; + }> { + return selectedServices.map((selectedService) => { + const baseDeployId = selectedService.baseDeploy.id; + const sandboxDeploy = baseDeployId ? sandboxDeploysByBaseDeployId.get(baseDeployId) : undefined; + + if (!sandboxDeploy?.id) { + throw new Error(`Sandbox deploy not found for ${selectedService.name} in ${selectedService.serviceRepo}`); + } + + return { + selectedService, + sandboxDeploy, + }; }); } private async resolveDependencyClosure( baseBuild: Build, - selectedService: ResolvedSandboxService, + selectedServices: ResolvedSandboxService[], environmentSource: EnvironmentSource - ): Promise> { + ): Promise> { const activeDeploys = this.getActiveDeploys(baseBuild); const configCache = new Map>(); - const included = new Set(); + const includedDeployIds = new Set(); const queue: Array<{ serviceRef: DependencyService; baseDeploy: Deploy; resolvedSource?: ResolvedLifecycleServiceSource; - }> = [ - { - serviceRef: { - name: selectedService.name, - repository: selectedService.serviceRepo, - branch: selectedService.serviceBranch, - }, - baseDeploy: selectedService.baseDeploy, - resolvedSource: { - repo: selectedService.serviceRepo, - branch: selectedService.serviceBranch, - yamlService: selectedService.yamlService, - }, + }> = selectedServices.map((selectedService) => ({ + serviceRef: { + name: selectedService.name, + repository: selectedService.serviceRepo, + branch: selectedService.serviceBranch, }, - ]; + baseDeploy: selectedService.baseDeploy, + resolvedSource: { + repo: selectedService.serviceRepo, + branch: selectedService.serviceBranch, + yamlService: selectedService.yamlService, + }, + })); while (queue.length > 0) { const current = queue.shift(); @@ -526,7 +694,7 @@ export default class AgentSandboxSessionService extends BaseService { } const baseDeploy = current.baseDeploy; - if (!baseDeploy?.deployable) { + if (!baseDeploy?.id || !baseDeploy.deployable) { throw new Error(`Active deploy not found for dependency ${serviceName} in base build ${baseBuild.uuid}`); } @@ -538,15 +706,11 @@ export default class AgentSandboxSessionService extends BaseService { fallbackSource: environmentSource, configCache, })); - const serviceKey = this.getServiceKey({ - name: serviceName, - repo: serviceSource.repo, - }); - if (included.has(serviceKey)) { + if (includedDeployIds.has(baseDeploy.id)) { continue; } - included.add(serviceKey); + includedDeployIds.add(baseDeploy.id); const yamlService = serviceSource.yamlService; for (const requiredService of yamlService?.requires || []) { @@ -566,7 +730,7 @@ export default class AgentSandboxSessionService extends BaseService { } } - return included; + return includedDeployIds; } private getEnvironmentSource(baseBuild: Build): EnvironmentSource { @@ -604,18 +768,10 @@ export default class AgentSandboxSessionService extends BaseService { return (baseBuild.deploys || []).filter((deploy) => deploy.active && deploy.deployable?.name); } - private getServiceKey(service: SandboxServiceIdentity): string { - return `${service.repo}::${service.name}`; - } - - private getDeployServiceKey(deploy: Deploy): string | null { - const name = deploy.deployable?.name; - const repo = deploy.repository?.fullName; - if (!name || !repo) { - return null; - } - - return this.getServiceKey({ name, repo }); + private getResolvedSandboxServiceKey( + service: Pick + ): string { + return `${normalizeRepoKey(service.serviceRepo)}::${service.serviceBranch}::${service.name}`; } private findActiveDeployForReference(activeDeploys: Deploy[], serviceRef: DependencyService): Deploy | null { @@ -624,9 +780,13 @@ export default class AgentSandboxSessionService extends BaseService { } const matchesByName = activeDeploys.filter((deploy) => deploy.deployable?.name === serviceRef.name); - const matches = serviceRef.repository + const repoMatches = serviceRef.repository ? matchesByName.filter((deploy) => deploy.repository?.fullName === serviceRef.repository) : matchesByName; + const requestedBranch = normalizeOptionalString(serviceRef.branch); + const matches = requestedBranch + ? repoMatches.filter((deploy) => normalizeOptionalString(deploy.branchName) === requestedBranch) + : repoMatches; if (matches.length === 0) { return null; @@ -636,7 +796,7 @@ export default class AgentSandboxSessionService extends BaseService { throw new Error( `Multiple active deploys matched sandbox service ${serviceRef.name}${ serviceRef.repository ? ` in ${serviceRef.repository}` : '' - }` + }${requestedBranch ? ` on ${requestedBranch}` : ''}` ); } diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index 1d2222e..3528977 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -16,6 +16,7 @@ import 'server/lib/dependencies'; import * as k8s from '@kubernetes/client-node'; +import { Writable } from 'stream'; import { v4 as uuid } from 'uuid'; import type Database from 'server/database'; import AgentSession from 'server/models/AgentSession'; @@ -45,7 +46,17 @@ import { renderAgentSessionClaudeAttribution, } from 'server/lib/agentSession/runtimeConfig'; import { cleanupForwardedAgentEnvSecrets, resolveForwardedAgentEnv } from 'server/lib/agentSession/forwardedEnv'; -import { AGENT_WORKSPACE_ROOT } from 'server/lib/agentSession/workspace'; +import { + AGENT_WORKSPACE_ROOT, + type AgentSessionSelectedService, + type AgentSessionWorkspaceRepo, +} from 'server/lib/agentSession/workspace'; +import { + applyWorkspaceReposToServices, + buildCombinedInstallCommand, + resolveAgentSessionServicePlan, + workspaceRepoKey, +} from 'server/lib/agentSession/servicePlan'; import { buildAgentSessionDynamicSystemPrompt, combineAgentSessionAppendSystemPrompt, @@ -62,8 +73,13 @@ import { } from 'server/lib/agentSession/startupFailureState'; import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables'; import AgentPrewarmService from './agentPrewarm'; +import { + loadAgentSessionServiceCandidates, + resolveRequestedAgentSessionServices, + type RequestedAgentSessionServiceRef, +} from './agentSessionCandidates'; -const logger = getLogger(); +const logger = () => getLogger(); const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; const SESSION_REDIS_TTL = 7200; const ACTIVE_ENVIRONMENT_SESSION_UNIQUE_INDEX = 'agent_sessions_active_environment_build_unique'; @@ -76,6 +92,8 @@ type AgentSessionSummaryRecordBase = AgentSession & { baseBuildUuid: string | null; repo: string | null; branch: string | null; + primaryRepo: string | null; + primaryBranch: string | null; services: string[]; }; @@ -145,6 +163,7 @@ async function attachStartupFailures; type SessionService = NonNullable[number]; +type RequestedSessionService = string | RequestedAgentSessionServiceRef; function getSessionSnapshot( snapshots: SessionSnapshotMap | null | undefined, @@ -154,6 +173,133 @@ function getSessionSnapshot( return snapshot ?? null; } +function mergeSelectedServices( + existingServices: AgentSessionSelectedService[] | null | undefined, + nextServices: AgentSessionSelectedService[] +): AgentSessionSelectedService[] { + const mergedServices = [...(existingServices || [])]; + const seenDeployIds = new Set(mergedServices.map((service) => service.deployId)); + + for (const service of nextServices) { + if (seenDeployIds.has(service.deployId)) { + continue; + } + + mergedServices.push(service); + seenDeployIds.add(service.deployId); + } + + return mergedServices; +} + +async function resolveAgentPodNodeName(namespace: string, podName: string): Promise { + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const coreApi = kc.makeApiClient(k8s.CoreV1Api); + const response = await coreApi.readNamespacedPod(podName, namespace); + + return response.body.spec?.nodeName || null; +} + +function getExecExitCode(status: any): number | null { + const causes = status?.details?.causes; + if (Array.isArray(causes)) { + const exitCodeCause = causes.find((cause) => cause?.reason === 'ExitCode'); + const parsedExitCode = Number.parseInt(exitCodeCause?.message || '', 10); + if (Number.isFinite(parsedExitCode)) { + return parsedExitCode; + } + } + + if (status?.status === 'Success') { + return 0; + } + + return null; +} + +async function runCommandInAgentPod( + namespace: string, + podName: string, + command: string, + container = 'agent' +): Promise { + if (!command.trim()) { + return; + } + + const kc = new k8s.KubeConfig(); + kc.loadFromDefault(); + const exec = new k8s.Exec(kc); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + + const stdout = new Writable({ + write(chunk, _encoding, callback) { + stdoutChunks.push(chunk.toString()); + callback(); + }, + }); + const stderr = new Writable({ + write(chunk, _encoding, callback) { + stderrChunks.push(chunk.toString()); + callback(); + }, + }); + + await new Promise((resolve, reject) => { + let settled = false; + + const settleResolve = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + const settleReject = (error: Error) => { + if (settled) { + return; + } + + settled = true; + reject(error); + }; + + void exec + .exec(namespace, podName, container, ['sh', '-lc', command], stdout, stderr, null, false, (status: any) => { + const exitCode = getExecExitCode(status); + if (exitCode === 0) { + settleResolve(); + return; + } + + const stderrOutput = stderrChunks.join('').trim(); + const stdoutOutput = stdoutChunks.join('').trim(); + const detail = + stderrOutput || stdoutOutput || status?.message || `Command exited with code ${exitCode ?? 'unknown'}`; + settleReject(new Error(detail)); + }) + .then((ws) => { + if (ws && typeof ws.on === 'function') { + ws.on('error', (error: Error) => { + settleReject(error); + }); + ws.on('close', () => { + if (!settled) { + settleResolve(); + } + }); + } + }) + .catch((error) => { + settleReject(error as Error); + }); + }); +} + async function resolveTemplatedDevConfigEnvs( buildUuid: string | undefined, namespace: string, @@ -257,22 +403,23 @@ function triggerDevModeDeployRestore( // Restore runs in the background after agent teardown so ending a session // does not block on workload rollout/readiness. void (async () => { + const deployIds = deploys.map( + (deploy) => deploy.uuid || deploy.deployable?.name || deploy.service?.name || deploy.id + ); + const deployList = deployIds.join(','); + try { await restoreDeploys(deploys); await cleanupDevModePatches(namespace, snapshots, deploys); - logger.info( - `Background dev mode restore finished: namespace=${namespace} deploys=${deploys - .map((deploy) => deploy.uuid || deploy.deployable?.name || deploy.service?.name || deploy.id) - .join(',')}` - ); + logger().info(`DevMode: restore complete mode=background namespace=${namespace} deploys=${deployList}`); } catch (error) { - logger.error( + logger().error( { error, namespace, - deploys: deploys.map((deploy) => deploy.uuid || deploy.deployable?.name || deploy.service?.name || deploy.id), + deploys: deployIds, }, - 'Background dev mode restore failed after session end' + `DevMode: restore failed mode=background namespace=${namespace} deploys=${deployList}` ); } })(); @@ -324,11 +471,22 @@ export interface CreateSessionOptions { githubToken?: string | null; buildUuid?: string; buildKind?: BuildKind; - services?: Array<{ name: string; deployId: number; devConfig: DevConfig; resourceName?: string }>; + services?: Array<{ + name: string; + deployId: number; + devConfig: DevConfig; + resourceName?: string; + repo?: string | null; + branch?: string | null; + revision?: string | null; + workspacePath?: string; + workDir?: string | null; + }>; model?: string; - repoUrl: string; - branch: string; + repoUrl?: string; + branch?: string; revision?: string; + workspaceRepos?: AgentSessionWorkspaceRepo[]; prNumber?: number; namespace: string; agentImage: string; @@ -428,26 +586,45 @@ export default class AgentSessionService { const enrichedSessions = sessions.map((session) => { const build = session.buildUuid ? buildByUuid.get(session.buildUuid) : null; + const primaryWorkspaceRepo = + session.workspaceRepos?.find((repo) => repo.primary) || session.workspaceRepos?.[0] || null; + const primarySelectedService = session.selectedServices?.[0] || null; const sessionDeploys = liveDeploysBySessionId.get(session.id) || Object.keys(session.devModeSnapshots || {}) .map((deployId) => snapshotDeployById.get(Number(deployId))) .filter((deploy): deploy is Deploy => Boolean(deploy)); - const primaryDeploy = sessionDeploys[0]; - const services = [ - ...new Set( - sessionDeploys - .map((deploy) => deploy.deployable?.name || deploy.service?.name || null) - .filter((name): name is string => Boolean(name)) - ), - ]; + const primaryDeploy = sessionDeploys[0] || null; + const persistedServices = (session.selectedServices || []).map((service) => service.name).filter(Boolean); + const liveServices = sessionDeploys + .map((deploy) => deploy.deployable?.name || deploy.service?.name || null) + .filter((name): name is string => Boolean(name)); + const services = [...new Set([...(persistedServices || []), ...liveServices])]; return { ...session, id: session.uuid, uuid: session.uuid, baseBuildUuid: build?.baseBuild?.uuid || null, + primaryRepo: + primaryWorkspaceRepo?.repo || + primarySelectedService?.repo || + primaryDeploy?.repository?.fullName || + build?.pullRequest?.fullName || + build?.pullRequest?.repository?.fullName || + build?.baseBuild?.pullRequest?.fullName || + build?.baseBuild?.pullRequest?.repository?.fullName || + null, + primaryBranch: + primaryWorkspaceRepo?.branch || + primarySelectedService?.branch || + primaryDeploy?.branchName || + build?.pullRequest?.branchName || + build?.baseBuild?.pullRequest?.branchName || + null, repo: + primaryWorkspaceRepo?.repo || + primarySelectedService?.repo || primaryDeploy?.repository?.fullName || build?.pullRequest?.fullName || build?.pullRequest?.repository?.fullName || @@ -455,6 +632,8 @@ export default class AgentSessionService { build?.baseBuild?.pullRequest?.repository?.fullName || null, branch: + primaryWorkspaceRepo?.branch || + primarySelectedService?.branch || primaryDeploy?.branchName || build?.pullRequest?.branchName || build?.baseBuild?.pullRequest?.branchName || @@ -516,9 +695,30 @@ export default class AgentSessionService { githubAppName ); const claudePrAttribution = renderAgentSessionClaudeAttribution(claudeConfig.attribution.prTemplate, githubAppName); - const resolvedServices = await resolveTemplatedDevConfigEnvs(opts.buildUuid, opts.namespace, opts.services); + const templatedServices = await resolveTemplatedDevConfigEnvs(opts.buildUuid, opts.namespace, opts.services); + const { + workspaceRepos, + services: resolvedServices, + selectedServices, + } = resolveAgentSessionServicePlan( + { + repoUrl: opts.repoUrl, + branch: opts.branch, + revision: opts.revision, + workspaceRepos: opts.workspaceRepos, + }, + templatedServices + ); + const primaryWorkspaceRepo = workspaceRepos.find((repo) => repo.primary) || workspaceRepos[0]; const resolvedServiceNames = (resolvedServices || []).map((service) => service.name); - const compatiblePrewarm = await resolveCompatiblePrewarm(opts.buildUuid, resolvedServiceNames, opts.revision); + const compatiblePrewarm = + workspaceRepos.length === 1 + ? await resolveCompatiblePrewarm( + opts.buildUuid, + resolvedServiceNames, + primaryWorkspaceRepo?.revision || opts.revision + ) + : null; const pvcName = compatiblePrewarm?.pvcName || `agent-pvc-${sessionUuid.slice(0, 8)}`; const forwardedAgentEnv = await resolveForwardedAgentEnv( resolvedServices, @@ -532,6 +732,12 @@ export default class AgentSessionService { ) ); + logger().info( + `Session: starting sessionId=${sessionUuid} buildKind=${buildKind} namespace=${opts.namespace} buildUuid=${ + opts.buildUuid || 'none' + } services=${resolvedServiceNames.join(',') || 'none'} prewarm=${compatiblePrewarm ? 'reused' : 'new'}` + ); + try { session = await AgentSession.query().insertAndFetch({ uuid: sessionUuid, @@ -546,6 +752,8 @@ export default class AgentSessionService { status: 'starting', devModeSnapshots, forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, + workspaceRepos, + selectedServices, } as unknown as Partial); sessionPersisted = true; @@ -563,10 +771,7 @@ export default class AgentSessionService { ]); const useGvisor = await isGvisorAvailable(); - const installCommands = (resolvedServices || []) - .map((service) => service.devConfig.installCommand) - .filter((command): command is string => Boolean(command)); - const combinedInstallCommand = installCommands.length > 0 ? installCommands.join('\n\n') : undefined; + const combinedInstallCommand = buildCombinedInstallCommand(resolvedServices); failureStage = 'connect_runtime'; const agentPod = await createAgentPod({ @@ -578,10 +783,11 @@ export default class AgentSessionService { apiKeySecretName, hasGitHubToken: Boolean(opts.githubToken), model, - repoUrl: opts.repoUrl, - branch: opts.branch, - revision: opts.revision, + repoUrl: primaryWorkspaceRepo?.repoUrl, + branch: primaryWorkspaceRepo?.branch, + revision: primaryWorkspaceRepo?.revision || undefined, workspacePath: AGENT_WORKSPACE_ROOT, + workspaceRepos, installCommand: combinedInstallCommand, claudePermissions: claudeConfig.permissions, claudeCommitAttribution, @@ -656,6 +862,12 @@ export default class AgentSessionService { await clearAgentSessionStartupFailure(redis, sessionUuid).catch(() => {}); + logger().info( + `Session: ready sessionId=${sessionUuid} namespace=${opts.namespace} podName=${podName} services=${ + resolvedServiceNames.join(',') || 'none' + } prewarm=${compatiblePrewarm ? 'reused' : 'new'}` + ); + return session!; } catch (err) { const startupFailure = buildAgentSessionStartupFailure({ @@ -675,7 +887,10 @@ export default class AgentSessionService { } } - logger.error(`Session creation failed, rolling back: sessionId=${sessionUuid} err=${(err as Error).message}`); + logger().error( + { error: err, sessionId: sessionUuid, failureStage }, + `Session: startup failed sessionId=${sessionUuid} stage=${failureStage}` + ); await setAgentSessionStartupFailure(redis, startupFailure).catch(() => {}); const endedAt = new Date().toISOString(); @@ -704,6 +919,8 @@ export default class AgentSessionService { endedAt, devModeSnapshots: {}, forwardedAgentSecretProviders: forwardedAgentEnv.secretProviders, + workspaceRepos, + selectedServices, } as unknown as Partial) .catch(() => {}); } @@ -757,6 +974,8 @@ export default class AgentSessionService { const apiKeySecretName = `agent-secret-${session.uuid.slice(0, 8)}`; const redis = RedisClient.getInstance().getRedis(); + logger().info(`Session: ending sessionId=${sessionId} status=${session.status} namespace=${session.namespace}`); + const build = session.buildUuid ? await Build.query() .findOne({ uuid: session.buildUuid }) @@ -785,14 +1004,14 @@ export default class AgentSessionService { ...extractContextForQueue(), }); } catch (error) { - logger.warn( + logger().warn( { error, buildUuid: build.uuid, sessionId }, - 'Sandbox delete queue enqueue failed, falling back to synchronous cleanup' + `Sandbox: cleanup enqueue failed action=sync_fallback sessionId=${sessionId} buildUuid=${build.uuid}` ); await buildService.deleteBuild(build); } - logger.info(`Sandbox session ended and cleanup queued: sessionId=${sessionId} buildUuid=${build.uuid}`); + logger().info(`Sandbox: ending sessionId=${sessionId} buildUuid=${build.uuid} cleanup=queued`); return; } @@ -824,7 +1043,189 @@ export default class AgentSessionService { await redis.del(`${SESSION_REDIS_PREFIX}${session.uuid}`); - logger.info(`Session ended: sessionId=${sessionId}`); + logger().info(`Session: ended sessionId=${sessionId} namespace=${session.namespace}`); + } + + static async attachServices(sessionId: string, requestedServices: RequestedSessionService[]): Promise { + if (!Array.isArray(requestedServices) || requestedServices.length === 0) { + return; + } + + const session = await AgentSession.query().findOne({ uuid: sessionId }); + if (!session) { + throw new Error('Session not found'); + } + + if (session.status !== 'active') { + throw new Error('Only active sessions can connect services'); + } + + if (session.buildKind !== BuildKind.ENVIRONMENT) { + throw new Error('Connecting services after startup is only supported for environment sessions'); + } + + if (!session.buildUuid) { + throw new Error('Session build context is missing'); + } + + const workspaceRepos = session.workspaceRepos || []; + if (workspaceRepos.length !== 1) { + throw new Error('Connecting services after startup is only supported for single-repo sessions'); + } + + const primaryWorkspaceRepo = workspaceRepos.find((repo) => repo.primary) || workspaceRepos[0]; + if (!primaryWorkspaceRepo?.repo || !primaryWorkspaceRepo.branch) { + throw new Error('Session workspace repository metadata is missing'); + } + + const candidates = await loadAgentSessionServiceCandidates(session.buildUuid); + const resolvedCandidates = resolveRequestedAgentSessionServices(candidates, requestedServices); + const attachedDeployIds = new Set([ + ...(session.selectedServices || []).map((service) => service.deployId), + ...Object.keys(session.devModeSnapshots || {}) + .map((deployId) => Number(deployId)) + .filter((deployId) => Number.isInteger(deployId)), + ]); + const attachableCandidates = resolvedCandidates.filter((candidate) => !attachedDeployIds.has(candidate.deployId)); + + if (attachableCandidates.length === 0) { + return; + } + + const incompatibleCandidates = attachableCandidates.filter( + (candidate) => + workspaceRepoKey(candidate.repo) !== workspaceRepoKey(primaryWorkspaceRepo.repo) || + candidate.branch !== primaryWorkspaceRepo.branch + ); + if (incompatibleCandidates.length > 0) { + const supportedTarget = `${primaryWorkspaceRepo.repo}:${primaryWorkspaceRepo.branch}`; + const requestedTargets = incompatibleCandidates.map( + (candidate) => `${candidate.name} (${candidate.repo}:${candidate.branch})` + ); + throw new Error( + `Only services from ${supportedTarget} can be connected after the session starts. Requested: ${requestedTargets.join( + ', ' + )}` + ); + } + + const forwardedEnvServices = attachableCandidates + .filter((candidate) => (candidate.devConfig.forwardEnvVarsToAgent || []).length > 0) + .map((candidate) => candidate.name); + if (forwardedEnvServices.length > 0) { + throw new Error( + `Services that forward env vars to the agent must be selected when the session starts: ${forwardedEnvServices.join( + ', ' + )}` + ); + } + + const candidateServices = attachableCandidates.map( + ({ name, deployId, devConfig, baseDeploy, repo, branch, revision }) => ({ + name, + deployId, + devConfig, + resourceName: baseDeploy.uuid || undefined, + repo, + branch, + revision: revision || null, + }) + ); + const templatedServices = await resolveTemplatedDevConfigEnvs( + session.buildUuid || undefined, + session.namespace, + candidateServices + ); + const { services: resolvedServices, selectedServices } = applyWorkspaceReposToServices( + templatedServices, + workspaceRepos + ); + const installCommand = buildCombinedInstallCommand(resolvedServices); + + logger().info( + `Session: services attaching sessionId=${sessionId} namespace=${session.namespace} services=${ + (resolvedServices || []).map((service) => service.name).join(',') || 'none' + }` + ); + + if (installCommand) { + await runCommandInAgentPod(session.namespace, session.podName, installCommand); + } + + const agentNodeName = await resolveAgentPodNodeName(session.namespace, session.podName); + + if (!agentNodeName) { + throw new Error(`Agent pod ${session.podName} did not report a scheduled node`); + } + + const devModeManager = new DevModeManager(); + const mutatedDeploys: number[] = []; + const addedSnapshots: SessionSnapshotMap = {}; + + try { + for (const service of resolvedServices || []) { + const resourceName = service.resourceName || service.name; + const snapshot = await devModeManager.enableDevMode({ + namespace: session.namespace, + deploymentName: resourceName, + serviceName: resourceName, + pvcName: session.pvcName, + devConfig: service.devConfig, + requiredNodeName: agentNodeName, + }); + + mutatedDeploys.push(service.deployId); + addedSnapshots[String(service.deployId)] = snapshot; + + await Deploy.query().findById(service.deployId).patch({ + devMode: true, + devModeSessionId: session.id, + }); + } + + await AgentSession.query() + .findById(session.id) + .patch({ + selectedServices: mergeSelectedServices(session.selectedServices, selectedServices), + devModeSnapshots: { + ...(session.devModeSnapshots || {}), + ...addedSnapshots, + }, + } as unknown as Partial); + + const serviceNames = resolvedServices?.map((service) => service.name) || []; + + logger().info( + { + sessionId, + namespace: session.namespace, + services: serviceNames, + }, + `Session: services attached sessionId=${sessionId} namespace=${session.namespace} services=${ + serviceNames.join(',') || 'none' + }` + ); + } catch (error) { + if (mutatedDeploys.length > 0) { + const deploysToRevert = await Deploy.query() + .whereIn('id', mutatedDeploys) + .withGraphFetched(DEV_MODE_REDEPLOY_GRAPH) + .catch(() => [] as Deploy[]); + + for (const deployId of mutatedDeploys) { + await Deploy.query() + .findById(deployId) + .patch({ devMode: false, devModeSessionId: null }) + .catch(() => {}); + } + + if (deploysToRevert.length > 0) { + await restoreDevModeDeploys(session.namespace, addedSnapshots, deploysToRevert).catch(() => {}); + } + } + + throw error; + } } static async getSession(sessionId: string) { @@ -857,7 +1258,7 @@ export default class AgentSessionService { return combineAgentSessionAppendSystemPrompt(configuredPrompt, buildAgentSessionDynamicSystemPrompt(context)); } catch (error) { - logger.warn({ err: error, sessionId }, 'Failed to resolve dynamic agent session prompt context'); + logger().warn({ error, sessionId }, `Session: prompt context resolution failed sessionId=${sessionId}`); return configuredPrompt; } } diff --git a/src/server/services/agentSessionCandidates.ts b/src/server/services/agentSessionCandidates.ts index b2e1823..63e2795 100644 --- a/src/server/services/agentSessionCandidates.ts +++ b/src/server/services/agentSessionCandidates.ts @@ -16,7 +16,7 @@ import Build from 'server/models/Build'; import type { Deploy } from 'server/models'; -import { fetchLifecycleConfig, type LifecycleConfig } from 'server/models/yaml'; +import { fetchLifecycleConfig, getDeployingServicesByName, type LifecycleConfig } from 'server/models/yaml'; import { getDeployType, hasLifecycleManagedDockerBuild, @@ -31,49 +31,188 @@ export interface AgentSessionServiceCandidate { detail?: string; deployId: number; devConfig: DevConfig; + repo: string; + branch: string; + revision?: string | null; baseDeploy: Deploy; } +export interface RequestedAgentSessionServiceRef { + name: string; + repo?: string | null; + branch?: string | null; +} + +type AgentSessionCandidateBuildContext = { + pullRequest?: { + fullName?: string | null; + branchName?: string | null; + } | null; + deploys?: Deploy[] | null; +}; + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function normalizeRepoKey(value: string): string { + return value.trim().toLowerCase(); +} + +function buildLifecycleConfigCacheKey(repo: string, branch: string): string { + return `${normalizeRepoKey(repo)}::${branch.trim()}`; +} + +async function fetchCachedLifecycleConfig( + repo: string, + branch: string, + cache: Map> +): Promise { + const cacheKey = buildLifecycleConfigCacheKey(repo, branch); + let promise = cache.get(cacheKey); + + if (!promise) { + promise = fetchLifecycleConfig(repo, branch).catch(() => null); + cache.set(cacheKey, promise); + } + + return promise; +} + +async function resolveCandidateForDeploy( + deploy: Deploy, + buildSource: { repo?: string; branch?: string }, + lifecycleConfigCache: Map> +): Promise { + const serviceName = + normalizeOptionalString(deploy.deployable?.name) || + normalizeOptionalString(deploy.service?.name) || + normalizeOptionalString(deploy.uuid); + const repo = normalizeOptionalString(deploy.repository?.fullName) || buildSource.repo; + const branch = normalizeOptionalString(deploy.branchName) || buildSource.branch; + + if (!deploy.active || !deploy.id || !serviceName || !repo || !branch) { + return null; + } + + const lifecycleConfig = await fetchCachedLifecycleConfig(repo, branch, lifecycleConfigCache); + if (!lifecycleConfig) { + return null; + } + + const yamlService = getDeployingServicesByName(lifecycleConfig, serviceName); + if (!yamlService || !isSessionSelectableService(yamlService)) { + return null; + } + + return { + name: yamlService.name, + type: getDeployType(yamlService), + detail: deploy.status, + deployId: deploy.id, + devConfig: yamlService.dev!, + repo, + branch, + revision: normalizeOptionalString(deploy.sha) || null, + baseDeploy: deploy, + }; +} + export async function loadAgentSessionServiceCandidates(buildUuid: string): Promise { const build = await Build.query() .findOne({ uuid: buildUuid }) - .withGraphFetched('[pullRequest, deploys.[deployable]]'); + .withGraphFetched('[pullRequest, deploys.[deployable, repository, service]]'); if (!build?.pullRequest) { throw new Error('Build not found'); } - const lifecycleConfig = await fetchLifecycleConfig(build.pullRequest.fullName, build.pullRequest.branchName); - if (!lifecycleConfig) { - throw new Error('Lifecycle config not found for build'); - } - - return resolveAgentSessionServiceCandidates(build.deploys || [], lifecycleConfig); + return resolveAgentSessionServiceCandidatesForBuild(build); } export function resolveRequestedAgentSessionServices( candidates: AgentSessionServiceCandidate[], - requestedServices: string[] + requestedServices: Array ): AgentSessionServiceCandidate[] { - const candidatesByName = new Map(candidates.map((candidate) => [candidate.name, candidate])); const missingServices: string[] = []; + const ambiguousServices: string[] = []; + + const resolved = requestedServices.flatMap((requestedService) => { + const serviceName = typeof requestedService === 'string' ? requestedService : requestedService.name; + const requestedRepo = + typeof requestedService === 'string' ? undefined : normalizeOptionalString(requestedService.repo); + const requestedBranch = + typeof requestedService === 'string' ? undefined : normalizeOptionalString(requestedService.branch); + const matches = candidates.filter((candidate) => { + if (candidate.name !== serviceName) { + return false; + } + + if (requestedRepo && normalizeRepoKey(candidate.repo) !== normalizeRepoKey(requestedRepo)) { + return false; + } + + if (requestedBranch && candidate.branch !== requestedBranch) { + return false; + } + + return true; + }); - const resolved = requestedServices.flatMap((serviceName) => { - const candidate = candidatesByName.get(serviceName); - if (!candidate) { - missingServices.push(serviceName); + if (matches.length === 0) { + missingServices.push( + requestedRepo && requestedBranch ? `${serviceName} (${requestedRepo}:${requestedBranch})` : serviceName + ); return []; } - return [candidate]; + if (matches.length > 1) { + ambiguousServices.push( + requestedRepo + ? `${serviceName} (${requestedRepo}${requestedBranch ? `:${requestedBranch}` : ''})` + : `${serviceName} (${matches.map((match) => `${match.repo}:${match.branch}`).join(', ')})` + ); + return []; + } + + return [matches[0]]; }); if (missingServices.length > 0) { throw new Error(`Unknown services for build: ${missingServices.join(', ')}`); } + if (ambiguousServices.length > 0) { + throw new Error( + `Multiple services matched the request; specify repo to disambiguate: ${ambiguousServices.join(', ')}` + ); + } + return resolved; } +export async function resolveAgentSessionServiceCandidatesForBuild( + build: AgentSessionCandidateBuildContext +): Promise { + const buildSource = { + repo: normalizeOptionalString(build.pullRequest?.fullName), + branch: normalizeOptionalString(build.pullRequest?.branchName), + }; + const lifecycleConfigCache = new Map>(); + const candidates = await Promise.all( + (build.deploys || []).map((deploy) => resolveCandidateForDeploy(deploy, buildSource, lifecycleConfigCache)) + ); + + return candidates + .filter((candidate): candidate is AgentSessionServiceCandidate => Boolean(candidate)) + .sort((left, right) => { + if (left.name === right.name) { + return `${left.repo}:${left.branch}`.localeCompare(`${right.repo}:${right.branch}`); + } + + return left.name.localeCompare(right.name); + }); +} + export function resolveAgentSessionServiceCandidates( deploys: Deploy[], lifecycleConfig: LifecycleConfig @@ -101,6 +240,9 @@ export function resolveAgentSessionServiceCandidates( detail: baseDeploy.status, deployId: baseDeploy.id, devConfig: service.dev!, + repo: normalizeOptionalString(baseDeploy.repository?.fullName) || '', + branch: normalizeOptionalString(baseDeploy.branchName) || '', + revision: normalizeOptionalString(baseDeploy.sha) || null, baseDeploy, }, ]; diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 6c12f72..4bf13d0 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -918,6 +918,156 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + FeedbackEntry: { + type: 'object', + properties: { + id: { type: 'string' }, + feedbackType: { + type: 'string', + enum: ['message', 'conversation'], + }, + buildUuid: { type: 'string' }, + rating: { + type: 'string', + enum: ['up', 'down'], + }, + text: { type: 'string', nullable: true }, + userIdentifier: { type: 'string', nullable: true }, + repo: { type: 'string' }, + prNumber: { type: 'integer', nullable: true }, + messageId: { type: 'integer', nullable: true }, + messagePreview: { type: 'string', nullable: true }, + costUsd: { type: 'number', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: [ + 'id', + 'feedbackType', + 'buildUuid', + 'rating', + 'text', + 'userIdentifier', + 'repo', + 'prNumber', + 'messageId', + 'messagePreview', + 'costUsd', + 'createdAt', + ], + }, + + FeedbackListPaginationMetadata: { + type: 'object', + properties: { + page: { type: 'integer' }, + limit: { type: 'integer' }, + totalCount: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + required: ['page', 'limit', 'totalCount', 'totalPages'], + }, + + FeedbackListResponseMetadata: { + type: 'object', + properties: { + pagination: { $ref: '#/components/schemas/FeedbackListPaginationMetadata' }, + }, + required: ['pagination'], + }, + + GetAdminFeedbackListSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/FeedbackEntry' }, + }, + metadata: { $ref: '#/components/schemas/FeedbackListResponseMetadata' }, + }, + required: ['data', 'metadata'], + }, + ], + }, + + ConversationReplayMessage: { + type: 'object', + properties: { + id: { type: 'integer' }, + role: { + type: 'string', + enum: ['user', 'assistant', 'system'], + }, + content: { type: 'string' }, + timestamp: { type: 'integer' }, + metadata: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['id', 'role', 'content', 'timestamp', 'metadata'], + }, + + FeedbackConversationReplayData: { + type: 'object', + properties: { + feedbackType: { + type: 'string', + enum: ['message', 'conversation'], + }, + feedbackId: { type: 'integer' }, + buildUuid: { type: 'string' }, + repo: { type: 'string' }, + ratedMessageId: { type: 'integer', nullable: true }, + feedbackRating: { + type: 'string', + enum: ['up', 'down'], + }, + feedbackText: { type: 'string', nullable: true }, + feedbackUserIdentifier: { type: 'string', nullable: true }, + feedbackCreatedAt: { type: 'string', format: 'date-time' }, + conversation: { + type: 'object', + properties: { + messageCount: { type: 'integer' }, + model: { type: 'string', nullable: true }, + messages: { + type: 'array', + items: { $ref: '#/components/schemas/ConversationReplayMessage' }, + }, + }, + required: ['messageCount', 'model', 'messages'], + }, + }, + required: [ + 'feedbackType', + 'feedbackId', + 'buildUuid', + 'repo', + 'ratedMessageId', + 'feedbackRating', + 'feedbackText', + 'feedbackUserIdentifier', + 'feedbackCreatedAt', + 'conversation', + ], + }, + + GetAdminFeedbackConversationSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/FeedbackConversationReplayData' }, + }, + required: ['data'], + }, + ], + }, + // =================================================================== // AI Chat Schemas // =================================================================== diff --git a/ws-server.ts b/ws-server.ts index c2b78ad..078e285 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -349,7 +349,12 @@ function closeSocket(ws: WebSocket, code: number, reason: string) { } const safeReason = Buffer.byteLength(reason, 'utf8') > 123 ? 'Connection error' : reason; - ws.close(code, safeReason); + if (isSendableCloseCode(code)) { + ws.close(code, safeReason); + return; + } + + ws.close(1000, safeReason); } function buildClaudeUserMessage(content: string): string { @@ -437,7 +442,7 @@ function scheduleAgentRuntimeCleanup( return; } - logger.debug(agentLogCtx, 'Closing idle agent runtime after client disconnect'); + logger.debug(agentLogCtx, `AgentRuntime: closing reason=idle_disconnect sessionId=${sessionId}`); disposeAgentRuntime(sessionId, runtime, true); }, AGENT_RUNTIME_IDLE_TIMEOUT_MS); } @@ -511,7 +516,7 @@ async function getOrCreateAgentRuntime( return; } - logger.debug(agentLogCtx, 'Agent exec connection closed'); + logger.debug(agentLogCtx, `AgentExec: connection closed sessionId=${sessionId}`); closeAgentClients(runtime!, 1012, 'Agent runtime restarted'); disposeAgentRuntime(sessionId, runtime!, false); }); @@ -521,10 +526,13 @@ async function getOrCreateAgentRuntime( return; } - logger.error({ ...agentLogCtx, err }, 'Agent exec error'); + logger.error({ ...agentLogCtx, error: err }, `AgentExec: connection failed sessionId=${sessionId}`); const failure = buildLocalAgentSessionFailure(sessionId, err); void persistAgentRuntimeFailure(sessionId, err).catch((persistError) => { - logger.warn({ ...agentLogCtx, err: persistError }, 'Failed to persist agent runtime failure'); + logger.warn( + { ...agentLogCtx, error: persistError }, + `Session: runtime failure persist failed sessionId=${sessionId}` + ); }); broadcastAgentMessage(runtime!, { type: 'status', @@ -540,7 +548,10 @@ async function getOrCreateAgentRuntime( })() .catch(async (error) => { await persistAgentRuntimeFailure(sessionId, error).catch((persistError) => { - logger.warn({ ...agentLogCtx, err: persistError }, 'Failed to persist agent runtime failure'); + logger.warn( + { ...agentLogCtx, error: persistError }, + `Session: runtime failure persist failed sessionId=${sessionId}` + ); }); disposeAgentRuntime(sessionId, runtime!, true); throw error; @@ -587,9 +598,9 @@ async function attachToAgentPodWithRetry( ...agentLogCtx, attempt, maxAttempts: AGENT_EXEC_ATTACH_MAX_ATTEMPTS, - err: error, + error, }, - 'Agent exec attach failed; retrying while pod becomes ready' + `AgentExec: attach retry podName=${podName} namespace=${namespace} attempt=${attempt} maxAttempts=${AGENT_EXEC_ATTACH_MAX_ATTEMPTS}` ); await new Promise((resolve) => setTimeout(resolve, AGENT_EXEC_ATTACH_RETRY_DELAY_MS)); @@ -683,7 +694,10 @@ async function handleAgentEditorHttp( return true; } catch (error: any) { - logger.error({ err: error, path: pathname, sessionId: match.sessionId }, 'Agent editor proxy request failed'); + logger.error( + { error, path: pathname, sessionId: match.sessionId }, + `AgentEditor: proxy failed sessionId=${match.sessionId} path=${pathname}` + ); res.statusCode = error?.message?.includes('Forbidden') || error?.message?.includes('Authentication') ? 401 @@ -730,12 +744,12 @@ app.prepare().then(() => { wss.emit('connection', ws, request); }); } else if (parseAgentEditorPath(pathname, false)) { - logger.debug(connectionLogCtx, 'Handling upgrade request for agent editor'); + logger.debug(connectionLogCtx, 'WebSocket: upgrade path=agent_editor'); wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { wss.emit('agent-editor', ws, request); }); } else if (pathname?.startsWith(AGENT_SESSION_PATH)) { - logger.debug(connectionLogCtx, 'Handling upgrade request for agent session'); + logger.debug(connectionLogCtx, 'WebSocket: upgrade path=agent_session'); wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { wss.emit('agent-session', ws, request); }); @@ -897,7 +911,10 @@ app.prepare().then(() => { }); ws.on('error', (error) => { - logger.warn({ ...editorLogCtx, err: error }, 'Agent editor client WebSocket error'); + logger.warn( + { ...editorLogCtx, error }, + `AgentEditor: websocket error source=client sessionId=${match.sessionId}` + ); closeUpstream(1011, Buffer.from('Client WebSocket error')); }); @@ -912,19 +929,25 @@ app.prepare().then(() => { }); upstream.on('error', (error) => { - logger.warn({ ...editorLogCtx, err: error }, 'Agent editor upstream WebSocket error'); + logger.warn( + { ...editorLogCtx, error }, + `AgentEditor: websocket error source=upstream sessionId=${match.sessionId}` + ); closeSocket(ws, 1011, 'Editor upstream error'); }); upstream.on('unexpected-response', (_req, response) => { logger.warn( { ...editorLogCtx, statusCode: response.statusCode }, - 'Agent editor upstream WebSocket rejected the upgrade' + `AgentEditor: upgrade rejected sessionId=${match.sessionId} statusCode=${response.statusCode}` ); closeSocket(ws, 1011, 'Editor upgrade rejected'); }); } catch (error: any) { - logger.error({ ...editorLogCtx, err: error, sessionId: match.sessionId }, 'Agent editor WebSocket setup error'); + logger.error( + { ...editorLogCtx, error, sessionId: match.sessionId }, + `AgentEditor: websocket setup failed sessionId=${match.sessionId}` + ); closeSocket(ws, 1008, `Connection error: ${error.message}`); if (upstream && upstream.readyState === WebSocket.CONNECTING) { upstream.terminate(); @@ -967,7 +990,7 @@ app.prepare().then(() => { agentLogCtx.podName = session.podName; agentLogCtx.namespace = session.namespace; - logger.debug(agentLogCtx, 'Agent session WebSocket connected'); + logger.debug(agentLogCtx, `AgentSession: websocket connected sessionId=${activeSessionId}`); runtime = await getOrCreateAgentRuntime( activeSessionId, @@ -1016,16 +1039,22 @@ app.prepare().then(() => { } break; default: - logger.debug({ ...agentLogCtx, msgType: msg.type }, 'Unknown client message type'); + logger.debug( + { ...agentLogCtx, msgType: msg.type }, + `AgentSession: message ignored reason=unknown_type sessionId=${activeSessionId} msgType=${msg.type}` + ); } } catch (err) { - logger.warn({ ...agentLogCtx, err }, 'Failed to process client message'); + logger.warn({ ...agentLogCtx, error: err }, `AgentSession: message failed sessionId=${activeSessionId}`); } }); ws.on('close', (code, reason) => { const reasonString = reason instanceof Buffer ? reason.toString() : String(reason); - logger.debug({ ...agentLogCtx, code, reason: reasonString }, 'Agent WebSocket closed by client'); + logger.debug( + { ...agentLogCtx, code, reason: reasonString }, + `AgentSession: websocket closed sessionId=${activeSessionId} code=${code} reason=${reasonString}` + ); cleanupLocal(); if (runtime && sessionId) { runtime.clients.delete(ws); @@ -1034,7 +1063,7 @@ app.prepare().then(() => { }); ws.on('error', (error) => { - logger.warn({ ...agentLogCtx, err: error }, 'Agent WebSocket error'); + logger.warn({ ...agentLogCtx, error }, `AgentSession: websocket error sessionId=${activeSessionId}`); cleanupLocal(); if (runtime && sessionId) { runtime.clients.delete(ws); @@ -1042,7 +1071,10 @@ app.prepare().then(() => { } }); } catch (error: any) { - logger.error({ ...agentLogCtx, err: error }, 'Agent session WebSocket setup error'); + logger.error( + { ...agentLogCtx, error }, + `AgentSession: websocket setup failed sessionId=${sessionId ?? 'unknown'}` + ); cleanupLocal(); if (runtime && sessionId) { runtime.clients.delete(ws); From 7b7b2883574056c710d1cd54e298bb33d3396e07 Mon Sep 17 00:00:00 2001 From: vmelikyan Date: Sun, 29 Mar 2026 11:08:18 -0700 Subject: [PATCH 17/18] harden editor disconnect --- ws-server.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/ws-server.ts b/ws-server.ts index 078e285..16fd5a8 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -55,6 +55,7 @@ const LEGACY_AGENT_EDITOR_PATH_PREFIX = '/api/v2/ai/agent/sessions/'; const LEGACY_AGENT_EDITOR_PATH_SUFFIX = '/editor'; const AGENT_EDITOR_COOKIE_NAME = 'lfc_agent_editor_auth'; const AGENT_EDITOR_PORT = parseInt(process.env.AGENT_EDITOR_PORT || '13337', 10); +const AGENT_EDITOR_HEARTBEAT_INTERVAL_MS = parseInt(process.env.AGENT_EDITOR_HEARTBEAT_INTERVAL_MS || '15000', 10); const AGENT_RUNTIME_IDLE_TIMEOUT_MS = parseInt(process.env.AGENT_RUNTIME_IDLE_TIMEOUT_MS || '60000', 10); const AGENT_EXEC_ATTACH_RETRY_DELAY_MS = parseInt(process.env.AGENT_EXEC_ATTACH_RETRY_DELAY_MS || '500', 10); const AGENT_EXEC_ATTACH_MAX_ATTEMPTS = parseInt(process.env.AGENT_EXEC_ATTACH_MAX_ATTEMPTS || '20', 10); @@ -357,6 +358,16 @@ function closeSocket(ws: WebSocket, code: number, reason: string) { ws.close(1000, safeReason); } +function normalizeWebSocketCloseReason(reason?: Buffer | string): string | undefined { + if (!reason) { + return undefined; + } + + const value = typeof reason === 'string' ? reason : reason.toString(); + const trimmed = value.trim(); + return trimmed || undefined; +} + function buildClaudeUserMessage(content: string): string { return `${JSON.stringify({ type: 'user', @@ -864,6 +875,7 @@ app.prepare().then(() => { } let upstream: WebSocket | null = null; + let heartbeatInterval: ReturnType | null = null; try { const queryToken = typeof parsedUrl.query.token === 'string' ? parsedUrl.query.token : null; @@ -892,7 +904,7 @@ app.prepare().then(() => { return; } - const closeReason = reason?.toString(); + const closeReason = normalizeWebSocketCloseReason(reason); if (isSendableCloseCode(code)) { upstream.close(code, closeReason); } else { @@ -900,6 +912,37 @@ app.prepare().then(() => { } }; + const clearHeartbeat = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + }; + + const logEditorClose = (source: 'client' | 'upstream', code: number, reason?: Buffer | string) => { + logger.info( + { + ...editorLogCtx, + source, + sessionId: match.sessionId, + code, + reason: normalizeWebSocketCloseReason(reason), + }, + `AgentEditor: websocket closed source=${source} sessionId=${match.sessionId} code=${code}` + ); + }; + + if (AGENT_EDITOR_HEARTBEAT_INTERVAL_MS > 0) { + heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.ping(); + } + if (upstream?.readyState === WebSocket.OPEN) { + upstream.ping(); + } + }, AGENT_EDITOR_HEARTBEAT_INTERVAL_MS); + } + ws.on('message', (data, isBinary) => { if (upstream?.readyState === WebSocket.OPEN) { upstream.send(data, { binary: isBinary }); @@ -907,10 +950,13 @@ app.prepare().then(() => { }); ws.on('close', (code, reason) => { + clearHeartbeat(); + logEditorClose('client', code, reason); closeUpstream(code, reason); }); ws.on('error', (error) => { + clearHeartbeat(); logger.warn( { ...editorLogCtx, error }, `AgentEditor: websocket error source=client sessionId=${match.sessionId}` @@ -925,10 +971,17 @@ app.prepare().then(() => { }); upstream.on('close', (code, reason) => { - closeSocket(ws, code === 1005 ? 1000 : code, reason.toString() || 'Editor connection closed'); + clearHeartbeat(); + logEditorClose('upstream', code, reason); + closeSocket( + ws, + code === 1005 ? 1000 : code, + normalizeWebSocketCloseReason(reason) || 'Editor connection closed' + ); }); upstream.on('error', (error) => { + clearHeartbeat(); logger.warn( { ...editorLogCtx, error }, `AgentEditor: websocket error source=upstream sessionId=${match.sessionId}` @@ -937,6 +990,7 @@ app.prepare().then(() => { }); upstream.on('unexpected-response', (_req, response) => { + clearHeartbeat(); logger.warn( { ...editorLogCtx, statusCode: response.statusCode }, `AgentEditor: upgrade rejected sessionId=${match.sessionId} statusCode=${response.statusCode}` @@ -948,6 +1002,9 @@ app.prepare().then(() => { { ...editorLogCtx, error, sessionId: match.sessionId }, `AgentEditor: websocket setup failed sessionId=${match.sessionId}` ); + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + } closeSocket(ws, 1008, `Connection error: ${error.message}`); if (upstream && upstream.readyState === WebSocket.CONNECTING) { upstream.terminate(); From 73b7dce65a1c3293c9511f7e1c3fe12eb701394a Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Tue, 31 Mar 2026 12:38:20 -0700 Subject: [PATCH 18/18] Support native Helm post renderers and custom images --- src/server/lib/config/ConfigBuilder.ts | 4 ++ .../lib/nativeHelm/__tests__/helm.test.ts | 46 +++++++++++++++++++ src/server/lib/nativeHelm/helm.ts | 15 ++++-- src/server/lib/nativeHelm/utils.ts | 15 ++++-- src/server/services/types/globalConfig.ts | 2 + 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/server/lib/config/ConfigBuilder.ts b/src/server/lib/config/ConfigBuilder.ts index ef5498e..b233416 100644 --- a/src/server/lib/config/ConfigBuilder.ts +++ b/src/server/lib/config/ConfigBuilder.ts @@ -31,6 +31,8 @@ export interface HelmConfig { nativeHelm?: { enabled?: boolean; defaultArgs?: string; + image?: string; + postRendererPath?: string; }; } @@ -57,6 +59,8 @@ export interface GlobalConfig { nativeHelm?: { enabled?: boolean; defaultArgs?: string; + image?: string; + postRendererPath?: string; }; } diff --git a/src/server/lib/nativeHelm/__tests__/helm.test.ts b/src/server/lib/nativeHelm/__tests__/helm.test.ts index eb069be..fc8dd56 100644 --- a/src/server/lib/nativeHelm/__tests__/helm.test.ts +++ b/src/server/lib/nativeHelm/__tests__/helm.test.ts @@ -398,6 +398,26 @@ describe('Native Helm', () => { expect(result).not.toContain('--timeout 30m'); }); + it('should include a post-renderer when configured', () => { + const result = constructHelmCommand( + 'upgrade --install', + 'my-chart', + 'my-release', + 'my-namespace', + ['key=value'], + ['values.yaml'], + ChartType.PUBLIC, + '--force --timeout 60m0s --wait', + undefined, + '--wait --timeout 30m', + '1.2.3', + '/opt/bin/post-renderer' + ); + + expect(result).toContain('--post-renderer /opt/bin/post-renderer'); + expect(result).toContain('--version 1.2.3'); + }); + it('should handle OCI chart URLs correctly', () => { const result = constructHelmCommand( 'upgrade --install', @@ -574,6 +594,32 @@ describe('Native Helm', () => { expect(result.args[0]).toContain('helm upgrade --install'); expect(result.args[0]).toContain('--force --timeout 60m0s --wait'); }); + + it('should use a configured custom helm image and post-renderer path', async () => { + const customImage = 'registry.example.com/custom/helm-with-renderer:1.2.3'; + const result = await createHelmContainer( + 'org/repo', + 'my-chart', + 'my-release', + 'my-namespace', + '3.12.0', + ['key=value'], + ['values.yaml'], + ChartType.PUBLIC, + 'my-service', + 'my-job-name', + '--force --timeout 60m0s --wait', + 'https://charts.example.com', + '--wait --timeout 30m', + '1.2.3', + undefined, + customImage, + '/opt/bin/post-renderer' + ); + + expect(result.image).toBe(customImage); + expect(result.args[0]).toContain('--post-renderer /opt/bin/post-renderer'); + }); }); describe('constructHelmCustomValues for ORG_CHART ingress', () => { diff --git a/src/server/lib/nativeHelm/helm.ts b/src/server/lib/nativeHelm/helm.ts index 51d3c93..d2b6eb4 100644 --- a/src/server/lib/nativeHelm/helm.ts +++ b/src/server/lib/nativeHelm/helm.ts @@ -85,7 +85,9 @@ export async function createHelmContainer( chartRepoUrl?: string, defaultArgs?: string, chartVersion?: string, - registryAuth?: RegistryAuthConfig + registryAuth?: RegistryAuthConfig, + helmImage?: string, + postRendererPath?: string ): Promise { const script = generateHelmInstallScript( repoName, @@ -99,12 +101,13 @@ export async function createHelmContainer( chartRepoUrl, defaultArgs, chartVersion, - registryAuth + registryAuth, + postRendererPath ); return { name: 'helm-deploy', - image: `${HELM_IMAGE_PREFIX}:${helmVersion}`, + image: helmImage || `${HELM_IMAGE_PREFIX}:${helmVersion}`, env: [ { name: 'HELM_CACHE_HOME', value: '/workspace/.helm/cache' }, { name: 'HELM_CONFIG_HOME', value: '/workspace/.helm/config' }, @@ -155,6 +158,8 @@ export async function generateHelmManifest( const helmArgs = mergedHelmConfig.args; const defaultArgs = mergedHelmConfig.nativeHelm?.defaultArgs; const registryAuth = detectRegistryAuth(chartRepoUrl); + const helmImage = mergedHelmConfig.nativeHelm?.image; + const postRendererPath = mergedHelmConfig.nativeHelm?.postRendererPath; const helmContainer = await createHelmContainer( repository?.fullName || 'no-repo', @@ -171,7 +176,9 @@ export async function generateHelmManifest( chartRepoUrl, defaultArgs, chartVersion, - registryAuth + registryAuth, + helmImage, + postRendererPath ); const volumeConfig = { diff --git a/src/server/lib/nativeHelm/utils.ts b/src/server/lib/nativeHelm/utils.ts index dc8a40e..43e5d80 100644 --- a/src/server/lib/nativeHelm/utils.ts +++ b/src/server/lib/nativeHelm/utils.ts @@ -56,7 +56,8 @@ export function constructHelmCommand( args?: string, chartRepoUrl?: string, defaultArgs?: string, - chartVersion?: string + chartVersion?: string, + postRendererPath?: string ): string { let command = `helm ${action} ${releaseName}`; @@ -91,6 +92,10 @@ export function constructHelmCommand( command += ` --version ${chartVersion}`; } + if (postRendererPath) { + command += ` --post-renderer ${postRendererPath}`; + } + customValues.forEach((value) => { const equalIndex = value.indexOf('='); if (equalIndex > -1) { @@ -131,7 +136,8 @@ export function generateHelmInstallScript( chartRepoUrl?: string, defaultArgs?: string, chartVersion?: string, - registryAuth?: RegistryAuthConfig + registryAuth?: RegistryAuthConfig, + postRendererPath?: string ): string { const helmCommand = constructHelmCommand( 'upgrade --install', @@ -144,7 +150,8 @@ export function generateHelmInstallScript( args, chartRepoUrl, defaultArgs, - chartVersion + chartVersion, + postRendererPath ); let script = [ @@ -866,7 +873,7 @@ export async function validateHelmConfiguration(deploy: Deploy): Promise