Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export * from "./types/deployment";
export * from "./resources/task";
export * from "./types/task";

// Export Run classes and types
export { default as Runs, Run } from "./resources/run";
export * from "./types/run";

// Export Pod classes and types
export { Pod, PodInstance } from "./resources/abstraction/pod";
export * from "./types/pod";
Expand Down
53 changes: 53 additions & 0 deletions lib/resources/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import APIResource, { ResourceObject } from "./base";
import { RunData } from "../types/run";
import beamClient, { beamOpts } from "../index";
import { EStubType } from "../types/stub";

class Runs extends APIResource<Run, RunData> {
public object: string = "task";

protected _constructResource(data: RunData): Run {
return new Run(this, data);
}

public async list(opts?: any): Promise<Run[]> {
return super.list({
stubType: EStubType.PodRun,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Runs.list allows opts.stubType to override the intended PodRun filter because the spread comes after stubType. This can return non-run tasks. Spread opts first, then set stubType to enforce the filter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/resources/run.ts, line 15:

<comment>Runs.list allows opts.stubType to override the intended PodRun filter because the spread comes after stubType. This can return non-run tasks. Spread opts first, then set stubType to enforce the filter.</comment>

<file context>
@@ -0,0 +1,53 @@
+
+  public async list(opts?: any): Promise<Run[]> {
+    return super.list({
+      stubType: EStubType.PodRun,
+      ...opts,
+    });
</file context>
Fix with Cubic

...opts,
});
}

public async cancel(runs: string[] | Run[]): Promise<void> {
const ids = runs.map((r) => (r instanceof Run ? r.data.id : r));
return await beamClient.request({
method: "DELETE",
url: `/api/v1/task/${beamOpts.workspaceId}`,
data: {
ids,
},
});
}
}

class Run implements ResourceObject<RunData> {
public data: RunData;
public manager: Runs;

constructor(resource: Runs, data: RunData) {
this.manager = resource;
this.data = data;
}

public async refresh(): Promise<Run> {
const data = await this.manager.get({ id: this.data.id });
this.data = data.data;
return this;
}

public async cancel(): Promise<void> {
return await this.manager.cancel([this]);
}
}

export default new Runs();
export { Run };
6 changes: 6 additions & 0 deletions lib/types/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { TaskData, ETaskStatus } from "./task";

// RunData represents a pod/run task
export type RunData = TaskData;

export { ETaskStatus };
233 changes: 233 additions & 0 deletions tests/runs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import Runs, { Run } from "../lib/resources/run";
import { ETaskStatus } from "../lib/types/task";
import { EStubType } from "../lib/types/stub";

// Mock the beamClient and beamOpts used by the resource
jest.mock("../lib/index", () => ({
__esModule: true,
default: {
request: jest.fn(),
_parseOptsToURLParams: jest.fn((opts) => new URLSearchParams(opts)),
},
beamOpts: {
token: "test-token",
workspaceId: "test-workspace",
gatewayUrl: "https://app.beam.cloud",
timeout: 30000,
},
}));

import beamClient, { beamOpts } from "../lib/index";

const mockBeamClient = beamClient as jest.Mocked<typeof beamClient>;

function makeRunData(overrides = {}) {
return {
id: "run-abc123",
status: ETaskStatus.RUNNING,
containerId: "container-xyz",
startedAt: "2024-01-01T00:00:00Z",
endedAt: "",
stubId: "stub-123",
stubName: "my-pod",
workspaceId: "test-workspace",
workspaceName: "my-workspace",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
...overrides,
};
}

describe("Run", () => {
describe("constructor", () => {
test("stores data and manager references", () => {
const data = makeRunData();
const run = new Run(Runs, data);

expect(run.data).toBe(data);
expect(run.manager).toBe(Runs);
});

test("exposes run fields via data", () => {
const data = makeRunData({ status: ETaskStatus.COMPLETE, containerId: "c-99" });
const run = new Run(Runs, data);

expect(run.data.status).toBe(ETaskStatus.COMPLETE);
expect(run.data.containerId).toBe("c-99");
expect(run.data.id).toBe("run-abc123");
});
});

describe("cancel", () => {
test("delegates to manager.cancel with self", async () => {
const data = makeRunData();
const run = new Run(Runs, data);
const cancelSpy = jest.spyOn(Runs, "cancel").mockResolvedValue(undefined);

await run.cancel();

expect(cancelSpy).toHaveBeenCalledWith([run]);
cancelSpy.mockRestore();
});
});

describe("refresh", () => {
test("calls manager.get with run id and updates data", async () => {
const originalData = makeRunData({ status: ETaskStatus.RUNNING });
const updatedData = makeRunData({ status: ETaskStatus.COMPLETE });
const run = new Run(Runs, originalData);

const getSpy = jest.spyOn(Runs, "get").mockResolvedValue(new Run(Runs, updatedData));

const result = await run.refresh();

expect(getSpy).toHaveBeenCalledWith({ id: "run-abc123" });
expect(run.data.status).toBe(ETaskStatus.COMPLETE);
expect(result).toBe(run);

getSpy.mockRestore();
});
});
});

describe("Runs", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("_constructResource", () => {
test("creates a Run instance from data", () => {
const data = makeRunData();
const run = (Runs as any)._constructResource(data);

expect(run).toBeInstanceOf(Run);
expect(run.data).toBe(data);
});
});

describe("object", () => {
test("uses task as the API object name", () => {
expect((Runs as any).object).toBe("task");
});
});

describe("list", () => {
test("passes pod/run stub type filter to the API", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue({
status: 200,
data: { data: [makeRunData()] },
});
(mockBeamClient._parseOptsToURLParams as jest.Mock).mockReturnValue(
new URLSearchParams({ stub_type: EStubType.PodRun })
);

await Runs.list();

expect(mockBeamClient._parseOptsToURLParams).toHaveBeenCalledWith(
expect.objectContaining({ stubType: EStubType.PodRun })
);
});

test("merges caller opts with pod/run stub type", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue({
status: 200,
data: { data: [] },
});
(mockBeamClient._parseOptsToURLParams as jest.Mock).mockReturnValue(
new URLSearchParams()
);

await Runs.list({ limit: 10 });

expect(mockBeamClient._parseOptsToURLParams).toHaveBeenCalledWith(
expect.objectContaining({ stubType: EStubType.PodRun, limit: 10 })
);
});

test("returns Run instances", async () => {
const runData = makeRunData();
(mockBeamClient.request as jest.Mock).mockResolvedValue({
status: 200,
data: { data: [runData] },
});
(mockBeamClient._parseOptsToURLParams as jest.Mock).mockReturnValue(
new URLSearchParams()
);

const runs = await Runs.list();

expect(runs).toHaveLength(1);
expect(runs[0]).toBeInstanceOf(Run);
expect(runs[0].data.id).toBe("run-abc123");
});

test("returns empty array when API returns no data", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue({
status: 200,
data: {},
});
(mockBeamClient._parseOptsToURLParams as jest.Mock).mockReturnValue(
new URLSearchParams()
);

const runs = await Runs.list();

expect(runs).toEqual([]);
});
});

describe("cancel", () => {
test("sends DELETE request with run ids from Run instances", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue(undefined);

const run1 = new Run(Runs, makeRunData({ id: "run-1" }));
const run2 = new Run(Runs, makeRunData({ id: "run-2" }));

await Runs.cancel([run1, run2]);

expect(mockBeamClient.request).toHaveBeenCalledWith({
method: "DELETE",
url: `/api/v1/task/${beamOpts.workspaceId}`,
data: { ids: ["run-1", "run-2"] },
});
});

test("sends DELETE request with raw id strings", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue(undefined);

await Runs.cancel(["run-abc", "run-def"]);

expect(mockBeamClient.request).toHaveBeenCalledWith({
method: "DELETE",
url: `/api/v1/task/${beamOpts.workspaceId}`,
data: { ids: ["run-abc", "run-def"] },
});
});

test("handles mixed Run instances and id strings", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue(undefined);

const run = new Run(Runs, makeRunData({ id: "run-obj" }));

await Runs.cancel([run, "run-str"]);

expect(mockBeamClient.request).toHaveBeenCalledWith({
method: "DELETE",
url: `/api/v1/task/${beamOpts.workspaceId}`,
data: { ids: ["run-obj", "run-str"] },
});
});

test("handles empty array", async () => {
(mockBeamClient.request as jest.Mock).mockResolvedValue(undefined);

await Runs.cancel([]);

expect(mockBeamClient.request).toHaveBeenCalledWith({
method: "DELETE",
url: `/api/v1/task/${beamOpts.workspaceId}`,
data: { ids: [] },
});
});
});
});