feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

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