feat: add higher-level git and code interpreter APIs
This commit is contained in:
@ -2346,6 +2346,54 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/admin/users/{id}/admin:
|
||||||
|
put:
|
||||||
|
summary: Grant or revoke platform admin
|
||||||
|
operationId: setUserAdmin
|
||||||
|
tags: [admin]
|
||||||
|
description: |
|
||||||
|
Sets the platform admin flag on a user. Cannot remove the last admin.
|
||||||
|
Requires platform admin access (JWT + is_admin).
|
||||||
|
The target user's JWT is not re-issued — their frontend will reflect the
|
||||||
|
change on next login or team switch.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "usr-a1b2c3d4"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [admin]
|
||||||
|
properties:
|
||||||
|
admin:
|
||||||
|
type: boolean
|
||||||
|
description: true to grant admin, false to revoke.
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Admin status updated
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"403":
|
||||||
|
description: Caller is not a platform admin
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
apiKeyAuth:
|
apiKeyAuth:
|
||||||
@ -2366,14 +2414,6 @@ components:
|
|||||||
name: X-Host-Token
|
name: X-Host-Token
|
||||||
description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days.
|
description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days.
|
||||||
|
|
||||||
responses:
|
|
||||||
BadRequest:
|
|
||||||
description: Invalid request
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Error"
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
SignupRequest:
|
SignupRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { CommandManager } from "./commands.js";
|
|||||||
import type { ClientConfig } from "./config.js";
|
import type { ClientConfig } from "./config.js";
|
||||||
import { TimeoutError, WrennError } from "./exceptions.js";
|
import { TimeoutError, WrennError } from "./exceptions.js";
|
||||||
import { FileManager } from "./files.js";
|
import { FileManager } from "./files.js";
|
||||||
|
import { Git } from "./git/index.js";
|
||||||
import { PtyManager } from "./pty.js";
|
import { PtyManager } from "./pty.js";
|
||||||
|
|
||||||
export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>;
|
export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>;
|
||||||
@ -93,6 +94,7 @@ export class Capsule {
|
|||||||
readonly client: WrennClient;
|
readonly client: WrennClient;
|
||||||
readonly commands: CommandManager;
|
readonly commands: CommandManager;
|
||||||
readonly files: FileManager;
|
readonly files: FileManager;
|
||||||
|
readonly git: Git;
|
||||||
readonly pty: PtyManager;
|
readonly pty: PtyManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,6 +108,7 @@ export class Capsule {
|
|||||||
this.client = new WrennClient(opts);
|
this.client = new WrennClient(opts);
|
||||||
this.commands = new CommandManager(id, this.client);
|
this.commands = new CommandManager(id, this.client);
|
||||||
this.files = new FileManager(id, this.client);
|
this.files = new FileManager(id, this.client);
|
||||||
|
this.git = new Git(this);
|
||||||
this.pty = new PtyManager(id, this.client);
|
this.pty = new PtyManager(id, this.client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
src/code-interpreter/index.ts
Normal file
58
src/code-interpreter/index.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Capsule, type CapsuleCreateOptions } from "../capsule.js";
|
||||||
|
import type { ClientConfig } from "../config.js";
|
||||||
|
|
||||||
|
export interface ExecCellOptions {
|
||||||
|
/** Command timeout in seconds. Defaults to 30. */
|
||||||
|
timeoutSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecCellResult {
|
||||||
|
/** Captured standard output from Python. */
|
||||||
|
stdout: string;
|
||||||
|
/** Captured standard error from Python. */
|
||||||
|
stderr: string;
|
||||||
|
/** Process exit code returned by Python. */
|
||||||
|
exitCode: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes notebook-style Python cells inside a capsule. */
|
||||||
|
export class Notebook {
|
||||||
|
constructor(private readonly capsule: Capsule) {}
|
||||||
|
|
||||||
|
/** Executes Python code using the capsule's Python 3 CLI. */
|
||||||
|
async execCell(
|
||||||
|
code: string,
|
||||||
|
opts?: ExecCellOptions,
|
||||||
|
): Promise<ExecCellResult> {
|
||||||
|
const result = await this.capsule.commands.exec("python3", {
|
||||||
|
args: ["-c", code],
|
||||||
|
timeoutSec: opts?.timeoutSec ?? 30,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
exitCode: result.exit_code,
|
||||||
|
stderr: result.stderr ?? "",
|
||||||
|
stdout: result.stdout ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Specialized capsule wrapper for Python/Jupyter code execution. */
|
||||||
|
export class CodeInterpreter {
|
||||||
|
readonly notebook: Notebook;
|
||||||
|
|
||||||
|
constructor(readonly capsule: Capsule) {
|
||||||
|
this.notebook = new Notebook(capsule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a capsule using the Jupyter template by default. */
|
||||||
|
static async create(opts?: CapsuleCreateOptions): Promise<CodeInterpreter> {
|
||||||
|
const template = opts?.template ?? "jupyter";
|
||||||
|
const capsule = await Capsule.create(template, opts);
|
||||||
|
return new CodeInterpreter(capsule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps an existing capsule ID without validating it remotely. */
|
||||||
|
static connect(id: string, opts?: ClientConfig): CodeInterpreter {
|
||||||
|
return new CodeInterpreter(Capsule.connect(id, opts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export type ProcessList = OperationJsonResponse<"listProcesses", 200>;
|
|||||||
export interface CommandOptions {
|
export interface CommandOptions {
|
||||||
args?: string[];
|
args?: string[];
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundCommandOptions extends CommandOptions {
|
export interface BackgroundCommandOptions extends CommandOptions {
|
||||||
@ -38,16 +39,16 @@ function commandBody(
|
|||||||
background: boolean,
|
background: boolean,
|
||||||
opts?: BackgroundCommandOptions,
|
opts?: BackgroundCommandOptions,
|
||||||
): OperationJsonBody<"execCommand"> {
|
): OperationJsonBody<"execCommand"> {
|
||||||
const body: OperationJsonBody<"execCommand"> = {
|
const body: Partial<OperationJsonBody<"execCommand">> = { cmd };
|
||||||
background,
|
if (background) body.background = true;
|
||||||
cmd,
|
if (background || opts?.timeoutSec !== undefined) {
|
||||||
timeout_sec: opts?.timeoutSec ?? DEFAULT_COMMAND_TIMEOUT_SEC,
|
body.timeout_sec = opts?.timeoutSec ?? DEFAULT_COMMAND_TIMEOUT_SEC;
|
||||||
};
|
}
|
||||||
if (opts?.args) body.args = opts.args;
|
if (opts?.args) body.args = opts.args;
|
||||||
if (opts?.tag) body.tag = opts.tag;
|
if (opts?.tag) body.tag = opts.tag;
|
||||||
if (opts?.envs) body.envs = opts.envs;
|
if (opts?.envs) body.envs = opts.envs;
|
||||||
if (opts?.cwd) body.cwd = opts.cwd;
|
if (opts?.cwd) body.cwd = opts.cwd;
|
||||||
return body;
|
return body as OperationJsonBody<"execCommand">;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalEvent(event: CommandStreamEvent): boolean {
|
function isTerminalEvent(event: CommandStreamEvent): boolean {
|
||||||
|
|||||||
114
src/git/index.ts
Normal file
114
src/git/index.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { Capsule } from "../capsule.js";
|
||||||
|
import type { CommandOptions, CommandResult } from "../commands.js";
|
||||||
|
|
||||||
|
export interface GitOptions {
|
||||||
|
/** Working directory used for repository-scoped git commands. */
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCloneOptions {
|
||||||
|
/** Destination path for the cloned repository. */
|
||||||
|
path?: string;
|
||||||
|
/** Branch to check out during clone. */
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRemoteBranchOptions extends GitOptions {
|
||||||
|
/** Remote name. Defaults to git's configured default when omitted. */
|
||||||
|
remote?: string;
|
||||||
|
/** Branch name. Defaults to git's configured default when omitted. */
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitLogOptions extends GitOptions {
|
||||||
|
/** Maximum number of commits to return. */
|
||||||
|
maxCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCheckoutOptions extends GitOptions {
|
||||||
|
/** Create the branch before checking it out. */
|
||||||
|
create?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** High-level Git helper that runs git CLI commands inside a capsule. */
|
||||||
|
export class Git {
|
||||||
|
constructor(
|
||||||
|
private readonly capsule: Capsule,
|
||||||
|
private readonly opts: GitOptions = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Clones a repository into the capsule. */
|
||||||
|
clone(url: string, opts?: GitCloneOptions): Promise<CommandResult> {
|
||||||
|
const args = ["clone"];
|
||||||
|
if (opts?.branch) args.push("--branch", opts.branch);
|
||||||
|
args.push(url);
|
||||||
|
if (opts?.path) args.push(opts.path);
|
||||||
|
return this.run(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns porcelain status for the current repository. */
|
||||||
|
status(opts?: GitOptions): Promise<CommandResult> {
|
||||||
|
return this.run(["status", "--short"], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pulls changes from a remote branch. */
|
||||||
|
pull(opts?: GitRemoteBranchOptions): Promise<CommandResult> {
|
||||||
|
return this.runRemoteBranch("pull", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushes changes to a remote branch. */
|
||||||
|
push(opts?: GitRemoteBranchOptions): Promise<CommandResult> {
|
||||||
|
return this.runRemoteBranch("push", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns compact commit history. */
|
||||||
|
log(opts?: GitLogOptions): Promise<CommandResult> {
|
||||||
|
const args = ["log", "--oneline"];
|
||||||
|
if (opts?.maxCount !== undefined) args.push("-n", String(opts.maxCount));
|
||||||
|
return this.run(args, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the current branch name. */
|
||||||
|
branch(opts?: GitOptions): Promise<CommandResult> {
|
||||||
|
return this.run(["branch", "--show-current"], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks out an existing branch or creates it when requested. */
|
||||||
|
checkout(branch: string, opts?: GitCheckoutOptions): Promise<CommandResult> {
|
||||||
|
const args = opts?.create
|
||||||
|
? ["checkout", "-b", branch]
|
||||||
|
: ["checkout", branch];
|
||||||
|
return this.run(args, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stages one or more files. */
|
||||||
|
add(files: string | string[], opts?: GitOptions): Promise<CommandResult> {
|
||||||
|
const fileList = Array.isArray(files) ? files : [files];
|
||||||
|
if (!fileList.length) {
|
||||||
|
return Promise.reject(new Error("At least one file is required"));
|
||||||
|
}
|
||||||
|
return this.run(["add", ...fileList], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a commit. Git user identity must already be configured inside the capsule. */
|
||||||
|
commit(message: string, opts?: GitOptions): Promise<CommandResult> {
|
||||||
|
return this.run(["commit", "-m", message], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(args: string[], opts?: GitOptions): Promise<CommandResult> {
|
||||||
|
const commandOpts: CommandOptions = { args };
|
||||||
|
const cwd = opts?.cwd ?? this.opts.cwd;
|
||||||
|
if (cwd) commandOpts.cwd = cwd;
|
||||||
|
return this.capsule.commands.exec("git", commandOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runRemoteBranch(
|
||||||
|
command: "pull" | "push",
|
||||||
|
opts?: GitRemoteBranchOptions,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
const args: string[] = [command];
|
||||||
|
if (opts?.remote) args.push(opts.remote);
|
||||||
|
if (opts?.branch) args.push(opts.branch);
|
||||||
|
return this.run(args, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/index.ts
13
src/index.ts
@ -31,6 +31,11 @@ export {
|
|||||||
UsersResource,
|
UsersResource,
|
||||||
WrennClient,
|
WrennClient,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
export type {
|
||||||
|
ExecCellOptions,
|
||||||
|
ExecCellResult,
|
||||||
|
} from "./code-interpreter/index.js";
|
||||||
|
export { CodeInterpreter, Notebook } from "./code-interpreter/index.js";
|
||||||
export type {
|
export type {
|
||||||
BackgroundCommandOptions,
|
BackgroundCommandOptions,
|
||||||
BackgroundProcess,
|
BackgroundProcess,
|
||||||
@ -70,5 +75,13 @@ export type {
|
|||||||
MakeDirectoryResult,
|
MakeDirectoryResult,
|
||||||
} from "./files.js";
|
} from "./files.js";
|
||||||
export { FileManager } from "./files.js";
|
export { FileManager } from "./files.js";
|
||||||
|
export type {
|
||||||
|
GitCheckoutOptions,
|
||||||
|
GitCloneOptions,
|
||||||
|
GitLogOptions,
|
||||||
|
GitOptions,
|
||||||
|
GitRemoteBranchOptions,
|
||||||
|
} from "./git/index.js";
|
||||||
|
export { Git } from "./git/index.js";
|
||||||
export type { PtyEvent, PtyStartOptions } from "./pty.js";
|
export type { PtyEvent, PtyStartOptions } from "./pty.js";
|
||||||
export { PtyManager, PtySession } from "./pty.js";
|
export { PtyManager, PtySession } from "./pty.js";
|
||||||
|
|||||||
56
tests/code-interpreter.test.ts
Normal file
56
tests/code-interpreter.test.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Capsule } from "../src/capsule.js";
|
||||||
|
import { CodeInterpreter } from "../src/code-interpreter/index.js";
|
||||||
|
|
||||||
|
describe("CodeInterpreter", () => {
|
||||||
|
it("creates a capsule with the jupyter template by default", async () => {
|
||||||
|
const create = vi.spyOn(Capsule, "create").mockResolvedValue(
|
||||||
|
new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const interpreter = await CodeInterpreter.create({
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(interpreter.capsule.id).toBe("cap_1");
|
||||||
|
expect(create).toHaveBeenCalledWith("jupyter", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
create.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects to an existing capsule", () => {
|
||||||
|
const interpreter = CodeInterpreter.connect("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(interpreter.capsule.id).toBe("cap_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes notebook cells through python3", async () => {
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||||
|
exit_code: 0,
|
||||||
|
stderr: "",
|
||||||
|
stdout: "42\n",
|
||||||
|
});
|
||||||
|
const interpreter = new CodeInterpreter(capsule);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
interpreter.notebook.execCell("print(6 * 7)"),
|
||||||
|
).resolves.toEqual({
|
||||||
|
exitCode: 0,
|
||||||
|
stderr: "",
|
||||||
|
stdout: "42\n",
|
||||||
|
});
|
||||||
|
expect(exec).toHaveBeenCalledWith("python3", {
|
||||||
|
args: ["-c", "print(6 * 7)"],
|
||||||
|
timeoutSec: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -21,7 +21,6 @@ describe("CommandManager", () => {
|
|||||||
|
|
||||||
expect(exec).toHaveBeenCalledWith("cap_1", {
|
expect(exec).toHaveBeenCalledWith("cap_1", {
|
||||||
args: ["--version"],
|
args: ["--version"],
|
||||||
background: false,
|
|
||||||
cmd: "node",
|
cmd: "node",
|
||||||
timeout_sec: 5,
|
timeout_sec: 5,
|
||||||
});
|
});
|
||||||
|
|||||||
74
tests/git.test.ts
Normal file
74
tests/git.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Capsule } from "../src/capsule.js";
|
||||||
|
import { Git } from "../src/git/index.js";
|
||||||
|
|
||||||
|
describe("Git", () => {
|
||||||
|
it("runs clone with optional path and branch", async () => {
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||||
|
exit_code: 0,
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
capsule.git.clone("https://example.com/repo.git", {
|
||||||
|
branch: "main",
|
||||||
|
path: "/work/repo",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ exit_code: 0 });
|
||||||
|
|
||||||
|
expect(exec).toHaveBeenCalledWith("git", {
|
||||||
|
args: [
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
"main",
|
||||||
|
"https://example.com/repo.git",
|
||||||
|
"/work/repo",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs repository commands in cwd", async () => {
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
const git = new Git(capsule, { cwd: "/work/repo" });
|
||||||
|
const exec = vi.spyOn(capsule.commands, "exec").mockResolvedValue({
|
||||||
|
exit_code: 0,
|
||||||
|
stdout: "main\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
await git.status();
|
||||||
|
await git.pull({ branch: "main", remote: "origin" });
|
||||||
|
await git.push({ branch: "main", remote: "origin" });
|
||||||
|
await git.log({ maxCount: 3 });
|
||||||
|
await git.branch();
|
||||||
|
await git.checkout("feature", { create: true });
|
||||||
|
await git.add(["src/a.ts", "src/b.ts"]);
|
||||||
|
await git.commit("test commit");
|
||||||
|
|
||||||
|
expect(exec.mock.calls).toEqual([
|
||||||
|
["git", { args: ["status", "--short"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["pull", "origin", "main"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["push", "origin", "main"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["log", "--oneline", "-n", "3"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["branch", "--show-current"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["checkout", "-b", "feature"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["add", "src/a.ts", "src/b.ts"], cwd: "/work/repo" }],
|
||||||
|
["git", { args: ["commit", "-m", "test commit"], cwd: "/work/repo" }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty git add file lists", async () => {
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(capsule.git.add([])).rejects.toThrow(
|
||||||
|
"At least one file is required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
202
tests/integration/higher-level-abstractions.integration.test.ts
Normal file
202
tests/integration/higher-level-abstractions.integration.test.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
|
||||||
|
import { CodeInterpreter } from "../../src/code-interpreter/index.js";
|
||||||
|
import { DEFAULT_BASE_URL } from "../../src/config.js";
|
||||||
|
import { Git } from "../../src/git/index.js";
|
||||||
|
|
||||||
|
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
|
||||||
|
const apiKey = process.env.WRENN_API_KEY;
|
||||||
|
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
|
||||||
|
const codeTemplate = process.env.WRENN_TEST_CODE_TEMPLATE ?? "jupyter";
|
||||||
|
const codeCapsuleId = process.env.WRENN_TEST_CODE_CAPSULE_ID;
|
||||||
|
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
|
||||||
|
const testTimeoutMs = waitTimeoutMs + 60_000;
|
||||||
|
const describeWithApiKey = apiKey ? describe : describe.skip;
|
||||||
|
|
||||||
|
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableServerError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"statusCode" in error &&
|
||||||
|
typeof error.statusCode === "number" &&
|
||||||
|
error.statusCode >= 500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCommandOk(
|
||||||
|
promise: Promise<{ exit_code?: number; stderr?: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await promise;
|
||||||
|
expect(result.exit_code, result.stderr).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withLiveCapsule(
|
||||||
|
fn: (capsule: Capsule) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
let capsule: Capsule | undefined;
|
||||||
|
try {
|
||||||
|
capsule = await Capsule.create(template, {
|
||||||
|
...clientOpts,
|
||||||
|
timeout_sec: 120,
|
||||||
|
});
|
||||||
|
await capsule.waitForReady({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
});
|
||||||
|
await fn(capsule);
|
||||||
|
} finally {
|
||||||
|
if (capsule) {
|
||||||
|
await capsule.destroy().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCodeInterpreterWithRetry(): Promise<CodeInterpreter> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await CodeInterpreter.create({
|
||||||
|
...clientOpts,
|
||||||
|
template: codeTemplate,
|
||||||
|
timeout_sec: 120,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!isRetryableServerError(error) || attempt === 3) break;
|
||||||
|
await delay(2_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCodeInterpreter(): Promise<{
|
||||||
|
interpreter: CodeInterpreter;
|
||||||
|
shouldDestroy: boolean;
|
||||||
|
}> {
|
||||||
|
if (!codeCapsuleId) {
|
||||||
|
return {
|
||||||
|
interpreter: await createCodeInterpreterWithRetry(),
|
||||||
|
shouldDestroy: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpreter = CodeInterpreter.connect(codeCapsuleId, clientOpts);
|
||||||
|
const info = await interpreter.capsule.getInfo();
|
||||||
|
if (info.status === "paused") {
|
||||||
|
await interpreter.capsule.resume({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interpreter.capsule.waitForReady({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { interpreter, shouldDestroy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
describeWithApiKey("Higher-level abstractions live integration", () => {
|
||||||
|
it(
|
||||||
|
"runs git workflows inside a live capsule",
|
||||||
|
async () => {
|
||||||
|
await withLiveCapsule(async (capsule) => {
|
||||||
|
const root = `/tmp/wrenn-git-${Date.now()}`;
|
||||||
|
const origin = `${root}/origin.git`;
|
||||||
|
const work = `${root}/work`;
|
||||||
|
|
||||||
|
await expectCommandOk(
|
||||||
|
capsule.commands.exec("mkdir", { args: ["-p", root] }),
|
||||||
|
);
|
||||||
|
await expectCommandOk(
|
||||||
|
capsule.commands.exec("git", { args: ["init", "--bare", origin] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectCommandOk(capsule.git.clone(origin, { path: work }));
|
||||||
|
const git = new Git(capsule, { cwd: work });
|
||||||
|
const pwd = await capsule.commands.exec("pwd", { cwd: work });
|
||||||
|
expect(pwd.stdout?.trim(), pwd.stderr).toBe(work);
|
||||||
|
|
||||||
|
await expectCommandOk(
|
||||||
|
capsule.commands.exec("git", {
|
||||||
|
args: ["config", "user.email", "integration@example.com"],
|
||||||
|
cwd: work,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expectCommandOk(
|
||||||
|
capsule.commands.exec("git", {
|
||||||
|
args: ["config", "user.name", "Integration Test"],
|
||||||
|
cwd: work,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectCommandOk(
|
||||||
|
capsule.commands.exec("sh", {
|
||||||
|
args: ["-lc", "printf 'phase5-git\\n' > README.md"],
|
||||||
|
cwd: work,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dirty = await git.status();
|
||||||
|
expect(dirty.exit_code, dirty.stderr).toBe(0);
|
||||||
|
expect(dirty.stdout, dirty.stderr).toContain("README.md");
|
||||||
|
|
||||||
|
await expectCommandOk(git.add("README.md"));
|
||||||
|
const commit = await git.commit("initial commit");
|
||||||
|
expect(commit.exit_code, commit.stderr).toBe(0);
|
||||||
|
|
||||||
|
const log = await git.log({ maxCount: 1 });
|
||||||
|
expect(log.exit_code, log.stderr).toBe(0);
|
||||||
|
expect(log.stdout, log.stderr).toContain("initial commit");
|
||||||
|
|
||||||
|
await expectCommandOk(git.branch());
|
||||||
|
await expectCommandOk(git.checkout("feature", { create: true }));
|
||||||
|
const branch = await git.branch();
|
||||||
|
expect(branch.exit_code, branch.stderr).toBe(0);
|
||||||
|
expect(branch.stdout?.trim(), branch.stderr).toBe("feature");
|
||||||
|
|
||||||
|
await expectCommandOk(
|
||||||
|
git.push({ branch: "feature", remote: "origin" }),
|
||||||
|
);
|
||||||
|
await expectCommandOk(
|
||||||
|
git.pull({ branch: "feature", remote: "origin" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
testTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"executes a code interpreter cell in a live capsule",
|
||||||
|
async () => {
|
||||||
|
let interpreter: CodeInterpreter | undefined;
|
||||||
|
let shouldDestroy = false;
|
||||||
|
try {
|
||||||
|
const codeInterpreter = await getCodeInterpreter();
|
||||||
|
interpreter = codeInterpreter.interpreter;
|
||||||
|
shouldDestroy = codeInterpreter.shouldDestroy;
|
||||||
|
|
||||||
|
const result = await interpreter.notebook.execCell("print(6 * 7)", {
|
||||||
|
timeoutSec: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout.trim()).toBe("42");
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
} finally {
|
||||||
|
if (interpreter && shouldDestroy) {
|
||||||
|
await interpreter.capsule.destroy().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
testTimeoutMs,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user