-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(electron): add opencode-backed coding workspace shell #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
crs48
wants to merge
19
commits into
main
Choose a base branch
from
codex/llm-coding-ui-mvp
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
a7731e9
docs(exploration): assess self-editing xNet viability
crs48 96558a8
docs(exploration): design fast electron workspace shell
crs48 f2054d5
docs(exploration): outline opencode-first electron MVP
crs48 34987c5
docs(plan): add llm coding ui rollout
crs48 0626d7f
feat(electron): add opencode host foundation
crs48 1e08629
feat(electron): add workspace session shell state
crs48 46bc8b9
feat(electron): add coding workspace shell
crs48 3c04306
feat(electron): add worktree-backed workspace sessions
crs48 244313f
feat(electron): add context review and pr shell flows
crs48 43595df
feat(electron): harden coding workspace shell
crs48 695d00c
fix(electron): harden workspace session runtime
crs48 5a52e98
fix(ui): avoid invalid base menu labels
crs48 6e3e1cf
fix(electron): upsert workspace session snapshots
crs48 e36754c
fix(electron): dedupe workspace session status churn
crs48 f63ba20
fix(electron): stabilize workspace sync hooks
crs48 26099ba
fix(electron): stop workspace sync refresh loop
crs48 5163c2f
chore(electron): add workspace sync debug logs
crs48 9e8c390
fix(electron): stabilize coding workspace sync
crs48 d44e9f4
fix(electron): stabilize worktree preview resolution
crs48 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
157 changes: 157 additions & 0 deletions
157
apps/electron/src/__tests__/opencode-host-controller.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import type { ServiceDefinition, ServiceStatus } from '@xnetjs/plugins/node' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import { createOpenCodeHostController } from '../main/opencode-host-controller' | ||
| import { | ||
| OPENCODE_SERVICE_ID, | ||
| createOpenCodeHostConfig, | ||
| type OpenCodeBinaryResolution, | ||
| type OpenCodeHostConfig, | ||
| type OpenCodeHostStatus | ||
| } from '../shared/opencode-host' | ||
|
|
||
| const createServiceStatus = ( | ||
| config: OpenCodeHostConfig, | ||
| overrides: Partial<ServiceStatus> = {} | ||
| ): ServiceStatus => ({ | ||
| id: OPENCODE_SERVICE_ID, | ||
| state: 'running', | ||
| port: config.port, | ||
| pid: 4242, | ||
| startedAt: 1, | ||
| restartCount: 0, | ||
| ...overrides | ||
| }) | ||
|
|
||
| const createBinaryResolution = (path: string): OpenCodeBinaryResolution => ({ | ||
| found: true, | ||
| path, | ||
| source: 'path' | ||
| }) | ||
|
|
||
| describe('createOpenCodeHostController', () => { | ||
| it('should return a missing-binary status without starting a service', async () => { | ||
| const config = createOpenCodeHostConfig({ | ||
| XNET_OPENCODE_PORT: '4100' | ||
| }) | ||
|
|
||
| const startService = vi.fn<(_: ServiceDefinition) => Promise<ServiceStatus>>() | ||
| const publishStatus = vi.fn<(status: OpenCodeHostStatus) => void>() | ||
|
|
||
| const controller = createOpenCodeHostController({ | ||
| getConfig: () => config, | ||
| getServiceStatus: () => undefined, | ||
| startService, | ||
| restartService: vi.fn(), | ||
| stopService: vi.fn(), | ||
| resolveBinary: vi.fn(async () => ({ | ||
| found: false, | ||
| checkedPaths: [], | ||
| error: 'OpenCode CLI was not found on PATH', | ||
| recovery: 'Install OpenCode' | ||
| })), | ||
| probeHealth: vi.fn(async () => null), | ||
| publishStatus | ||
| }) | ||
|
|
||
| const status = await controller.ensure() | ||
|
|
||
| expect(status.state).toBe('missing-binary') | ||
| expect(startService).not.toHaveBeenCalled() | ||
| expect(publishStatus).toHaveBeenCalledWith(status) | ||
| }) | ||
|
|
||
| it('should dedupe concurrent ensure calls', async () => { | ||
| const config = createOpenCodeHostConfig({ | ||
| XNET_OPENCODE_PORT: '4101' | ||
| }) | ||
|
|
||
| let serviceStatus: ServiceStatus | undefined | ||
| let resolveStart: ((status: ServiceStatus) => void) | null = null | ||
|
|
||
| const startService = vi.fn(async (_definition: ServiceDefinition) => { | ||
| const nextStatus = await new Promise<ServiceStatus>((resolve) => { | ||
| resolveStart = resolve | ||
| }) | ||
| serviceStatus = nextStatus | ||
| return nextStatus | ||
| }) | ||
|
|
||
| const controller = createOpenCodeHostController({ | ||
| getConfig: () => config, | ||
| getServiceStatus: () => serviceStatus, | ||
| startService, | ||
| restartService: vi.fn(), | ||
| stopService: vi.fn(), | ||
| resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), | ||
| probeHealth: vi.fn(async () => ({ healthy: true, version: '1.0.0' })), | ||
| publishStatus: vi.fn() | ||
| }) | ||
|
|
||
| const first = controller.ensure() | ||
| const second = controller.ensure() | ||
|
|
||
| await new Promise((resolve) => setTimeout(resolve, 0)) | ||
|
|
||
| expect(startService).toHaveBeenCalledTimes(1) | ||
|
|
||
| resolveStart?.(createServiceStatus(config)) | ||
|
|
||
| const [firstStatus, secondStatus] = await Promise.all([first, second]) | ||
|
|
||
| expect(firstStatus.state).toBe('ready') | ||
| expect(secondStatus).toEqual(firstStatus) | ||
| expect(startService).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('should reuse an existing running service', async () => { | ||
| const config = createOpenCodeHostConfig({ | ||
| XNET_OPENCODE_PORT: '4102' | ||
| }) | ||
|
|
||
| const serviceStatus = createServiceStatus(config) | ||
| const startService = vi.fn() | ||
| const restartService = vi.fn() | ||
|
|
||
| const controller = createOpenCodeHostController({ | ||
| getConfig: () => config, | ||
| getServiceStatus: () => serviceStatus, | ||
| startService, | ||
| restartService, | ||
| stopService: vi.fn(), | ||
| resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), | ||
| probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.3' })), | ||
| publishStatus: vi.fn() | ||
| }) | ||
|
|
||
| const status = await controller.ensure() | ||
|
|
||
| expect(status.state).toBe('ready') | ||
| expect(status.version).toBe('1.2.3') | ||
| expect(startService).not.toHaveBeenCalled() | ||
| expect(restartService).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('should restart a stopped service instead of returning an idle status', async () => { | ||
| const config = createOpenCodeHostConfig({ | ||
| XNET_OPENCODE_PORT: '4103' | ||
| }) | ||
|
|
||
| const restartService = vi.fn(async () => createServiceStatus(config)) | ||
|
|
||
| const controller = createOpenCodeHostController({ | ||
| getConfig: () => config, | ||
| getServiceStatus: () => createServiceStatus(config, { state: 'stopped' }), | ||
| startService: vi.fn(), | ||
| restartService, | ||
| stopService: vi.fn(), | ||
| resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')), | ||
| probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.4' })), | ||
| publishStatus: vi.fn() | ||
| }) | ||
|
|
||
| const status = await controller.ensure() | ||
|
|
||
| expect(status.state).toBe('ready') | ||
| expect(restartService).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { SERVICE_IPC_CHANNELS } from '@xnetjs/plugins' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { ALLOWED_SERVICE_CHANNELS, isAllowedServiceChannel } from '../shared/service-ipc' | ||
|
|
||
| describe('service IPC allowlist', () => { | ||
| it('should expose the full shared service contract', () => { | ||
| expect([...ALLOWED_SERVICE_CHANNELS].sort()).toEqual(Object.values(SERVICE_IPC_CHANNELS).sort()) | ||
| }) | ||
|
|
||
| it('should reject stale channel names', () => { | ||
| expect(isAllowedServiceChannel('xnet:service:list')).toBe(false) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { formatCommandFailure } from './command-errors' | ||
|
|
||
| describe('command-errors', () => { | ||
| it('formats missing command failures with recovery guidance', () => { | ||
| const error = Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) | ||
|
|
||
| expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain('Install Git') | ||
| }) | ||
|
|
||
| it('preserves the original failure message for non-ENOENT errors', () => { | ||
| const error = new Error('fatal: not a git repository') | ||
|
|
||
| expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain( | ||
| 'fatal: not a git repository' | ||
| ) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /** | ||
| * Shared command-failure formatting for Electron workspace services. | ||
| */ | ||
|
|
||
| const COMMAND_RECOVERY_HINTS: Record<string, string> = { | ||
| git: 'Install Git and ensure `git` is available on PATH before using the coding workspace shell.', | ||
| gh: 'Install GitHub CLI (`gh`) and run `gh auth login` before creating pull requests from the workspace shell.', | ||
| pnpm: 'Install pnpm and run `pnpm install` in this repository before starting worktree previews.' | ||
| } | ||
|
|
||
| function getErrorCode(error: unknown): string | null { | ||
| if (!error || typeof error !== 'object' || !('code' in error)) { | ||
| return null | ||
| } | ||
|
|
||
| const code = Reflect.get(error, 'code') | ||
| return typeof code === 'string' ? code : null | ||
| } | ||
|
|
||
| export function formatCommandFailure( | ||
| command: string, | ||
| args: readonly string[], | ||
| cwd: string, | ||
| error: unknown | ||
| ): string { | ||
| const errorCode = getErrorCode(error) | ||
| const fallbackMessage = error instanceof Error ? error.message : String(error) | ||
| const commandLabel = `${command} ${args.join(' ')}`.trim() | ||
| const recovery = COMMAND_RECOVERY_HINTS[command] | ||
|
|
||
| if (errorCode === 'ENOENT') { | ||
| return recovery | ||
| ? `${command} is required but was not found while running ${commandLabel} in ${cwd}. ${recovery}` | ||
| : `${command} is required but was not found while running ${commandLabel} in ${cwd}.` | ||
| } | ||
|
|
||
| return recovery | ||
| ? `${commandLabel} failed in ${cwd}: ${fallbackMessage}. ${recovery}` | ||
| : `${commandLabel} failed in ${cwd}: ${fallbackMessage}` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| deriveWorktreeContainerPath, | ||
| deriveWorktreeName, | ||
| isManagedWorktreePath, | ||
| parseGitStatusSummary, | ||
| parseWorktreeListOutput | ||
| } from './git-service' | ||
|
|
||
| describe('git-service', () => { | ||
| describe('parseWorktreeListOutput', () => { | ||
| it('parses porcelain worktree output', () => { | ||
| const output = [ | ||
| 'worktree /tmp/xnet', | ||
| 'HEAD abc123', | ||
| 'branch refs/heads/main', | ||
| '', | ||
| 'worktree /tmp/xnet-feature', | ||
| 'HEAD def456', | ||
| 'branch refs/heads/codex/layout-pass', | ||
| 'locked', | ||
| '' | ||
| ].join('\n') | ||
|
|
||
| expect(parseWorktreeListOutput(output)).toEqual([ | ||
| { | ||
| path: '/tmp/xnet', | ||
| head: 'abc123', | ||
| branch: 'main', | ||
| bare: false, | ||
| detached: false, | ||
| locked: false, | ||
| prunable: false | ||
| }, | ||
| { | ||
| path: '/tmp/xnet-feature', | ||
| head: 'def456', | ||
| branch: 'codex/layout-pass', | ||
| bare: false, | ||
| detached: false, | ||
| locked: true, | ||
| prunable: false | ||
| } | ||
| ]) | ||
| }) | ||
|
|
||
| it('ignores unexpected branch formats instead of slicing corrupt names', () => { | ||
| const output = ['worktree /tmp/xnet', 'HEAD abc123', 'branch detached-head', ''].join('\n') | ||
|
|
||
| expect(parseWorktreeListOutput(output)).toEqual([ | ||
| { | ||
| path: '/tmp/xnet', | ||
| head: 'abc123', | ||
| branch: null, | ||
| bare: false, | ||
| detached: false, | ||
| locked: false, | ||
| prunable: false | ||
| } | ||
| ]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('parseGitStatusSummary', () => { | ||
| it('counts unique changed files', () => { | ||
| const output = [ | ||
| 'M apps/electron/src/main/index.ts', | ||
| '?? docs/notes.md', | ||
| 'M docs/notes.md' | ||
| ].join('\n') | ||
|
|
||
| expect(parseGitStatusSummary(output)).toEqual({ | ||
| changedFilesCount: 2, | ||
| isDirty: true, | ||
| files: ['apps/electron/src/main/index.ts', 'docs/notes.md'] | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('deriveWorktreeName', () => { | ||
| it('builds a stable worktree name from the branch and session id', () => { | ||
| expect(deriveWorktreeName('codex/layout-pass', 'xnet:workspace-session:abc123')).toBe( | ||
| 'layout-pass-xnet-wor' | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| describe('managed worktree paths', () => { | ||
| it('derives the managed worktree container from the repo root', () => { | ||
| expect(deriveWorktreeContainerPath('/Users/crs/src/xNet')).toBe( | ||
| '/Users/crs/src/.xnet-worktrees/xnet' | ||
| ) | ||
| }) | ||
|
|
||
| it('accepts only worktrees inside the managed container', () => { | ||
| const repoRoot = '/Users/crs/src/xNet' | ||
|
|
||
| expect( | ||
| isManagedWorktreePath(repoRoot, '/Users/crs/src/.xnet-worktrees/xnet/layout-pass-xnet-wor') | ||
| ).toBe(true) | ||
| expect(isManagedWorktreePath(repoRoot, '/Users/crs/src/xNet')).toBe(false) | ||
| expect(isManagedWorktreePath(repoRoot, '/tmp/layout-pass-xnet-wor')).toBe(false) | ||
| }) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: crs48/xNet
Length of output: 443
🏁 Script executed:
Repository: crs48/xNet
Length of output: 1494
Replace hard-coded Electron target and arch with dynamic or placeholder values.
Line 73 hard-codes
--target=33.4.11and--arch=arm64in the recovery fallback. The--arch=arm64will fail silently or mislead developers on x64 and other architectures. The--target=33.4.11is currently compatible with the app's configured^33.0.0but will drift once Electron is upgraded to 34.x or later. Extract or document these as placeholders (e.g.,--arch=$(uname -m)or reference the actual Electron version frompackage.json).🤖 Prompt for AI Agents