mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
@@ -99,7 +100,7 @@ async function ensureCaseImage(
|
||||
sessionDocker: SessionDocker,
|
||||
name: string
|
||||
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
|
||||
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
|
||||
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
|
||||
return { ok: true, imageBuilding: false };
|
||||
@@ -111,7 +112,7 @@ async function ensureCaseImage(
|
||||
};
|
||||
}
|
||||
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
|
||||
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
||||
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
|
||||
})
|
||||
.then((r) =>
|
||||
@@ -127,7 +128,7 @@ async function ensureCaseImage(
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -652,29 +653,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const timestamp = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
|
||||
result = await importDockerBundle({
|
||||
bundlePath,
|
||||
destWorkspace: destWorkspacePath,
|
||||
engine: 'docker',
|
||||
timestamp,
|
||||
newCaseName,
|
||||
});
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Create a dedicated docker host pointing at the quarantined imported image
|
||||
// (full mode) or the manifest's base image (workspace-only).
|
||||
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
|
||||
// imported image (full mode) or the manifest's base image (workspace-only).
|
||||
// Refresh matters: after a case-delete + re-import of the same name, a stale
|
||||
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
|
||||
const hostId = `imported-${newCaseName}`;
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
if (!hosts.some((h) => h.id === hostId)) {
|
||||
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
|
||||
...hosts,
|
||||
{
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
|
||||
? result.manifest.network
|
||||
: 'bridge') as 'bridge' | 'none' | 'custom',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const importedHost = {
|
||||
id: hostId,
|
||||
label: `Imported: ${newCaseName}`,
|
||||
engine: result.manifest.engine,
|
||||
image: result.importedImage ?? result.manifest.image,
|
||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
||||
| 'bridge'
|
||||
| 'none'
|
||||
| 'custom',
|
||||
};
|
||||
await writeDockerHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.some((h) => h.id === hostId)
|
||||
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
|
||||
: [...hosts, importedHost]
|
||||
);
|
||||
const newCase = {
|
||||
name: newCaseName,
|
||||
type: 'docker' as const,
|
||||
@@ -687,6 +698,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return { success: true, data: { case: newCase } };
|
||||
});
|
||||
|
||||
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
|
||||
// container so the next launch recreates it with the CURRENT host config. The
|
||||
// workspace + transcripts ride bind mounts and survive; the conversation resumes
|
||||
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
|
||||
// (removal would yank the container out from under their panes).
|
||||
app.post(
|
||||
'/api/docker-cases/:name/recreate',
|
||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await removeDockerContainer(sessionDocker);
|
||||
} catch (err) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Failed to remove container: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
// Drop the per-container claude-config seed; it is regenerated at next launch.
|
||||
await fs
|
||||
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
|
||||
.catch(() => {});
|
||||
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
|
||||
return { success: true, data: { name, container: sessionDocker.containerName } };
|
||||
}
|
||||
);
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
||||
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerHookEventRoutes(
|
||||
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
|
||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const prevClaudeSessionId = session?.claudeSessionId;
|
||||
session?.adoptClaudeSessionId(data.session_id);
|
||||
// Docker sessions: keep the case's resume seed following the LIVE
|
||||
// conversation (post-/clear id switches), so a container stop/reboot
|
||||
// relaunch resumes the right transcript.
|
||||
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
getDataDir(),
|
||||
session.docker.containerName,
|
||||
session.claudeSessionId
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize forwarded data: only include known safe fields, limit size
|
||||
|
||||
@@ -76,9 +76,11 @@ import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
persistDockerCaseClaudeSessionId,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
@@ -936,6 +938,12 @@ export function registerSessionRoutes(
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Docker sessions: keep the case's resume seed following the live conversation.
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
@@ -1683,6 +1691,7 @@ export function registerSessionRoutes(
|
||||
caseName = 'testcase',
|
||||
sessionName,
|
||||
mode = 'claude',
|
||||
modelOverride,
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
@@ -1713,13 +1722,14 @@ export function registerSessionRoutes(
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1764,7 +1774,7 @@ export function registerSessionRoutes(
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
@@ -1783,6 +1793,19 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
||||
// longer matches the existing container's codeman.confighash label. Refuse to
|
||||
// silently launch into the stale container — the frontend confirms a recreate
|
||||
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
||||
// mounts and the conversation resumes), or the user reverts the host edit.
|
||||
const drift = await checkDockerConfigDrift(sessionDocker);
|
||||
if (drift.exists && drift.drifted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
|
||||
);
|
||||
}
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
@@ -1894,6 +1917,13 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (mode === 'claude' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
if (
|
||||
@@ -1990,6 +2020,19 @@ export function registerSessionRoutes(
|
||||
}
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Docker + claude: the pane command pins the conversation id (--session-id /
|
||||
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
||||
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
||||
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
||||
// listener). Hook/last-response adoption updates it again after /clear.
|
||||
if (docker && mode === 'claude') {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
docker.containerName,
|
||||
session.claudeSessionId || session.id
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
const settingsFilePath = SETTINGS_PATH;
|
||||
|
||||
Reference in New Issue
Block a user