mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 13:04:11 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
159
foundry/packages/cli/test/backend-manager.test.ts
Normal file
159
foundry/packages/cli/test/backend-manager.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: spawnMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
|
||||
const sanitized = host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
|
||||
return join(baseDir, `backend-${sanitized}-${port}.${suffix}`);
|
||||
}
|
||||
|
||||
function healthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
runtime: "rivetkit",
|
||||
actorNames: {
|
||||
workspace: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("backend manager", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStateDir = process.env.HF_BACKEND_STATE_DIR;
|
||||
const originalBuildId = process.env.HF_BUILD_ID;
|
||||
|
||||
const config: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/foundry/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HF_BUILD_ID = "test-build";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
spawnMock.mockReset();
|
||||
execFileSyncMock.mockReset();
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.HF_BACKEND_STATE_DIR;
|
||||
} else {
|
||||
process.env.HF_BACKEND_STATE_DIR = originalStateDir;
|
||||
}
|
||||
|
||||
if (originalBuildId === undefined) {
|
||||
delete process.env.HF_BUILD_ID;
|
||||
} else {
|
||||
process.env.HF_BUILD_ID = originalBuildId;
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts backend when healthy but build is outdated", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const pidPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "pid");
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(pidPath, "999999", "utf8");
|
||||
writeFileSync(versionPath, "old-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValueOnce(healthyMetadataResponse())
|
||||
.mockResolvedValueOnce(unhealthyMetadataResponse())
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const fakeChild = Object.assign(new EventEmitter(), {
|
||||
pid: process.pid,
|
||||
unref: vi.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
spawnMock.mockReturnValue(fakeChild);
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const launchCommand = spawnMock.mock.calls[0]?.[0];
|
||||
const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
|
||||
expect(launchCommand === "pnpm" || launchCommand === "bun" || (typeof launchCommand === "string" && launchCommand.endsWith("/bun"))).toBe(true);
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)]));
|
||||
if (launchCommand === "pnpm") {
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"]));
|
||||
}
|
||||
expect(readFileSync(pidPath, "utf8").trim()).toBe(String(process.pid));
|
||||
expect(readFileSync(versionPath, "utf8").trim()).toBe("test-build");
|
||||
});
|
||||
|
||||
it("does not restart when backend is healthy and build is current", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(versionPath, "test-build", "utf8");
|
||||
|
||||
const fetchMock = vi.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>().mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates backend port parsing", () => {
|
||||
expect(parseBackendPort(undefined, 7741)).toBe(7741);
|
||||
expect(parseBackendPort("8080", 7741)).toBe(8080);
|
||||
expect(() => parseBackendPort("0", 7741)).toThrow("Invalid backend port");
|
||||
expect(() => parseBackendPort("abc", 7741)).toThrow("Invalid backend port");
|
||||
});
|
||||
});
|
||||
25
foundry/packages/cli/test/task-editor.test.ts
Normal file
25
foundry/packages/cli/test/task-editor.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeEditorTask } from "../src/task-editor.js";
|
||||
|
||||
describe("task editor helpers", () => {
|
||||
it("strips comment lines and trims whitespace", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# comment
|
||||
Implement feature
|
||||
|
||||
# another comment
|
||||
with more detail
|
||||
`);
|
||||
|
||||
expect(value).toBe("Implement feature\n\nwith more detail");
|
||||
});
|
||||
|
||||
it("returns empty string when only comments are present", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# hello
|
||||
# world
|
||||
`);
|
||||
|
||||
expect(value).toBe("");
|
||||
});
|
||||
});
|
||||
104
foundry/packages/cli/test/theme.test.ts
Normal file
104
foundry/packages/cli/test/theme.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
import { resolveTuiTheme } from "../src/theme.js";
|
||||
|
||||
function withEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
describe("resolveTuiTheme", () => {
|
||||
let tempDir: string | null = null;
|
||||
const originalState = process.env.XDG_STATE_HOME;
|
||||
const originalConfig = process.env.XDG_CONFIG_HOME;
|
||||
|
||||
const baseConfig: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/foundry/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
withEnv("XDG_STATE_HOME", originalState);
|
||||
withEnv("XDG_CONFIG_HOME", originalConfig);
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default theme when no theme sources are present", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("default");
|
||||
expect(resolution.theme.text).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("loads theme from opencode state when configured", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(join(stateHome, "opencode", "kv.json"), JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }), "utf8");
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("gruvbox");
|
||||
expect(resolution.source).toContain("opencode state");
|
||||
expect(resolution.mode).toBe("dark");
|
||||
expect(resolution.theme.selectionBorder.toLowerCase()).not.toContain("dark");
|
||||
});
|
||||
|
||||
it("resolves OpenCode token references in theme defs", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(join(stateHome, "opencode", "kv.json"), JSON.stringify({ theme: "orng", theme_mode: "dark" }), "utf8");
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("orng");
|
||||
expect(resolution.theme.selectionBorder).toBe("#EE7948");
|
||||
expect(resolution.theme.background).toBe("#0a0a0a");
|
||||
});
|
||||
|
||||
it("prefers explicit foundry theme override from config", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const config = { ...baseConfig, theme: "default" } as AppConfig & { theme: string };
|
||||
const resolution = resolveTuiTheme(config, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("foundry config");
|
||||
});
|
||||
});
|
||||
10
foundry/packages/cli/test/tmux.test.ts
Normal file
10
foundry/packages/cli/test/tmux.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stripStatusPrefix } from "../src/tmux.js";
|
||||
|
||||
describe("tmux helpers", () => {
|
||||
it("strips running and idle markers from window names", () => {
|
||||
expect(stripStatusPrefix("▶ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("✓ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("feature/auth")).toBe("feature/auth");
|
||||
});
|
||||
});
|
||||
93
foundry/packages/cli/test/tui-format.test.ts
Normal file
93
foundry/packages/cli/test/tui-format.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { filterTasks, fuzzyMatch } from "@sandbox-agent/foundry-client";
|
||||
import { formatRows } from "../src/tui.js";
|
||||
|
||||
const sample: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/test",
|
||||
title: "Test Title",
|
||||
task: "Do test",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
describe("formatRows", () => {
|
||||
it("renders rust-style table header and empty state", () => {
|
||||
const output = formatRows([], 0, "default", "ok");
|
||||
expect(output).toContain("Branch/PR (type to filter)");
|
||||
expect(output).toContain("No branches found.");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
expect(output).toContain("ok");
|
||||
});
|
||||
|
||||
it("marks selected row with highlight", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready");
|
||||
expect(output).toContain("┃ ");
|
||||
expect(output).toContain("Test Title");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
});
|
||||
|
||||
it("pins footer to the last terminal row", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready", "", false, {
|
||||
width: 80,
|
||||
height: 12,
|
||||
});
|
||||
const lines = output.split("\n");
|
||||
expect(lines).toHaveLength(12);
|
||||
expect(lines[11]).toContain("Ctrl-H:cheatsheet");
|
||||
expect(lines[11]).toContain("v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("supports ordered fuzzy matching", () => {
|
||||
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
|
||||
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows across branch and title", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
sample,
|
||||
{
|
||||
...sample,
|
||||
taskId: "task-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle",
|
||||
},
|
||||
];
|
||||
expect(filterTasks(rows, "doc")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "h2")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "test")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
28
foundry/packages/cli/test/workspace-config.test.ts
Normal file
28
foundry/packages/cli/test/workspace-config.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ConfigSchema } from "@sandbox-agent/foundry-shared";
|
||||
import { resolveWorkspace } from "../src/workspace/config.js";
|
||||
|
||||
describe("cli workspace resolution", () => {
|
||||
it("uses default workspace when no flag", () => {
|
||||
const config = ConfigSchema.parse({
|
||||
auto_submit: true as const,
|
||||
notify: ["terminal" as const],
|
||||
workspace: { default: "team" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/foundry/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveWorkspace(undefined, config)).toBe("team");
|
||||
expect(resolveWorkspace("alpha", config)).toBe("alpha");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue