Probe tagged-union ListTags shape + surface raw bodies on miss

read/ListTags with an empty body was returning something we couldn't
parse, so every tag lookup silently missed and the CreateTag duplicate
fallback dead-ended with no diagnostic.

Now the tag lookup tries two request styles in order — the per-endpoint
`read/ListTags {}` used by the rest of this file, then the tagged-union
`read {type: "ListTags", params: {}}` used by mbecker20/komodo's
canonical API. If both fail to yield a match, both raw response bodies
get logged so the actual Komodo shape is diagnosable on the next run
without another commit-and-deploy cycle.
This commit is contained in:
2026-09-17 09:26:07 -04:00
parent 66f70cd9f3
commit e6b5705478
+53 -20
View File
@@ -548,27 +548,55 @@ function unwrapTagList(body: unknown): unknown[] {
return []; return [];
} }
/**
* Fetch the tag list from Komodo. Two request styles are attempted in order:
*
* 1. `POST /read/ListTags` with an empty body — the per-endpoint style
* already used by the rest of this file (see execute/RestartDeployment).
* 2. `POST /read` with `{type: "ListTags", params: {}}` — the tagged-union
* style used by mbecker20/komodo's canonical API. If your Komodo build
* wires up only the tagged-union path, style #1 returns something we
* can't parse (empty array or non-tag payload) and we fall through.
*
* The winning parsed list is returned. The raw response bodies get logged
* only when both attempts fail to yield a match, so a fresh Komodo API shape
* is diagnosable without spamming stderr on the happy path.
*/
async function fetchTagsWithFallback(): Promise<{ tags: unknown[]; rawBodies: string[] }> {
const attempts: Array<{ path: string; body: unknown }> = [
{ path: 'read/ListTags', body: {} },
{ path: 'read', body: { type: 'ListTags', params: {} } }
];
const rawBodies: string[] = [];
for (const attempt of attempts) {
let res: Response;
try {
res = await komodoCall(attempt.path, attempt.body);
} catch (err) {
rawBodies.push(`${attempt.path}: network error — ${(err as Error).message}`);
continue;
}
if (!res.ok) {
const snippet = await res.text().catch(() => '');
rawBodies.push(`${attempt.path}: HTTP ${res.status}${snippet ? ` — ${truncate(snippet)}` : ''}`);
continue;
}
const raw = await res.text().catch(() => '');
rawBodies.push(`${attempt.path}: ${truncate(raw)}`);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const tags = unwrapTagList(parsed);
if (tags.length > 0) return { tags, rawBodies };
}
return { tags: [], rawBodies };
}
async function findTagByName(name: string): Promise<KomodoTagResult | null> { async function findTagByName(name: string): Promise<KomodoTagResult | null> {
let listRes: Response; const { tags, rawBodies } = await fetchTagsWithFallback();
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) { for (const t of tags) {
if (!t || typeof t !== 'object') continue; if (!t || typeof t !== 'object') continue;
const tagName = (t as { name?: unknown }).name; const tagName = (t as { name?: unknown }).name;
@@ -576,6 +604,11 @@ async function findTagByName(name: string): Promise<KomodoTagResult | null> {
const id = extractTagId(t); const id = extractTagId(t);
if (id) return { ok: true, id }; if (id) return { ok: true, id };
} }
// Miss — dump the raw bodies so the next iteration knows the real shape.
console.warn(
`[komodoEnsureTag] no matchable tag "${name}" in any ListTags variant. Bodies:\n ` +
rawBodies.join('\n ')
);
return null; return null;
} }