Worker: persistent per-user home + dotfiles materialization

- Each job/terminal now mounts a persistent per-user home (${workdir}/homes/
  {username}) at /home/{username}; the thread checkout lives at
  ~/Code/{spoon}/{branch} so every thread shows up as a folder in one home and
  dotfiles/tools/nvim plugins persist across sessions
- docker.ts helpers + git.ts cloneRepository take container home/cwd + dir name
  (backward-compatible defaults); codex/opencode/terminal use the per-user paths
- new user-environment.ts: fetchUserEnvironment (worker-token Convex action) +
  materializeUserHome — ensures ~/.bash_profile, applies the editable overlay
  files, and (hashed/idempotent) clones the public dotfiles repo + runs the
  setup command inside the job image
- stopWorkspace no longer deletes the home; only the container stops
- Verified: codex runs a real turn under the new /home/{user} + ~/Code layout;
  overlay .bashrc loads in the interactive shell
This commit is contained in:
Gabriel Brown
2026-06-24 09:51:39 -04:00
parent 683fc62129
commit 4c0de2cbf3
5 changed files with 224 additions and 33 deletions
+67 -16
View File
@@ -17,11 +17,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events';
import type { OpenCodeSession } from './opencode-session';
import { normalizeCodexJsonLine } from './agent-events';
import {
codexContainerRepo,
codexContainerWorkspace,
prepareCodexWorkspaceFiles,
} from './codex-runtime';
import { prepareCodexWorkspaceFiles } from './codex-runtime';
import { env } from './env';
import {
cloneRepository,
@@ -45,6 +41,7 @@ import {
stopWorkspaceContainer,
streamInJobContainer,
} from './runtime/docker';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = {
job: {
@@ -98,7 +95,14 @@ type Claim = {
type ActiveWorkspace = {
claim: Claim;
// Host path of the persistent per-user home (mounted at `containerHome`).
// Equal to `homeDir`; kept as `workdir` for the container mount source.
workdir: string;
homeDir: string;
username: string;
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
containerHome: string;
containerRepo: string;
repoDir: string;
githubToken: string;
redact: (value: string) => string;
@@ -620,6 +624,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
containerName,
environment: {
...aiEnv,
@@ -649,7 +655,7 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
const session = await createOpenCodeSession({
baseUrl,
password,
directory: '/workspace/repo',
directory: workspace.containerRepo,
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(
@@ -721,7 +727,7 @@ const runCodexTurn = async (args: {
outputFileName,
);
const outputFileContainerPath = path.posix.join(
codexContainerWorkspace,
workspace.containerHome,
'.codex',
outputFileName,
);
@@ -747,15 +753,17 @@ const runCodexTurn = async (args: {
'--output-last-message',
outputFileContainerPath,
'--cd',
codexContainerRepo,
workspace.containerRepo,
prompt,
];
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamInJobContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
command,
environment: {
...aiEnv,
@@ -866,7 +874,7 @@ const runOpenCodeTurn = async (args: {
session,
prompt,
model: opencodeModel(workspace.claim),
directory: '/workspace/repo',
directory: workspace.containerRepo,
});
await turnDone;
};
@@ -1268,9 +1276,15 @@ const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
}
};
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-') || 'x';
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const workdir = path.resolve(env.workdir, jobId);
const secretValues = [
claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '',
@@ -1288,8 +1302,27 @@ const runClaim = async (claim: Claim) => {
throw new Error('GitHub installation ID is missing.');
}
const githubToken = await getInstallationToken(claim.github.installationId);
// Resolve the persistent per-user home and lay the checkout out as
// ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
// thread shows up as a folder in one home.
const userEnv = await fetchUserEnvironment(jobId);
const username = userEnv?.username ?? 'user';
const homeDir = path.resolve(env.workdir, 'homes', username);
const containerHome = path.posix.join('/home', username);
const spoonSlug = slugify(claim.spoon.name);
const branchSlug = slugify(claim.job.workBranch);
const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
const containerRepo = path.posix.join(
containerHome,
'Code',
spoonSlug,
branchSlug,
);
const repoDir = await cloneRepository({
workdir,
workdir: checkoutParent,
dirName: branchSlug,
token: githubToken,
owner: claim.job.forkOwner,
repo: claim.job.forkRepo,
@@ -1300,11 +1333,24 @@ const runClaim = async (claim: Claim) => {
});
const workspace: ActiveWorkspace = {
claim,
workdir,
workdir: homeDir,
homeDir,
username,
containerHome,
containerRepo,
repoDir,
githubToken,
redact,
};
if (userEnv) {
await appendEvent(
jobId,
'info',
'clone',
'Applying your dotfiles and environment.',
);
await materializeUserHome({ homeDir, containerHome, userEnv, redact });
}
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
}
@@ -1471,6 +1517,9 @@ export const getTerminalWorkspace = (jobId: string) => {
if (!workspace) return null;
return {
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerRepo: workspace.containerRepo,
username: workspace.username,
secrets: workspace.claim.secrets,
};
};
@@ -1532,7 +1581,7 @@ export const replyToInteraction = async (
session: workspace.opencodeSession,
permissionId: args.externalRequestId,
response: mapped,
directory: '/workspace/repo',
directory: workspace.containerRepo,
});
await patchInteractionRequest({
interactionId: args.interactionId,
@@ -1781,7 +1830,8 @@ export const openWorkspacePullRequest = async (jobId: string) => {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
@@ -1796,7 +1846,8 @@ export const stopWorkspace = async (jobId: string) => {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
return { success: true };
};