Handle Komodo tag response shape variance + duplicate recovery

Two real-world Komodo responses broke the first pass:

1. read/ListTags didn't surface tag "t5" in a matchable shape, so we
   fell through to CreateTag and got HTTP 500 "duplicate key: t5".
2. write/CreateTag returned 200 with an id field named "id" (not "_id"),
   which the parser rejected as an unexpected shape even on success.

Fixes:
- extractTagId accepts either "id" or "_id"
- unwrapTagList peels back common list wrappers (bare array, {tags},
  {data}, {results}, {items}) instead of requiring a top-level array
- CreateTag duplicate-key error triggers a re-list so the existing tag
  can be resolved to its id instead of failing the whole tag pass
- Both ListTags and CreateTag branches log the raw body when parsing
  fails, so the next unfamiliar shape is diagnosable at a glance
This commit is contained in:
2026-09-17 09:20:20 -04:00
parent 5c674615d3
commit 66f70cd9f3
+83 -32
View File
@@ -525,6 +525,60 @@ export type KomodoTagResult =
| { ok: true; id: string } | { ok: true; id: string }
| { ok: false; message: 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<string, unknown>)[key];
if (Array.isArray(val)) return val;
}
}
return [];
}
async function findTagByName(name: string): Promise<KomodoTagResult | null> {
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 * 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. * Komodo id (Mongo ObjectId string) for use in UpdateTagsOnResource.
@@ -536,50 +590,47 @@ export async function komodoEnsureTag(name: string): Promise<KomodoTagResult> {
if (!komodoConfigured()) return { ok: false, message: 'komodo not configured' }; if (!komodoConfigured()) return { ok: false, message: 'komodo not configured' };
// 1. Look for an existing tag by name. // 1. Look for an existing tag by name.
try { const existing = await findTagByName(name);
const listRes = await komodoCall('read/ListTags', {}); if (existing) return existing;
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);
}
// 2. Create the tag. Response shape: `{ _id, name, ... }` on success. // 2. Not found — create it.
let createRes: Response; let createRes: Response;
try { try {
createRes = await komodoCall('write/CreateTag', { name }); createRes = await komodoCall('write/CreateTag', { name });
} catch (err) { } catch (err) {
return { ok: false, message: `CreateTag ${name}: ${(err as Error).message}` }; return { ok: false, message: `CreateTag ${name}: ${(err as Error).message}` };
} }
if (!createRes.ok) { 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 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}: duplicate reported but re-list missed it. Body: ${truncate(snippet)}`
};
}
return { return {
ok: false, ok: false,
message: `CreateTag ${name}: HTTP ${createRes.status}${snippet ? ` — ${truncate(snippet)}` : ''}` message: `CreateTag ${name}: HTTP ${createRes.status}${snippet ? ` — ${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}` };
}
} }
/** /**