Compare commits
8 Commits
65aae85369
...
8d2a089268
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker
|
||||
docker/*
|
||||
!docker/agent-job-rootfs
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export const env = {
|
||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||
'',
|
||||
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
||||
// How long a per-user box container survives with no active jobs/terminals.
|
||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
|
||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
||||
workBranch: string;
|
||||
redact: (value: string) => string;
|
||||
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 });
|
||||
const dirName = args.dirName ?? 'repo';
|
||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||
{
|
||||
cwd: args.workdir,
|
||||
redact: args.redact,
|
||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
||||
if (clone.exitCode !== 0) {
|
||||
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], {
|
||||
cwd: repoDir,
|
||||
redact: args.redact,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
@@ -80,18 +81,23 @@ export const containerVolumeSuffix = () =>
|
||||
|
||||
export { hostWorkspacePath };
|
||||
|
||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
||||
export const jobWorkspaceVolumeSpec = (
|
||||
workdir: string,
|
||||
containerHome = '/workspace',
|
||||
) => {
|
||||
const volumeOptions =
|
||||
env.containerVolumeOptions ??
|
||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||
const source = hostWorkspacePath(workdir);
|
||||
return volumeOptions
|
||||
? `${source}:/workspace:${volumeOptions}`
|
||||
: `${source}:/workspace`;
|
||||
? `${source}:${containerHome}:${volumeOptions}`
|
||||
: `${source}:${containerHome}`;
|
||||
};
|
||||
|
||||
export const runInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
@@ -110,9 +116,9 @@ export const runInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
@@ -128,6 +134,8 @@ export const runInJobContainer = async (args: {
|
||||
|
||||
export const startWorkspaceContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
containerName: string;
|
||||
environment: Record<string, string>;
|
||||
command?: string[];
|
||||
@@ -154,9 +162,9 @@ export const startWorkspaceContainer = async (args: {
|
||||
: []),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...(args.command ?? ['sleep', 'infinity']),
|
||||
],
|
||||
@@ -218,8 +226,71 @@ export const execInWorkspaceContainer = async (args: {
|
||||
};
|
||||
};
|
||||
|
||||
// Shared line-streaming + result normalization for a started subprocess
|
||||
// (used by both `docker run` and `docker exec` paths).
|
||||
type StreamingSubprocess = {
|
||||
stdout: Readable | null;
|
||||
stderr: Readable | null;
|
||||
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||
|
||||
const streamSubprocess = async (
|
||||
subprocess: StreamingSubprocess,
|
||||
redact: (value: string) => string,
|
||||
onStdoutLine?: (line: string) => Promise<void>,
|
||||
onStderrLine?: (line: string) => Promise<void>,
|
||||
): Promise<CommandResult> => {
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
const output: string[] = [];
|
||||
let lineHandlers = Promise.resolve();
|
||||
const consume = async (
|
||||
chunk: Buffer,
|
||||
source: 'stdout' | 'stderr',
|
||||
handler?: (line: string) => Promise<void>,
|
||||
) => {
|
||||
output.push(chunk.toString('utf8'));
|
||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||
const lines = next.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? '';
|
||||
if (source === 'stdout') stdoutBuffer = remainder;
|
||||
else stderrBuffer = remainder;
|
||||
for (const line of lines) {
|
||||
if (handler) await handler(redact(line));
|
||||
}
|
||||
};
|
||||
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stdout', onStdoutLine),
|
||||
);
|
||||
});
|
||||
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stderr', onStderrLine),
|
||||
);
|
||||
});
|
||||
let result: Awaited<StreamingSubprocess>;
|
||||
try {
|
||||
result = await subprocess;
|
||||
} catch (error) {
|
||||
await lineHandlers;
|
||||
const outputText = output.join('');
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Container command failed.';
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||
};
|
||||
}
|
||||
await lineHandlers;
|
||||
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||
return normalizeRunResult(result, output.join(''), redact);
|
||||
};
|
||||
|
||||
export const streamInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
@@ -240,9 +311,9 @@ export const streamInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
@@ -253,58 +324,110 @@ export const streamInJobContainer = async (args: {
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
const output: string[] = [];
|
||||
let lineHandlers = Promise.resolve();
|
||||
const consume = async (
|
||||
chunk: Buffer,
|
||||
source: 'stdout' | 'stderr',
|
||||
handler?: (line: string) => Promise<void>,
|
||||
) => {
|
||||
output.push(chunk.toString('utf8'));
|
||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||
const lines = next.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? '';
|
||||
if (source === 'stdout') stdoutBuffer = remainder;
|
||||
else stderrBuffer = remainder;
|
||||
for (const line of lines) {
|
||||
if (handler) {
|
||||
await handler(args.redact(line));
|
||||
}
|
||||
}
|
||||
};
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stdout', args.onStdoutLine),
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
args.onStdoutLine,
|
||||
args.onStderrLine,
|
||||
);
|
||||
});
|
||||
subprocess.stderr.on('data', (chunk: Buffer) => {
|
||||
lineHandlers = lineHandlers.then(() =>
|
||||
consume(chunk, 'stderr', args.onStderrLine),
|
||||
};
|
||||
|
||||
// Per-user persistent "box" container that all of a user's threads exec into
|
||||
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||
export const userContainerName = (username: string) =>
|
||||
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
export const ensureUserContainer = async (args: {
|
||||
username: string;
|
||||
workdir: string;
|
||||
containerHome: string;
|
||||
}): Promise<string> => {
|
||||
await ensureJobImagePulled();
|
||||
const name = userContainerName(args.username);
|
||||
const inspect = await execa(
|
||||
containerRuntime(),
|
||||
['inspect', '-f', '{{.State.Running}}', name],
|
||||
{ reject: false, stdin: 'ignore' },
|
||||
);
|
||||
});
|
||||
let result: Awaited<typeof subprocess>;
|
||||
try {
|
||||
result = await subprocess;
|
||||
} catch (error) {
|
||||
await lineHandlers;
|
||||
const outputText = output.join('');
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Container command failed.';
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: args.redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||
};
|
||||
}
|
||||
await lineHandlers;
|
||||
if (stdoutBuffer && args.onStdoutLine) {
|
||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
||||
}
|
||||
if (stderrBuffer && args.onStderrLine) {
|
||||
await args.onStderrLine(args.redact(stderrBuffer));
|
||||
}
|
||||
return normalizeRunResult(result, output.join(''), args.redact);
|
||||
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||
// Not running: remove any stale container, then start fresh.
|
||||
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||
await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
name,
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs(),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
args.containerHome,
|
||||
env.jobImage,
|
||||
'sleep',
|
||||
'infinity',
|
||||
],
|
||||
{ stdin: 'ignore' },
|
||||
);
|
||||
return name;
|
||||
};
|
||||
|
||||
export const streamExecInContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
onStdoutLine?: (line: string) => Promise<void>;
|
||||
onStderrLine?: (line: string) => Promise<void>;
|
||||
}): Promise<CommandResult> => {
|
||||
const subprocess = execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
);
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
args.onStdoutLine,
|
||||
args.onStderrLine,
|
||||
);
|
||||
};
|
||||
|
||||
export const runExecInContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
);
|
||||
return normalizeRunResult(result, result.all, args.redact);
|
||||
};
|
||||
|
||||
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||
|
||||
@@ -5,66 +5,12 @@ import Docker from 'dockerode';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { env } from './env';
|
||||
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
|
||||
import { verifyTerminalToken } from './terminal-token';
|
||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||
import { getTerminalWorkspace } from './worker';
|
||||
|
||||
const TERMINAL_IMAGE = env.terminalImage;
|
||||
const IDLE_STOP_MS = env.terminalIdleMs;
|
||||
const CONTAINER_WORKDIR = '/workspace/repo';
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
const containerName = (jobId: string) =>
|
||||
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
|
||||
const name = containerName(jobId);
|
||||
const container = docker.getContainer(name);
|
||||
const info = await container.inspect().catch(() => null);
|
||||
if (info?.State.Running) return container;
|
||||
if (info && !info.State.Running) {
|
||||
await container.remove({ force: true }).catch(() => undefined);
|
||||
}
|
||||
const suffix = containerVolumeSuffix();
|
||||
const source = hostWorkspacePath(workdir);
|
||||
const created = await docker.createContainer({
|
||||
name,
|
||||
Image: TERMINAL_IMAGE,
|
||||
Cmd: ['sleep', 'infinity'],
|
||||
WorkingDir: CONTAINER_WORKDIR,
|
||||
Tty: false,
|
||||
Labels: { 'spoon.agent.terminal': jobId },
|
||||
HostConfig: {
|
||||
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
|
||||
NetworkMode: env.network,
|
||||
Memory: 4 * 1024 * 1024 * 1024,
|
||||
AutoRemove: false,
|
||||
},
|
||||
});
|
||||
await created.start();
|
||||
return created;
|
||||
};
|
||||
|
||||
const stopTerminalContainer = async (jobId: string) => {
|
||||
await docker
|
||||
.getContainer(containerName(jobId))
|
||||
.remove({ force: true })
|
||||
.catch(() => undefined);
|
||||
sessions.delete(jobId);
|
||||
};
|
||||
|
||||
const scheduleIdleStop = (jobId: string) => {
|
||||
const session = sessions.get(jobId);
|
||||
if (!session || session.connections > 0) return;
|
||||
session.idleTimer = setTimeout(() => {
|
||||
void stopTerminalContainer(jobId);
|
||||
}, IDLE_STOP_MS);
|
||||
};
|
||||
|
||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
const workspace = getTerminalWorkspace(jobId);
|
||||
if (!workspace) {
|
||||
@@ -72,17 +18,27 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(jobId) ?? { connections: 0 };
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleTimer = undefined;
|
||||
session.connections += 1;
|
||||
sessions.set(jobId, session);
|
||||
// Hold the per-user box open while this terminal is connected; the agent and
|
||||
// the terminal share the exact same container (Phase 2).
|
||||
let boxName: string;
|
||||
try {
|
||||
boxName = await acquireUserBox({
|
||||
username: workspace.username,
|
||||
workdir: workspace.workdir,
|
||||
containerHome: workspace.containerHome,
|
||||
});
|
||||
} catch (error) {
|
||||
ws.close(
|
||||
1011,
|
||||
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let stream: Duplex | undefined;
|
||||
let exec: Docker.Exec | undefined;
|
||||
try {
|
||||
const container = await ensureTerminalContainer(jobId, workspace.workdir);
|
||||
exec = await container.exec({
|
||||
exec = await docker.getContainer(boxName).exec({
|
||||
// Reattach a persistent tmux session across reconnects when tmux is
|
||||
// available; otherwise fall back to a plain login shell.
|
||||
Cmd: [
|
||||
@@ -94,9 +50,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
WorkingDir: CONTAINER_WORKDIR,
|
||||
WorkingDir: workspace.containerRepo,
|
||||
Env: [
|
||||
'TERM=xterm-256color',
|
||||
`HOME=${workspace.containerHome}`,
|
||||
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
|
||||
],
|
||||
});
|
||||
@@ -106,8 +63,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
1011,
|
||||
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
session.connections -= 1;
|
||||
scheduleIdleStop(jobId);
|
||||
releaseUserBox(workspace.username);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,13 +99,12 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
activeStream.write(data);
|
||||
});
|
||||
|
||||
let released = false;
|
||||
const cleanup = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeStream.end();
|
||||
const current = sessions.get(jobId);
|
||||
if (current) {
|
||||
current.connections = Math.max(0, current.connections - 1);
|
||||
scheduleIdleStop(jobId);
|
||||
}
|
||||
releaseUserBox(workspace.username);
|
||||
};
|
||||
ws.on('close', cleanup);
|
||||
ws.on('error', cleanup);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { env } from './env';
|
||||
import {
|
||||
ensureUserContainer,
|
||||
stopWorkspaceContainer,
|
||||
userContainerName,
|
||||
} from './runtime/docker';
|
||||
|
||||
// Phase 2: one persistent "box" container per user that all of their threads
|
||||
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
||||
// up while any thread workspace is active or a terminal is connected, and is
|
||||
// reaped after an idle period once nothing holds it.
|
||||
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
||||
const boxes = new Map<string, Box>();
|
||||
|
||||
export const acquireUserBox = async (args: {
|
||||
username: string;
|
||||
workdir: string;
|
||||
containerHome: string;
|
||||
}): Promise<string> => {
|
||||
const name = await ensureUserContainer(args);
|
||||
const box = boxes.get(args.username) ?? { refs: 0 };
|
||||
if (box.idleTimer) {
|
||||
clearTimeout(box.idleTimer);
|
||||
box.idleTimer = undefined;
|
||||
}
|
||||
box.refs += 1;
|
||||
boxes.set(args.username, box);
|
||||
return name;
|
||||
};
|
||||
|
||||
export const releaseUserBox = (username: string) => {
|
||||
const box = boxes.get(username);
|
||||
if (!box) return;
|
||||
box.refs = Math.max(0, box.refs - 1);
|
||||
if (box.refs > 0) return;
|
||||
box.idleTimer = setTimeout(() => {
|
||||
void stopWorkspaceContainer(userContainerName(username));
|
||||
boxes.delete(username);
|
||||
}, env.boxIdleMs);
|
||||
};
|
||||
@@ -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 { runExecInContainer } 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;
|
||||
boxName: string;
|
||||
userEnv: UserEnvironment;
|
||||
redact: (value: string) => string;
|
||||
}): Promise<void> => {
|
||||
const { homeDir, containerHome, boxName, 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 runExecInContainer({
|
||||
containerName: boxName,
|
||||
command: ['bash', '-lc', script],
|
||||
containerCwd: containerHome,
|
||||
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);
|
||||
}
|
||||
};
|
||||
+107
-30
@@ -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,
|
||||
@@ -40,11 +36,13 @@ import {
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import {
|
||||
listWorkspaceContainerNames,
|
||||
runInJobContainer,
|
||||
runExecInContainer,
|
||||
startWorkspaceContainer,
|
||||
stopWorkspaceContainer,
|
||||
streamInJobContainer,
|
||||
streamExecInContainer,
|
||||
} from './runtime/docker';
|
||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
@@ -98,8 +96,17 @@ 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;
|
||||
// Phase 2: the per-user box container this thread execs into.
|
||||
boxName: string;
|
||||
githubToken: string;
|
||||
redact: (value: string) => string;
|
||||
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||
@@ -620,6 +627,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
||||
);
|
||||
const container = await startWorkspaceContainer({
|
||||
workdir: workspace.workdir,
|
||||
containerHome: workspace.containerHome,
|
||||
containerCwd: workspace.containerRepo,
|
||||
containerName,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
@@ -649,7 +658,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 +730,7 @@ const runCodexTurn = async (args: {
|
||||
outputFileName,
|
||||
);
|
||||
const outputFileContainerPath = path.posix.join(
|
||||
codexContainerWorkspace,
|
||||
workspace.containerHome,
|
||||
'.codex',
|
||||
outputFileName,
|
||||
);
|
||||
@@ -747,15 +756,16 @@ 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,
|
||||
const result = await streamExecInContainer({
|
||||
containerName: workspace.boxName,
|
||||
containerCwd: workspace.containerRepo,
|
||||
command,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
@@ -866,7 +876,7 @@ const runOpenCodeTurn = async (args: {
|
||||
session,
|
||||
prompt,
|
||||
model: opencodeModel(workspace.claim),
|
||||
directory: '/workspace/repo',
|
||||
directory: workspace.containerRepo,
|
||||
});
|
||||
await turnDone;
|
||||
};
|
||||
@@ -998,27 +1008,29 @@ const runProjectCommand = async (args: {
|
||||
command: string;
|
||||
phase: 'install' | 'check' | 'test';
|
||||
claim: Claim;
|
||||
workdir: string;
|
||||
boxName: string;
|
||||
containerHome: string;
|
||||
containerCwd: string;
|
||||
repoDir: string;
|
||||
redact: (value: string) => string;
|
||||
}) => {
|
||||
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
||||
const secretEnv = Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir: args.workdir,
|
||||
? await runExecInContainer({
|
||||
containerName: args.boxName,
|
||||
command: commandToShell(args.command),
|
||||
environment: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
containerCwd: args.containerCwd,
|
||||
environment: { HOME: args.containerHome, ...secretEnv },
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run('bash', ['-lc', args.command], {
|
||||
cwd: args.repoDir,
|
||||
env: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
env: secretEnv,
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
@@ -1268,9 +1280,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 ?? '',
|
||||
@@ -1278,6 +1296,7 @@ const runClaim = async (claim: Claim) => {
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
].filter(Boolean);
|
||||
const redact = createRedactor(secretValues);
|
||||
let acquiredBoxUser: string | undefined;
|
||||
try {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
@@ -1288,8 +1307,36 @@ 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,
|
||||
);
|
||||
|
||||
// Start (or reuse) the persistent per-user box that this thread — and the
|
||||
// terminal — exec into. It mounts the home, so the clone below is visible.
|
||||
const boxName = await acquireUserBox({
|
||||
username,
|
||||
workdir: homeDir,
|
||||
containerHome,
|
||||
});
|
||||
acquiredBoxUser = username;
|
||||
|
||||
const repoDir = await cloneRepository({
|
||||
workdir,
|
||||
workdir: checkoutParent,
|
||||
dirName: branchSlug,
|
||||
token: githubToken,
|
||||
owner: claim.job.forkOwner,
|
||||
repo: claim.job.forkRepo,
|
||||
@@ -1300,11 +1347,31 @@ const runClaim = async (claim: Claim) => {
|
||||
});
|
||||
const workspace: ActiveWorkspace = {
|
||||
claim,
|
||||
workdir,
|
||||
workdir: homeDir,
|
||||
homeDir,
|
||||
username,
|
||||
containerHome,
|
||||
containerRepo,
|
||||
repoDir,
|
||||
boxName,
|
||||
githubToken,
|
||||
redact,
|
||||
};
|
||||
if (userEnv) {
|
||||
await appendEvent(
|
||||
jobId,
|
||||
'info',
|
||||
'clone',
|
||||
'Applying your dotfiles and environment.',
|
||||
);
|
||||
await materializeUserHome({
|
||||
homeDir,
|
||||
containerHome,
|
||||
boxName,
|
||||
userEnv,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
await prepareCodexAuth(workspace);
|
||||
}
|
||||
@@ -1366,6 +1433,7 @@ const runClaim = async (claim: Claim) => {
|
||||
).catch((stopError: unknown) => {
|
||||
console.error(stopError);
|
||||
});
|
||||
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1451,7 +1519,9 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
command,
|
||||
phase: command.includes('test') ? 'test' : 'check',
|
||||
claim: workspace.claim,
|
||||
workdir: workspace.workdir,
|
||||
boxName: workspace.boxName,
|
||||
containerHome: workspace.containerHome,
|
||||
containerCwd: workspace.containerRepo,
|
||||
repoDir: workspace.repoDir,
|
||||
redact: workspace.redact,
|
||||
});
|
||||
@@ -1471,6 +1541,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 +1605,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 +1854,9 @@ 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;
|
||||
// release the box (reaped once no other thread/terminal holds it).
|
||||
releaseUserBox(workspace.username);
|
||||
return {
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
@@ -1796,7 +1871,9 @@ 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;
|
||||
// release the box (reaped once no other thread/terminal holds it).
|
||||
releaseUserBox(workspace.username);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
'use server';
|
||||
|
||||
import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager';
|
||||
|
||||
const SettingsDotfilesPage = () => {
|
||||
return (
|
||||
<section className='space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Dotfiles</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Your personal shell, editor, and tool config — applied to the
|
||||
workspace terminal in every thread. Files are placed relative to your
|
||||
home directory (e.g. <code>.bashrc</code>,{' '}
|
||||
<code>.config/nvim/init.lua</code>).
|
||||
</p>
|
||||
</div>
|
||||
<DotfilesManager />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDotfilesPage;
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -11,6 +11,7 @@ const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
@import 'tw-animate-css';
|
||||
@import '@spoon/tailwind-config/theme';
|
||||
|
||||
/*
|
||||
* Nerd Font icons for the workspace terminal + editor. Scoped to the Nerd Font
|
||||
* glyph ranges via unicode-range, so the ~1MB file is only fetched when an icon
|
||||
* actually renders (latin text stays on Victor Mono). Used as a fallback in the
|
||||
* terminal/editor font stacks.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Symbols Nerd Font Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/SymbolsNerdFontMono.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+23fb-23fe, U+2665, U+26a1, U+2b58, U+e000-f8ff, U+f0000-fffff;
|
||||
}
|
||||
|
||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -20,7 +20,7 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
});
|
||||
|
||||
const EDITOR_FONT_FAMILY =
|
||||
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@spoon/ui';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const TERMINAL_FONT =
|
||||
"var(--font-victor-mono), 'Geist Mono', ui-monospace, monospace";
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||
|
||||
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
|
||||
|
||||
@@ -146,6 +146,15 @@ export const WorkspaceTerminal = ({
|
||||
fit.fit();
|
||||
termRef.current = term;
|
||||
|
||||
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
|
||||
// repaint once ready so powerline/oh-my-posh/eza icons render.
|
||||
void document.fonts
|
||||
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
|
||||
.then(() => {
|
||||
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
'use client';
|
||||
|
||||
import type { FileTreeNode } from '@/components/agent-workspace/types';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CodeEditor } from '@/components/agent-workspace/code-editor';
|
||||
import { FileTree } from '@/components/agent-workspace/file-tree';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { FilePlus, FolderUp, Trash2, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, Input, Label } from '@spoon/ui';
|
||||
|
||||
type DotfileMeta = {
|
||||
_id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
isExecutable: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type UploadFile = { path: string; content: string; isExecutable?: boolean };
|
||||
|
||||
// Minimal typed surface of the drag-and-drop FileSystem entry API.
|
||||
type FsEntry = {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
|
||||
createReader?: () => {
|
||||
readEntries: (
|
||||
cb: (e: FsEntry[]) => void,
|
||||
err: (e: unknown) => void,
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const buildTree = (files: DotfileMeta[], rootLabel: string): FileTreeNode => {
|
||||
const root: FileTreeNode = {
|
||||
name: rootLabel,
|
||||
path: '',
|
||||
type: 'directory',
|
||||
children: [],
|
||||
};
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
const segments = file.path.split('/');
|
||||
let node = root;
|
||||
segments.forEach((segment, index) => {
|
||||
const isLeaf = index === segments.length - 1;
|
||||
const childPath = segments.slice(0, index + 1).join('/');
|
||||
node.children ??= [];
|
||||
let child = node.children.find((c) => c.path === childPath);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: segment,
|
||||
path: childPath,
|
||||
type: isLeaf ? 'file' : 'directory',
|
||||
children: isLeaf ? undefined : [],
|
||||
};
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
});
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
const readAllEntries = (reader: {
|
||||
readEntries: (cb: (e: FsEntry[]) => void, err: (e: unknown) => void) => void;
|
||||
}) =>
|
||||
new Promise<FsEntry[]>((resolve, reject) => {
|
||||
const all: FsEntry[] = [];
|
||||
const next = () =>
|
||||
reader.readEntries((batch) => {
|
||||
if (batch.length === 0) resolve(all);
|
||||
else {
|
||||
all.push(...batch);
|
||||
next();
|
||||
}
|
||||
}, reject);
|
||||
next();
|
||||
});
|
||||
|
||||
const collectEntry = async (
|
||||
entry: FsEntry,
|
||||
prefix: string,
|
||||
out: UploadFile[],
|
||||
) => {
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((res, rej) => entry.file?.(res, rej));
|
||||
out.push({ path: `${prefix}${entry.name}`, content: await file.text() });
|
||||
} else if (entry.isDirectory && entry.createReader) {
|
||||
const entries = await readAllEntries(entry.createReader());
|
||||
for (const child of entries) {
|
||||
await collectEntry(child, `${prefix}${entry.name}/`, out);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const DotfilesManager = () => {
|
||||
const settings = useQuery(api.userEnvironment.getMine);
|
||||
const filesQuery = useQuery(api.userDotfiles.listMine);
|
||||
const files = useMemo(
|
||||
() => (filesQuery ?? []) as DotfileMeta[],
|
||||
[filesQuery],
|
||||
);
|
||||
const getFileContent = useAction(api.userDotfilesNode.getFileContent);
|
||||
const putFile = useAction(api.userDotfilesNode.putFile);
|
||||
const importFiles = useAction(api.userDotfilesNode.importFiles);
|
||||
const removeFile = useMutation(api.userDotfiles.remove);
|
||||
const updateEnv = useMutation(api.userEnvironment.updateMine);
|
||||
|
||||
const [selected, setSelected] = useState<DotfileMeta>();
|
||||
const [content, setContent] = useState('');
|
||||
const [savedContent, setSavedContent] = useState('');
|
||||
const [expandedOverride, setExpandedOverride] = useState<string[] | null>(
|
||||
null,
|
||||
);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const filesInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const firstName = settings?.firstName ?? 'you';
|
||||
const tree = useMemo(
|
||||
() => buildTree(files, `home/${firstName}`),
|
||||
[files, firstName],
|
||||
);
|
||||
|
||||
// Directories default to expanded; once the user toggles, their choice wins.
|
||||
const allDirs = useMemo(
|
||||
() =>
|
||||
files
|
||||
.flatMap((f) => {
|
||||
const segs = f.path.split('/');
|
||||
return segs
|
||||
.slice(0, -1)
|
||||
.map((_, i) => segs.slice(0, i + 1).join('/'));
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i),
|
||||
[files],
|
||||
);
|
||||
const expanded = expandedOverride ?? allDirs;
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
const file = files.find((f) => f.path === path);
|
||||
if (!file) return; // directory
|
||||
setSelected(file);
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
try {
|
||||
const { content: text } = await getFileContent({
|
||||
fileId: file._id as never,
|
||||
});
|
||||
setContent(text);
|
||||
setSavedContent(text);
|
||||
} catch {
|
||||
toast.error('Could not open file.');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSelected = async (next: string) => {
|
||||
if (!selected) return;
|
||||
await putFile({
|
||||
path: selected.path,
|
||||
content: next,
|
||||
isExecutable: selected.isExecutable,
|
||||
});
|
||||
setSavedContent(next);
|
||||
toast.success('Saved.');
|
||||
};
|
||||
|
||||
const importAll = async (incoming: UploadFile[]) => {
|
||||
const valid = incoming.filter((f) => f.path.trim());
|
||||
if (valid.length === 0) return;
|
||||
try {
|
||||
await importFiles({ files: valid });
|
||||
toast.success(`Imported ${valid.length} file(s).`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Import failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
const out: UploadFile[] = [];
|
||||
const entries: FsEntry[] = [];
|
||||
for (const item of Array.from(event.dataTransfer.items)) {
|
||||
const entry = item.webkitGetAsEntry() as FsEntry | null;
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
for (const entry of entries) await collectEntry(entry, '', out);
|
||||
} else {
|
||||
for (const file of Array.from(event.dataTransfer.files)) {
|
||||
out.push({ path: file.name, content: await file.text() });
|
||||
}
|
||||
}
|
||||
await importAll(out);
|
||||
};
|
||||
|
||||
const onPickFiles = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
stripFirstSegment: boolean,
|
||||
) => {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
const out: UploadFile[] = [];
|
||||
for (const file of picked) {
|
||||
const relative =
|
||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||
file.name;
|
||||
const path = stripFirstSegment
|
||||
? relative.split('/').slice(1).join('/')
|
||||
: relative;
|
||||
out.push({ path, content: await file.text() });
|
||||
}
|
||||
event.target.value = '';
|
||||
await importAll(out);
|
||||
};
|
||||
|
||||
const newFile = async () => {
|
||||
const path = window.prompt('New file path (relative to home):', '.bashrc');
|
||||
if (!path?.trim()) return;
|
||||
try {
|
||||
await putFile({ path: path.trim(), content: '' });
|
||||
toast.success('Created.');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Could not create.');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (!selected) return;
|
||||
await removeFile({ fileId: selected._id as never });
|
||||
setSelected(undefined);
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
toast.success('Deleted.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card className='gap-0 overflow-hidden p-0 shadow-none'>
|
||||
<div className='border-border flex flex-wrap items-center gap-2 border-b p-2'>
|
||||
<Button type='button' variant='outline' size='sm' onClick={newFile}>
|
||||
<FilePlus className='size-4' /> New file
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
>
|
||||
<FolderUp className='size-4' /> Upload folder
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => filesInputRef.current?.click()}
|
||||
>
|
||||
<Upload className='size-4' /> Upload files
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-destructive ml-auto'
|
||||
onClick={() => void deleteSelected()}
|
||||
>
|
||||
<Trash2 className='size-4' /> Delete
|
||||
</Button>
|
||||
) : null}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type='file'
|
||||
// @ts-expect-error non-standard but widely supported folder picker
|
||||
webkitdirectory=''
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => void onPickFiles(e, true)}
|
||||
/>
|
||||
<input
|
||||
ref={filesInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => void onPickFiles(e, false)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid min-h-[28rem] grid-cols-1 md:grid-cols-[16rem_1fr]'>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
className={`border-border min-h-0 overflow-auto border-b md:border-r md:border-b-0 ${
|
||||
dragOver ? 'bg-primary/10' : ''
|
||||
}`}
|
||||
>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selected?.path}
|
||||
expandedPaths={expanded}
|
||||
onSelect={(path) => void openFile(path)}
|
||||
onToggleDirectory={(path) =>
|
||||
setExpandedOverride(
|
||||
expanded.includes(path)
|
||||
? expanded.filter((p) => p !== path)
|
||||
: [...expanded, path],
|
||||
)
|
||||
}
|
||||
/>
|
||||
{files.length === 0 ? (
|
||||
<p className='text-muted-foreground p-4 text-center text-xs'>
|
||||
Drag files or folders here, or use the buttons above. They land
|
||||
relative to your home directory.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='min-h-0'>
|
||||
{selected ? (
|
||||
<CodeEditor
|
||||
path={selected.path}
|
||||
content={content}
|
||||
savedContent={savedContent}
|
||||
readOnly={false}
|
||||
vimEnabled={false}
|
||||
onSave={saveSelected}
|
||||
onChange={setContent}
|
||||
onVimEnabledChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex h-full items-center justify-center p-6 text-sm'>
|
||||
Select a file to edit, or add files to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<RepoPanel
|
||||
settings={settings}
|
||||
onSave={async (values) => {
|
||||
await updateEnv(values);
|
||||
toast.success('Saved.');
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Dotfiles are encrypted at rest. For real API keys or tokens, use the
|
||||
Secrets feature on a Spoon instead — those are injected as environment
|
||||
variables.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RepoPanel = ({
|
||||
settings,
|
||||
onSave,
|
||||
}: {
|
||||
settings:
|
||||
| {
|
||||
dotfilesRepoUrl?: string;
|
||||
dotfilesRepoRef?: string;
|
||||
setupCommand?: string;
|
||||
}
|
||||
| undefined;
|
||||
onSave: (values: {
|
||||
dotfilesRepoUrl?: string;
|
||||
dotfilesRepoRef?: string;
|
||||
setupCommand?: string;
|
||||
}) => Promise<void>;
|
||||
}) => {
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [repoRef, setRepoRef] = useState('');
|
||||
const [setupCommand, setSetupCommand] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || hydrated) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setRepoUrl(settings.dotfilesRepoUrl ?? '');
|
||||
setRepoRef(settings.dotfilesRepoRef ?? '');
|
||||
setSetupCommand(settings.setupCommand ?? '');
|
||||
setHydrated(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [settings, hydrated]);
|
||||
|
||||
return (
|
||||
<Card className='space-y-3 p-4 shadow-none'>
|
||||
<div>
|
||||
<h3 className='font-medium'>Dotfiles repo (optional)</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
A public git repo cloned to <code>~/.dotfiles</code> on start. The
|
||||
setup command runs in the container afterwards (e.g.{' '}
|
||||
<code>install</code> to symlink, like a dotfiles bootstrap). Your
|
||||
edited files above are applied on top.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='repoUrl'>Public repo URL</Label>
|
||||
<Input
|
||||
id='repoUrl'
|
||||
placeholder='https://github.com/you/dotfiles'
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='repoRef'>Branch / ref (optional)</Label>
|
||||
<Input
|
||||
id='repoRef'
|
||||
placeholder='main'
|
||||
value={repoRef}
|
||||
onChange={(e) => setRepoRef(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='setupCommand'>Setup script path (optional)</Label>
|
||||
<Input
|
||||
id='setupCommand'
|
||||
placeholder='install.sh'
|
||||
value={setupCommand}
|
||||
onChange={(e) => setSetupCommand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
void onSave({
|
||||
dotfilesRepoUrl: repoUrl,
|
||||
dotfilesRepoRef: repoRef,
|
||||
setupCommand,
|
||||
}).finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save repo settings'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
# Spoon container — neutral interactive shell defaults (system-wide).
|
||||
# Tools here benefit everyone; a user's ~/.bashrc (loaded via ~/.bash_profile,
|
||||
# which the worker ensures) layers on top and can override any of this.
|
||||
|
||||
# Interactive shells only.
|
||||
case $- in
|
||||
*i*) ;;
|
||||
*) return ;;
|
||||
esac
|
||||
|
||||
export EDITOR="${EDITOR:-nvim}"
|
||||
export PAGER="${PAGER:-less}"
|
||||
# User-local + bun install locations.
|
||||
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"
|
||||
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first --icons'
|
||||
alias ll='eza -lh --group-directories-first --icons --git'
|
||||
alias la='eza -lha --group-directories-first --icons --git'
|
||||
alias lt='eza --tree --level=2 --icons --git'
|
||||
fi
|
||||
|
||||
command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never --style=plain'
|
||||
alias n='nvim'
|
||||
alias g='git'
|
||||
alias cl='clear'
|
||||
|
||||
# fzf keybindings + completion when present.
|
||||
for f in /usr/share/fzf/shell/key-bindings.bash \
|
||||
/usr/share/bash-completion/completions/fzf; do
|
||||
[ -f "$f" ] && . "$f"
|
||||
done
|
||||
|
||||
if command -v oh-my-posh >/dev/null 2>&1 && [ -f /etc/spoon/omp.json ]; then
|
||||
eval "$(oh-my-posh init bash --config /etc/spoon/omp.json)"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
|
||||
"version": 3,
|
||||
"final_space": true,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "prompt",
|
||||
"alignment": "left",
|
||||
"segments": [
|
||||
{
|
||||
"type": "path",
|
||||
"style": "plain",
|
||||
"foreground": "#5fd0e0",
|
||||
"template": " {{ .Path }} ",
|
||||
"properties": { "style": "agnoster_short", "max_depth": 3 }
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"style": "plain",
|
||||
"foreground": "#8fd6b4",
|
||||
"template": "{{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} ",
|
||||
"properties": {
|
||||
"fetch_status": true,
|
||||
"branch_icon": " "
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "prompt",
|
||||
"alignment": "left",
|
||||
"newline": true,
|
||||
"segments": [
|
||||
{
|
||||
"type": "text",
|
||||
"style": "plain",
|
||||
"foreground": "#1fb895",
|
||||
"foreground_templates": ["{{ if gt .Code 0 }}#f3625d{{ end }}"],
|
||||
"template": "❯ "
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Spoon container — system tmux defaults. A user's ~/.config/tmux/tmux.conf (or
|
||||
# ~/.tmux.conf) is read after this and overrides it.
|
||||
|
||||
# Login shells so /etc/profile.d/spoon.sh (tools) and ~/.bash_profile load.
|
||||
set -g default-command "exec bash -l"
|
||||
set -g default-terminal "tmux-256color"
|
||||
set -ag terminal-overrides ",xterm-256color:RGB"
|
||||
set -g mouse on
|
||||
set -g history-limit 50000
|
||||
set -g escape-time 10
|
||||
set -g focus-events on
|
||||
setw -g mode-keys vi
|
||||
+42
-11
@@ -1,30 +1,61 @@
|
||||
FROM docker.io/library/node:22-bookworm
|
||||
FROM registry.fedoraproject.org/fedora:41
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
|
||||
# default Fedora repos (no COPR needed). The QoL set mirrors the user's Panama
|
||||
# workstation setup so the terminal feels like a real dev box for everyone.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
|
||||
bash \
|
||||
bash-completion \
|
||||
bat \
|
||||
bubblewrap \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
eza \
|
||||
fd-find \
|
||||
findutils \
|
||||
fzf \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
gh \
|
||||
git \
|
||||
glibc-langpack-en \
|
||||
gum \
|
||||
gzip \
|
||||
jq \
|
||||
less \
|
||||
locales \
|
||||
make \
|
||||
ncurses \
|
||||
neovim \
|
||||
openssh-client \
|
||||
nodejs \
|
||||
nodejs-npm \
|
||||
openssh-clients \
|
||||
procps-ng \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
tar \
|
||||
tmux \
|
||||
unzip \
|
||||
wget \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@latest --activate \
|
||||
&& corepack prepare yarn@stable --activate \
|
||||
&& npm install -g bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
which \
|
||||
zoxide \
|
||||
&& dnf clean all \
|
||||
&& rm -rf /var/cache/dnf
|
||||
|
||||
# Package managers + pinned agent CLIs (kept identical to the prior image).
|
||||
# Fedora's nodejs-npm doesn't ship corepack, so install pnpm/yarn via npm.
|
||||
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
|
||||
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
|
||||
&& oh-my-posh version
|
||||
|
||||
# Neutral system-wide defaults: /etc/profile.d/spoon.sh, /etc/tmux.conf, theme.
|
||||
COPY docker/agent-job-rootfs/ /
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Personalized dev environment (dotfiles + persistent home)
|
||||
|
||||
Makes the workspace terminal feel like the user's own machine: a Fedora image
|
||||
preloaded with QoL CLI tooling, a persistent per-user home, and user dotfiles.
|
||||
|
||||
## The model
|
||||
|
||||
- **Persistent per-user home.** Each user gets a home directory on the worker
|
||||
host at `${SPOON_AGENT_WORKDIR}/homes/{username}`, bind-mounted into every
|
||||
job/terminal container at `/home/{username}` (`HOME`). It survives across
|
||||
sessions, so dotfiles, installed tools, nvim plugins, and shell history persist.
|
||||
`username` is derived from the user's profile first name (sanitized).
|
||||
- **Threads as folders.** Each thread's checkout lives at
|
||||
`~/Code/{spoon}/{branch}` inside that home, so every thread shows up as a
|
||||
folder in one home. The agent (`codex --cd …`) and the terminal both open there.
|
||||
- **Neutral defaults (everyone).** The Fedora job image
|
||||
(`docker/agent-job.Dockerfile`) ships zoxide, eza, bat, fzf, fd, ripgrep, gh,
|
||||
gum, neovim, tmux, oh-my-posh, etc., plus system-wide defaults that work even
|
||||
with an empty home: `/etc/profile.d/spoon.sh` (tool init + aliases),
|
||||
`/etc/tmux.conf` (login-shell panes), `/etc/spoon/omp.json` (prompt theme).
|
||||
- **User dotfiles (per-user).** Configured in **Settings → Dotfiles**, applied on
|
||||
top of the neutral defaults.
|
||||
|
||||
## Settings → Dotfiles
|
||||
|
||||
A mini file-browser workspace rooted at `home/{firstName}`:
|
||||
|
||||
- **Editable overlay tree** — drag in files/folders (or use Upload folder/files),
|
||||
edit them in the Monaco editor, add/delete. Files are placed **relative to
|
||||
`$HOME`** (`.bashrc` → `~/.bashrc`, `.config/nvim/…` → `~/.config/nvim/…`).
|
||||
Stored encrypted at rest (`userDotfiles`, AES-256-GCM via `secretCrypto`).
|
||||
- **Dotfiles repo (optional)** — a **public** git repo URL + optional ref + a
|
||||
setup script path. On start the container clones it to `~/.dotfiles` and runs
|
||||
`bash ~/.dotfiles/<setup>` (e.g. a bootstrap that symlinks configs, like the
|
||||
user's Panama `install`).
|
||||
- **Precedence (hybrid):** repo clone + setup runs first; then the editable
|
||||
overlay is written on top — **overlay wins**.
|
||||
|
||||
Secrets: dotfiles are encrypted, but real API keys/tokens belong in a Spoon's
|
||||
**Secrets** feature (injected as env vars), not in dotfiles. The UI nudges this.
|
||||
|
||||
## Materialization (worker)
|
||||
|
||||
`apps/agent-worker/src/user-environment.ts`:
|
||||
|
||||
1. `fetchUserEnvironment(jobId)` — a worker-token Convex action
|
||||
(`userDotfilesNode.getEnvironmentForJob`) returns the owner's decrypted
|
||||
dotfiles + repo/setup config.
|
||||
2. `materializeUserHome` — ensures `~/.bash_profile` (so login shells source
|
||||
`~/.bashrc` in a mounted home with no `/etc/skel`); clones the repo + runs the
|
||||
setup command **inside the job image** (so the user's tools/paths apply), only
|
||||
when the config hash changes (`~/.spoon/env-hash`); writes the overlay files.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `SPOON_AGENT_WORKDIR` | `.local/agent-work` (dev) / `/var/lib/spoon-agent/work` (prod) | Per-user homes live under `homes/{username}`; reuses the existing host-path translation. |
|
||||
| `SPOON_ENCRYPTION_KEY` / `INSTANCE_SECRET` | — | Already required; encrypts dotfiles like other secrets. |
|
||||
|
||||
No new required env. The home is a host directory under the existing workdir, so
|
||||
the prod bind-mount + `SPOON_AGENT_HOST_WORKDIR` translation already covers it.
|
||||
|
||||
## Notes / limits (Phase 1)
|
||||
|
||||
- **Repo auth:** public repos only. Private/self-hosted (e.g. Gitea) dotfiles
|
||||
repos are a follow-up (store a token/deploy key).
|
||||
- **Binary files:** the overlay is text-first.
|
||||
- **Cleanup:** `~/Code/{spoon}/{branch}` checkouts persist (threads as folders);
|
||||
a per-thread "delete checkout" action is a follow-up.
|
||||
- **Concurrency:** jobs share one home; fine at the default
|
||||
`SPOON_AGENT_MAX_CONCURRENT_JOBS=1`.
|
||||
|
||||
## Phase 2 north star
|
||||
|
||||
A single long-running per-user container that every thread `exec`s into (agent
|
||||
via `docker exec`, not `docker run --rm`). The per-user home + `~/Code/{spoon}/
|
||||
{branch}` layout built here is its foundation.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Server deploy changes (terminal + dotfiles + Fedora + Phase 2)
|
||||
|
||||
Everything the production host / compose / `.env` needs for the workspace
|
||||
terminal, personalized dev environment, Nerd Font, and the per-user container.
|
||||
Most items have safe defaults; the **Required** ones are the only must-dos.
|
||||
|
||||
## Required
|
||||
|
||||
1. **Build-time env for the Next image** — add to the build env file (the one CI /
|
||||
`scripts/build-next-app` passes as build args; e.g. `DOTENV_PROD`):
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org
|
||||
```
|
||||
|
||||
This is a `NEXT_PUBLIC` (build-time) var — it must be present **when the
|
||||
`spoon-next` image is built**, not just at runtime. Already wired into
|
||||
`docker/Dockerfile` + `docker/compose.yml` build args. Without it, the
|
||||
workspace **Terminal** tab shows "not configured".
|
||||
|
||||
2. **nginx: expose the worker for the terminal WebSocket.** Add a TLS server
|
||||
block proxying the worker domain to the worker on the shared network, with WS
|
||||
upgrade:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name worker.spoon.gbrown.org; # + your ssl_certificate lines
|
||||
location / {
|
||||
proxy_pass http://spoon-agent-worker:3921;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Rebuild + redeploy all three images** (CI does this on push to `main`):
|
||||
`spoon-next`, `spoon-agent-worker`, and `spoon-agent-job` (now **Fedora**).
|
||||
The worker auto-`docker pull`s the job image once per process, so a worker
|
||||
restart picks up the new Fedora job image. Make sure the prod registry has the
|
||||
new `spoon-agent-job:latest`.
|
||||
|
||||
4. **Deploy Convex functions** (new tables `userDotfiles`, `userEnvironment`).
|
||||
`SPOON_ENCRYPTION_KEY` (or `INSTANCE_SECRET`) is already required and is what
|
||||
encrypts dotfiles at rest — no change, just confirm it's set.
|
||||
|
||||
5. **Confirm `SPOON_AGENT_HOST_WORKDIR`** on the `spoon-agent-worker` service is
|
||||
the absolute host path backing `SPOON_AGENT_WORKDIR` (the fix from the terminal
|
||||
work). The per-user homes live under `${SPOON_AGENT_WORKDIR}/homes/{username}`
|
||||
and are bind-mounted into the box via the host daemon — this only resolves if
|
||||
the host-workdir translation is correct. (No new var; just verify.)
|
||||
|
||||
## Optional (safe defaults — only set to override)
|
||||
|
||||
On the `spoon-agent-worker` service:
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `SPOON_AGENT_TERMINAL_SECRET` | falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | HMAC secret for terminal tokens (must match the Next app's, which also falls back). Leave unset to use the shared token. |
|
||||
| `SPOON_AGENT_BOX_IDLE_MS` | `1800000` (30m) | How long a per-user box survives idle before being reaped. |
|
||||
| `SPOON_AGENT_TERMINAL_IDLE_MS` | `1800000` | (Legacy; box idle now governs cleanup.) |
|
||||
|
||||
No new env is needed for dotfiles, the per-user home, or the Nerd Font.
|
||||
|
||||
## Notes / one-time cleanup
|
||||
|
||||
- **Layout change:** thread checkouts moved from `${WORKDIR}/{jobId}/repo` to
|
||||
`${WORKDIR}/homes/{username}/Code/{spoon}/{branch}` (persistent). Old per-job
|
||||
dirs are orphaned and safe to delete.
|
||||
- **Containers:** per-thread agent containers (`docker run --rm`) and per-job
|
||||
terminal containers (`spoon-agent-term-*`) are gone; everything runs in one
|
||||
`spoon-box-{username}` per user. Any lingering `spoon-agent-term-*` containers
|
||||
can be removed.
|
||||
- **Resources:** each active user holds one box (4 GB mem cap, `sleep infinity`)
|
||||
until 30m idle. Single-user = one box.
|
||||
- Compose already mounts `/var/run/docker.sock` into the worker (unchanged) — the
|
||||
box is created/exec'd through it.
|
||||
|
||||
## Quick post-deploy checks
|
||||
|
||||
```bash
|
||||
docker exec spoon-agent-worker docker --version # CLI present (29.x)
|
||||
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest codex --version # 0.142
|
||||
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest bash -lc 'eza --version; zoxide --version; oh-my-posh --version'
|
||||
# then: open a thread → Terminal tab; Settings → Dotfiles add a .bashrc alias.
|
||||
```
|
||||
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
// Linux username for the per-user container home (/home/<username>). Derived
|
||||
// from the first token of the profile name, sanitized; falls back to "user".
|
||||
export const deriveHomeUsername = (name?: string): string => {
|
||||
const first = (name ?? '').trim().split(/\s+/)[0] ?? '';
|
||||
const sanitized = first.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
return sanitized || 'user';
|
||||
};
|
||||
|
||||
// Normalizes a dotfile path to a safe HOME-relative path (no leading slash, no
|
||||
// "..", no empty segments). Throws on anything that would escape HOME.
|
||||
export const normalizeDotfilePath = (rawPath: string): string => {
|
||||
const cleaned = rawPath
|
||||
.trim()
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/^\/+/, '');
|
||||
const segments = cleaned.split('/').filter((s) => s.length > 0);
|
||||
if (segments.length === 0) {
|
||||
throw new ConvexError('A dotfile path is required.');
|
||||
}
|
||||
if (segments.some((s) => s === '..' || s === '.')) {
|
||||
throw new ConvexError(`Invalid dotfile path: ${rawPath}`);
|
||||
}
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
@@ -348,6 +348,30 @@ const applicationTables = {
|
||||
})
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_user_provider', ['userId', 'provider']),
|
||||
// Per-user dotfiles: one row per file, materialized into the workspace
|
||||
// container's HOME. Content is encrypted at rest (reuses secretCrypto).
|
||||
// `path` is relative to HOME, e.g. ".bashrc" or ".config/nvim/init.lua".
|
||||
userDotfiles: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
path: v.string(),
|
||||
encryptedContent: v.string(),
|
||||
size: v.number(),
|
||||
isExecutable: v.optional(v.boolean()),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_owner_path', ['ownerId', 'path']),
|
||||
// Per-user environment config: the persistent home username + an optional
|
||||
// public dotfiles repo and setup command run in the container.
|
||||
userEnvironment: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
enabled: v.boolean(),
|
||||
homeUsername: v.optional(v.string()),
|
||||
dotfilesRepoUrl: v.optional(v.string()),
|
||||
dotfilesRepoRef: v.optional(v.string()),
|
||||
setupCommand: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_owner', ['ownerId']),
|
||||
aiProviderProfiles: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import {
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from './_generated/server';
|
||||
import { getRequiredUserId, normalizeDotfilePath } from './model';
|
||||
|
||||
const fileMeta = (file: Doc<'userDotfiles'>) => ({
|
||||
_id: file._id,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
isExecutable: file.isExecutable ?? false,
|
||||
updatedAt: file.updatedAt,
|
||||
});
|
||||
|
||||
/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const files = await ctx.db
|
||||
.query('userDotfiles')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect();
|
||||
return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path));
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { fileId: v.id('userDotfiles') },
|
||||
handler: async (ctx, { fileId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const file = await ctx.db.get(fileId);
|
||||
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||
await ctx.db.delete(fileId);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */
|
||||
export const removeDirectory = mutation({
|
||||
args: { prefix: v.string() },
|
||||
handler: async (ctx, { prefix }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const normalized = normalizeDotfilePath(prefix);
|
||||
const files = await ctx.db
|
||||
.query('userDotfiles')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect();
|
||||
const matches = files.filter(
|
||||
(f) => f.path === normalized || f.path.startsWith(`${normalized}/`),
|
||||
);
|
||||
await Promise.all(matches.map((f) => ctx.db.delete(f._id)));
|
||||
return { removed: matches.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const rename = mutation({
|
||||
args: { fileId: v.id('userDotfiles'), path: v.string() },
|
||||
handler: async (ctx, { fileId, path }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const file = await ctx.db.get(fileId);
|
||||
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||
const normalized = normalizeDotfilePath(path);
|
||||
const clash = await ctx.db
|
||||
.query('userDotfiles')
|
||||
.withIndex('by_owner_path', (q) =>
|
||||
q.eq('ownerId', ownerId).eq('path', normalized),
|
||||
)
|
||||
.unique();
|
||||
if (clash && clash._id !== fileId) {
|
||||
throw new ConvexError(`A dotfile already exists at ${normalized}.`);
|
||||
}
|
||||
await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Read by the decrypting Node action (userDotfilesNode.getFileContent).
|
||||
export const getRawFileInternal = internalQuery({
|
||||
args: { fileId: v.id('userDotfiles') },
|
||||
handler: async (ctx, { fileId }) => await ctx.db.get(fileId),
|
||||
});
|
||||
|
||||
// Called by the encrypting Node action (userDotfilesNode). Upserts one file by
|
||||
// (owner, path).
|
||||
export const upsertFileInternal = internalMutation({
|
||||
args: {
|
||||
ownerId: v.id('users'),
|
||||
path: v.string(),
|
||||
encryptedContent: v.string(),
|
||||
size: v.number(),
|
||||
isExecutable: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query('userDotfiles')
|
||||
.withIndex('by_owner_path', (q) =>
|
||||
q.eq('ownerId', args.ownerId).eq('path', args.path),
|
||||
)
|
||||
.unique();
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
encryptedContent: args.encryptedContent,
|
||||
size: args.size,
|
||||
isExecutable: args.isExecutable,
|
||||
updatedAt: now,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert('userDotfiles', {
|
||||
...args,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
'use node';
|
||||
|
||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { ActionCtx } from './_generated/server';
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
import { normalizeDotfilePath } from './model';
|
||||
import { decryptSecret, encryptSecret } from './secretCrypto';
|
||||
|
||||
const MAX_FILE_BYTES = 512 * 1024; // 512 KB per dotfile
|
||||
|
||||
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new ConvexError('Not authenticated.');
|
||||
return userId;
|
||||
};
|
||||
|
||||
const requireWorkerToken = (workerToken: string) => {
|
||||
const expected = process.env.SPOON_WORKER_TOKEN;
|
||||
if (!expected) throw new ConvexError('Worker token is not configured.');
|
||||
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
||||
};
|
||||
|
||||
const putOne = async (
|
||||
ctx: ActionCtx,
|
||||
ownerId: Id<'users'>,
|
||||
rawPath: string,
|
||||
content: string,
|
||||
isExecutable?: boolean,
|
||||
) => {
|
||||
const path = normalizeDotfilePath(rawPath);
|
||||
const size = Buffer.byteLength(content, 'utf8');
|
||||
if (size > MAX_FILE_BYTES) {
|
||||
throw new ConvexError(`${path} is too large (max 512 KB).`);
|
||||
}
|
||||
await ctx.runMutation(internal.userDotfiles.upsertFileInternal, {
|
||||
ownerId,
|
||||
path,
|
||||
encryptedContent: encryptSecret(content),
|
||||
size,
|
||||
isExecutable,
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
/** Create/update a single dotfile (used by the in-app editor and "new file"). */
|
||||
export const putFile = action({
|
||||
args: {
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
isExecutable: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ path: string }> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const path = await putOne(
|
||||
ctx,
|
||||
ownerId,
|
||||
args.path,
|
||||
args.content,
|
||||
args.isExecutable,
|
||||
);
|
||||
return { path };
|
||||
},
|
||||
});
|
||||
|
||||
/** Bulk import (drag-and-drop folder/files). */
|
||||
export const importFiles = action({
|
||||
args: {
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
isExecutable: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ imported: number }> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
for (const file of args.files) {
|
||||
await putOne(ctx, ownerId, file.path, file.content, file.isExecutable);
|
||||
}
|
||||
return { imported: args.files.length };
|
||||
},
|
||||
});
|
||||
|
||||
/** Decrypts one file's content for the editor (owner only). */
|
||||
export const getFileContent = action({
|
||||
args: { fileId: v.id('userDotfiles') },
|
||||
handler: async (ctx, { fileId }): Promise<{ content: string }> => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const file = await ctx.runQuery(internal.userDotfiles.getRawFileInternal, {
|
||||
fileId,
|
||||
});
|
||||
if (file?.ownerId !== ownerId) {
|
||||
throw new ConvexError('Dotfile not found.');
|
||||
}
|
||||
return { content: decryptSecret(file.encryptedContent) };
|
||||
},
|
||||
});
|
||||
|
||||
type WorkerEnvironment = {
|
||||
username: string;
|
||||
enabled: boolean;
|
||||
dotfilesRepoUrl?: string;
|
||||
dotfilesRepoRef?: string;
|
||||
setupCommand?: string;
|
||||
files: { path: string; content: string; isExecutable: boolean }[];
|
||||
};
|
||||
|
||||
/** Worker-facing: the job owner's full environment with dotfiles decrypted. */
|
||||
export const getEnvironmentForJob = action({
|
||||
args: { workerToken: v.string(), jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, args): Promise<WorkerEnvironment | null> => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const raw = await ctx.runQuery(
|
||||
internal.userEnvironment.getRawEnvironmentForJobInternal,
|
||||
{ jobId: args.jobId },
|
||||
);
|
||||
if (!raw) return null;
|
||||
return {
|
||||
username: raw.username,
|
||||
enabled: raw.enabled,
|
||||
dotfilesRepoUrl: raw.dotfilesRepoUrl,
|
||||
dotfilesRepoRef: raw.dotfilesRepoRef,
|
||||
setupCommand: raw.setupCommand,
|
||||
files: raw.files.map((f) => ({
|
||||
path: f.path,
|
||||
content: decryptSecret(f.encryptedContent),
|
||||
isExecutable: f.isExecutable,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { QueryCtx } from './_generated/server';
|
||||
import { internalQuery, mutation, query } from './_generated/server';
|
||||
import { deriveHomeUsername, getRequiredUserId, optionalText } from './model';
|
||||
|
||||
const loadSettings = async (ctx: QueryCtx, ownerId: Id<'users'>) =>
|
||||
await ctx.db
|
||||
.query('userEnvironment')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.unique();
|
||||
|
||||
/** Current user's environment settings + the resolved home username/first name. */
|
||||
export const getMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const [user, settings] = await Promise.all([
|
||||
ctx.db.get(ownerId),
|
||||
loadSettings(ctx, ownerId),
|
||||
]);
|
||||
const firstName = (user?.name ?? '').trim().split(/\s+/)[0] || 'you';
|
||||
const username = settings?.homeUsername ?? deriveHomeUsername(user?.name);
|
||||
return {
|
||||
enabled: settings?.enabled ?? true,
|
||||
username,
|
||||
firstName,
|
||||
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
|
||||
dotfilesRepoRef: settings?.dotfilesRepoRef,
|
||||
setupCommand: settings?.setupCommand,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const updateMine = mutation({
|
||||
args: {
|
||||
enabled: v.optional(v.boolean()),
|
||||
dotfilesRepoUrl: v.optional(v.string()),
|
||||
dotfilesRepoRef: v.optional(v.string()),
|
||||
setupCommand: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const repoUrl = optionalText(args.dotfilesRepoUrl);
|
||||
if (repoUrl && !/^https?:\/\//.test(repoUrl)) {
|
||||
throw new ConvexError('Dotfiles repo must be a public http(s) URL.');
|
||||
}
|
||||
const existing = await loadSettings(ctx, ownerId);
|
||||
const patch = {
|
||||
enabled: args.enabled ?? existing?.enabled ?? true,
|
||||
dotfilesRepoUrl: repoUrl,
|
||||
dotfilesRepoRef: optionalText(args.dotfilesRepoRef),
|
||||
setupCommand: optionalText(args.setupCommand),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return { success: true };
|
||||
}
|
||||
await ctx.db.insert('userEnvironment', { ownerId, ...patch });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Worker-facing: everything needed to materialize a job's owner's environment.
|
||||
// Content stays encrypted here; the Node action decrypts it. Resolves the owner
|
||||
// from the job.
|
||||
export const getRawEnvironmentForJobInternal = internalQuery({
|
||||
args: { jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, { jobId }) => {
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (!job) return null;
|
||||
const ownerId = job.ownerId;
|
||||
const [user, settings, files] = await Promise.all([
|
||||
ctx.db.get(ownerId),
|
||||
loadSettings(ctx, ownerId),
|
||||
ctx.db
|
||||
.query('userDotfiles')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect(),
|
||||
]);
|
||||
return {
|
||||
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
|
||||
enabled: settings?.enabled ?? true,
|
||||
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
|
||||
dotfilesRepoRef: settings?.dotfilesRepoRef,
|
||||
setupCommand: settings?.setupCommand,
|
||||
files: files.map((f) => ({
|
||||
path: f.path,
|
||||
encryptedContent: f.encryptedContent,
|
||||
isExecutable: f.isExecutable ?? false,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user