feat: update codex live chat workflow
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -118,6 +119,259 @@ const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
|
||||
const DEFERRED_RESTART_DELAY_MS = 2_000;
|
||||
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
|
||||
const DEFERRED_RESTART_POLL_INTERVAL_MS = 150;
|
||||
const APP_SOURCE_TARGET_PATHS = [
|
||||
'src',
|
||||
'public',
|
||||
'index.html',
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
'tsconfig.app.json',
|
||||
'vite.config.ts',
|
||||
'scripts',
|
||||
] as const;
|
||||
const APP_BUILD_INFO_FILE_CANDIDATES = [
|
||||
'/tmp/ai-code-test-app-dist/index.html',
|
||||
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
|
||||
'/tmp/ai-code-test-app-dist/assets',
|
||||
] as const;
|
||||
|
||||
async function readLocalBuildTimestamp(targetPath: string) {
|
||||
try {
|
||||
const targetStat = await stat(targetPath);
|
||||
return normalizeDateTimeValue(targetStat.mtime.toISOString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readContainerBuildTimestamp(definition: ServerDefinition, targetPath: string) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'docker',
|
||||
['exec', definition.containerName, 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`],
|
||||
{
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: 8000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeDateTimeValue(stdout.trim());
|
||||
} catch (error) {
|
||||
if (!shouldRetryWithDockerSocket(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readContainerBuildTimestampViaSocket(definition, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
type SourceChangeInfo = {
|
||||
changedAt: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
|
||||
try {
|
||||
const targetStat = await stat(targetPath);
|
||||
|
||||
if (targetStat.isFile()) {
|
||||
return {
|
||||
changedAt: normalizeDateTimeValue(targetStat.mtime.toISOString()) ?? targetStat.mtime.toISOString(),
|
||||
path: path.relative(rootPath, targetPath) || path.basename(targetPath),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetStat.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(targetPath, { withFileTypes: true });
|
||||
let latest: SourceChangeInfo | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === '.docker') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childPath = path.join(targetPath, entry.name);
|
||||
const candidate = await findLatestSourceChangeInPath(rootPath, childPath);
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || candidate.changedAt > latest.changedAt) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLatestAppSourceChange() {
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
let latest: SourceChangeInfo | null = null;
|
||||
|
||||
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
|
||||
const candidate = await findLatestSourceChangeInPath(projectRoot, path.join(projectRoot, relativePath));
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || candidate.changedAt > latest.changedAt) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function requestDockerEngine<T = unknown>(
|
||||
method: string,
|
||||
requestPath: string,
|
||||
payload?: unknown,
|
||||
): Promise<T> {
|
||||
const socketPath = resolveDockerSocketPath(process.env);
|
||||
const body = payload == null ? null : JSON.stringify(payload);
|
||||
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
socketPath,
|
||||
path: requestPath,
|
||||
method,
|
||||
headers: body
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.on('end', () => {
|
||||
const responseText = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
if ((response.statusCode ?? 500) >= 400) {
|
||||
reject(
|
||||
new Error(
|
||||
trimPreview(`Docker API ${method} ${requestPath} failed: ${response.statusCode} ${responseText}`, 400) ??
|
||||
`Docker API ${method} ${requestPath} failed: ${response.statusCode}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!responseText.trim()) {
|
||||
resolve(undefined as T);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(responseText) as T);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.once('error', reject);
|
||||
request.setTimeout(8000, () => {
|
||||
request.destroy(new Error(`Docker API timeout: ${method} ${requestPath}`));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
request.write(body);
|
||||
}
|
||||
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
type DockerContainerInspect = {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
State?: {
|
||||
StartedAt?: string;
|
||||
Status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function inspectContainerViaSocket(containerName: string) {
|
||||
return requestDockerEngine<DockerContainerInspect>('GET', `/containers/${encodeURIComponent(containerName)}/json`);
|
||||
}
|
||||
|
||||
async function execContainerCommandViaSocket(containerName: string, command: string[]) {
|
||||
const execCreated = await requestDockerEngine<{ Id?: string }>('POST', `/containers/${encodeURIComponent(containerName)}/exec`, {
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Cmd: command,
|
||||
});
|
||||
const execId = execCreated.Id?.trim();
|
||||
|
||||
if (!execId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
socketPath: resolveDockerSocketPath(process.env),
|
||||
path: `/exec/${encodeURIComponent(execId)}/start`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve(decodeDockerExecStream(Buffer.concat(chunks)));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.once('error', reject);
|
||||
request.setTimeout(8000, () => {
|
||||
request.destroy(new Error(`Docker exec timeout: ${containerName}`));
|
||||
});
|
||||
request.write(JSON.stringify({ Detach: false, Tty: false }));
|
||||
request.end();
|
||||
});
|
||||
|
||||
const execState = await requestDockerEngine<{ ExitCode?: number | null }>('GET', `/exec/${encodeURIComponent(execId)}/json`);
|
||||
if ((execState.ExitCode ?? 1) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
async function readContainerBuildTimestampViaSocket(definition: ServerDefinition, targetPath: string) {
|
||||
try {
|
||||
const output = await execContainerCommandViaSocket(definition.containerName, [
|
||||
'sh',
|
||||
'-lc',
|
||||
`if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`,
|
||||
]);
|
||||
|
||||
return normalizeDateTimeValue(output);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
@@ -181,7 +435,27 @@ function shouldRetryWithDockerSocket(error: unknown) {
|
||||
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
|
||||
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
|
||||
|
||||
return failure?.code === 127 || /docker CLI not found/i.test(detail);
|
||||
return failure?.code === 127 || failure?.code === 'ENOENT' || /docker CLI not found|spawn docker ENOENT/i.test(detail);
|
||||
}
|
||||
|
||||
function decodeDockerExecStream(buffer: Buffer) {
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const frameLength = buffer.readUInt32BE(offset + 4);
|
||||
const frameStart = offset + 8;
|
||||
const frameEnd = frameStart + frameLength;
|
||||
|
||||
if (frameEnd > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(buffer.subarray(frameStart, frameEnd));
|
||||
offset = frameEnd;
|
||||
}
|
||||
|
||||
return (chunks.length > 0 ? Buffer.concat(chunks) : buffer).toString('utf8');
|
||||
}
|
||||
|
||||
export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) {
|
||||
@@ -794,6 +1068,19 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
|
||||
composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null),
|
||||
};
|
||||
} catch (error) {
|
||||
if (shouldRetryWithDockerSocket(error)) {
|
||||
try {
|
||||
const inspected = await inspectContainerViaSocket(definition.containerName);
|
||||
return {
|
||||
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
|
||||
composeStatus: inspected.State?.Status?.trim() || null,
|
||||
composeDetails: trimPreview(inspected.Name?.trim() ? `name:${inspected.Name.trim().replace(/^\//, '')}` : null),
|
||||
};
|
||||
} catch {
|
||||
// fall through to compose inspection
|
||||
}
|
||||
}
|
||||
|
||||
return inspectComposeStatus(definition);
|
||||
}
|
||||
}
|
||||
@@ -935,8 +1222,61 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
|
||||
return inspectContainerRuntime(definition);
|
||||
}
|
||||
|
||||
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
|
||||
if (definition.key !== 'test') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestSourceChange = await readLatestAppSourceChange();
|
||||
const latestSourceChangedAt = latestSourceChange?.changedAt ?? null;
|
||||
|
||||
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
|
||||
const builtAt =
|
||||
(await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath));
|
||||
|
||||
if (!builtAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
runningVersion: null,
|
||||
runningBuiltAt: builtAt,
|
||||
latestVersion: null,
|
||||
latestBuiltAt: builtAt,
|
||||
latestSourceChangeAt: latestSourceChangedAt,
|
||||
latestSourceChangePath: latestSourceChange?.path ?? null,
|
||||
buildRequired: Boolean(latestSourceChangedAt && latestSourceChangedAt > builtAt),
|
||||
updateAvailable: false,
|
||||
updateSummary:
|
||||
latestSourceChangedAt && latestSourceChangedAt > builtAt
|
||||
? `수정된 소스가 테스트 빌드보다 새롭습니다.${latestSourceChange?.path ? ` (${latestSourceChange.path})` : ''} 테스트 앱을 다시 빌드해야 합니다.`
|
||||
: `테스트 빌드 기준: ${builtAt}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
runningVersion: null,
|
||||
runningBuiltAt: null,
|
||||
latestVersion: null,
|
||||
latestBuiltAt: null,
|
||||
latestSourceChangeAt: latestSourceChangedAt,
|
||||
latestSourceChangePath: latestSourceChange?.path ?? null,
|
||||
buildRequired: Boolean(latestSourceChangedAt),
|
||||
updateAvailable: false,
|
||||
updateSummary: latestSourceChangedAt
|
||||
? `테스트 빌드 시각을 읽지 못했습니다.${latestSourceChange?.path ? ` 최근 소스 변경: ${latestSourceChange.path}` : ''}`
|
||||
: '테스트 빌드 시각을 읽지 못했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectBuild(definition: ServerDefinition): Promise<BuildInspectionResult> {
|
||||
if (definition.key !== 'work-server') {
|
||||
const appBuildInfo = await inspectAppContainerBuild(definition);
|
||||
|
||||
if (appBuildInfo) {
|
||||
return appBuildInfo;
|
||||
}
|
||||
|
||||
return {
|
||||
runningVersion: null,
|
||||
runningBuiltAt: null,
|
||||
|
||||
Reference in New Issue
Block a user