mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.
master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.
This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.
Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.
test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.
Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+337
-19
@@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeCasePicker();
|
||||
this.updateDirDisplayForCase(select.value);
|
||||
this.updateMobileCaseLabel(select.value);
|
||||
// Warm the container's CLI list HERE rather than when the run menu opens.
|
||||
// The probe is a `docker exec` round trip, so gating it on the menu meant the
|
||||
// menu painted every mode first and only narrowed a moment later — which
|
||||
// reads as "it shows all of them" and lets a mode be picked that the
|
||||
// container does not have.
|
||||
const picked = (this.cases || []).find((c) => c.name === select.value);
|
||||
if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null);
|
||||
if (save) {
|
||||
this.saveLastUsedCase(select.value);
|
||||
}
|
||||
@@ -477,10 +484,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
// A DOCKER case runs its agents INSIDE the container, so host CLI
|
||||
// availability answers the wrong question: the host may have no claude at
|
||||
// all while the container ships one, and gating on the host hides a mode
|
||||
// that would have worked. Adoption records what the container really has
|
||||
// (`availableModes`); an owned container runs our base image, which ships
|
||||
// every CLI, so an absent list means "do not gate" rather than "nothing".
|
||||
// Same source every run* path reads the selected case from.
|
||||
const caseName = document.getElementById('quickStartCase')?.value;
|
||||
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
||||
const isDocker = activeCase?.location === 'docker';
|
||||
// Prefer a LIVE probe over the value stored at attach time: a container's
|
||||
// CLIs can be installed or removed long after the case was linked, and a
|
||||
// case linked before that field existed has none at all.
|
||||
const containerModes = isDocker
|
||||
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
|
||||
: null;
|
||||
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
|
||||
// An unreachable container hides every agent mode and explains why, instead
|
||||
// of silently offering modes that cannot start.
|
||||
//
|
||||
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
|
||||
// state before the first session — the launch chain creates and starts it — so
|
||||
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
||||
// behind "start it yourself first", for a container Codeman was about to create.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
if (!btn) continue;
|
||||
let available;
|
||||
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
||||
else available = this.isCliAvailable(mode);
|
||||
btn.style.display = available ? 'flex' : 'none';
|
||||
}
|
||||
this._renderRunModeNotice(menu, probeError);
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||
// ships no terminal front door. In that state the honest offer is "add one",
|
||||
@@ -622,6 +659,81 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* One-line explanation at the top of the run menu. Only a container that could
|
||||
* not be read produces one; everything else removes it, so a stale reason can
|
||||
* never outlive the condition that caused it.
|
||||
*/
|
||||
_renderRunModeNotice(menu, message) {
|
||||
if (!menu) return;
|
||||
let el = menu.querySelector('.run-mode-notice');
|
||||
if (!message) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'run-mode-notice';
|
||||
menu.prepend(el);
|
||||
}
|
||||
// Server-supplied text: set it, never parse it as markup.
|
||||
el.textContent = message;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the container which CLIs it actually has, and re-gate the menu once the
|
||||
* answer lands. Cached per case for the page's lifetime: the menu re-opens
|
||||
* often and the probe is a `docker exec` round trip.
|
||||
*
|
||||
* Best-effort by design — an unreachable daemon or a stopped container leaves
|
||||
* the cache empty, which the caller reads as "unknown" and therefore does not
|
||||
* gate. Hiding every mode because a probe failed would be worse than showing
|
||||
* one that turns out to be missing, which the launch path already refuses with
|
||||
* a specific message.
|
||||
*/
|
||||
async _probeDockerCaseModes(activeCase, menu) {
|
||||
const name = activeCase?.name;
|
||||
const container = activeCase?.docker?.container;
|
||||
const hostId = activeCase?.docker?.hostId;
|
||||
if (!name || !container || !hostId) return;
|
||||
this._dockerCaseModes = this._dockerCaseModes || {};
|
||||
if (this._dockerModeProbeInFlight?.[name]) return;
|
||||
this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {};
|
||||
this._dockerModeProbeInFlight[name] = true;
|
||||
try {
|
||||
// ⚠️ _api serializes `body` and sets Content-Type itself. Passing an
|
||||
// already-stringified body double-encodes it and the server rejects a
|
||||
// JSON string where it expects an object (400 INVALID_INPUT).
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container },
|
||||
});
|
||||
if (probe?.ok && Array.isArray(probe.availableModes)) {
|
||||
this._dockerCaseModes[name] = probe.availableModes;
|
||||
delete this._dockerCaseProbeError?.[name];
|
||||
} else {
|
||||
// An ADOPTED container that cannot be probed — recreated, stopped, engine
|
||||
// down — must NOT fall through to "show everything". Offering claude on a
|
||||
// container that is not running is a click that can only fail, with the
|
||||
// reason visible nowhere. Record the reason and say it in the menu.
|
||||
//
|
||||
// ⚠️ An OWNED container gets no error: it does not exist until the first
|
||||
// session launches it, so "not found" is the expected answer for every
|
||||
// newly linked Docker case, and gating on it made those cases unusable.
|
||||
// Leaving the cache empty reads as "unknown", which does not gate.
|
||||
if (activeCase?.docker?.owned === false) {
|
||||
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
|
||||
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
|
||||
}
|
||||
delete this._dockerCaseModes[name];
|
||||
}
|
||||
// Only repaint while the menu the user opened is still on screen.
|
||||
if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu);
|
||||
} finally {
|
||||
delete this._dockerModeProbeInFlight[name];
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
const container = document.getElementById('runModeHistory');
|
||||
if (!container) return;
|
||||
@@ -2387,6 +2499,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
||||
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
||||
});
|
||||
// Adopt-an-existing-container toggle + its read-only preflight. Assigned (not
|
||||
// addEventListener) so reopening the modal cannot stack duplicate handlers,
|
||||
// matching the rail wiring right above.
|
||||
const adoptToggle = document.getElementById('dockerAdoptExisting');
|
||||
if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode();
|
||||
const adoptCheck = document.getElementById('dockerAdoptCheckBtn');
|
||||
if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight();
|
||||
const adoptJump = document.getElementById('dockerAdoptJumpBtn');
|
||||
if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt();
|
||||
// Containers come from the host profile, so switching Host ID invalidates the
|
||||
// suggestions. Dropping the marker (rather than refetching here) keeps the
|
||||
// fetch lazy — it happens when adopt mode is actually on.
|
||||
const hostIdInput = document.getElementById('dockerHostId');
|
||||
if (hostIdInput) {
|
||||
hostIdInput.onchange = () => {
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions();
|
||||
};
|
||||
}
|
||||
// A fresh open re-reads the engine: containers start and stop between visits.
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
this._syncDockerAdoptMode();
|
||||
// Scroll-into-view on focus for mobile keyboard visibility
|
||||
modal.querySelectorAll('input[type="text"]').forEach(input => {
|
||||
if (!input._mobileScrollWired) {
|
||||
@@ -2416,15 +2550,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A switched-to panel starts at its own top.
|
||||
const doc = document.getElementById('createCaseDoc');
|
||||
if (doc) doc.scrollTop = 0;
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
// Update submit buttons (hide for manage tab). Two of them: mobile.css hides
|
||||
// this modal's .set-foot, so phones submit through the header button instead.
|
||||
const submitBtns = ['caseModalSubmit', 'caseModalSubmitMobile']
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
this.renderCaseManageList();
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent =
|
||||
const label =
|
||||
tabName === 'case-create'
|
||||
? 'Create'
|
||||
: tabName === 'case-clone'
|
||||
@@ -2434,6 +2572,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
: tabName === 'case-docker'
|
||||
? 'Link Docker'
|
||||
: 'Link';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = '';
|
||||
btn.textContent = label;
|
||||
});
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
@@ -2454,14 +2596,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async submitCaseModal() {
|
||||
const btn = document.getElementById('caseModalSubmit');
|
||||
const originalText = btn.textContent;
|
||||
btn.classList.add('loading');
|
||||
btn.textContent =
|
||||
// Both submit buttons move together: whichever one the user pressed, the
|
||||
// other must show the same pending state and be equally unclickable.
|
||||
const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean);
|
||||
const originalText = btns.map((btn) => btn.textContent);
|
||||
const pendingText =
|
||||
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
|
||||
// A clone holds this request open for minutes; without disabling the button a
|
||||
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
|
||||
btn.disabled = true;
|
||||
btns.forEach((btn) => {
|
||||
btn.classList.add('loading');
|
||||
btn.textContent = pendingText;
|
||||
btn.disabled = true;
|
||||
});
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
@@ -2475,9 +2622,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.linkCase();
|
||||
}
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
btns.forEach((btn, index) => {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText[index];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2862,6 +3011,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/** HOST workspace directory — the same picker Link Existing uses. */
|
||||
openDockerWorkspacePathPicker() {
|
||||
const pathInput = document.getElementById('dockerWorkspacePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Host Workspace Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('dockerCaseName');
|
||||
if (nameInput && !nameInput.value.trim()) {
|
||||
const folder = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Container workdir. Browses INSIDE the container, because for an adopted
|
||||
* container nothing is mounted at a matching host path — the host picker would
|
||||
* be listing a different filesystem, and typing this field blind is exactly
|
||||
* what makes the launch fail with an OCI chdir error.
|
||||
*/
|
||||
openDockerWorkdirPicker() {
|
||||
const pathInput = document.getElementById('dockerAdoptWorkdir');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (!container) {
|
||||
this.showToast('Enter the container name first', 'error');
|
||||
return;
|
||||
}
|
||||
PathPicker.open({
|
||||
title: `Select Folder Inside ${container}`,
|
||||
initialPath: pathInput.value.trim() || '/',
|
||||
directoriesOnly: true,
|
||||
fetchListing: async (path) => {
|
||||
const data = await this._apiJson('/api/docker-cases/browse', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, path: path || '/' },
|
||||
});
|
||||
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
|
||||
if (data.error) return { success: false, error: data.error };
|
||||
// Shape it like the host endpoint: one root, so Up/Location behave.
|
||||
return {
|
||||
success: true,
|
||||
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
|
||||
};
|
||||
},
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
@@ -2943,10 +3147,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reflect the "attach to an existing container" checkbox onto the modal so CSS
|
||||
* can swap which half of the Docker panel applies. An attribute rather than
|
||||
* per-row inline styles: the panel is rebuilt by nothing, but the create-time
|
||||
* rows are a SET (image, network, advanced block) and one attribute keeps them
|
||||
* in lockstep with the container-name row.
|
||||
*/
|
||||
_syncDockerAdoptMode() {
|
||||
const modal = document.getElementById('createCaseModal');
|
||||
if (!modal) return;
|
||||
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
||||
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
||||
else modal.removeAttribute('data-docker-adopt');
|
||||
if (adopting) void this._loadDockerContainerOptions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill the container-name `<datalist>`. A native datalist is deliberate: the
|
||||
* field must accept a free-typed name (the engine may be remote, or the
|
||||
* container may not exist yet when the form is filled), and datalist gives
|
||||
* type-to-filter over the suggestions without a custom dropdown.
|
||||
*
|
||||
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
||||
* and an empty list simply leaves the field as plain text input.
|
||||
*/
|
||||
async _loadDockerContainerOptions() {
|
||||
const list = document.getElementById('dockerContainerList');
|
||||
if (!list) return;
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (list.dataset.loadedFor === hostId) return; // one fetch per host per open
|
||||
const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`);
|
||||
const containers = data?.containers || [];
|
||||
list.textContent = '';
|
||||
for (const c of containers) {
|
||||
const option = document.createElement('option');
|
||||
option.value = c.name;
|
||||
// Engine-supplied strings: set as text, never as markup.
|
||||
option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`;
|
||||
list.appendChild(option);
|
||||
}
|
||||
list.dataset.loadedFor = hostId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-link from the Create New tab's "Run in an isolated Docker container"
|
||||
* row. Adoption lives on the Docker tab, but the place users actually look for
|
||||
* anything container-shaped is that checkbox, so this jumps them there with the
|
||||
* toggle already on rather than leaving the feature undiscoverable.
|
||||
*/
|
||||
jumpToDockerAdopt() {
|
||||
this.switchCaseModalTab('case-docker');
|
||||
const toggle = document.getElementById('dockerAdoptExisting');
|
||||
if (toggle) toggle.checked = true;
|
||||
this._syncDockerAdoptMode();
|
||||
document.getElementById('dockerContainerName')?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read-only preflight against an existing container. It links nothing, so the
|
||||
* user can find out "not running" / "no tmux" / "codex present, claude missing"
|
||||
* before committing to a case name — the same reason the server refuses at link
|
||||
* time rather than at session launch.
|
||||
*/
|
||||
async _dockerAdoptPreflight() {
|
||||
const statusEl = document.getElementById('dockerLinkStatus');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
if (!container) {
|
||||
if (statusEl) statusEl.textContent = 'Enter a container name first.';
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Inspecting container...';
|
||||
// _apiJson folds every failure to null, and a preflight's whole value is the
|
||||
// reason it failed, so the envelope is unwrapped by hand here.
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) },
|
||||
});
|
||||
if (!statusEl) return;
|
||||
if (!probe) {
|
||||
statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.';
|
||||
return;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
statusEl.textContent = probe.error || 'Container is not adoptable.';
|
||||
return;
|
||||
}
|
||||
const modes = (probe.availableModes || []).filter((m) => m !== 'shell');
|
||||
statusEl.textContent = modes.length
|
||||
? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.`
|
||||
: `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`;
|
||||
},
|
||||
|
||||
async linkDockerCase() {
|
||||
const name = document.getElementById('dockerCaseName').value.trim();
|
||||
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
const adopting = !!document.getElementById('dockerAdoptExisting')?.checked;
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim() || '';
|
||||
const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || '';
|
||||
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
||||
const network = document.getElementById('dockerNetwork').value;
|
||||
const memory = document.getElementById('dockerMemory').value.trim();
|
||||
@@ -2967,9 +3268,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Workspace path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
if (adopting && !container) {
|
||||
this.showToast('Enter the name of the running container to attach to', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...';
|
||||
}
|
||||
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
||||
const resources = {};
|
||||
if (memory) resources.memory = memory;
|
||||
@@ -3000,16 +3307,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
||||
|
||||
const caseRes = await fetch('/api/cases/docker-link', {
|
||||
// Adoption reuses this whole flow and differs only in the final call: a
|
||||
// different endpoint (which never creates a container) plus the container
|
||||
// name. The host upsert above still applies — it is what resolves the
|
||||
// engine/context/daemon for the `docker exec`; its create-time fields are
|
||||
// simply never read for an adopted case.
|
||||
const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
||||
body: JSON.stringify(
|
||||
adopting
|
||||
? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) }
|
||||
: { name, hostId, hostWorkspacePath }
|
||||
),
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
||||
const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell');
|
||||
const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : '';
|
||||
this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
@@ -3046,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user