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:
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
|||||||
workBranch: string;
|
workBranch: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
// Directory name to clone into under `workdir` (default "repo"). Used to lay
|
||||||
|
// out checkouts as ~/Code/{spoon}/{branch}.
|
||||||
|
dirName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await mkdir(args.workdir, { recursive: true });
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
const dirName = args.dirName ?? 'repo';
|
||||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||||
const clone = await run(
|
const clone = await run(
|
||||||
'git',
|
'git',
|
||||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||||
{
|
{
|
||||||
cwd: args.workdir,
|
cwd: args.workdir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
|||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(`git clone failed:\n${clone.output}`);
|
throw new Error(`git clone failed:\n${clone.output}`);
|
||||||
}
|
}
|
||||||
const repoDir = path.join(args.workdir, 'repo');
|
const repoDir = path.join(args.workdir, dirName);
|
||||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
|
|||||||
@@ -80,18 +80,23 @@ export const containerVolumeSuffix = () =>
|
|||||||
|
|
||||||
export { hostWorkspacePath };
|
export { hostWorkspacePath };
|
||||||
|
|
||||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
export const jobWorkspaceVolumeSpec = (
|
||||||
|
workdir: string,
|
||||||
|
containerHome = '/workspace',
|
||||||
|
) => {
|
||||||
const volumeOptions =
|
const volumeOptions =
|
||||||
env.containerVolumeOptions ??
|
env.containerVolumeOptions ??
|
||||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
const source = hostWorkspacePath(workdir);
|
const source = hostWorkspacePath(workdir);
|
||||||
return volumeOptions
|
return volumeOptions
|
||||||
? `${source}:/workspace:${volumeOptions}`
|
? `${source}:${containerHome}:${volumeOptions}`
|
||||||
: `${source}:/workspace`;
|
: `${source}:${containerHome}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
export const runInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
@@ -110,9 +115,9 @@ export const runInJobContainer = async (args: {
|
|||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
@@ -128,6 +133,8 @@ export const runInJobContainer = async (args: {
|
|||||||
|
|
||||||
export const startWorkspaceContainer = async (args: {
|
export const startWorkspaceContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
containerName: string;
|
containerName: string;
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
command?: string[];
|
command?: string[];
|
||||||
@@ -154,9 +161,9 @@ export const startWorkspaceContainer = async (args: {
|
|||||||
: []),
|
: []),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...(args.command ?? ['sleep', 'infinity']),
|
...(args.command ?? ['sleep', 'infinity']),
|
||||||
],
|
],
|
||||||
@@ -220,6 +227,8 @@ export const execInWorkspaceContainer = async (args: {
|
|||||||
|
|
||||||
export const streamInJobContainer = async (args: {
|
export const streamInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
@@ -240,9 +249,9 @@ export const streamInJobContainer = async (args: {
|
|||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
...environmentArgs(args.environment),
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getTerminalWorkspace } from './worker';
|
|||||||
|
|
||||||
const TERMINAL_IMAGE = env.terminalImage;
|
const TERMINAL_IMAGE = env.terminalImage;
|
||||||
const IDLE_STOP_MS = env.terminalIdleMs;
|
const IDLE_STOP_MS = env.terminalIdleMs;
|
||||||
const CONTAINER_WORKDIR = '/workspace/repo';
|
|
||||||
|
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
|
|
||||||
@@ -21,7 +20,11 @@ const containerName = (jobId: string) =>
|
|||||||
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
|
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
|
||||||
const sessions = new Map<string, Session>();
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
|
const ensureTerminalContainer = async (
|
||||||
|
jobId: string,
|
||||||
|
workdir: string,
|
||||||
|
containerHome: string,
|
||||||
|
) => {
|
||||||
const name = containerName(jobId);
|
const name = containerName(jobId);
|
||||||
const container = docker.getContainer(name);
|
const container = docker.getContainer(name);
|
||||||
const info = await container.inspect().catch(() => null);
|
const info = await container.inspect().catch(() => null);
|
||||||
@@ -35,11 +38,11 @@ const ensureTerminalContainer = async (jobId: string, workdir: string) => {
|
|||||||
name,
|
name,
|
||||||
Image: TERMINAL_IMAGE,
|
Image: TERMINAL_IMAGE,
|
||||||
Cmd: ['sleep', 'infinity'],
|
Cmd: ['sleep', 'infinity'],
|
||||||
WorkingDir: CONTAINER_WORKDIR,
|
WorkingDir: containerHome,
|
||||||
Tty: false,
|
Tty: false,
|
||||||
Labels: { 'spoon.agent.terminal': jobId },
|
Labels: { 'spoon.agent.terminal': jobId },
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
|
Binds: [`${source}:${containerHome}${suffix ? `:${suffix}` : ''}`],
|
||||||
NetworkMode: env.network,
|
NetworkMode: env.network,
|
||||||
Memory: 4 * 1024 * 1024 * 1024,
|
Memory: 4 * 1024 * 1024 * 1024,
|
||||||
AutoRemove: false,
|
AutoRemove: false,
|
||||||
@@ -81,7 +84,11 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
let stream: Duplex | undefined;
|
let stream: Duplex | undefined;
|
||||||
let exec: Docker.Exec | undefined;
|
let exec: Docker.Exec | undefined;
|
||||||
try {
|
try {
|
||||||
const container = await ensureTerminalContainer(jobId, workspace.workdir);
|
const container = await ensureTerminalContainer(
|
||||||
|
jobId,
|
||||||
|
workspace.workdir,
|
||||||
|
workspace.containerHome,
|
||||||
|
);
|
||||||
exec = await container.exec({
|
exec = await container.exec({
|
||||||
// Reattach a persistent tmux session across reconnects when tmux is
|
// Reattach a persistent tmux session across reconnects when tmux is
|
||||||
// available; otherwise fall back to a plain login shell.
|
// available; otherwise fall back to a plain login shell.
|
||||||
@@ -94,9 +101,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
WorkingDir: CONTAINER_WORKDIR,
|
WorkingDir: workspace.containerRepo,
|
||||||
Env: [
|
Env: [
|
||||||
'TERM=xterm-256color',
|
'TERM=xterm-256color',
|
||||||
|
`HOME=${workspace.containerHome}`,
|
||||||
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
|
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { runInJobContainer } from './runtime/docker';
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
|
export type UserEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The job owner's resolved environment (username + dotfiles, decrypted). */
|
||||||
|
export const fetchUserEnvironment = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
): Promise<UserEnvironment | null> =>
|
||||||
|
await client.action(api.userDotfilesNode.getEnvironmentForJob, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
// Keep a written path inside the home directory.
|
||||||
|
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
||||||
|
const target = path.resolve(homeDir, relPath);
|
||||||
|
const root = path.resolve(homeDir);
|
||||||
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
|
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
||||||
|
* repo + the setup command, run inside the job image so the user's tools/paths
|
||||||
|
* apply; then the editable overlay files (which win over the repo). Idempotent
|
||||||
|
* via a hash marker so the repo/setup only re-runs when the config changes.
|
||||||
|
*/
|
||||||
|
export const materializeUserHome = async (args: {
|
||||||
|
homeDir: string;
|
||||||
|
containerHome: string;
|
||||||
|
userEnv: UserEnvironment;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { homeDir, containerHome, userEnv, redact } = args;
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
|
||||||
|
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
|
||||||
|
const bashProfile = path.join(homeDir, '.bash_profile');
|
||||||
|
await readFile(bashProfile, 'utf8').catch(async () => {
|
||||||
|
await writeFile(
|
||||||
|
bashProfile,
|
||||||
|
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
// Public dotfiles repo + setup command, only re-run when the config changes.
|
||||||
|
if (userEnv.dotfilesRepoUrl) {
|
||||||
|
const configHash = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
repo: userEnv.dotfilesRepoUrl,
|
||||||
|
ref: userEnv.dotfilesRepoRef ?? '',
|
||||||
|
setup: userEnv.setupCommand ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
const markerPath = path.join(homeDir, '.spoon', 'env-hash');
|
||||||
|
const previous = await readFile(markerPath, 'utf8').catch(() => '');
|
||||||
|
if (previous.trim() !== configHash) {
|
||||||
|
const branch = userEnv.dotfilesRepoRef
|
||||||
|
? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} `
|
||||||
|
: '';
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
'rm -rf ~/.dotfiles',
|
||||||
|
`git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`,
|
||||||
|
userEnv.setupCommand
|
||||||
|
? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await runInJobContainer({
|
||||||
|
workdir: homeDir,
|
||||||
|
containerHome,
|
||||||
|
containerCwd: containerHome,
|
||||||
|
command: ['bash', '-lc', script],
|
||||||
|
environment: { HOME: containerHome },
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
|
await writeFile(markerPath, configHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
for (const file of userEnv.files) {
|
||||||
|
const target = safeHomeJoin(homeDir, file.path);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, file.content);
|
||||||
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -17,11 +17,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
import type { OpenCodeSession } from './opencode-session';
|
import type { OpenCodeSession } from './opencode-session';
|
||||||
import { normalizeCodexJsonLine } from './agent-events';
|
import { normalizeCodexJsonLine } from './agent-events';
|
||||||
import {
|
import { prepareCodexWorkspaceFiles } from './codex-runtime';
|
||||||
codexContainerRepo,
|
|
||||||
codexContainerWorkspace,
|
|
||||||
prepareCodexWorkspaceFiles,
|
|
||||||
} from './codex-runtime';
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -45,6 +41,7 @@ import {
|
|||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
streamInJobContainer,
|
streamInJobContainer,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
|
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||||
|
|
||||||
type Claim = {
|
type Claim = {
|
||||||
job: {
|
job: {
|
||||||
@@ -98,7 +95,14 @@ type Claim = {
|
|||||||
|
|
||||||
type ActiveWorkspace = {
|
type ActiveWorkspace = {
|
||||||
claim: Claim;
|
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;
|
workdir: string;
|
||||||
|
homeDir: string;
|
||||||
|
username: string;
|
||||||
|
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
|
||||||
|
containerHome: string;
|
||||||
|
containerRepo: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
@@ -620,6 +624,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
|||||||
);
|
);
|
||||||
const container = await startWorkspaceContainer({
|
const container = await startWorkspaceContainer({
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
containerName,
|
containerName,
|
||||||
environment: {
|
environment: {
|
||||||
...aiEnv,
|
...aiEnv,
|
||||||
@@ -649,7 +655,7 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
|||||||
const session = await createOpenCodeSession({
|
const session = await createOpenCodeSession({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
password,
|
password,
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
||||||
onEvent: async (event) => {
|
onEvent: async (event) => {
|
||||||
const messageId = workspaceCurrentMessage.get(
|
const messageId = workspaceCurrentMessage.get(
|
||||||
@@ -721,7 +727,7 @@ const runCodexTurn = async (args: {
|
|||||||
outputFileName,
|
outputFileName,
|
||||||
);
|
);
|
||||||
const outputFileContainerPath = path.posix.join(
|
const outputFileContainerPath = path.posix.join(
|
||||||
codexContainerWorkspace,
|
workspace.containerHome,
|
||||||
'.codex',
|
'.codex',
|
||||||
outputFileName,
|
outputFileName,
|
||||||
);
|
);
|
||||||
@@ -747,15 +753,17 @@ const runCodexTurn = async (args: {
|
|||||||
'--output-last-message',
|
'--output-last-message',
|
||||||
outputFileContainerPath,
|
outputFileContainerPath,
|
||||||
'--cd',
|
'--cd',
|
||||||
codexContainerRepo,
|
workspace.containerRepo,
|
||||||
prompt,
|
prompt,
|
||||||
];
|
];
|
||||||
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||||
const secretEnv = Object.fromEntries(
|
const secretEnv = Object.fromEntries(
|
||||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
);
|
);
|
||||||
const result = await streamInJobContainer({
|
const result = await streamInJobContainer({
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: {
|
environment: {
|
||||||
...aiEnv,
|
...aiEnv,
|
||||||
@@ -866,7 +874,7 @@ const runOpenCodeTurn = async (args: {
|
|||||||
session,
|
session,
|
||||||
prompt,
|
prompt,
|
||||||
model: opencodeModel(workspace.claim),
|
model: opencodeModel(workspace.claim),
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
});
|
});
|
||||||
await turnDone;
|
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 runClaim = async (claim: Claim) => {
|
||||||
const jobId = claim.job._id;
|
const jobId = claim.job._id;
|
||||||
const workdir = path.resolve(env.workdir, jobId);
|
|
||||||
const secretValues = [
|
const secretValues = [
|
||||||
claim.openai.apiKey ?? '',
|
claim.openai.apiKey ?? '',
|
||||||
claim.aiProviderProfile?.secret ?? '',
|
claim.aiProviderProfile?.secret ?? '',
|
||||||
@@ -1288,8 +1302,27 @@ const runClaim = async (claim: Claim) => {
|
|||||||
throw new Error('GitHub installation ID is missing.');
|
throw new Error('GitHub installation ID is missing.');
|
||||||
}
|
}
|
||||||
const githubToken = await getInstallationToken(claim.github.installationId);
|
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({
|
const repoDir = await cloneRepository({
|
||||||
workdir,
|
workdir: checkoutParent,
|
||||||
|
dirName: branchSlug,
|
||||||
token: githubToken,
|
token: githubToken,
|
||||||
owner: claim.job.forkOwner,
|
owner: claim.job.forkOwner,
|
||||||
repo: claim.job.forkRepo,
|
repo: claim.job.forkRepo,
|
||||||
@@ -1300,11 +1333,24 @@ const runClaim = async (claim: Claim) => {
|
|||||||
});
|
});
|
||||||
const workspace: ActiveWorkspace = {
|
const workspace: ActiveWorkspace = {
|
||||||
claim,
|
claim,
|
||||||
workdir,
|
workdir: homeDir,
|
||||||
|
homeDir,
|
||||||
|
username,
|
||||||
|
containerHome,
|
||||||
|
containerRepo,
|
||||||
repoDir,
|
repoDir,
|
||||||
githubToken,
|
githubToken,
|
||||||
redact,
|
redact,
|
||||||
};
|
};
|
||||||
|
if (userEnv) {
|
||||||
|
await appendEvent(
|
||||||
|
jobId,
|
||||||
|
'info',
|
||||||
|
'clone',
|
||||||
|
'Applying your dotfiles and environment.',
|
||||||
|
);
|
||||||
|
await materializeUserHome({ homeDir, containerHome, userEnv, redact });
|
||||||
|
}
|
||||||
if (isCodexLoginProfile(claim)) {
|
if (isCodexLoginProfile(claim)) {
|
||||||
await prepareCodexAuth(workspace);
|
await prepareCodexAuth(workspace);
|
||||||
}
|
}
|
||||||
@@ -1471,6 +1517,9 @@ export const getTerminalWorkspace = (jobId: string) => {
|
|||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
return {
|
return {
|
||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
containerRepo: workspace.containerRepo,
|
||||||
|
username: workspace.username,
|
||||||
secrets: workspace.claim.secrets,
|
secrets: workspace.claim.secrets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1532,7 +1581,7 @@ export const replyToInteraction = async (
|
|||||||
session: workspace.opencodeSession,
|
session: workspace.opencodeSession,
|
||||||
permissionId: args.externalRequestId,
|
permissionId: args.externalRequestId,
|
||||||
response: mapped,
|
response: mapped,
|
||||||
directory: '/workspace/repo',
|
directory: workspace.containerRepo,
|
||||||
});
|
});
|
||||||
await patchInteractionRequest({
|
await patchInteractionRequest({
|
||||||
interactionId: args.interactionId,
|
interactionId: args.interactionId,
|
||||||
@@ -1781,7 +1830,8 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
|||||||
await stopWorkspaceContainer(workspace.containerName);
|
await stopWorkspaceContainer(workspace.containerName);
|
||||||
}
|
}
|
||||||
activeWorkspaces.delete(jobId);
|
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 {
|
return {
|
||||||
pullRequestUrl: pullRequest.html_url,
|
pullRequestUrl: pullRequest.html_url,
|
||||||
pullRequestNumber: pullRequest.number,
|
pullRequestNumber: pullRequest.number,
|
||||||
@@ -1796,7 +1846,8 @@ export const stopWorkspace = async (jobId: string) => {
|
|||||||
await stopWorkspaceContainer(workspace.containerName);
|
await stopWorkspaceContainer(workspace.containerName);
|
||||||
}
|
}
|
||||||
activeWorkspaces.delete(jobId);
|
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 };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user