mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-11-30 10:15:40 +03:00
Adds jobs. Adds better stats.
Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
This commit is contained in:
parent
cf9b85466e
commit
307a56637c
@ -71,8 +71,6 @@ action startGeneratingNewApp {
|
||||
fn: import { startGeneratingNewApp } from "@server/operations.js",
|
||||
entities: [
|
||||
Project,
|
||||
File,
|
||||
Log,
|
||||
]
|
||||
}
|
||||
|
||||
@ -95,6 +93,7 @@ entity User {=psl
|
||||
|
||||
email String @unique
|
||||
externalAuthAssociations SocialLogin[]
|
||||
projects Project[]
|
||||
psl=}
|
||||
|
||||
entity SocialLogin {=psl
|
||||
@ -114,7 +113,9 @@ entity Project {=psl
|
||||
primaryColor String @default("sky")
|
||||
authMethod String @default("usernameAndPassword")
|
||||
createdAt DateTime @default(now())
|
||||
status String @default("in-progress")
|
||||
status String @default("pending")
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
files File[]
|
||||
logs Log[]
|
||||
psl=}
|
||||
@ -137,3 +138,37 @@ entity Log {=psl
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
psl=}
|
||||
|
||||
job checkPendingAppsJob {
|
||||
executor: PgBoss,
|
||||
schedule: {
|
||||
cron: "* * * * *",
|
||||
},
|
||||
perform: {
|
||||
fn: import { checkForPendingApps } from "@server/jobs/checkForPendingApps.js"
|
||||
},
|
||||
entities: [Project]
|
||||
}
|
||||
|
||||
job failStaleAppsJobs {
|
||||
executor: PgBoss,
|
||||
schedule: {
|
||||
cron: "* * * * *",
|
||||
},
|
||||
perform: {
|
||||
fn: import { failStaleGenerations } from "@server/jobs/failStaleGenerations.js",
|
||||
},
|
||||
entities: [Project, Log]
|
||||
}
|
||||
|
||||
job generateAppJob {
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { generateApp } from "@server/jobs/generateApp.js",
|
||||
},
|
||||
entities: [
|
||||
Project,
|
||||
File,
|
||||
Log
|
||||
]
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ALTER COLUMN "status" SET DEFAULT 'pending';
|
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "userId" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -61,7 +61,7 @@ export function Logs({ logs, status, onRetry }) {
|
||||
/>
|
||||
<div className="flex justify-between items-flex-start">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
{status === "inProgress" && <Loader />}
|
||||
{(status === "inProgress" || status === "pending") && <Loader />}
|
||||
{status === "success" && (
|
||||
<div className="status-icon bg-green-500">
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
|
@ -1,9 +1,11 @@
|
||||
export function StatusPill({ children, status, className = "", sm = false }) {
|
||||
const statusToClassName = {
|
||||
idle: "bg-gray-100 text-gray-700",
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
inProgress: "bg-sky-100 text-sky-700",
|
||||
success: "bg-green-100 text-green-700",
|
||||
error: "bg-red-100 text-red-700",
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
warning: "bg-yellow-100 text-yellow-700",
|
||||
};
|
||||
return (
|
||||
|
@ -49,8 +49,8 @@ const MainPage = () => {
|
||||
async function startGenerating(event) {
|
||||
event.preventDefault();
|
||||
setCurrentStatus({
|
||||
status: "inProgress",
|
||||
message: "Booting up AI",
|
||||
status: "idle",
|
||||
message: "Starting...",
|
||||
});
|
||||
try {
|
||||
const appId = await startGeneratingNewApp({
|
||||
|
@ -28,36 +28,42 @@ export const ResultPage = () => {
|
||||
const [activeFilePath, setActiveFilePath] = useState(null);
|
||||
const [currentStatus, setCurrentStatus] = useState({
|
||||
status: "idle",
|
||||
message: "Waiting for instructions",
|
||||
message: "Waiting",
|
||||
});
|
||||
const [currentFiles, setCurrentFiles] = useState({});
|
||||
const history = useHistory();
|
||||
const [isMobileFileBrowserOpen, setIsMobileFileBrowserOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const backendStatusToPillStatus = {
|
||||
pending: "pending",
|
||||
"in-progress": "inProgress",
|
||||
success: "success",
|
||||
failure: "error",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
const backendStatusToPillText = {
|
||||
pending: "In the queue",
|
||||
"in-progress": "Generating app",
|
||||
success: "Finished",
|
||||
failure: "There was an error",
|
||||
cancelled: "The generation was cancelled",
|
||||
};
|
||||
if (!appGenerationResult?.project) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
appGenerationResult?.project?.status === "success" ||
|
||||
appGenerationResult?.project?.status === "failure"
|
||||
appGenerationResult?.project?.status === "failure" ||
|
||||
appGenerationResult?.project?.status === "cancelled" ||
|
||||
isError
|
||||
) {
|
||||
setGenerationDone(true);
|
||||
setCurrentStatus({
|
||||
status:
|
||||
appGenerationResult.project.status === "success"
|
||||
? "success"
|
||||
: "error",
|
||||
message:
|
||||
appGenerationResult.project.status === "success"
|
||||
? "Finished"
|
||||
: "There was an error",
|
||||
});
|
||||
} else if (isError) {
|
||||
setGenerationDone(true);
|
||||
} else {
|
||||
setCurrentStatus({
|
||||
status: "inProgress",
|
||||
message: "Generating app",
|
||||
});
|
||||
}
|
||||
setCurrentStatus({
|
||||
status: backendStatusToPillStatus[appGenerationResult.project.status],
|
||||
message: backendStatusToPillText[appGenerationResult.project.status],
|
||||
});
|
||||
}, [appGenerationResult, isError]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -9,9 +9,11 @@ import { BarChart } from "../components/BarChart";
|
||||
import ParentSize from "@visx/responsive/lib/components/ParentSize";
|
||||
import { poolOfExampleIdeas } from "../examples";
|
||||
import logout from "@wasp/auth/logout";
|
||||
import { WaspIcon } from "../components/WaspIcon";
|
||||
|
||||
export function Stats() {
|
||||
const [filterOutExampleApps, setFilterOutExampleApps] = useState(true);
|
||||
const [filterOutExampleApps, setFilterOutExampleApps] = useState(false);
|
||||
const [filterOutKnownUsers, setFilterOutKnownUsers] = useState(false);
|
||||
|
||||
const { data: stats, isLoading, error } = useQuery(getStats);
|
||||
|
||||
@ -27,6 +29,8 @@ export function Stats() {
|
||||
return "success";
|
||||
case "failure":
|
||||
return "error";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "idle";
|
||||
}
|
||||
@ -40,35 +44,64 @@ export function Stats() {
|
||||
return "Success";
|
||||
case "failure":
|
||||
return "Error";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "pending":
|
||||
return "Pending";
|
||||
default:
|
||||
return "Idle";
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const filteredStats = useMemo(
|
||||
() =>
|
||||
stats
|
||||
? stats.projects.filter((stat) => {
|
||||
if (filterOutExampleApps) {
|
||||
return !poolOfExampleIdeas.some(
|
||||
(example) =>
|
||||
example.name === stat.name &&
|
||||
example.description === stat.description
|
||||
);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
: [],
|
||||
[stats, filterOutExampleApps]
|
||||
);
|
||||
const filteredStats = useMemo(() => {
|
||||
const filters = [];
|
||||
if (filterOutExampleApps) {
|
||||
filters.push(
|
||||
(stat) =>
|
||||
!poolOfExampleIdeas.some(
|
||||
(example) =>
|
||||
example.name === stat.name &&
|
||||
example.description === stat.description
|
||||
)
|
||||
);
|
||||
}
|
||||
if (filterOutKnownUsers) {
|
||||
filters.push((stat) => !stat.user);
|
||||
}
|
||||
return stats
|
||||
? stats.projects.filter((stat) => {
|
||||
return filters.every((filter) => filter(stat));
|
||||
})
|
||||
: [];
|
||||
}, [stats, stats?.projects, filterOutExampleApps, filterOutKnownUsers]);
|
||||
|
||||
function getFormattedDiff(start, end) {
|
||||
const diff = (end - start) / 1000;
|
||||
const minutes = Math.round(diff / 60);
|
||||
const remainingSeconds = Math.round(diff % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function getDuration(stat) {
|
||||
const start = stat.logs[stat.logs.length - 1].createdAt;
|
||||
const end = stat.logs[0].createdAt;
|
||||
return getFormattedDiff(start, end);
|
||||
}
|
||||
|
||||
function getWaitingInQueueDuration(stat) {
|
||||
const start = stat.createdAt;
|
||||
const end = stat.logs[stat.logs.length - 1].createdAt;
|
||||
return getFormattedDiff(start, end);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="big-box">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-3xl font-semibold text-slate-800">Stats</h1>
|
||||
<div>
|
||||
<button className="button sm" onClick={logout}>Logout</button>
|
||||
<button className="button sm" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,13 +109,11 @@ export function Stats() {
|
||||
|
||||
{error && <p>Error: {error.message}</p>}
|
||||
|
||||
{stats && filteredStats.length === 0 && (
|
||||
<p className="text-sm text-slate-500">
|
||||
No projects created yet.
|
||||
</p>
|
||||
{stats && stats.projects.length === 0 && (
|
||||
<p className="text-sm text-slate-500">No projects created yet.</p>
|
||||
)}
|
||||
|
||||
{stats && filteredStats.length > 0 && (
|
||||
{stats && stats.projects.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm text-slate-500 mb-2">
|
||||
Number of projects created in the last 24 hours:{" "}
|
||||
@ -100,23 +131,43 @@ export function Stats() {
|
||||
</div>
|
||||
|
||||
<div className="py-2 flex justify-between items-center">
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
id="default-checkbox"
|
||||
type="checkbox"
|
||||
checked={filterOutExampleApps}
|
||||
onChange={(event) =>
|
||||
setFilterOutExampleApps(event.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="default-checkbox"
|
||||
className="ml-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Filter out example apps
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
id="filter"
|
||||
type="checkbox"
|
||||
checked={filterOutExampleApps}
|
||||
onChange={(event) =>
|
||||
setFilterOutExampleApps(event.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="filter"
|
||||
className="ml-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Filter out example apps
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
id="default-checkbox"
|
||||
type="checkbox"
|
||||
checked={filterOutKnownUsers}
|
||||
onChange={(event) =>
|
||||
setFilterOutKnownUsers(event.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="default-checkbox"
|
||||
className="ml-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Filter out known users
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
Number of displayed apps: {filteredStats.length}
|
||||
</p>
|
||||
@ -135,6 +186,9 @@ export function Stats() {
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Created At
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Time in Queue → Build
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -144,10 +198,17 @@ export function Stats() {
|
||||
<th
|
||||
scope="row"
|
||||
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
|
||||
title={stat.description}
|
||||
>
|
||||
<Color value={getColorValue(stat.primaryColor)} />{" "}
|
||||
{stat.name}
|
||||
<span title={stat.description}>{stat.name}</span>{" "}
|
||||
{stat.user && (
|
||||
<span
|
||||
className="text-slate-300"
|
||||
title={stat.user.email}
|
||||
>
|
||||
<WaspIcon className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<td className="px-6 py-4">
|
||||
<StatusPill status={getStatusName(stat.status)} sm>
|
||||
@ -160,6 +221,9 @@ export function Stats() {
|
||||
>
|
||||
{format(stat.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getWaitingInQueueDuration(stat)} → {getDuration(stat)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Link
|
||||
to={`/result/${stat.id}`}
|
||||
|
32
wasp-ai/src/server/jobs/checkForPendingApps.ts
Normal file
32
wasp-ai/src/server/jobs/checkForPendingApps.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { generateAppJob } from "@wasp/jobs/generateAppJob.js";
|
||||
|
||||
const maxProjectsInProgress = process.env.MAX_PROJECTS_IN_PROGRESS
|
||||
? parseInt(process.env.MAX_PROJECTS_IN_PROGRESS, 10)
|
||||
: 5;
|
||||
|
||||
export async function checkForPendingApps(
|
||||
_args: void,
|
||||
context: {
|
||||
entities: {
|
||||
Project: any;
|
||||
};
|
||||
}
|
||||
) {
|
||||
console.log("Checking for pending apps");
|
||||
const { Project } = context.entities;
|
||||
|
||||
const pendingProjects = await Project.findMany({
|
||||
where: { status: "pending" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const inProgressProjects = await Project.findMany({
|
||||
where: { status: "in-progress" },
|
||||
});
|
||||
|
||||
// Generate X new apps until there are 5 in progress
|
||||
const numAppsToGenerate = maxProjectsInProgress - inProgressProjects.length;
|
||||
const appsToGenerate = pendingProjects.slice(0, numAppsToGenerate);
|
||||
for (const app of appsToGenerate) {
|
||||
generateAppJob.submit({ appId: app.id });
|
||||
}
|
||||
}
|
57
wasp-ai/src/server/jobs/failStaleGenerations.ts
Normal file
57
wasp-ai/src/server/jobs/failStaleGenerations.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export async function failStaleGenerations(
|
||||
_args: void,
|
||||
context: {
|
||||
entities: {
|
||||
Project: any;
|
||||
Log: any;
|
||||
};
|
||||
}
|
||||
) {
|
||||
// If a generation has been in progress for > 5 minutes, it fails it
|
||||
console.log("Failing stale generations");
|
||||
const { Project, Log } = context.entities;
|
||||
|
||||
const now = getNowInUTC();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
try {
|
||||
const staleProjects = await Project.findMany({
|
||||
where: {
|
||||
status: "in-progress",
|
||||
logs: {
|
||||
every: {
|
||||
createdAt: {
|
||||
lte: fiveMinutesAgo,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const project of staleProjects) {
|
||||
await Project.update({
|
||||
where: { id: project.id },
|
||||
data: { status: "cancelled" },
|
||||
});
|
||||
await Log.create({
|
||||
data: {
|
||||
project: { connect: { id: project.id } },
|
||||
content: "The generation took too long.",
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
console.log("Error fetching projects:", e);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getNowInUTC() {
|
||||
const now = new Date();
|
||||
return new Date(now.toUTCString());
|
||||
}
|
169
wasp-ai/src/server/jobs/generateApp.ts
Normal file
169
wasp-ai/src/server/jobs/generateApp.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { spawn } from "child_process";
|
||||
import { Mutex } from "async-mutex";
|
||||
|
||||
const appGenerationResults: Record<string, any> = {};
|
||||
|
||||
export async function generateApp(
|
||||
args: { appId: string },
|
||||
context: {
|
||||
entities: {
|
||||
Project: any;
|
||||
Log: any;
|
||||
File: any;
|
||||
};
|
||||
}
|
||||
) {
|
||||
// Given the appID, it generates the app
|
||||
console.log("Generating app");
|
||||
const appId = args.appId;
|
||||
|
||||
const { Project, Log, File } = context.entities;
|
||||
|
||||
const project = await Project.findUniqueOrThrow({
|
||||
where: { id: appId },
|
||||
});
|
||||
|
||||
if (project.status !== "pending") {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "in-progress" },
|
||||
});
|
||||
|
||||
appGenerationResults[appId] = {
|
||||
unconsumedStdout: "",
|
||||
};
|
||||
|
||||
// { auth: 'UsernameAndPassword', primaryColor: string }
|
||||
const projectConfig = {
|
||||
primaryColor: project.primaryColor,
|
||||
};
|
||||
|
||||
const stdoutMutex = new Mutex();
|
||||
let waspCliProcess = null;
|
||||
const waspCliProcessArgs = [
|
||||
"new-ai",
|
||||
project.name,
|
||||
project.description,
|
||||
JSON.stringify(projectConfig),
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
waspCliProcess = spawn("wasp", waspCliProcessArgs);
|
||||
} else {
|
||||
// NOTE: In dev when we use `wasp-cli`, we want to make sure that if this app is run via `wasp` that its datadir env var does not propagate,
|
||||
// so we reset it here. This is problem only if you run app with `wasp` and let it call `wasp-cli` here.
|
||||
waspCliProcess = spawn("wasp-cli", waspCliProcessArgs, {
|
||||
env: { ...process.env, waspc_datadir: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
waspCliProcess.stdout.on("data", async (data) => {
|
||||
const release = await stdoutMutex.acquire();
|
||||
try {
|
||||
appGenerationResults[appId].unconsumedStdout += data;
|
||||
const patterns = [
|
||||
{
|
||||
regex: /==== WASP AI: LOG ====\n([\s\S]*?)\n===\/ WASP AI: LOG ====/,
|
||||
action: async (match: RegExpMatchArray) => {
|
||||
const content = match[1];
|
||||
// console.log(`Log: ${content}`);
|
||||
await Log.create({
|
||||
data: {
|
||||
content,
|
||||
project: {
|
||||
connect: { id: appId },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/==== WASP AI: WRITE FILE ====\n([\s\S]*?)\n===\/ WASP AI: WRITE FILE ====/,
|
||||
action: async (match: RegExpMatchArray) => {
|
||||
const text = match[1];
|
||||
const [filename, ...rest] = text.split("\n");
|
||||
const content = rest.join("\n");
|
||||
const file = await File.findFirst({
|
||||
where: { name: filename, projectId: appId },
|
||||
});
|
||||
if (file) {
|
||||
// console.log(`Updating file ${filename} in project ${appId}.`);
|
||||
await File.update({
|
||||
where: { id: file.id },
|
||||
data: { content },
|
||||
});
|
||||
} else {
|
||||
// console.log(`Creating file ${filename} in project ${appId}.`);
|
||||
await File.create({
|
||||
data: {
|
||||
name: filename,
|
||||
content,
|
||||
projectId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let match: any = null;
|
||||
do {
|
||||
match = null;
|
||||
for (const pattern of patterns) {
|
||||
const newMatch = pattern.regex.exec(
|
||||
appGenerationResults[appId].unconsumedStdout
|
||||
);
|
||||
if (newMatch && (!match || newMatch.index < match.index)) {
|
||||
match = {
|
||||
...newMatch,
|
||||
action: pattern.action,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
await match.action(match);
|
||||
appGenerationResults[appId].unconsumedStdout = appGenerationResults[
|
||||
appId
|
||||
].unconsumedStdout.replace(match[0], "");
|
||||
}
|
||||
} while (match);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
waspCliProcess.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
waspCliProcess.on("close", async (code) => {
|
||||
if (code === 0) {
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "success" },
|
||||
});
|
||||
} else {
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "failure" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
waspCliProcess.on("error", async (err) => {
|
||||
console.error("WASP CLI PROCESS ERROR", err);
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "failure" },
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
@ -1,10 +1,7 @@
|
||||
import { StartGeneratingNewApp } from "@wasp/actions/types";
|
||||
import { GetAppGenerationResult, GetStats } from "@wasp/queries/types";
|
||||
import HttpError from "@wasp/core/HttpError.js";
|
||||
import { spawn } from "child_process";
|
||||
import { Mutex } from "async-mutex";
|
||||
|
||||
const appGenerationResults: Record<string, any> = {};
|
||||
import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.js";
|
||||
|
||||
export const startGeneratingNewApp: StartGeneratingNewApp<
|
||||
{
|
||||
@ -27,147 +24,24 @@ export const startGeneratingNewApp: StartGeneratingNewApp<
|
||||
if (!args.appDesc) {
|
||||
throw new HttpError(422, "App description is required.");
|
||||
}
|
||||
const { Project, File, Log } = context.entities;
|
||||
const { Project } = context.entities;
|
||||
const project = await Project.create({
|
||||
data: {
|
||||
name: args.appName,
|
||||
description: args.appDesc,
|
||||
primaryColor: args.appPrimaryColor,
|
||||
authMethod: args.appAuthMethod,
|
||||
user: {
|
||||
connect: {
|
||||
id: context.user?.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const appId = project.id;
|
||||
appGenerationResults[appId] = {
|
||||
unconsumedStdout: "",
|
||||
};
|
||||
|
||||
// { auth: 'UsernameAndPassword', primaryColor: string }
|
||||
const projectConfig = {
|
||||
primaryColor: args.appPrimaryColor,
|
||||
};
|
||||
|
||||
const stdoutMutex = new Mutex();
|
||||
let waspCliProcess = null;
|
||||
const waspCliProcessArgs = [
|
||||
"new-ai",
|
||||
args.appName,
|
||||
args.appDesc,
|
||||
JSON.stringify(projectConfig),
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
waspCliProcess = spawn("wasp", waspCliProcessArgs);
|
||||
} else {
|
||||
// NOTE: In dev when we use `wasp-cli`, we want to make sure that if this app is run via `wasp` that its datadir env var does not propagate,
|
||||
// so we reset it here. This is problem only if you run app with `wasp` and let it call `wasp-cli` here.
|
||||
waspCliProcess = spawn("wasp-cli", waspCliProcessArgs, {
|
||||
env: { ...process.env, waspc_datadir: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
waspCliProcess.stdout.on("data", async (data) => {
|
||||
const release = await stdoutMutex.acquire();
|
||||
try {
|
||||
appGenerationResults[appId].unconsumedStdout += data;
|
||||
const patterns = [
|
||||
{
|
||||
regex: /==== WASP AI: LOG ====\n([\s\S]*?)\n===\/ WASP AI: LOG ====/,
|
||||
action: async (match: RegExpMatchArray) => {
|
||||
const content = match[1];
|
||||
// console.log(`Log: ${content}`);
|
||||
await Log.create({
|
||||
data: {
|
||||
content,
|
||||
project: {
|
||||
connect: { id: appId },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/==== WASP AI: WRITE FILE ====\n([\s\S]*?)\n===\/ WASP AI: WRITE FILE ====/,
|
||||
action: async (match: RegExpMatchArray) => {
|
||||
const text = match[1];
|
||||
const [filename, ...rest] = text.split("\n");
|
||||
const content = rest.join("\n");
|
||||
const file = await File.findFirst({
|
||||
where: { name: filename, projectId: appId },
|
||||
});
|
||||
if (file) {
|
||||
// console.log(`Updating file ${filename} in project ${appId}.`);
|
||||
await File.update({
|
||||
where: { id: file.id },
|
||||
data: { content },
|
||||
});
|
||||
} else {
|
||||
// console.log(`Creating file ${filename} in project ${appId}.`);
|
||||
await File.create({
|
||||
data: {
|
||||
name: filename,
|
||||
content,
|
||||
projectId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let match: any = null;
|
||||
do {
|
||||
match = null;
|
||||
for (const pattern of patterns) {
|
||||
const newMatch = pattern.regex.exec(
|
||||
appGenerationResults[appId].unconsumedStdout
|
||||
);
|
||||
if (newMatch && (!match || newMatch.index < match.index)) {
|
||||
match = {
|
||||
...newMatch,
|
||||
action: pattern.action,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
await match.action(match);
|
||||
appGenerationResults[appId].unconsumedStdout = appGenerationResults[
|
||||
appId
|
||||
].unconsumedStdout.replace(match[0], "");
|
||||
}
|
||||
} while (match);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
waspCliProcess.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
|
||||
waspCliProcess.on("close", async (code) => {
|
||||
if (code === 0) {
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "success" },
|
||||
});
|
||||
} else {
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "failure" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
waspCliProcess.on("error", async (err) => {
|
||||
console.error("WASP CLI PROCESS ERROR", err);
|
||||
await Project.update({
|
||||
where: { id: appId },
|
||||
data: { status: "failure" },
|
||||
});
|
||||
});
|
||||
checkPendingAppsJob.submit({});
|
||||
|
||||
return appId;
|
||||
};
|
||||
@ -197,7 +71,7 @@ export const getAppGenerationResult = (async (args, context) => {
|
||||
appId: string;
|
||||
}>;
|
||||
|
||||
export const getStats = (async (args, context) => {
|
||||
export const getStats = (async (_args, context) => {
|
||||
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
|
||||
if (!context.user || !emailsWhitelist.includes(context.user.email)) {
|
||||
throw new HttpError(401, "Only admins can access stats.");
|
||||
@ -208,6 +82,18 @@ export const getStats = (async (args, context) => {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
logs: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
projects,
|
||||
|
Loading…
Reference in New Issue
Block a user