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:
@@ -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<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
|
||||
* 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' };
|
||||
|
||||
// 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)}` : ''}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user