Skip to content

Commit ce2dc67

Browse files
committed
refactor(docker): track docker source and project build context
1 parent 363373b commit ce2dc67

11 files changed

Lines changed: 101 additions & 17 deletions

File tree

electron/ipc/pty.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ vi.mock('node-pty', () => ({
7777
}));
7878

7979
import {
80+
buildDockerImage,
8081
DOCKER_CONTAINER_HOME,
8182
dockerImageExists,
8283
hashDockerfile,
@@ -352,3 +353,25 @@ describe('dockerImageExists', () => {
352353
).resolves.toBe(false);
353354
});
354355
});
356+
357+
describe('buildDockerImage', () => {
358+
it('uses the provided build context for a project dockerfile', () => {
359+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-build-context-'));
360+
tempPaths.push(projectRoot);
361+
const dockerDir = path.join(projectRoot, '.parallel-code');
362+
fs.mkdirSync(dockerDir, { recursive: true });
363+
const dockerfilePath = path.join(dockerDir, 'Dockerfile');
364+
fs.writeFileSync(dockerfilePath, 'FROM node:20\n');
365+
366+
buildDockerImage(createMockWindow(), 'channel:build-test', {
367+
dockerfilePath,
368+
imageTag: 'parallel-code-project:test',
369+
buildContext: projectRoot,
370+
} as unknown as Parameters<typeof buildDockerImage>[2]);
371+
372+
const lastCall = mockChildProcessSpawn.mock.lastCall;
373+
expect(lastCall).toBeTruthy();
374+
const args = ((lastCall as unknown as [string, string[]])?.[1] ?? []) as string[];
375+
expect(args[args.length - 1]).toBe(projectRoot);
376+
});
377+
});

electron/ipc/pty.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -731,9 +731,9 @@ let activeBuild: Promise<{ ok: boolean; error?: string }> | null = null;
731731
export function buildDockerImage(
732732
win: BrowserWindow,
733733
onOutputChannel: string,
734-
opts?: { dockerfilePath?: string; imageTag?: string },
734+
opts?: { dockerfilePath?: string; buildContext?: string; imageTag?: string },
735735
): Promise<{ ok: boolean; error?: string }> {
736-
const isDefaultBuild = !opts?.dockerfilePath && !opts?.imageTag;
736+
const isDefaultBuild = !opts?.dockerfilePath && !opts?.buildContext && !opts?.imageTag;
737737

738738
// Only dedup when building the default image
739739
if (isDefaultBuild && activeBuild !== null) {
@@ -753,7 +753,7 @@ export function buildDockerImage(
753753
finish({ ok: false, error: 'Dockerfile not found' });
754754
return;
755755
}
756-
const dockerDir = path.dirname(resolvedDockerfilePath);
756+
const buildContext = opts?.buildContext ?? path.dirname(resolvedDockerfilePath);
757757
const hash = hashDockerfile(resolvedDockerfilePath) ?? 'unknown';
758758
const imageTag = opts?.imageTag ?? DOCKER_DEFAULT_IMAGE;
759759

@@ -773,7 +773,7 @@ export function buildDockerImage(
773773
`${DOCKERFILE_HASH_LABEL}=${hash}`,
774774
'-f',
775775
resolvedDockerfilePath,
776-
dockerDir,
776+
buildContext,
777777
],
778778
{
779779
stdio: ['ignore', 'pipe', 'pipe'],

electron/ipc/register.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ function getOptionalDockerfilePath(value: unknown): string | undefined {
9292
return value;
9393
}
9494

95+
function getOptionalBuildContext(value: unknown): string | undefined {
96+
assertOptionalString(value, 'buildContext');
97+
if (value !== undefined) validatePath(value, 'buildContext');
98+
return value;
99+
}
100+
95101
function getOptionalImageTag(value: unknown): string | undefined {
96102
assertOptionalString(value, 'imageTag');
97103
const imageTag = value?.trim();
@@ -199,11 +205,14 @@ export function registerAllHandlers(win: BrowserWindow): void {
199205
ipcMain.handle(IPC.BuildDockerImage, (_e, args) => {
200206
assertString(args.onOutputChannel, 'onOutputChannel');
201207
const dockerfilePath = getOptionalDockerfilePath(args.dockerfilePath);
208+
const buildContext = getOptionalBuildContext(args.buildContext);
202209
const imageTag = getOptionalImageTag(args.imageTag);
203210
return buildDockerImage(
204211
win,
205212
args.onOutputChannel,
206-
dockerfilePath || imageTag ? { dockerfilePath, imageTag } : undefined,
213+
dockerfilePath || buildContext || imageTag
214+
? { dockerfilePath, buildContext, imageTag }
215+
: undefined,
207216
);
208217
});
209218
ipcMain.handle(IPC.ResolveProjectDockerfile, (_e, args) => {
@@ -213,6 +222,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
213222
return {
214223
dockerfilePath,
215224
imageTag: projectImageTag(dockerfilePath),
225+
buildContext: args.projectRoot,
216226
};
217227
});
218228

src/components/NewTaskDialog.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
5858
const [projectDockerfile, setProjectDockerfile] = createSignal<{
5959
dockerfilePath: string;
6060
imageTag: string;
61+
buildContext: string;
6162
} | null>(null);
6263
const [branchPrefix, setBranchPrefix] = createSignal('');
6364
let promptRef!: HTMLTextAreaElement;
@@ -297,9 +298,10 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
297298
}
298299

299300
let cancelled = false;
300-
invoke<{ dockerfilePath: string; imageTag: string } | null>(IPC.ResolveProjectDockerfile, {
301-
projectRoot,
302-
}).then(
301+
invoke<{ dockerfilePath: string; imageTag: string; buildContext: string } | null>(
302+
IPC.ResolveProjectDockerfile,
303+
{ projectRoot },
304+
).then(
303305
(result) => {
304306
if (!cancelled) setProjectDockerfile(result);
305307
},
@@ -369,6 +371,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
369371
if (projDocker) {
370372
buildArgs.dockerfilePath = projDocker.dockerfilePath;
371373
buildArgs.imageTag = projDocker.imageTag;
374+
buildArgs.buildContext = projDocker.buildContext;
372375
}
373376
const result = await invoke<{ ok: boolean; error?: string }>(IPC.BuildDockerImage, buildArgs);
374377
if (result.ok) {
@@ -472,6 +475,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
472475
}
473476
}
474477

478+
const projDocker = projectDockerfile();
475479
const taskId = await createTask({
476480
name: n,
477481
agentDef: agent,
@@ -484,8 +488,15 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
484488
githubUrl: ghUrl,
485489
skipPermissions: agentSupportsSkipPermissions() && skipPermissions(),
486490
dockerMode: dockerMode() || undefined,
491+
dockerSource: dockerMode()
492+
? projDocker
493+
? 'project'
494+
: store.dockerImage && store.dockerImage !== DEFAULT_DOCKER_IMAGE
495+
? 'custom'
496+
: 'default'
497+
: undefined,
487498
dockerImage: dockerMode()
488-
? (projectDockerfile()?.imageTag ?? store.dockerImage)
499+
? (projDocker?.imageTag ?? (store.dockerImage || DEFAULT_DOCKER_IMAGE))
489500
: undefined,
490501
});
491502
if (isFromDrop && p) {

src/components/TaskAITerminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface TaskAITerminalProps {
3333
export function TaskAITerminal(props: TaskAITerminalProps) {
3434
onCleanup(() => unregisterFocusFn(`${props.task.id}:ai-terminal`));
3535

36-
const dockerOverlayLabel = () => getTaskDockerOverlayLabel(props.task.dockerImage);
36+
const dockerOverlayLabel = () => getTaskDockerOverlayLabel(props.task.dockerSource);
3737
const [mdViewerContent, setMdViewerContent] = createSignal('');
3838
const [mdViewerFileName, setMdViewerFileName] = createSignal('');
3939
const [mdViewerOpen, setMdViewerOpen] = createSignal(false);

src/components/TaskTitleBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ interface TaskTitleBarProps {
3939
}
4040

4141
export function TaskTitleBar(props: TaskTitleBarProps) {
42-
const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerImage);
42+
const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerSource);
4343

4444
function handleTitleMouseDown(e: MouseEvent) {
4545
handleDragReorder(e, {

src/lib/docker.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getTaskDockerBadgeLabel, getTaskDockerOverlayLabel } from './docker';
3+
4+
describe('docker display labels', () => {
5+
it('renders project labels from explicit docker source metadata', () => {
6+
expect(getTaskDockerBadgeLabel('project')).toBe('Docker (project)');
7+
expect(getTaskDockerOverlayLabel('project')).toBe('project dockerfile');
8+
});
9+
10+
it('keeps generic labels for default and custom sources', () => {
11+
expect(getTaskDockerBadgeLabel('default')).toBe('Docker');
12+
expect(getTaskDockerBadgeLabel('custom')).toBe('Docker');
13+
expect(getTaskDockerOverlayLabel('default')).toBe('docker');
14+
expect(getTaskDockerOverlayLabel('custom')).toBe('docker');
15+
});
16+
});

src/lib/docker.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
export type DockerSource = 'default' | 'project' | 'custom';
2+
13
export const DEFAULT_DOCKER_IMAGE = 'parallel-code-agent:latest';
24
export const PROJECT_DOCKER_IMAGE_PREFIX = 'parallel-code-project:';
35
export const PROJECT_DOCKERFILE_RELATIVE_PATH = '.parallel-code/Dockerfile';
46

5-
export function isProjectDockerImage(image?: string): boolean {
6-
return Boolean(image?.startsWith(PROJECT_DOCKER_IMAGE_PREFIX));
7+
export function inferDockerSource(image?: string): DockerSource {
8+
if (image?.startsWith(PROJECT_DOCKER_IMAGE_PREFIX)) return 'project';
9+
if (image && image !== DEFAULT_DOCKER_IMAGE) return 'custom';
10+
return 'default';
711
}
812

9-
export function getTaskDockerBadgeLabel(image?: string): string {
10-
return isProjectDockerImage(image) ? 'Docker (project)' : 'Docker';
13+
export function getTaskDockerBadgeLabel(source?: DockerSource): string {
14+
return source === 'project' ? 'Docker (project)' : 'Docker';
1115
}
1216

13-
export function getTaskDockerOverlayLabel(image?: string): string {
14-
return isProjectDockerImage(image) ? 'project dockerfile' : 'docker';
17+
export function getTaskDockerOverlayLabel(source?: DockerSource): string {
18+
return source === 'project' ? 'project dockerfile' : 'docker';
1519
}

src/store/persistence.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
Project,
1515
} from './types';
1616
import type { AgentDef } from '../ipc/types';
17+
import { inferDockerSource } from '../lib/docker';
1718
import { DEFAULT_TERMINAL_FONT } from '../lib/fonts';
1819
import { isLookPreset } from '../lib/look';
1920
import { syncTerminalCounter } from './terminals';
@@ -79,6 +80,7 @@ export async function saveState(): Promise<void> {
7980
baseBranch: task.baseBranch,
8081
skipPermissions: task.skipPermissions,
8182
dockerMode: task.dockerMode,
83+
dockerSource: task.dockerSource,
8284
dockerImage: task.dockerImage,
8385
githubUrl: task.githubUrl,
8486
savedInitialPrompt: task.savedInitialPrompt,
@@ -106,6 +108,7 @@ export async function saveState(): Promise<void> {
106108
baseBranch: task.baseBranch,
107109
skipPermissions: task.skipPermissions,
108110
dockerMode: task.dockerMode,
111+
dockerSource: task.dockerSource,
109112
dockerImage: task.dockerImage,
110113
githubUrl: task.githubUrl,
111114
savedInitialPrompt: task.savedInitialPrompt,
@@ -373,6 +376,11 @@ export async function loadState(): Promise<void> {
373376
baseBranch: legacy.baseBranch || undefined,
374377
skipPermissions: pt.skipPermissions === true,
375378
dockerMode: pt.dockerMode === true ? true : undefined,
379+
dockerSource:
380+
pt.dockerMode === true
381+
? (pt.dockerSource ??
382+
inferDockerSource(typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined))
383+
: undefined,
376384
dockerImage: typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined,
377385
githubUrl: pt.githubUrl,
378386
savedInitialPrompt: pt.savedInitialPrompt,
@@ -435,6 +443,11 @@ export async function loadState(): Promise<void> {
435443
baseBranch: legacyCollapsed.baseBranch || undefined,
436444
skipPermissions: pt.skipPermissions === true,
437445
dockerMode: pt.dockerMode === true ? true : undefined,
446+
dockerSource:
447+
pt.dockerMode === true
448+
? (pt.dockerSource ??
449+
inferDockerSource(typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined))
450+
: undefined,
438451
dockerImage: typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined,
439452
githubUrl: pt.githubUrl,
440453
savedInitialPrompt: pt.savedInitialPrompt,

src/store/tasks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { recordMergedLines, recordTaskCompleted } from './completion';
1616
import type { AgentDef, CreateTaskResult, MergeResult } from '../ipc/types';
1717
import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url';
1818
import type { Agent, Task, GitIsolationMode } from './types';
19+
import type { DockerSource } from '../lib/docker';
1920

2021
function initTaskInStore(
2122
taskId: string,
@@ -82,6 +83,7 @@ export interface CreateTaskOptions {
8283
githubUrl?: string;
8384
skipPermissions?: boolean;
8485
dockerMode?: boolean;
86+
dockerSource?: DockerSource;
8587
dockerImage?: string;
8688
}
8789

@@ -97,6 +99,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
9799
githubUrl,
98100
skipPermissions,
99101
dockerMode,
102+
dockerSource,
100103
dockerImage,
101104
} = opts;
102105
const projectRoot = getProjectPath(projectId);
@@ -145,6 +148,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
145148
savedInitialPrompt: initialPrompt ?? undefined,
146149
skipPermissions: skipPermissions ?? undefined,
147150
dockerMode: dockerMode ?? undefined,
151+
dockerSource: dockerSource ?? undefined,
148152
dockerImage: dockerImage ?? undefined,
149153
githubUrl,
150154
};

0 commit comments

Comments
 (0)