diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index 603b725..6e5ed49 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -525,6 +525,60 @@ export type KomodoTagResult = | { ok: true; id: string } | { ok: false; message: string }; +// Komodo serializes Mongo ObjectIds as `_id` in the raw mongo docs and as +// `id` in the API DTOs (rust serde default). Accept either everywhere. +function extractTagId(t: unknown): string | null { + if (!t || typeof t !== 'object') return null; + const o = t as { id?: unknown; _id?: unknown }; + if (typeof o.id === 'string' && o.id.length > 0) return o.id; + if (typeof o._id === 'string' && o._id.length > 0) return o._id; + return null; +} + +// ListTags response shape varies by Komodo version: bare array `[...]`, or +// wrapped under one of a few common keys. Peel back the common wrappers. +function unwrapTagList(body: unknown): unknown[] { + if (Array.isArray(body)) return body; + if (body && typeof body === 'object') { + for (const key of ['tags', 'data', 'results', 'items']) { + const val = (body as Record)[key]; + if (Array.isArray(val)) return val; + } + } + return []; +} + +async function findTagByName(name: string): Promise { + let listRes: Response; + try { + listRes = await komodoCall('read/ListTags', {}); + } catch (err) { + console.warn(`[komodoEnsureTag] ListTags network failure for "${name}":`, (err as Error).message); + return null; + } + if (!listRes.ok) { + const snippet = await listRes.text().catch(() => ''); + console.warn(`[komodoEnsureTag] ListTags HTTP ${listRes.status} for "${name}"${snippet ? `: ${truncate(snippet)}` : ''}`); + return null; + } + let body: unknown; + try { + body = await listRes.json(); + } catch (err) { + console.warn(`[komodoEnsureTag] ListTags non-JSON body for "${name}":`, (err as Error).message); + return null; + } + const tags = unwrapTagList(body); + for (const t of tags) { + if (!t || typeof t !== 'object') continue; + const tagName = (t as { name?: unknown }).name; + if (typeof tagName !== 'string' || tagName !== name) continue; + const id = extractTagId(t); + if (id) return { ok: true, id }; + } + return null; +} + /** * Resolve a tag by name, creating it if it doesn't exist. Returns the tag's * Komodo id (Mongo ObjectId string) for use in UpdateTagsOnResource. @@ -536,50 +590,47 @@ export async function komodoEnsureTag(name: string): Promise { if (!komodoConfigured()) return { ok: false, message: 'komodo not configured' }; // 1. Look for an existing tag by name. - try { - const listRes = await komodoCall('read/ListTags', {}); - if (listRes.ok) { - const tags = await listRes.json().catch(() => null); - if (Array.isArray(tags)) { - const match = tags.find( - (t: unknown): t is { _id: string; name: string } => - !!t && typeof t === 'object' && - typeof (t as { name?: unknown }).name === 'string' && - (t as { name: string }).name === name && - typeof (t as { _id?: unknown })._id === 'string' - ); - if (match) return { ok: true, id: match._id }; - } - } - } catch (err) { - // Fall through to create — list failure isn't fatal, we can still try - // to create; if the name is taken, CreateTag will surface a clean error. - console.warn(`[komodoEnsureTag] ListTags failed for "${name}":`, (err as Error).message); - } + const existing = await findTagByName(name); + if (existing) return existing; - // 2. Create the tag. Response shape: `{ _id, name, ... }` on success. + // 2. Not found — create it. let createRes: Response; try { createRes = await komodoCall('write/CreateTag', { name }); } catch (err) { return { ok: false, message: `CreateTag ${name}: ${(err as Error).message}` }; } - if (!createRes.ok) { - const snippet = await createRes.text().catch(() => ''); + if (createRes.ok) { + const body = await createRes.json().catch(() => null); + const id = extractTagId(body); + if (id) return { ok: true, id }; + // 200 but shape we don't recognize — fall back to re-list rather than + // fail, since the tag definitely exists now. + const fresh = await findTagByName(name); + if (fresh) return fresh; + console.warn(`[komodoEnsureTag] CreateTag ${name}: unexpected 2xx body, re-list also missed`, body); + return { ok: false, message: `CreateTag ${name}: unexpected response shape` }; + } + + // Non-2xx. Duplicate-key 500 means the tag exists under a different + // ListTags projection than we probed — re-list and try to find it. If + // that still misses, surface the create error. + const snippet = await createRes.text().catch(() => ''); + const looksLikeDuplicate = + createRes.status === 500 && + (/duplicate key/i.test(snippet) || /E11000/i.test(snippet) || /dup key/i.test(snippet)); + if (looksLikeDuplicate) { + const fresh = await findTagByName(name); + if (fresh) return fresh; return { ok: false, - message: `CreateTag ${name}: HTTP ${createRes.status}${snippet ? ` — ${truncate(snippet)}` : ''}` + message: `CreateTag ${name}: duplicate reported but re-list missed it. Body: ${truncate(snippet)}` }; } - try { - const body = await createRes.json(); - if (body && typeof body === 'object' && typeof (body as { _id?: unknown })._id === 'string') { - return { ok: true, id: (body as { _id: string })._id }; - } - return { ok: false, message: `CreateTag ${name}: unexpected response shape` }; - } catch (err) { - return { ok: false, message: `CreateTag ${name}: ${(err as Error).message}` }; - } + return { + ok: false, + message: `CreateTag ${name}: HTTP ${createRes.status}${snippet ? ` — ${truncate(snippet)}` : ''}` + }; } /**