codebay 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mochi/manifest.json +216 -149
- package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +2 -0
- package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js → _hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js} +1 -1
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +38 -0
- package/.mochi/svelte-client/{chunk-2j95sv83.js → chunk-0fjx3z3r.js} +1 -1
- package/.mochi/svelte-client/chunk-8xvr02wh.js +2 -0
- package/.mochi/svelte-client/{chunk-m497q88s.js → chunk-9143wy3j.js} +2 -2
- package/.mochi/svelte-client/{chunk-v65n8th6.js → chunk-bn731rda.js} +1 -1
- package/.mochi/svelte-client/{chunk-a2psjmrm.js → chunk-j72r8pkk.js} +2 -2
- package/.mochi/svelte-client/chunk-k37wj1jm.js +2 -0
- package/.mochi/svelte-client/{chunk-mpv3qx28.js → chunk-p20tkpde.js} +1 -1
- package/.mochi/svelte-client/chunk-pc0fm37c.js +2 -0
- package/.mochi/svelte-client/{chunk-6rbs0mhx.js → chunk-t0ap7q89.js} +1 -1
- package/.mochi/svelte-client/{chunk-rs41by6w.js → chunk-wvtkwfsh.js} +1 -1
- package/.mochi/svelte-compile/{App-7vjcp38v.server.js → App-59cdj86f.server.js} +167 -103
- package/.mochi/svelte-compile/{Avatars-e2cfc95t.server.js → Avatars-fmagfyj2.server.js} +1 -1
- package/.mochi/svelte-compile/{Instance-h78n2dsv.server.js → Instance-s1gqwzmx.server.js} +16 -10
- package/.mochi/svelte-compile/{Settings-tav2asn7.server.js → Settings-k447mt0x.server.js} +540 -88
- package/.mochi/svelte-compile/{chunk-brjv9pyd.js → chunk-3t7v4hk0.js} +1 -7
- package/.mochi/svelte-compile/chunk-gkjgybb2.js +15 -0
- package/.mochi/svelte-compile/{chunk-m8ygpyhr.js → chunk-th2dbqvc.js} +20 -32
- package/.mochi/svelte-css/Button-oharop2xz6am.css +1 -0
- package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +1 -0
- package/.mochi/svelte-css/{InstanceView-3vzow3ttxzlh3.css → InstanceView-1pd7j16bd2b16.css} +1 -1
- package/.mochi/svelte-css/SettingsView-n3oy75uonzp2.css +1 -0
- package/.mochi/svelte-css/{TopBar-2q87li6yt8ids.css → TopBar-2y76zxwehrfos.css} +1 -1
- package/migrations/0007_instance_avatar.sql +1 -0
- package/package.json +2 -2
- package/src/avatars/diamond.ts +5 -6
- package/src/avatars/index.ts +1 -16
- package/src/avatars/pick.server.ts +25 -0
- package/src/avatars/whale.ts +7 -8
- package/src/components/AppShell.svelte +29 -4
- package/src/components/Avatar.svelte +8 -8
- package/src/components/Button.svelte +22 -1
- package/src/components/DashboardView.svelte +14 -2
- package/src/components/FolderBrowser.svelte +6 -2
- package/src/components/IdeBar.svelte +56 -13
- package/src/components/IdeLoader.svelte +15 -3
- package/src/components/InstanceCard.svelte +6 -1
- package/src/components/InstanceView.svelte +33 -15
- package/src/components/SettingsView.svelte +548 -1
- package/src/components/TopBar.svelte +37 -10
- package/src/components/UiShowcase.svelte +5 -10
- package/src/container-injections/claude-aliases.ts +1 -1
- package/src/container-injections/claude-code-custom.ts +2 -1
- package/src/container-injections/claude-code-ide-extension.ts +5 -0
- package/src/container-injections/claude-code-install.ts +67 -0
- package/src/container-injections/claude-code-models.ts +2 -1
- package/src/container-injections/claude-code-update.ts +62 -1
- package/src/container-injections/claude-effort-level.ts +32 -0
- package/src/container-injections/claude-permission-mode.ts +40 -0
- package/src/container-injections/custom-env-vars.ts +82 -0
- package/src/container-injections/host-env-vars.ts +2 -1
- package/src/lib/config.server.ts +3 -0
- package/src/lib/db.server.ts +8 -3
- package/src/lib/devcontainer.server.ts +182 -47
- package/src/lib/health.server.ts +55 -11
- package/src/lib/injections.server.ts +46 -36
- package/src/lib/instances.server.ts +185 -19
- package/src/lib/log-capture.server.ts +262 -0
- package/src/lib/ports.server.ts +31 -0
- package/src/lib/proxy.server.ts +21 -76
- package/src/pages/Settings.svelte +22 -1
- package/src/routes.ts +98 -2
- package/src/types.ts +36 -0
- package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
- package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
- package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
- package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
- package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
- package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
- package/src/container-injections/claude-skip-permissions.ts +0 -28
|
@@ -41,17 +41,32 @@ import {
|
|
|
41
41
|
devcontainerCliAvailable,
|
|
42
42
|
devcontainerUp,
|
|
43
43
|
INJECTIONS_DONE_FILE,
|
|
44
|
+
launchCommandFor,
|
|
44
45
|
readDeclaredContainerPorts,
|
|
46
|
+
TERMINAL_LAUNCHED_MARKER,
|
|
45
47
|
writeOverrideConfig
|
|
46
48
|
} from './devcontainer.server.ts';
|
|
47
49
|
import { writeContainerFile } from './container-files.server.ts';
|
|
50
|
+
import { execInContainer } from './exec.server.ts';
|
|
48
51
|
import { clearAttention, getAttention } from './bridge.server.ts';
|
|
49
52
|
import { proxyPathFor } from './proxy.server.ts';
|
|
50
|
-
import {
|
|
53
|
+
import { resolveInjectionStages } from './injections.server.ts';
|
|
51
54
|
import { cloneRepo, readGitBranch } from './git.server.ts';
|
|
52
55
|
import { isRepoUrl, parseRepoUrl } from './repo-url.ts';
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
56
|
+
import { getClaudePermissionMode } from '../container-injections/claude-permission-mode.ts';
|
|
57
|
+
import {
|
|
58
|
+
customEnvVarsConfig,
|
|
59
|
+
customEnvVarValues
|
|
60
|
+
} from '../container-injections/custom-env-vars.ts';
|
|
61
|
+
import {
|
|
62
|
+
currentHealthSnapshots,
|
|
63
|
+
stopHealthMonitor,
|
|
64
|
+
surfaceAccessible,
|
|
65
|
+
syncHealthMonitors
|
|
66
|
+
} from './health.server.ts';
|
|
67
|
+
import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
|
|
68
|
+
import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
|
|
69
|
+
import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
|
|
55
70
|
import type { ServerWebSocket } from 'bun';
|
|
56
71
|
import {
|
|
57
72
|
isInstanceFilter,
|
|
@@ -61,13 +76,17 @@ import {
|
|
|
61
76
|
type InstanceHealth
|
|
62
77
|
} from '../types.ts';
|
|
63
78
|
|
|
79
|
+
/** The sprite a row shows: its persisted choice, or the id hash for rows created before avatars were stored. */
|
|
80
|
+
const effectiveAvatarName = (row: InstanceRow): string => row.avatar ?? pickAvatar(row.id).name;
|
|
81
|
+
|
|
64
82
|
/**
|
|
65
|
-
* `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it
|
|
66
|
-
*
|
|
83
|
+
* `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it must never reach the
|
|
84
|
+
* browser — every row handed to a route goes through here. Also resolves the avatar name so the
|
|
85
|
+
* client renders by name (`findAvatar`) and never needs a hash function of its own.
|
|
67
86
|
*/
|
|
68
87
|
export function sanitizeInstance(row: InstanceRow): Omit<InstanceRow, 'bridge_token'> {
|
|
69
88
|
const { bridge_token: _token, ...rest } = row;
|
|
70
|
-
return rest;
|
|
89
|
+
return { ...rest, avatar: effectiveAvatarName(row) };
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
interface LiveState {
|
|
@@ -87,7 +106,36 @@ function live(id: string): LiveState {
|
|
|
87
106
|
return state;
|
|
88
107
|
}
|
|
89
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Cached so the redaction on every log chunk doesn't re-read the DB. Rebuilt lazily after the
|
|
111
|
+
* settings action invalidates it; values shorter than 4 chars are skipped so a trivially short
|
|
112
|
+
* secret can't blank out unrelated log text, and longest-first avoids a short value masking a
|
|
113
|
+
* substring of a longer one.
|
|
114
|
+
*/
|
|
115
|
+
let secretValuesCache: string[] | null = null;
|
|
116
|
+
|
|
117
|
+
function getSecretValues(): string[] {
|
|
118
|
+
if (secretValuesCache === null) {
|
|
119
|
+
secretValuesCache = customEnvVarValues()
|
|
120
|
+
.filter((v) => v.length >= 4)
|
|
121
|
+
.sort((a, b) => b.length - a.length);
|
|
122
|
+
}
|
|
123
|
+
return secretValuesCache;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Call after the custom-env-vars setting changes so newly-added values redact from the next boot. */
|
|
127
|
+
export function invalidateSecretValues(): void {
|
|
128
|
+
secretValuesCache = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Exported for tests; the boot flow reaches it only through `appendLog`. */
|
|
132
|
+
export function redactSecrets(text: string): string {
|
|
133
|
+
for (const value of getSecretValues()) text = text.split(value).join('••••');
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
|
|
90
137
|
function appendLog(id: string, chunk: string): void {
|
|
138
|
+
chunk = redactSecrets(chunk);
|
|
91
139
|
const state = live(id);
|
|
92
140
|
state.logs.push(chunk);
|
|
93
141
|
if (state.logs.length > 2000) state.logs.splice(0, state.logs.length - 2000);
|
|
@@ -255,7 +303,10 @@ async function allocatePort(): Promise<number> {
|
|
|
255
303
|
for (const [port, reservedAt] of reservedPorts) {
|
|
256
304
|
if (dbPorts.has(port) || now - reservedAt > RESERVATION_TTL_MS) reservedPorts.delete(port);
|
|
257
305
|
}
|
|
258
|
-
const port =
|
|
306
|
+
const port = await pickBindablePort(
|
|
307
|
+
[dbPorts, dockerPorts, new Set(reservedPorts.keys())],
|
|
308
|
+
isHostPortBindable
|
|
309
|
+
);
|
|
259
310
|
reservedPorts.set(port, now);
|
|
260
311
|
return port;
|
|
261
312
|
}
|
|
@@ -270,6 +321,10 @@ async function assertDir(path: string): Promise<void> {
|
|
|
270
321
|
if (!info.isDirectory()) throw new Error(`Not a folder: ${path}`);
|
|
271
322
|
}
|
|
272
323
|
|
|
324
|
+
function elapsed(startMs: number): string {
|
|
325
|
+
return `${((Date.now() - startMs) / 1000).toFixed(1)}s`;
|
|
326
|
+
}
|
|
327
|
+
|
|
273
328
|
function failInstance(id: string, err: unknown): void {
|
|
274
329
|
const message = (err as Error).message;
|
|
275
330
|
updateInstance(id, { status: 'error', error: message });
|
|
@@ -279,15 +334,18 @@ function failInstance(id: string, err: unknown): void {
|
|
|
279
334
|
|
|
280
335
|
async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<void> {
|
|
281
336
|
try {
|
|
337
|
+
const sourceStart = Date.now();
|
|
282
338
|
if (isRepoUrl(row.source_path)) {
|
|
283
339
|
appendLog(row.id, `Cloning ${row.source_path} → ${row.workspace_path}\n`);
|
|
284
340
|
await cloneRepo(row.source_path, row.workspace_path, (chunk) => appendLog(row.id, chunk), {
|
|
285
341
|
branch: opts.branch
|
|
286
342
|
});
|
|
343
|
+
appendLog(row.id, `⏱ clone: ${elapsed(sourceStart)}\n`);
|
|
287
344
|
} else {
|
|
288
345
|
appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
|
|
289
346
|
const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
|
|
290
347
|
await copyWorkspace(row.source_path, row.workspace_path, ignore);
|
|
348
|
+
appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
|
|
291
349
|
}
|
|
292
350
|
await seedDeclaredPorts(row);
|
|
293
351
|
} catch (err) {
|
|
@@ -314,22 +372,52 @@ async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
|
|
|
314
372
|
}
|
|
315
373
|
}
|
|
316
374
|
|
|
375
|
+
const surfaceLabel = (mode: InstanceMode) =>
|
|
376
|
+
mode === 'terminal' ? 'ttyd terminal' : 'code-server';
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Moves an instance off a host port that something outside Docker has taken over, which otherwise
|
|
380
|
+
* strands it forever — the container publishes fine but nothing reaches it from the host. Requires
|
|
381
|
+
* the port to be *both* unbindable and dead, because a rebuild doesn't stop the old container
|
|
382
|
+
* first: a healthy instance's port is always unbindable here, and its surface still answers.
|
|
383
|
+
*/
|
|
384
|
+
async function rescueHijackedPort(row: InstanceRow): Promise<void> {
|
|
385
|
+
if (await isHostPortBindable(row.host_port)) return;
|
|
386
|
+
if (await surfaceAccessible(row.host_port)) return;
|
|
387
|
+
const stale = row.host_port;
|
|
388
|
+
const port = await allocatePort();
|
|
389
|
+
updateInstance(row.id, { host_port: port });
|
|
390
|
+
row.host_port = port;
|
|
391
|
+
appendLog(
|
|
392
|
+
row.id,
|
|
393
|
+
`⚠ Host port ${stale} is held by another process and isn't reachable — moving to ${port}\n`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
317
397
|
/** Never re-copies the workspace, so in-container edits survive a rebuild. */
|
|
318
398
|
async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Promise<void> {
|
|
319
399
|
try {
|
|
400
|
+
// On a rebuild `devcontainer up --remove-existing-container` discards the old container's
|
|
401
|
+
// ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
|
|
402
|
+
if (row.container_id) {
|
|
403
|
+
stopLogCapture(row.id);
|
|
404
|
+
await runCapturePass(row).catch(() => undefined);
|
|
405
|
+
}
|
|
406
|
+
await rescueHijackedPort(row);
|
|
320
407
|
const forwards = listForwards(row.id).map((f) => ({
|
|
321
408
|
container_port: f.container_port,
|
|
322
409
|
host_port: f.host_port
|
|
323
410
|
}));
|
|
324
|
-
|
|
325
|
-
appendLog(row.id, `Injecting ${surface} (host port ${row.host_port})\n`);
|
|
411
|
+
appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
|
|
326
412
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
327
413
|
const { imageSource } = await writeOverrideConfig(
|
|
328
414
|
row.workspace_path,
|
|
329
415
|
row.host_port,
|
|
330
416
|
forwards,
|
|
331
417
|
defaultImage,
|
|
332
|
-
row.mode
|
|
418
|
+
row.mode,
|
|
419
|
+
getClaudePermissionMode(),
|
|
420
|
+
customEnvVarsConfig()?.vars ?? []
|
|
333
421
|
);
|
|
334
422
|
updateInstance(row.id, { image_source: imageSource });
|
|
335
423
|
|
|
@@ -337,9 +425,11 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
337
425
|
if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
|
|
338
426
|
|
|
339
427
|
appendLog(row.id, `Starting devcontainer…\n`);
|
|
428
|
+
const upStart = Date.now();
|
|
340
429
|
const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
|
|
341
430
|
noCache
|
|
342
431
|
});
|
|
432
|
+
appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
|
|
343
433
|
|
|
344
434
|
if (result.outcome !== 'success' || !result.containerId) {
|
|
345
435
|
throw new Error(
|
|
@@ -362,25 +452,41 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
362
452
|
row.remote_user = result.remoteUser ?? null;
|
|
363
453
|
row.status = 'running';
|
|
364
454
|
|
|
455
|
+
// Start the health monitor now so the IDE mounts as soon as code-server answers,
|
|
456
|
+
// instead of after every injection plus a full reconcile/health poll cycle.
|
|
457
|
+
triggerReconcile();
|
|
458
|
+
|
|
365
459
|
const target = {
|
|
366
460
|
containerId: result.containerId,
|
|
367
461
|
remoteUser: result.remoteUser,
|
|
368
462
|
instance: row
|
|
369
463
|
};
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
464
|
+
// Stages parallelize independent injections; per-injection failures are still swallowed
|
|
465
|
+
// so one bad injection can't abort the rest of provisioning.
|
|
466
|
+
const injectionsStart = Date.now();
|
|
467
|
+
for (const stage of resolveInjectionStages(row.mode)) {
|
|
468
|
+
await Promise.all(
|
|
469
|
+
stage.map(async (injection) => {
|
|
470
|
+
try {
|
|
471
|
+
await injection.apply(target, (msg) => appendLog(row.id, msg));
|
|
472
|
+
} catch (err) {
|
|
473
|
+
appendLog(row.id, `⚠ ${injection.label} injection failed: ${(err as Error).message}\n`);
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
);
|
|
377
477
|
}
|
|
478
|
+
appendLog(row.id, `⏱ injections: ${elapsed(injectionsStart)}\n`);
|
|
378
479
|
|
|
379
480
|
// Unblocks the terminal launcher's wait; written even after ⚠s — its timeout is the fallback.
|
|
380
481
|
await writeContainerFile(target, { dir: '$HOME', name: INJECTIONS_DONE_FILE }, 'done\n').catch(
|
|
381
482
|
() => undefined
|
|
382
483
|
);
|
|
383
484
|
|
|
485
|
+
// postStartCommand ran before the injections above, and its launcher bails when the binary
|
|
486
|
+
// is missing — so a container whose build-time ttyd install failed got it too late to be
|
|
487
|
+
// started. No-ops (one accessibility probe) whenever postStart already brought the port up.
|
|
488
|
+
await relaunchSurface(row);
|
|
489
|
+
|
|
384
490
|
appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
|
|
385
491
|
triggerReconcile();
|
|
386
492
|
} catch (err) {
|
|
@@ -437,6 +543,8 @@ export async function createInstance(
|
|
|
437
543
|
bridge_token: crypto.randomUUID().replace(/-/g, ''),
|
|
438
544
|
remote_user: null,
|
|
439
545
|
image_source: null,
|
|
546
|
+
// Kept synchronous through insertInstance so two concurrent creations can't claim the same free sprite.
|
|
547
|
+
avatar: pickUniqueAvatar(id, allInstances().map(effectiveAvatarName)).name,
|
|
440
548
|
mode: opts.mode ?? getDefaultMode(),
|
|
441
549
|
terminal_split: 0
|
|
442
550
|
};
|
|
@@ -468,6 +576,7 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
468
576
|
);
|
|
469
577
|
// Reconcile is the only place that knows the current running set.
|
|
470
578
|
syncHealthMonitors(rows);
|
|
579
|
+
syncLogCapture(rows);
|
|
471
580
|
// Distinguishes a forward that's live from one still pending a rebuild.
|
|
472
581
|
const openPorts = new Map<string, Set<number>>();
|
|
473
582
|
for (const { id, health } of currentHealthSnapshots()) {
|
|
@@ -488,6 +597,9 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
488
597
|
}
|
|
489
598
|
return rows.map((row) => ({
|
|
490
599
|
...sanitizeInstance(row),
|
|
600
|
+
// Resolve legacy null rows to their hashed sprite here so the client only sees an
|
|
601
|
+
// unresolvable name when a persisted sprite was actually removed from the catalog.
|
|
602
|
+
avatar: effectiveAvatarName(row),
|
|
491
603
|
git_branch: branches.get(row.id) ?? null,
|
|
492
604
|
attention: getAttention(row.id),
|
|
493
605
|
forwarded_ports: forwards.get(row.id) ?? []
|
|
@@ -524,6 +636,10 @@ export async function addForwardedPort(id: string, containerPort: number): Promi
|
|
|
524
636
|
if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
|
|
525
637
|
throw new Error('Port must be an integer between 1 and 65535');
|
|
526
638
|
}
|
|
639
|
+
// Forwards publish on PUBLISH_HOST (0.0.0.0 under HOST=0.0.0.0), so forwarding the port the
|
|
640
|
+
// instance is actually served on would republish code-server/ttyd — neither of which has auth
|
|
641
|
+
// of its own — outside the Basic-Auth-gated proxy. Scoped to the live surface on purpose: in
|
|
642
|
+
// terminal mode nothing listens on 8080, and it's far too common an app port to reserve.
|
|
527
643
|
const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
|
|
528
644
|
if (containerPort === reserved) {
|
|
529
645
|
throw new Error(
|
|
@@ -575,11 +691,51 @@ export function rebuildRunningInstancesNoCache(): number {
|
|
|
575
691
|
return running.length;
|
|
576
692
|
}
|
|
577
693
|
|
|
694
|
+
/** Single-quote a path for the shell, so a workspace folder with spaces survives the `cd`. */
|
|
695
|
+
function quote(value: string): string {
|
|
696
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `postStartCommand` is a devcontainer-CLI lifecycle hook, not a container entrypoint, so a plain
|
|
701
|
+
* `docker start` never re-runs it and the ttyd daemon it backgrounded is gone. IDE mode only
|
|
702
|
+
* survives because the code-server feature ships an entrypoint of its own; ttyd has no equivalent.
|
|
703
|
+
* Both launchers are guarded (ttyd by process name, code-server by its port), so re-running one
|
|
704
|
+
* against a healthy container is a no-op.
|
|
705
|
+
*/
|
|
706
|
+
export async function relaunchSurface(row: InstanceRow): Promise<void> {
|
|
707
|
+
if (!row.container_id) return;
|
|
708
|
+
|
|
709
|
+
const steps: string[] = [];
|
|
710
|
+
// The folderOpen task's run-once gate is meant to span one container run, but the marker file
|
|
711
|
+
// outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
|
|
712
|
+
if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
|
|
713
|
+
// code-server comes back on its own (its feature ships a container entrypoint), so only launch
|
|
714
|
+
// a surface that is actually down rather than racing the entrypoint for the port.
|
|
715
|
+
if (!(await surfaceAccessible(row.host_port))) steps.push(launchCommandFor(row.mode));
|
|
716
|
+
if (!steps.length) return;
|
|
717
|
+
|
|
718
|
+
// Both launchers resolve paths off `$PWD`, which under postStartCommand is the workspace
|
|
719
|
+
// folder; an exec only inherits the image's WorkingDir, so re-establish it explicitly.
|
|
720
|
+
const cd = row.remote_workspace_folder
|
|
721
|
+
? `cd ${quote(row.remote_workspace_folder)} || exit 1; `
|
|
722
|
+
: '';
|
|
723
|
+
const res = await execInContainer(
|
|
724
|
+
{ containerId: row.container_id, remoteUser: row.remote_user },
|
|
725
|
+
{ script: cd + steps.join('; ') }
|
|
726
|
+
);
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
appendLog(row.id, `⚠ Could not relaunch ${surfaceLabel(row.mode)}: ${res.error}\n`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
578
732
|
export async function startInstance(id: string): Promise<InstanceRow> {
|
|
579
733
|
const row = getInstance(id);
|
|
580
734
|
if (!row) throw new Error('Instance not found');
|
|
581
735
|
if (!row.container_id) throw new Error('Instance has no container yet');
|
|
582
736
|
const ok = await startContainer(row.container_id);
|
|
737
|
+
// Awaited so the first health tick after the reconcile below already sees a listening port.
|
|
738
|
+
if (ok) await relaunchSurface(row);
|
|
583
739
|
updateInstance(id, {
|
|
584
740
|
status: ok ? 'running' : 'error',
|
|
585
741
|
error: ok ? null : 'Failed to start container'
|
|
@@ -591,6 +747,9 @@ export async function startInstance(id: string): Promise<InstanceRow> {
|
|
|
591
747
|
export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
592
748
|
const row = getInstance(id);
|
|
593
749
|
if (!row) throw new Error('Instance not found');
|
|
750
|
+
// A later delete can't exec into a stopped container, so this is the last chance to mirror.
|
|
751
|
+
stopLogCapture(id);
|
|
752
|
+
if (row.container_id) await runCapturePass(row).catch(() => undefined);
|
|
594
753
|
const ok = row.container_id ? await stopContainer(row.container_id) : true;
|
|
595
754
|
updateInstance(id, {
|
|
596
755
|
status: ok ? 'stopped' : 'error',
|
|
@@ -604,12 +763,19 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
|
604
763
|
export async function deleteInstance(id: string): Promise<void> {
|
|
605
764
|
const row = getInstance(id);
|
|
606
765
|
if (!row) return;
|
|
607
|
-
|
|
766
|
+
stopHealthMonitor(id);
|
|
767
|
+
// Mirror the tail of the session before the transcripts die with the container; the periodic
|
|
768
|
+
// chain is stopped first so it can't race this last pass. Retention outlives the instance —
|
|
769
|
+
// <LOGS_DIR>/*-<id>.jsonl is deliberately left on disk.
|
|
770
|
+
stopLogCapture(id);
|
|
771
|
+
if (row.container_id) {
|
|
772
|
+
await runCapturePass(row).catch(() => undefined);
|
|
773
|
+
await removeContainer(row.container_id);
|
|
774
|
+
}
|
|
608
775
|
await rm(join(INSTANCES_DIR, id), { recursive: true, force: true });
|
|
609
776
|
deleteForwards(id);
|
|
610
777
|
deleteInstanceRow(id);
|
|
611
778
|
registry.delete(id);
|
|
612
|
-
stopHealthMonitor(id);
|
|
613
779
|
clearAttention(id);
|
|
614
780
|
triggerReconcile();
|
|
615
781
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { LOGS_DIR } from './config.server.ts';
|
|
12
|
+
import { getInstance, type InstanceRow } from './db.server.ts';
|
|
13
|
+
import { isRunning } from './docker.server.ts';
|
|
14
|
+
import { execInContainer } from './exec.server.ts';
|
|
15
|
+
|
|
16
|
+
/** Delta pulls are cheap, so a lazier cadence than the 5s health probe is plenty. */
|
|
17
|
+
const CAPTURE_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
|
|
20
|
+
const FETCH_MARKER = '__CODEBAY_FETCH__';
|
|
21
|
+
const FILE_MARKER = '__CODEBAY_FILE__';
|
|
22
|
+
const END_MARKER = '__CODEBAY_END__';
|
|
23
|
+
|
|
24
|
+
export interface ManifestEntry {
|
|
25
|
+
path: string;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FetchJob {
|
|
30
|
+
path: string;
|
|
31
|
+
hostFileName: string;
|
|
32
|
+
/** 1-indexed byte to `tail -c +N` from; 1 re-pulls the whole file. */
|
|
33
|
+
startByte: number;
|
|
34
|
+
mode: 'append' | 'rollover';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lists the transcript + history files with their byte sizes. `find`/`wc -c` (not GNU `-printf`)
|
|
39
|
+
* so it works on busybox containers too; the marker line lets the parser skip login-shell noise.
|
|
40
|
+
*/
|
|
41
|
+
function manifestScript(): string {
|
|
42
|
+
return (
|
|
43
|
+
`h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
|
|
44
|
+
`printf '%s\\n' '${MANIFEST_MARKER}'; ` +
|
|
45
|
+
`{ find "$cfg" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/null; ` +
|
|
46
|
+
`find "$cfg/projects" -type f -name '*.jsonl' 2>/dev/null; } | ` +
|
|
47
|
+
`while IFS= read -r f; do printf '%s\\t%s\\n' "$f" "$(wc -c < "$f" | tr -d ' ')"; done; true`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
|
|
52
|
+
function fetchScript(): string {
|
|
53
|
+
return (
|
|
54
|
+
`printf '%s\\n' '${FETCH_MARKER}'; ` +
|
|
55
|
+
`while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
|
|
56
|
+
`printf '${FILE_MARKER}\\t%s\\n' "$p"; ` +
|
|
57
|
+
`tail -c "+$o" "$p" 2>/dev/null | base64; ` +
|
|
58
|
+
`printf '\\n${END_MARKER}\\n'; done`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseManifest(stdout: string): ManifestEntry[] {
|
|
63
|
+
const at = stdout.indexOf(MANIFEST_MARKER);
|
|
64
|
+
if (at === -1) return [];
|
|
65
|
+
const out: ManifestEntry[] = [];
|
|
66
|
+
for (const line of stdout.slice(at + MANIFEST_MARKER.length).split('\n')) {
|
|
67
|
+
const tab = line.indexOf('\t');
|
|
68
|
+
if (tab === -1) continue;
|
|
69
|
+
const path = line.slice(0, tab);
|
|
70
|
+
const size = Number(line.slice(tab + 1).trim());
|
|
71
|
+
if (path && Number.isFinite(size)) out.push({ path, size });
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A `/projects/` path is a session transcript; the only other file we list is `history.jsonl`. */
|
|
77
|
+
export function hostFileNameFor(instanceId: string, containerPath: string): string {
|
|
78
|
+
const base = containerPath.split('/').pop() ?? containerPath;
|
|
79
|
+
if (containerPath.includes('/projects/')) {
|
|
80
|
+
return `transcript-${instanceId}-${base.replace(/\.jsonl$/, '')}.jsonl`;
|
|
81
|
+
}
|
|
82
|
+
return `history-${instanceId}.jsonl`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Diffs container sizes against the host mirrors: append new bytes, or re-pull a file that shrank. */
|
|
86
|
+
export function planFetch(
|
|
87
|
+
instanceId: string,
|
|
88
|
+
manifest: ManifestEntry[],
|
|
89
|
+
hostSize: (hostFileName: string) => number
|
|
90
|
+
): FetchJob[] {
|
|
91
|
+
const jobs: FetchJob[] = [];
|
|
92
|
+
for (const { path, size } of manifest) {
|
|
93
|
+
const hostFileName = hostFileNameFor(instanceId, path);
|
|
94
|
+
const existing = hostSize(hostFileName);
|
|
95
|
+
if (size === existing) continue;
|
|
96
|
+
if (size < existing) jobs.push({ path, hostFileName, startByte: 1, mode: 'rollover' });
|
|
97
|
+
else jobs.push({ path, hostFileName, startByte: existing + 1, mode: 'append' });
|
|
98
|
+
}
|
|
99
|
+
return jobs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseFetchBlocks(stdout: string): { path: string; base64: string }[] {
|
|
103
|
+
const at = stdout.indexOf(FETCH_MARKER);
|
|
104
|
+
if (at === -1) return [];
|
|
105
|
+
const blocks: { path: string; base64: string }[] = [];
|
|
106
|
+
for (const part of stdout
|
|
107
|
+
.slice(at + FETCH_MARKER.length)
|
|
108
|
+
.split(FILE_MARKER)
|
|
109
|
+
.slice(1)) {
|
|
110
|
+
const end = part.indexOf(END_MARKER);
|
|
111
|
+
const seg = end === -1 ? part : part.slice(0, end);
|
|
112
|
+
const nl = seg.indexOf('\n');
|
|
113
|
+
if (nl === -1) continue;
|
|
114
|
+
const path = seg.slice(0, nl).replace(/^\t/, '');
|
|
115
|
+
// base64 wrapping (GNU wraps at 76 cols) is stripped so multi-line blocks decode cleanly.
|
|
116
|
+
blocks.push({ path, base64: seg.slice(nl + 1).replace(/\s+/g, '') });
|
|
117
|
+
}
|
|
118
|
+
return blocks;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sizeOnDisk(path: string): number {
|
|
122
|
+
try {
|
|
123
|
+
return statSync(path).size;
|
|
124
|
+
} catch {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A rebuild recreates the container (`--remove-existing-container`), so its `~/.claude` starts empty
|
|
131
|
+
* and the next manifest reports a file smaller than our mirror. Archiving the mirror aside — rather
|
|
132
|
+
* than overwriting it with the new container's few hundred bytes — is what makes retention real.
|
|
133
|
+
*/
|
|
134
|
+
function rollOver(dest: string): void {
|
|
135
|
+
if (!existsSync(dest)) return;
|
|
136
|
+
const dot = dest.lastIndexOf('.');
|
|
137
|
+
const stem = dot === -1 ? dest : dest.slice(0, dot);
|
|
138
|
+
const ext = dot === -1 ? '' : dest.slice(dot);
|
|
139
|
+
for (let n = 1; ; n++) {
|
|
140
|
+
const archive = `${stem}.${n}${ext}`;
|
|
141
|
+
if (existsSync(archive)) continue;
|
|
142
|
+
renameSync(dest, archive);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Keeps `index.json` mapping the opaque UUID filenames back to a human-readable instance. */
|
|
148
|
+
function updateIndex(logsDir: string, row: InstanceRow): void {
|
|
149
|
+
const indexPath = join(logsDir, 'index.json');
|
|
150
|
+
let index: Record<string, unknown> = {};
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
153
|
+
if (parsed && typeof parsed === 'object') index = parsed as Record<string, unknown>;
|
|
154
|
+
} catch {
|
|
155
|
+
// Absent or corrupt — start fresh rather than lose this pass.
|
|
156
|
+
}
|
|
157
|
+
index[row.id] = {
|
|
158
|
+
name: row.name,
|
|
159
|
+
source_path: row.source_path,
|
|
160
|
+
created_at: row.created_at,
|
|
161
|
+
last_captured: Date.now()
|
|
162
|
+
};
|
|
163
|
+
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface CaptureDeps {
|
|
167
|
+
exec?: typeof execInContainer;
|
|
168
|
+
logsDir?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One extraction pass: manifest → plan deltas → fetch → append/roll over host mirrors → index.
|
|
173
|
+
* Resolves false when the container could not be read, which is how `tick` detects a dead chain.
|
|
174
|
+
*/
|
|
175
|
+
export async function runCapturePass(row: InstanceRow, deps: CaptureDeps = {}): Promise<boolean> {
|
|
176
|
+
if (!row.container_id) return false;
|
|
177
|
+
const exec = deps.exec ?? execInContainer;
|
|
178
|
+
const logsDir = deps.logsDir ?? LOGS_DIR;
|
|
179
|
+
const target = { containerId: row.container_id, remoteUser: row.remote_user };
|
|
180
|
+
|
|
181
|
+
const manifestRes = await exec(target, { script: manifestScript(), capture: true });
|
|
182
|
+
if (!manifestRes.ok) return false;
|
|
183
|
+
const manifest = parseManifest(manifestRes.stdout);
|
|
184
|
+
|
|
185
|
+
mkdirSync(logsDir, { recursive: true });
|
|
186
|
+
const jobs = planFetch(row.id, manifest, (name) => sizeOnDisk(join(logsDir, name)));
|
|
187
|
+
|
|
188
|
+
if (jobs.length) {
|
|
189
|
+
const args = ['fetch'];
|
|
190
|
+
for (const j of jobs) args.push(j.path, String(j.startByte));
|
|
191
|
+
const fetchRes = await exec(target, { script: fetchScript(), args, capture: true });
|
|
192
|
+
if (fetchRes.ok) {
|
|
193
|
+
const byPath = new Map(jobs.map((j) => [j.path, j]));
|
|
194
|
+
for (const block of parseFetchBlocks(fetchRes.stdout)) {
|
|
195
|
+
const job = byPath.get(block.path);
|
|
196
|
+
if (!job) continue;
|
|
197
|
+
const bytes = Buffer.from(block.base64, 'base64');
|
|
198
|
+
const dest = join(logsDir, job.hostFileName);
|
|
199
|
+
if (job.mode === 'rollover') rollOver(dest);
|
|
200
|
+
appendFileSync(dest, bytes);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
updateIndex(logsDir, row);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface Capture {
|
|
209
|
+
timer: ReturnType<typeof setTimeout>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Pinned to globalThis so dev-mode hot reload doesn't orphan the interval timers.
|
|
213
|
+
const globalForCapture = globalThis as unknown as { __codebayLogCapture?: Map<string, Capture> };
|
|
214
|
+
const captures: Map<string, Capture> = (globalForCapture.__codebayLogCapture ??= new Map());
|
|
215
|
+
|
|
216
|
+
async function tick(id: string, cap: Capture): Promise<void> {
|
|
217
|
+
// Re-read every tick: a rename has to reach index.json, and a stale container_id would exec
|
|
218
|
+
// against a container that no longer exists.
|
|
219
|
+
const row = getInstance(id);
|
|
220
|
+
// Best-effort: a failed pass (container just stopped, transient exec error) must not kill the loop.
|
|
221
|
+
const ok = row ? await runCapturePass(row).catch(() => false) : false;
|
|
222
|
+
// syncLogCapture would normally stop us, but it only runs from the reconcile loop, which is idle
|
|
223
|
+
// whenever no client is connected — so a container that died on its own has to end the chain here.
|
|
224
|
+
const alive =
|
|
225
|
+
ok || (!!row?.container_id && (await isRunning(row.container_id).catch(() => false)));
|
|
226
|
+
// Stopped or replaced while the pass was in flight — rescheduling would leak a second chain.
|
|
227
|
+
if (captures.get(id) !== cap) return;
|
|
228
|
+
if (!alive) {
|
|
229
|
+
stopLogCapture(id);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
cap.timer = setTimeout(() => void tick(id, cap), CAPTURE_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function startLogCapture(id: string): void {
|
|
236
|
+
if (captures.has(id)) return;
|
|
237
|
+
const cap: Capture = { timer: setTimeout(() => undefined, 0) };
|
|
238
|
+
captures.set(id, cap);
|
|
239
|
+
// Seed a pass now so a short-lived session is captured without waiting a full interval.
|
|
240
|
+
void tick(id, cap);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Stops the timer only — the mirrored logs are retained on disk past instance deletion. */
|
|
244
|
+
export function stopLogCapture(id: string): void {
|
|
245
|
+
const cap = captures.get(id);
|
|
246
|
+
if (!cap) return;
|
|
247
|
+
clearTimeout(cap.timer);
|
|
248
|
+
captures.delete(id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Driven from the reconcile loop, so capture tracks the container lifecycle automatically. */
|
|
252
|
+
export function syncLogCapture(rows: InstanceRow[]): void {
|
|
253
|
+
const running = new Set(
|
|
254
|
+
rows.filter((r) => r.status === 'running' && r.container_id).map((r) => r.id)
|
|
255
|
+
);
|
|
256
|
+
for (const row of rows) {
|
|
257
|
+
if (running.has(row.id) && !captures.has(row.id)) startLogCapture(row.id);
|
|
258
|
+
}
|
|
259
|
+
for (const id of [...captures.keys()]) {
|
|
260
|
+
if (!running.has(id)) stopLogCapture(id);
|
|
261
|
+
}
|
|
262
|
+
}
|
package/src/lib/ports.server.ts
CHANGED
|
@@ -7,3 +7,34 @@ export function pickFreePort(usedSets: ReadonlySet<number>[]): number {
|
|
|
7
7
|
}
|
|
8
8
|
throw new Error('No free host ports available.');
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A listener outside Docker's view is invisible to the DB/Docker union — and on a VM-backed daemon
|
|
13
|
+
* the collision never surfaces as an error, because the `-p` bind happens inside the VM where the
|
|
14
|
+
* port is free while the host-side forward fails (colima logs that as a warning). Claiming the port
|
|
15
|
+
* ourselves first is the only way the collision reaches us.
|
|
16
|
+
*/
|
|
17
|
+
export async function isHostPortBindable(port: number): Promise<boolean> {
|
|
18
|
+
try {
|
|
19
|
+
// Loopback specifically: it's where code-server is always published, and a wildcard squatter
|
|
20
|
+
// blocks this bind too, so the one probe covers both.
|
|
21
|
+
const server = Bun.listen({ hostname: '127.0.0.1', port, socket: { data() {} } });
|
|
22
|
+
server.stop(true);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Takes the probe as a parameter so the scan is testable without binding real sockets. */
|
|
30
|
+
export async function pickBindablePort(
|
|
31
|
+
usedSets: ReadonlySet<number>[],
|
|
32
|
+
isBindable: (port: number) => Promise<boolean>
|
|
33
|
+
): Promise<number> {
|
|
34
|
+
const rejected = new Set<number>();
|
|
35
|
+
for (;;) {
|
|
36
|
+
const port = pickFreePort([...usedSets, rejected]);
|
|
37
|
+
if (await isBindable(port)) return port;
|
|
38
|
+
rejected.add(port);
|
|
39
|
+
}
|
|
40
|
+
}
|