feat(tasks): add skill-templated task graph execution

This commit is contained in:
2026-06-23 10:22:58 +08:00
parent 6843d89b2c
commit 53b13e8eac
53 changed files with 4773 additions and 756 deletions

View File

@ -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

View File

@ -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) });
}

View File

@ -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) });
}

View File

@ -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> {

View 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 });

View File

@ -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}`);
}