mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 16:02:13 +00:00
feat: Add LangGraph Deployment GitHub Action and TypeScript Deployment Script (#550)
* Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * move into workflows dir * cr --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: bracesproul <braceasproul@gmail.com>
This commit is contained in:
parent
d0fe5f5323
commit
8eb83c11e5
2 changed files with 289 additions and 0 deletions
60
.github/workflows/deploy-langgraph.yml
vendored
Normal file
60
.github/workflows/deploy-langgraph.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
name: Deploy LangGraph
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "apps/open-swe/**"
|
||||||
|
- "packages/shared/**"
|
||||||
|
- "package.json"
|
||||||
|
workflow_dispatch: # Allows manual triggering
|
||||||
|
|
||||||
|
# If another push happens while this workflow is still running,
|
||||||
|
# cancel the earlier run in favor of the next run.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to LangGraph Platform
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable --mode=skip-build
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Compile deployment script
|
||||||
|
run: |
|
||||||
|
cd apps/open-swe
|
||||||
|
yarn tsc --module commonjs --skipLibCheck --outDir ./dist/scripts ./scripts/deploy-langgraph.ts
|
||||||
|
mv ./dist/scripts/deploy-langgraph.js ./dist/scripts/deploy-langgraph.cjs
|
||||||
|
|
||||||
|
- name: Execute deployment
|
||||||
|
run: |
|
||||||
|
cd apps/open-swe
|
||||||
|
node ./dist/scripts/deploy-langgraph.cjs
|
||||||
|
env:
|
||||||
|
CONTROL_PLANE_HOST: ${{ secrets.CONTROL_PLANE_HOST }}
|
||||||
|
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||||
|
INTEGRATION_ID: ${{ secrets.INTEGRATION_ID }}
|
||||||
|
DEPLOYMENT_ID: ${{ secrets.DEPLOYMENT_ID }}
|
||||||
|
timeout-minutes: 35
|
||||||
|
|
||||||
|
- name: Deployment status
|
||||||
|
if: always()
|
||||||
|
run: echo "Deployment workflow completed with status ${{ job.status }}"
|
||||||
229
apps/open-swe/scripts/deploy-langgraph.ts
Normal file
229
apps/open-swe/scripts/deploy-langgraph.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { createLogger, LogLevel } from "../src/utils/logger.js";
|
||||||
|
|
||||||
|
const logger = createLogger(LogLevel.INFO, "DeployLangGraph");
|
||||||
|
|
||||||
|
interface DeploymentConfig {
|
||||||
|
controlPlaneHost: string;
|
||||||
|
langsmithApiKey: string;
|
||||||
|
integrationId: string;
|
||||||
|
deploymentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeploymentResponse {
|
||||||
|
id: string;
|
||||||
|
latest_revision_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevisionResponse {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevisionsListResponse {
|
||||||
|
resources: RevisionResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_WAIT_TIME = 1800; // 30 minutes
|
||||||
|
const POLL_INTERVAL = 60; // 60 seconds
|
||||||
|
|
||||||
|
function getRequiredEnvVar(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Required environment variable ${name} is not set`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeploymentConfig(): DeploymentConfig {
|
||||||
|
return {
|
||||||
|
controlPlaneHost: getRequiredEnvVar("CONTROL_PLANE_HOST"),
|
||||||
|
langsmithApiKey: getRequiredEnvVar("LANGSMITH_API_KEY"),
|
||||||
|
integrationId: getRequiredEnvVar("INTEGRATION_ID"),
|
||||||
|
deploymentId: process.env.DEPLOYMENT_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaders(
|
||||||
|
apiKey: string,
|
||||||
|
includeContentType = false,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-Api-Key": apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeContentType) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRequest<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
expectedStatus: number,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (response.status !== expectedStatus) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Request failed with status ${response.status}: ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedStatus === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`HTTP request failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("HTTP request failed with unknown error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeployment(
|
||||||
|
config: DeploymentConfig,
|
||||||
|
deploymentId: string,
|
||||||
|
): Promise<DeploymentResponse> {
|
||||||
|
logger.info(`Getting deployment ${deploymentId}`);
|
||||||
|
|
||||||
|
const url = `${config.controlPlaneHost}/v2/deployments/${deploymentId}`;
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: getHeaders(config.langsmithApiKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
return makeRequest<DeploymentResponse>(url, options, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRevisions(
|
||||||
|
config: DeploymentConfig,
|
||||||
|
deploymentId: string,
|
||||||
|
): Promise<RevisionsListResponse> {
|
||||||
|
logger.info(`Listing revisions for deployment ${deploymentId}`);
|
||||||
|
|
||||||
|
const url = `${config.controlPlaneHost}/v2/deployments/${deploymentId}/revisions`;
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: getHeaders(config.langsmithApiKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
return makeRequest<RevisionsListResponse>(url, options, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRevision(
|
||||||
|
config: DeploymentConfig,
|
||||||
|
deploymentId: string,
|
||||||
|
revisionId: string,
|
||||||
|
): Promise<RevisionResponse> {
|
||||||
|
const url = `${config.controlPlaneHost}/v2/deployments/${deploymentId}/revisions/${revisionId}`;
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: getHeaders(config.langsmithApiKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
return makeRequest<RevisionResponse>(url, options, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDeployment(
|
||||||
|
config: DeploymentConfig,
|
||||||
|
deploymentId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info(`Patching deployment ${deploymentId} to trigger new revision`);
|
||||||
|
|
||||||
|
const url = `${config.controlPlaneHost}/v2/deployments/${deploymentId}`;
|
||||||
|
const requestBody = {
|
||||||
|
source_revision_config: {
|
||||||
|
repo_ref: "main",
|
||||||
|
langgraph_config_path: "langgraph.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getHeaders(config.langsmithApiKey, true),
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
};
|
||||||
|
|
||||||
|
await makeRequest<void>(url, options, 200);
|
||||||
|
logger.info(`Successfully patched deployment ${deploymentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDeployment(
|
||||||
|
config: DeploymentConfig,
|
||||||
|
deploymentId: string,
|
||||||
|
revisionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info(`Waiting for revision ${revisionId} to be deployed`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < MAX_WAIT_TIME * 1000) {
|
||||||
|
const revision = await getRevision(config, deploymentId, revisionId);
|
||||||
|
const status = revision.status;
|
||||||
|
|
||||||
|
logger.info(`Revision ${revisionId} status: ${status}`);
|
||||||
|
|
||||||
|
if (status === "DEPLOYED") {
|
||||||
|
logger.info(`Revision ${revisionId} successfully deployed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("FAILED")) {
|
||||||
|
throw new Error(`Revision ${revisionId} failed with status: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Waiting ${POLL_INTERVAL} seconds before next status check...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timeout waiting for revision ${revisionId} to be deployed after ${MAX_WAIT_TIME} seconds`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deployLangGraph(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info("Starting LangGraph deployment process");
|
||||||
|
|
||||||
|
const config = getDeploymentConfig();
|
||||||
|
|
||||||
|
if (!config.deploymentId) {
|
||||||
|
throw new Error("DEPLOYMENT_ID environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deployment exists
|
||||||
|
await getDeployment(config, config.deploymentId);
|
||||||
|
|
||||||
|
// Patch deployment to trigger new revision
|
||||||
|
await patchDeployment(config, config.deploymentId);
|
||||||
|
|
||||||
|
// Get the latest revision after patching
|
||||||
|
const revisions = await listRevisions(config, config.deploymentId);
|
||||||
|
const latestRevision = revisions.resources[0];
|
||||||
|
|
||||||
|
if (!latestRevision) {
|
||||||
|
throw new Error("No revisions found for deployment");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the new revision to be deployed
|
||||||
|
await waitForDeployment(config, config.deploymentId, latestRevision.id);
|
||||||
|
|
||||||
|
logger.info("LangGraph deployment completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("LangGraph deployment failed", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute deployment if this script is run directly
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
deployLangGraph();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue