projects.get: auto-enrich with possibleDeployments[] from fuzzy app/service name match

Project records and Coolify deployments live in separate worlds —
nothing writes the Coolify UUID back into fs_projects.data on deploy.
Now projects.get also scans apps + services in the workspace and
returns any whose name fuzzy-matches the project (lowercased token
overlap), plus any explicitly-linked one. Self-healing forever; the
AI can immediately tell the user what's running for a project even
when the link was never stored.

Made-with: Cursor
This commit is contained in:
2026-04-27 18:38:01 -07:00
parent 4c804d670b
commit ddc5c37a8e

View File

@@ -382,19 +382,94 @@ async function toolProjectsGet(principal: Principal, params: Record<string, any>
}
const r = rows[0];
const d = r.data || {};
// Return a clean summary rather than the raw JSONB, which contains noisy
// internal scaffold fields (product/website/admin/storybook sub-app configs)
// that the AI tends to misread as live deployed services.
const projectName = d.productName || d.name || d.title || 'Untitled';
// Auto-enrich: if no Coolify link is stored yet, scan apps + services in
// the workspace and surface any whose name fuzzy-matches the project. Lets
// the AI tell the user "this is probably your deployment" even when the
// backend never wrote the link.
let possibleDeployments: Array<{
uuid: string;
name: string;
status: string;
fqdn: string | null;
resourceType: 'application' | 'service';
}> = [];
const linkedUuid = d.coolifyAppUuid || d.coolifyServiceUuid || null;
const projectUuid = principal.workspace.coolify_project_uuid;
if (projectUuid) {
try {
const [appsRes, servicesRes, projectRes] = await Promise.allSettled([
listApplicationsInProject(projectUuid),
listAllServices(),
getProject(projectUuid),
]);
const envIds = new Set<number>(
projectRes.status === 'fulfilled'
? (projectRes.value.environments ?? []).map((e) => e.id)
: [],
);
const apps = appsRes.status === 'fulfilled' ? appsRes.value : [];
const services = (servicesRes.status === 'fulfilled' && Array.isArray(servicesRes.value)
? (servicesRes.value as Array<Record<string, unknown>>)
: []
).filter((s) => envIds.has(Number(s.environment_id)));
// Build searchable tokens from the project name (lowercased words, length >= 3)
const tokens = projectName
.toLowerCase()
.replace(/[^a-z0-9 ]/g, ' ')
.split(/\s+/)
.filter((t: string) => t.length >= 3);
const matches = (name: string) => {
const n = name.toLowerCase();
return tokens.some((t: string) => n.includes(t));
};
for (const a of apps) {
if (matches(a.name) || a.uuid === linkedUuid) {
possibleDeployments.push({
uuid: a.uuid,
name: a.name,
status: a.status,
fqdn: a.fqdn ?? null,
resourceType: 'application',
});
}
}
for (const s of services) {
const name = String(s.name ?? '');
const uuid = String(s.uuid);
if (matches(name) || uuid === linkedUuid) {
const subApps = (s.applications as Array<Record<string, unknown>>) || [];
const publicApp = subApps.find((a) => a.fqdn);
possibleDeployments.push({
uuid,
name,
status: String(s.status ?? 'unknown'),
fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null,
resourceType: 'service',
});
}
}
} catch {
// Best-effort enrichment — never let it block the project read
}
}
return NextResponse.json({
result: {
id: r.id,
name: d.productName || d.name || d.title || 'Untitled',
name: projectName,
status: d.status || 'defining',
vision: d.productVision || d.vision || null,
domain: d.domain || d.customDomain || null,
coolifyAppUuid: d.coolifyAppUuid || d.coolifyServiceUuid || null,
coolifyAppUuid: linkedUuid,
coolifyDomain: d.coolifyDomain || null,
repositoryUrl: d.repositoryUrl || null,
possibleDeployments,
createdAt: r.created_at,
updatedAt: r.updated_at,
},