feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
@@ -104,8 +104,13 @@ describe('safe operation client', () => {
]);
});
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
const fetcher = vi.fn(async () => response(page));
it('refuses uncatalogued operations and mismatched schemas without a prepare request', async () => {
const fetcher = vi.fn(async (url: string) => {
if (String(url).includes('search=hiddenOp')) {
return response({ items: [], page: { page: 1, pageSize: 100, total: 0 } });
}
return response(page);
});
const client = createOperationClient(fetcher as typeof fetch);
await client.list();
@@ -123,7 +128,52 @@ describe('safe operation client', () => {
parameters: { parameterSchemaId: 'wrong', fields: [] },
}),
).rejects.toThrow('Operation parameter schema does not match the catalog.');
expect(fetcher).toHaveBeenCalledOnce();
// list + one search hydrate for the missing op; no prepare POST.
expect(fetcher.mock.calls.every((call) => !String(call[0]).includes('/prepare'))).toBe(true);
});
it('hydrates late-sorted restart ops when the first pageSize=100 page omits them', async () => {
const restartEntry = {
operationId: 'postServiceRestart',
title: 'Restart Service',
risk: 'R3',
capability: 'job',
batchable: false,
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
};
const firstPage = {
items: [entry],
page: { page: 1, pageSize: 100, total: 117 },
};
const searchPage = {
items: [restartEntry],
page: { page: 1, pageSize: 100, total: 1 },
};
const restartPreparation = {
...preparation,
operationId: 'postServiceRestart',
risk: 'R3',
confirmationPrompt: 'Confirm restart service',
};
const fetcher = vi
.fn()
.mockResolvedValueOnce(response(firstPage))
.mockResolvedValueOnce(response(searchPage))
.mockResolvedValueOnce(response(restartPreparation));
const client = createOperationClient(fetcher as typeof fetch);
await client.list({ pageSize: 100 });
const result = await client.prepare({
operationId: 'postServiceRestart',
targets: [{ instanceId: 'instance-1', revision: 1 }],
parameters: {
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
fields: [],
},
});
expect(result.operationId).toBe('postServiceRestart');
expect(String(fetcher.mock.calls[1]?.[0])).toContain('search=postServiceRestart');
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/operations/prepare');
});
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
+29 -15
View File
@@ -369,25 +369,39 @@ function queryString(query: OperationCatalogQuery): string {
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
const catalog = new Map<string, OperationCatalogEntry>();
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
async function list(query: OperationCatalogQuery = {}): Promise<OperationCatalogPage> {
const body = await jsonResponse(
await fetcher(`/api/v1/operations${queryString(query)}`, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
const parsed = parseCatalog(body);
if (!parsed) throw new Error('Operation catalog response is invalid.');
// Merge rather than replace: a pageSize-capped first page must not wipe later lookups
// for late-sorted ids such as postServiceRestart / postSystemReboot.
for (const item of parsed.items) catalog.set(item.operationId, item);
return parsed;
}
async function ensureCatalogEntry(
operationId: string,
): Promise<OperationCatalogEntry | undefined> {
const existing = catalog.get(operationId);
if (existing) return existing;
// Catalog is sorted by operationId and capped at pageSize 100; hydrate by exact search.
await list({ search: operationId, pageSize: 100 });
return catalog.get(operationId);
}
return {
async list(query = {}) {
const body = await jsonResponse(
await fetcher(`/api/v1/operations${queryString(query)}`, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
}),
);
const parsed = parseCatalog(body);
if (!parsed) throw new Error('Operation catalog response is invalid.');
catalog.clear();
for (const item of parsed.items) catalog.set(item.operationId, item);
return parsed;
},
list,
async prepare(input) {
const safeInput = safePrepareInput(input);
if (!safeInput) throw new Error('Operation preparation request is invalid.');
const allowed = catalog.get(safeInput.operationId);
const allowed = await ensureCatalogEntry(safeInput.operationId);
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
throw new Error('Operation parameter schema does not match the catalog.');