feat(tasks): add skill-templated task graph execution
This commit is contained in:
@ -2,7 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
|
||||
import {
|
||||
HttpError,
|
||||
callDeployControl,
|
||||
callInstanceApi,
|
||||
normalizeTokenResponse,
|
||||
targetFrontendBaseUrl,
|
||||
waitForFrontendReady,
|
||||
} from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
if (error instanceof HttpError) {
|
||||
@ -49,7 +56,9 @@ export async function POST(request: NextRequest) {
|
||||
password,
|
||||
});
|
||||
|
||||
return NextResponse.json(normalizeTokenResponse(response, routing));
|
||||
const normalized = normalizeTokenResponse(response, routing);
|
||||
await waitForFrontendReady(targetFrontendBaseUrl(normalized));
|
||||
return NextResponse.json(normalized);
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
const detail = status === 404 || status === 401
|
||||
|
||||
@ -2,7 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
|
||||
import {
|
||||
HttpError,
|
||||
callDeployControl,
|
||||
callInstanceApi,
|
||||
normalizeTokenResponse,
|
||||
targetFrontendBaseUrl,
|
||||
waitForFrontendReady,
|
||||
} from '@/lib/runtime-control';
|
||||
|
||||
const PROVIDER_ONBOARDING_TIMEOUT_MS = 120000;
|
||||
const KNOWN_PROVIDERS = new Set([
|
||||
@ -113,7 +120,9 @@ export async function POST(request: NextRequest) {
|
||||
password,
|
||||
});
|
||||
|
||||
return NextResponse.json(normalizeTokenResponse(response, configuredRouting));
|
||||
const normalized = normalizeTokenResponse(response, configuredRouting);
|
||||
await waitForFrontendReady(targetFrontendBaseUrl(normalized));
|
||||
return NextResponse.json(normalized);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: errorDetail(error) }, { status: errorStatus(error) });
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
callAuthzService,
|
||||
callDeployControl,
|
||||
normalizeTokenResponse,
|
||||
targetFrontendBaseUrl,
|
||||
waitForFrontendReady,
|
||||
} from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
@ -62,6 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasTargetFrontendUrl(response)) {
|
||||
await waitForFrontendReady(targetFrontendBaseUrl(response));
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
@ -72,7 +75,9 @@ export async function POST(request: NextRequest) {
|
||||
instance?: unknown;
|
||||
}>('/api/instances/resolve', { username });
|
||||
|
||||
return NextResponse.json(normalizeTokenResponse(response, routing));
|
||||
const normalized = normalizeTokenResponse(response, routing);
|
||||
await waitForFrontendReady(targetFrontendBaseUrl(normalized));
|
||||
return NextResponse.json(normalized);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: errorDetail(error) }, { status: errorStatus(error) });
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import type { TokenResponse } from '@/types/auth';
|
||||
import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const LOGIN_REQUEST_TIMEOUT_MS = 30000;
|
||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||
const PROVIDER_ONBOARDING_TIMEOUT_MS = 120000;
|
||||
|
||||
@ -90,7 +91,7 @@ export async function login(username: string, password: string): Promise<TokenRe
|
||||
return fetchJSON('/api/runtime/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}, LOGIN_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export async function register(username: string, email: string, password: string): Promise<TokenResponse> {
|
||||
|
||||
43
auth-portal/src/lib/runtime-control-ready.test.mjs
Normal file
43
auth-portal/src/lib/runtime-control-ready.test.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = await readFile(new URL('./runtime-control.ts', import.meta.url), 'utf8');
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ES2022,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
});
|
||||
const dir = await mkdtemp(join(tmpdir(), 'runtime-control-test-'));
|
||||
const modulePath = join(dir, 'runtime-control.mjs');
|
||||
await writeFile(modulePath, output.outputText, 'utf8');
|
||||
|
||||
const runtimeControl = await import(pathToFileURL(modulePath).href);
|
||||
|
||||
assert.equal(typeof runtimeControl.waitForFrontendReady, 'function');
|
||||
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (calls.length < 3) {
|
||||
return { ok: false, status: 502, text: async () => 'bad gateway' };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => '' };
|
||||
};
|
||||
|
||||
await runtimeControl.waitForFrontendReady('http://workspace.example:8088', {
|
||||
timeoutMs: 1000,
|
||||
intervalMs: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'http://workspace.example:8088/handoff',
|
||||
'http://workspace.example:8088/handoff',
|
||||
'http://workspace.example:8088/handoff',
|
||||
]);
|
||||
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
@ -5,6 +5,8 @@ const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.
|
||||
const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||
const FRONTEND_READY_TIMEOUT_MS = Number(process.env.FRONTEND_READY_TIMEOUT_MS || '20000');
|
||||
const FRONTEND_READY_INTERVAL_MS = Number(process.env.FRONTEND_READY_INTERVAL_MS || '1000');
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@ -135,3 +137,54 @@ export function normalizeTokenResponse(
|
||||
backend_connection: mergedBackendConnection,
|
||||
};
|
||||
}
|
||||
|
||||
export function targetFrontendBaseUrl(response: TokenResponse): string {
|
||||
return (
|
||||
asString(response.backend_connection?.frontend_base_url) ||
|
||||
asString(response.backend_connection?.public_base_url) ||
|
||||
asString(response.backend_connection?.api_base_url) ||
|
||||
asString(response.local_backend?.public_base_url)
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function waitForFrontendReady(
|
||||
frontendBaseUrl: string,
|
||||
options: { timeoutMs?: number; intervalMs?: number } = {}
|
||||
): Promise<void> {
|
||||
const normalized = frontendBaseUrl.trim().replace(/\/+$/, '');
|
||||
if (!normalized) return;
|
||||
|
||||
let readyUrl: URL;
|
||||
try {
|
||||
readyUrl = new URL('/handoff', normalized);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1, options.timeoutMs ?? FRONTEND_READY_TIMEOUT_MS);
|
||||
const intervalMs = Math.max(1, options.intervalMs ?? FRONTEND_READY_INTERVAL_MS);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = 'frontend is not ready';
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
const response = await fetch(readyUrl.toString(), {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = `frontend returned ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'frontend request failed';
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
throw new HttpError(502, `frontend is not ready: ${lastError}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user