fix(ai): close remaining duplication + stale-context gaps
Round two of AI-hardening based on what bit us with the twenty-* fan-out:
1. apps_create idempotency now covers ALL four pathways (template /
image / composeRaw / repo), not just templates. Same dedup-by-name
check inside the project, same alreadyExisted: true response shape.
Pass force: true to opt out for legitimate dev/staging duplicates.
2. databases_create gets the same idempotency treatment — and now
also scopes to the per-project Coolify project when projectId is
supplied (previously only apps_create did this).
3. New shared helper findExistingResourceByName scans apps + services
+ databases in a project and matches case-insensitively on name.
4. System prompt: three new hard rules teaching the model how to
handle tool results and anchor on reality:
- Tool results are authoritative; conversation history is not.
If a tool contradicts an earlier assertion, discard the
assertion. Don't keep telling the user it's broken when
apps_get now says it's healthy.
- When the user reports an error, FIRST tool call is a
current-state read (apps_get / databases_get / apps_logs).
Stop re-debugging problems that were already fixed.
- Trust idempotency. alreadyExisted means done; don't loop
trying a different name.
Made-with: Cursor
This commit is contained in:
@@ -237,6 +237,12 @@ When you write to Plan, the user does NOT need a long acknowledgment. "Logged th
|
||||
- ALWAYS call \`apps_templates_search\` BEFORE \`apps_create\` when the user names a known third-party app. Hand-rolling a Dockerfile when a maintained template exists is how supply-chain bugs ship.
|
||||
- **NEVER delete-and-recreate a service to escape an error.** When a deploy fails with "Conflict. The container name … is already in use" or any orphan-container symptom, the recovery is: \`apps_unstick { uuid }\` → \`apps_deploy { uuid }\`. Deleting the service to side-step the conflict creates a new uuid with new container names AND leaves the orphan running AND forks a duplicate stack. We've shipped 4 orphan twenty-* services this way before. Don't repeat it.
|
||||
- **If a deploy fails twice in a row with the same error, STOP.** Don't loop. Surface the error and the two recovery attempts you've already tried, and ask the user how to proceed.
|
||||
|
||||
- **Tool results are authoritative; conversation history is not.** When a tool result contradicts something you said earlier in this thread, DISCARD your prior assertion. State the new ground truth from the tool. Do not paper over the contradiction or restate the old belief. Example: if you told the user "X is broken" earlier and \`apps_get\` now reports \`status: running:healthy\`, say "X is actually healthy — my earlier read was stale." Don't keep telling them it's broken.
|
||||
|
||||
- **Anchor on current state before troubleshooting.** When the user reports an error, your FIRST tool call must be a current-state read: \`apps_get { uuid }\` for an app, \`databases_get { uuid }\` for a db, \`apps_logs { uuid, lines: 50 }\` for runtime errors. Don't react to symptoms the user described 30 minutes ago — the world has probably moved. We've burned a session re-debugging a problem that was already fixed.
|
||||
|
||||
- **Trust idempotency.** \`apps_create\` and \`databases_create\` will return \`alreadyExisted: true\` with the existing uuid when a duplicate is detected. When you see this flag, your job is DONE — don't try to "make sure" it's right by calling apps_create again with a different name. Use the returned uuid and proceed to whatever comes next (env vars, domains, deploy).
|
||||
- Destructive ops (\`*_delete\`, \`*_volumes_wipe\`) require \`confirm\` equal to the resource's exact name. Always fetch the name first with a \`*_get\` call. Confirm with the user before executing irreversible deletes unless they explicitly said "delete X".
|
||||
- Long-running ops (deploys, DNS provisioning, db provisioning) take 1–5 min. Tell the user up front so they don't think you're stuck. Don't poll in a tight loop — it wastes tool rounds.
|
||||
- After a \`ship\` or \`apps.deploy\`, the result is authoritative. Don't call gitea_*, shell_exec, or apps_* to "verify" — read the response and report.
|
||||
|
||||
@@ -1426,6 +1426,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
|
||||
if (fqdn instanceof NextResponse) return fqdn;
|
||||
|
||||
if (params.projectId && !(params.force === true || params.force === 'true')) {
|
||||
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
|
||||
if (existing) {
|
||||
await linkIfRequested(existing.uuid, existing.type);
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: existing.uuid,
|
||||
name: existing.name,
|
||||
alreadyExisted: true,
|
||||
summaryHint: dedupHint(existing, 'image'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createDockerImageApp({
|
||||
...commonOpts,
|
||||
image,
|
||||
@@ -1447,6 +1462,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
|
||||
if (fqdn instanceof NextResponse) return fqdn;
|
||||
|
||||
if (params.projectId && !(params.force === true || params.force === 'true')) {
|
||||
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
|
||||
if (existing) {
|
||||
await linkIfRequested(existing.uuid, existing.type);
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: existing.uuid,
|
||||
name: existing.name,
|
||||
alreadyExisted: true,
|
||||
summaryHint: dedupHint(existing, 'composeRaw'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createDockerComposeApp({
|
||||
...commonOpts,
|
||||
composeRaw,
|
||||
@@ -1552,6 +1582,21 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
|
||||
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
|
||||
if (fqdn instanceof NextResponse) return fqdn;
|
||||
|
||||
if (params.projectId && !(params.force === true || params.force === 'true')) {
|
||||
const existing = await findExistingResourceByName(targetCoolifyProjectUuid, appName);
|
||||
if (existing) {
|
||||
await linkIfRequested(existing.uuid, existing.type);
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: existing.uuid,
|
||||
name: existing.name,
|
||||
alreadyExisted: true,
|
||||
summaryHint: dedupHint(existing, 'repo'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await createPublicApp({
|
||||
...commonOpts,
|
||||
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
|
||||
@@ -2277,8 +2322,30 @@ async function toolDatabasesList(principal: Principal) {
|
||||
|
||||
async function toolDatabasesCreate(principal: Principal, params: Record<string, any>) {
|
||||
const ws = principal.workspace;
|
||||
const projectUuid = requireCoolifyProject(principal);
|
||||
let projectUuid = requireCoolifyProject(principal);
|
||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||
|
||||
// Scope to the Vibn project's dedicated Coolify project when projectId
|
||||
// is supplied (auto-mint if needed). This both narrows the dedup scope
|
||||
// and prevents cross-project leaks.
|
||||
if (params.projectId) {
|
||||
const projectId = String(params.projectId);
|
||||
const projectRow = await queryOne<{ id: string; data: any; slug: string }>(
|
||||
`SELECT id, data, slug FROM fs_projects
|
||||
WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`,
|
||||
[projectId, ws.id, ws.slug],
|
||||
);
|
||||
if (!projectRow) {
|
||||
return NextResponse.json({ error: `Project ${projectId} not found in this workspace` }, { status: 404 });
|
||||
}
|
||||
const projectName = projectRow.data?.productName || projectRow.data?.name || projectRow.slug;
|
||||
const ensured = await ensureProjectCoolifyProject(projectId, ws, {
|
||||
projectSlug: projectRow.slug,
|
||||
projectName,
|
||||
});
|
||||
if (ensured) projectUuid = ensured;
|
||||
}
|
||||
|
||||
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
|
||||
const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType;
|
||||
if (!DB_TYPES.includes(type)) {
|
||||
@@ -2288,6 +2355,30 @@ async function toolDatabasesCreate(principal: Principal, params: Record<string,
|
||||
);
|
||||
}
|
||||
const name = slugify(String(params.name ?? `${type}-${Date.now().toString(36)}`));
|
||||
|
||||
// Idempotency: when a database with the same name already exists in
|
||||
// this Coolify project, return it instead of creating a duplicate.
|
||||
// The AI fans out duplicate dbs the same way it fans out apps when
|
||||
// its first attempt has issues — same fix.
|
||||
if (params.projectId && !(params.force === true || params.force === 'true')) {
|
||||
const existing = await findExistingResourceByName(projectUuid as string, name);
|
||||
if (existing) {
|
||||
if (params.projectId) {
|
||||
try {
|
||||
await linkResourceToProject(String(params.projectId), ws.slug, existing.uuid, existing.type);
|
||||
} catch {}
|
||||
}
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: existing.uuid,
|
||||
name: existing.name,
|
||||
alreadyExisted: true,
|
||||
summaryHint: dedupHint(existing, 'databases.create'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { uuid } = await createDatabase({
|
||||
type,
|
||||
name,
|
||||
@@ -3922,6 +4013,63 @@ async function findExistingTemplateService(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing app/service/database in the given Coolify project by
|
||||
* name (case-insensitive). Used to short-circuit duplicate apps_create
|
||||
* and databases_create calls regardless of pathway (template / image /
|
||||
* compose / repo). Returns the first match across all resource types.
|
||||
*/
|
||||
async function findExistingResourceByName(
|
||||
coolifyProjectUuid: string,
|
||||
name: string,
|
||||
): Promise<{ uuid: string; name: string; type: 'application' | 'service' | 'database' } | null> {
|
||||
const target = name.trim().toLowerCase();
|
||||
if (!target) return null;
|
||||
try {
|
||||
const apps = await listApplicationsInProject(coolifyProjectUuid);
|
||||
for (const a of apps) {
|
||||
if ((a.name ?? '').toLowerCase() === target) {
|
||||
return { uuid: a.uuid, name: a.name, type: 'application' };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[findExistingResourceByName] apps lookup failed', e);
|
||||
}
|
||||
try {
|
||||
const services = await listServicesInProject(coolifyProjectUuid);
|
||||
for (const s of services) {
|
||||
if ((s.name ?? '').toLowerCase() === target) {
|
||||
return { uuid: s.uuid, name: s.name, type: 'service' };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[findExistingResourceByName] services lookup failed', e);
|
||||
}
|
||||
try {
|
||||
const dbs = await listDatabasesInProject(coolifyProjectUuid);
|
||||
for (const d of dbs) {
|
||||
if ((d.name ?? '').toLowerCase() === target) {
|
||||
return { uuid: d.uuid, name: d.name, type: 'database' };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[findExistingResourceByName] databases lookup failed', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dedupHint(
|
||||
existing: { uuid: string; name: string; type: string },
|
||||
pathway: string,
|
||||
): string {
|
||||
return (
|
||||
`A ${existing.type} named "${existing.name}" (uuid ${existing.uuid}) already exists in this project ` +
|
||||
`(detected via ${pathway} path). Returning it instead of creating a duplicate. ` +
|
||||
`If the existing one is broken, call apps_unstick { uuid } and apps_deploy { uuid } — DO NOT delete-and-recreate. ` +
|
||||
`If you genuinely need a SECOND independent instance, re-call with { force: true } and a different { name: }.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* apps.unstick — recover a service stuck on a "container name already
|
||||
* in use" Docker conflict. Force-removes the orphan containers (and
|
||||
|
||||
Reference in New Issue
Block a user