Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.


ALTER TYPE "StakworkRunType" ADD VALUE 'PLAN_CHAT';
ALTER TYPE "StakworkRunType" ADD VALUE 'WORKFLOW_EDITOR';

-- AlterTable
ALTER TABLE "stakwork_runs" ADD COLUMN "task_id" TEXT;

-- CreateIndex
CREATE INDEX "stakwork_runs_task_id_idx" ON "stakwork_runs"("task_id");

-- AddForeignKey
ALTER TABLE "stakwork_runs" ADD CONSTRAINT "stakwork_runs_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ model Task {
deployments Deployment[]
notifications NotificationTrigger[]
screenshots Screenshot[]
stakworkRuns StakworkRun[]
workflowTask WorkflowTask?
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
Expand Down Expand Up @@ -861,6 +862,7 @@ model StakworkRun {
type StakworkRunType
featureId String? @map("feature_id")
workspaceId String @map("workspace_id")
taskId String? @map("task_id")
status WorkflowStatus @default(PENDING)
result String?
dataType String @default("string") @map("data_type")
Expand All @@ -871,10 +873,12 @@ model StakworkRun {
autoAccept Boolean @default(false) @map("auto_accept")
agentLogs AgentLog[]
feature Feature? @relation(fields: [featureId], references: [id])
task Task? @relation(fields: [taskId], references: [id])
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)

@@index([workspaceId])
@@index([featureId])
@@index([taskId])
@@index([type])
@@index([status])
@@index([projectId])
Expand Down Expand Up @@ -1338,6 +1342,8 @@ enum StakworkRunType {
DIAGRAM_GENERATION
LEARNING
REPO_AGENT
PLAN_CHAT
WORKFLOW_EDITOR
}

enum StakworkRunDecision {
Expand Down
190 changes: 190 additions & 0 deletions src/__tests__/unit/services/feature-chat-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Unit tests for PLAN_CHAT StakworkRun creation in sendFeatureChatMessage
* (src/services/roadmap/feature-chat.ts)
*/

import { describe, test, expect, vi, beforeEach } from "vitest";

// ─── Mocks ────────────────────────────────────────────────────────────────────

vi.mock("@/lib/db", () => ({
db: {
chatMessage: { create: vi.fn(), findMany: vi.fn().mockResolvedValue([]) },
feature: { findUnique: vi.fn(), update: vi.fn() },
artifact: { findFirst: vi.fn().mockResolvedValue(null) },
stakworkRun: { create: vi.fn() },
},
}));

vi.mock("@/config/env", () => ({
config: {
STAKWORK_API_KEY: "test-key",
STAKWORK_BASE_URL: "https://test.stakwork.com",
STAKWORK_WORKFLOW_ID: "10",
},
}));

vi.mock("@/services/s3", () => ({
getS3Service: vi.fn(() => ({
generatePresignedDownloadUrl: vi.fn().mockResolvedValue("https://s3.example.com/file"),
})),
}));

vi.mock("@/services/task-workflow", () => ({
callStakworkAPI: vi.fn().mockResolvedValue(null),
}));

vi.mock("@/services/roadmap/orgContextScout", () => ({
scoutOrgContext: vi.fn().mockResolvedValue(null),
}));

vi.mock("@/services/task-coordinator", () => ({
buildFeatureContext: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/lib/pusher", () => ({
pusherServer: { trigger: vi.fn().mockResolvedValue(undefined) },
getFeatureChannelName: vi.fn().mockReturnValue("feature-channel"),
PUSHER_EVENTS: { NEW_MESSAGE: "new-message", WORKFLOW_STATUS_UPDATE: "workflow-status" },
}));

vi.mock("@/lib/auth/nextauth", () => ({
getGithubUsernameAndPAT: vi.fn().mockResolvedValue(null),
}));

vi.mock("@/lib/helpers/repository", () => ({
joinRepoUrls: vi.fn().mockReturnValue(""),
}));

vi.mock("@/lib/utils/swarm", () => ({
transformSwarmUrlToRepo2Graph: vi.fn().mockReturnValue(""),
extractSwarmSuffix: vi.fn().mockReturnValue("suffix-1"),
}));

vi.mock("@/lib/encryption", () => ({
EncryptionService: {
getInstance: vi.fn(() => ({ decryptField: vi.fn().mockReturnValue("api-key") })),
},
}));

vi.mock("@/lib/utils", () => ({
getBaseUrl: vi.fn().mockReturnValue("http://localhost:3000"),
}));

vi.mock("@/lib/mcp/orgTokenMint", () => ({
mintOrgToken: vi.fn().mockResolvedValue({ token: null, error: null }),
}));

// ─── Subject ──────────────────────────────────────────────────────────────────

import { sendFeatureChatMessage } from "@/services/roadmap/feature-chat";
import { db } from "@/lib/db";
import { WorkflowStatus } from "@prisma/client";
import { callStakworkAPI } from "@/services/task-workflow";

const mockedDb = vi.mocked(db);
const mockedCallStakwork = vi.mocked(callStakworkAPI);

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makeFeature(id = "feature-1") {
return {
id,
workspaceId: "ws-1",
workflowStatus: null,
model: null,
planUpdatedAt: new Date(),
phases: [],
workspace: {
slug: "test-workspace",
ownerId: "user-1",
sourceControlOrg: null,
swarm: {
swarmUrl: "http://swarm/api",
swarmSecretAlias: "secret",
poolName: "pool-1",
name: "swarm-1",
id: "swarm-id-1",
apiKey: "api-key",
agentApiKey: "agent-key",
},
members: [{ userId: "user-1", role: "OWNER" }],
extraSwarms: [],
mcpServers: [],
},
};
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe("sendFeatureChatMessage — PLAN_CHAT StakworkRun creation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedDb.feature.findUnique = vi.fn().mockResolvedValue(makeFeature()) as never;
mockedDb.chatMessage.create = vi.fn().mockResolvedValue({ id: "msg-1" }) as never;
mockedDb.feature.update = vi.fn().mockResolvedValue({}) as never;
mockedDb.stakworkRun.create = vi.fn().mockResolvedValue({}) as never;
});

test("creates StakworkRun with PLAN_CHAT type when Stakwork returns a projectId", async () => {
mockedCallStakwork.mockResolvedValue({ projectId: 42, success: true } as never);

await sendFeatureChatMessage({
featureId: "feature-1",
userId: "user-1",
message: "Plan my feature",
});

expect(mockedDb.stakworkRun.create).toHaveBeenCalledWith({
data: {
type: "PLAN_CHAT",
featureId: "feature-1",
workspaceId: "ws-1",
projectId: 42,
status: WorkflowStatus.IN_PROGRESS,
webhookUrl: "http://localhost:3000/api/stakwork/webhook?task_id=feature-1",
},
});
});

test("does NOT create StakworkRun when Stakwork returns no projectId", async () => {
mockedCallStakwork.mockResolvedValue({ projectId: null, success: false } as never);

await sendFeatureChatMessage({
featureId: "feature-1",
userId: "user-1",
message: "Plan my feature",
});

expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled();
});

test("does NOT create StakworkRun when callStakworkAPI returns null", async () => {
mockedCallStakwork.mockResolvedValue(null as never);

await sendFeatureChatMessage({
featureId: "feature-1",
userId: "user-1",
message: "Plan my feature",
});

expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled();
});

test("webhookUrl contains the correct featureId in the query param", async () => {
mockedDb.feature.findUnique = vi.fn().mockResolvedValue(makeFeature("feature-abc")) as never;
mockedCallStakwork.mockResolvedValue({ projectId: 99, success: true } as never);

await sendFeatureChatMessage({
featureId: "feature-abc",
userId: "user-1",
message: "Plan my feature",
});

const createCall = (mockedDb.stakworkRun.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.data.webhookUrl).toBe(
"http://localhost:3000/api/stakwork/webhook?task_id=feature-abc",
);
expect(createCall.data.featureId).toBe("feature-abc");
});
});
33 changes: 33 additions & 0 deletions src/__tests__/unit/services/workflow-editor-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function makeFullTask(overrides: Record<string, unknown> = {}) {
id: "task-1",
createdById: "user-1",
workspaceId: "ws-1",
featureId: "feature-1",
workspace: {
slug: "test-workspace",
ownerId: "user-1",
Expand Down Expand Up @@ -135,6 +136,7 @@ describe("executeWorkflowEditorRetry", () => {
mockedDb.chatMessage.create = vi.fn().mockResolvedValue({ id: "msg-new" }) as never;
mockedDb.chatMessage.findFirst = vi.fn().mockResolvedValue({ id: "msg-user-1" }) as never;
mockedDb.chatMessage.update = vi.fn().mockResolvedValue({}) as never;
mockedDb.stakworkRun = { create: vi.fn().mockResolvedValue({}) } as never;
});

afterEach(() => {
Expand Down Expand Up @@ -361,6 +363,37 @@ describe("executeWorkflowEditorRetry", () => {

expect(mockedDb.chatMessage.update).not.toHaveBeenCalled();
});

test("creates StakworkRun with WORKFLOW_EDITOR type on successful retry", async () => {
mockedDb.task.findFirst = vi.fn().mockResolvedValue(makeFullTask()) as never;
mockFetchSuccess(888);

await executeWorkflowEditorRetry("task-1", "user-1");

expect(mockedDb.stakworkRun.create).toHaveBeenCalledWith({
data: {
type: "WORKFLOW_EDITOR",
taskId: "task-1",
featureId: "feature-1",
workspaceId: "ws-1",
projectId: 888,
status: WorkflowStatus.IN_PROGRESS,
webhookUrl: "http://localhost:3000/api/stakwork/webhook?task_id=task-1",
},
});
});

test("does NOT create StakworkRun when project_id is absent in retry response", async () => {
mockedDb.task.findFirst = vi.fn().mockResolvedValue(makeFullTask()) as never;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: {} }),
}) as unknown as typeof fetch;

await executeWorkflowEditorRetry("task-1", "user-1");

expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled();
});
});

// ─── retryWorkflowEditorTask tests ────────────────────────────────────────────
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/unit/services/workflow-editor-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function makeTask() {
return {
id: "task-1",
workspaceId: "ws-1",
featureId: "feature-1",
workspace: {
slug: "stakwork",
ownerId: "user-1",
Expand Down Expand Up @@ -108,6 +109,7 @@ describe("triggerWorkflowEditorRun", () => {
mockedDb.task.findFirst = vi.fn().mockResolvedValue(makeTask()) as never;
mockedDb.task.update = vi.fn().mockResolvedValue({}) as never;
mockedDb.chatMessage.create = vi.fn().mockResolvedValue({ id: "msg-1" }) as never;
mockedDb.stakworkRun = { create: vi.fn().mockResolvedValue({}) } as never;
});

afterEach(() => {
Expand Down Expand Up @@ -224,4 +226,43 @@ describe("triggerWorkflowEditorRun", () => {
expect(mockedDb.task.findFirst).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalled();
});

test("creates StakworkRun with WORKFLOW_EDITOR type when project_id is present", async () => {
mockFetchSuccess(456);

await triggerWorkflowEditorRun({
taskId: "task-1",
userId: "user-1",
message: "Edit the workflow",
workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" },
});

expect(mockedDb.stakworkRun.create).toHaveBeenCalledWith({
data: {
type: "WORKFLOW_EDITOR",
taskId: "task-1",
featureId: "feature-1",
workspaceId: "ws-1",
projectId: 456,
status: WorkflowStatus.IN_PROGRESS,
webhookUrl: "http://localhost:3000/api/stakwork/webhook?task_id=task-1",
},
});
});

test("does NOT create StakworkRun when project_id is absent", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: {} }),
}) as unknown as typeof fetch;

await triggerWorkflowEditorRun({
taskId: "task-1",
userId: "user-1",
message: "Edit the workflow",
workflowTask: { workflowId: 99, workflowName: "My Workflow", workflowRefId: "ref-abc" },
});

expect(mockedDb.stakworkRun.create).not.toHaveBeenCalled();
});
});
Loading
Loading