From 2ae8ffc16b9d46bec26a17f52d0ec37db3350ad8 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 23:40:05 -0700 Subject: [PATCH] feat(workspaces): implement workspace container image and initial hub api --- packages/workspace-manager/docker/Dockerfile | 50 +++++++ .../workspace-manager/docker/entrypoint.sh | 21 +++ packages/workspace-manager/package.json | 27 ++++ packages/workspace-manager/src/index.ts | 125 ++++++++++++++++++ packages/workspace-manager/tsconfig.json | 10 ++ 5 files changed, 233 insertions(+) create mode 100644 packages/workspace-manager/docker/Dockerfile create mode 100644 packages/workspace-manager/docker/entrypoint.sh create mode 100644 packages/workspace-manager/package.json create mode 100644 packages/workspace-manager/src/index.ts create mode 100644 packages/workspace-manager/tsconfig.json diff --git a/packages/workspace-manager/docker/Dockerfile b/packages/workspace-manager/docker/Dockerfile new file mode 100644 index 0000000000..197440da98 --- /dev/null +++ b/packages/workspace-manager/docker/Dockerfile @@ -0,0 +1,50 @@ +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 + +FROM node:20-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + rsync \ + vim \ + tmux \ + procps \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Install GitHub CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install gh -y + +# Install Rust (to install shpool) +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +# Install shpool +RUN cargo install shpool \ + && mv /root/.cargo/bin/shpool /usr/local/bin/shpool + +# Install global dev tools +RUN npm install -g tsx eslint vitest typescript prettier @google/gemini-cli@nightly + +# Create workspace directory +WORKDIR /home/node/workspace +RUN chown -R node:node /home/node/workspace + +# Entrypoint script +COPY --chown=node:node entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER node + +# Environment variables +ENV GEMINI_CLI_WORKSPACE=1 +ENV PATH=$PATH:/usr/local/bin + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["/bin/bash"] diff --git a/packages/workspace-manager/docker/entrypoint.sh b/packages/workspace-manager/docker/entrypoint.sh new file mode 100644 index 0000000000..1c9a7b2f2d --- /dev/null +++ b/packages/workspace-manager/docker/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 + +# Ensure GH_TOKEN is set from memory-only mount if available +if [ -f /dev/shm/.gh_token ]; then + export GH_TOKEN=$(cat /dev/shm/.gh_token) + echo "GitHub token injected from memory." +fi + +# Start shpool daemon in the background +/usr/local/bin/shpool daemon & + +# Restore ~/.gemini settings if they are provided in a mount or PD +# (Assuming PD is mounted at /home/node/persistent_home for now) +if [ -d /home/node/persistent_home/.gemini ]; then + rsync -a /home/node/persistent_home/.gemini/ /home/node/.gemini/ +fi + +# Execute the CMD passed to docker +exec "$@" diff --git a/packages/workspace-manager/package.json b/packages/workspace-manager/package.json new file mode 100644 index 0000000000..76122a730b --- /dev/null +++ b/packages/workspace-manager/package.json @@ -0,0 +1,27 @@ +{ + "name": "@google/gemini-cli-workspace-manager", + "version": "0.1.0", + "description": "Workspace Hub for Gemini CLI", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "test": "vitest run", + "build": "tsc" + }, + "dependencies": { + "@google-cloud/compute": "^4.10.0", + "@google-cloud/firestore": "^7.11.0", + "express": "^4.21.2", + "uuid": "^13.0.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^20.0.0", + "@types/uuid": "^10.0.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + } +} diff --git a/packages/workspace-manager/src/index.ts b/packages/workspace-manager/src/index.ts new file mode 100644 index 0000000000..7dd77f4530 --- /dev/null +++ b/packages/workspace-manager/src/index.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import type { Request, Response, RequestHandler } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { Firestore } from '@google-cloud/firestore'; + +interface WorkspaceData { + owner_id: string; + name: string; + instance_name: string; + status: string; + machine_type: string; + created_at: string; +} + +const app = express(); +app.use(express.json()); + +// Initialize Firestore +const firestore = new Firestore(); + +const PORT = process.env.PORT || 8080; + +app.get('/health', (_req: Request, res: Response) => { + res.send({ status: 'ok' }); +}); + +/** + * List all workspaces for the authenticated user + */ +const listWorkspaces: RequestHandler = async (_req, res) => { + try { + const ownerId = 'default-user'; // TODO: Get from OAuth/IAP headers + const snapshot = await firestore + .collection('workspaces') + .where('owner_id', '==', ownerId) + .get(); + + const workspaces = snapshot.docs.map((doc) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const data = doc.data() as WorkspaceData; + return { + id: doc.id, + ...data, + }; + }); + + res.json(workspaces); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } +}; + +app.get('/workspaces', listWorkspaces); + +/** + * Create a new workspace (GCE VM) + */ +const createWorkspace: RequestHandler = async (req, res) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const body = req.body as Record; + const name = typeof body['name'] === 'string' ? body['name'] : 'unnamed'; + const machineType = + typeof body['machineType'] === 'string' + ? body['machineType'] + : 'e2-standard-4'; + + const ownerId = 'default-user'; // TODO: Get from OAuth/IAP headers + const workspaceId = uuidv4(); + const instanceName = `workspace-${workspaceId.slice(0, 8)}`; + + const workspaceData: WorkspaceData = { + owner_id: ownerId, + name, + instance_name: instanceName, + status: 'PROVISIONING', + machine_type: machineType, + created_at: new Date().toISOString(), + }; + + await firestore + .collection('workspaces') + .doc(workspaceId) + .set(workspaceData); + + res.status(201).json({ id: workspaceId, ...workspaceData }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } +}; + +app.post('/workspaces', createWorkspace); + +/** + * Delete a workspace + */ +const deleteWorkspace: RequestHandler = async (req, res) => { + try { + const { id } = req.params; + if (!id) { + res.status(400).json({ error: 'Workspace ID is required' }); + return; + } + await firestore.collection('workspaces').doc(id).delete(); + res.status(204).send(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } +}; + +app.delete('/workspaces/:id', deleteWorkspace); + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Workspace Hub listening on port ${PORT}`); +}); diff --git a/packages/workspace-manager/tsconfig.json b/packages/workspace-manager/tsconfig.json new file mode 100644 index 0000000000..47845999bc --- /dev/null +++ b/packages/workspace-manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +}