mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ chore(agent): agencyConfig contract + git-over-RPC backend (#15542)
* ✨ feat(agent): agencyConfig contract — workingDirByDevice + executionTarget Type-only contract for the unified per-device working-directory work. Adds `workingDirByDevice` (per-device cwd) and `executionTarget` to agencyConfig. No runtime logic consumes them yet — the server/client wiring lands in the UI PR so it can be validated as one unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ✨ feat(agent): device gitInfo over RPC + shared local-file-shell git impl Backend/RPC capability for "git branch / changes / PR for remote devices". Dormant — no client caller yet; merging changes no existing behavior. - `@lobechat/local-file-shell/git`: repoType + branch / linked-PR / working-tree / ahead-behind + `gitInfo` aggregate + `DeviceGitInfo` type (desktop + CLI). - desktop `GitCtr.gitInfo()` (@IpcMethod) delegates to it; registered in GatewayConnectionCtr's RPC dispatch. `utils/git` re-exports the helpers. - server: `deviceGateway.gitInfo()` wrapper + `device.gitInfo` TRPC query. - `@lobechat/types`: `DeviceGitInfo` shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ✅ test(desktop): fix stale mocks after git impl moved to local-file-shell Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ♻️ refactor(server): extract DeviceGateway into its own service dir deviceGateway is a device-scoped gateway client (status/list/tool-call/git/ workspace RPC), not tool-execution-specific. Move it out of toolExecution/ into its own services/deviceGateway/ and update all import sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
@@ -168,6 +169,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(WorkspaceCtr);
|
||||
}
|
||||
|
||||
private get gitCtr() {
|
||||
return this.app.getController(GitCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
@@ -356,6 +361,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
|
||||
}
|
||||
|
||||
case 'gitInfo': {
|
||||
return this.gitCtr.gitInfo(params as { isGithub?: boolean; scope: string });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown device RPC method: ${method}`);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,16 @@ import type {
|
||||
GitWorkingTreeStatus,
|
||||
SubmoduleWorkingTreePatches,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
type DeviceGitInfo,
|
||||
getGitAheadBehind as computeGitAheadBehind,
|
||||
getGitBranch as computeGitBranch,
|
||||
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
|
||||
getLinkedPullRequest as computeLinkedPullRequest,
|
||||
gitInfo as computeGitInfo,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
import { detectRepoType, resolveGitDir } from '@/utils/git';
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -450,23 +458,17 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
|
||||
try {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return {};
|
||||
return computeGitBranch(dirPath);
|
||||
}
|
||||
|
||||
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
if (refMatch) {
|
||||
return { branch: refMatch[1] };
|
||||
}
|
||||
// Detached HEAD — HEAD file contains the full sha
|
||||
if (/^[\da-f]{40}$/i.test(head)) {
|
||||
return { branch: head.slice(0, 7), detached: true };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
/**
|
||||
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
|
||||
* directory. The single entry point shared by the local desktop display, the
|
||||
* device `gitInfo` RPC, and the CLI — implemented in `@lobechat/local-file-shell`.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
|
||||
return computeGitInfo(params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,58 +481,7 @@ export default class GitController extends ControllerModule {
|
||||
branch: string;
|
||||
path: string;
|
||||
}): Promise<GitLinkedPullRequestResult> {
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'number,url,title,state',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 8000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
if (parsed.length === 0) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
const [primary, ...rest] = parsed;
|
||||
return {
|
||||
extraCount: rest.length,
|
||||
pullRequest: primary,
|
||||
status: 'ok',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const code = error?.code;
|
||||
const stderr: string = error?.stderr ?? '';
|
||||
// `gh` binary not on PATH
|
||||
if (code === 'ENOENT') {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
// gh reports auth issues via stderr; treat as a soft-fail
|
||||
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
|
||||
return { pullRequest: null, status: 'error' };
|
||||
}
|
||||
return computeLinkedPullRequest(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,42 +586,7 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 2) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (x === '?' && y === '?') {
|
||||
added++;
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored — skip
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
deleted++;
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
added++;
|
||||
} else {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
const total = added + modified + deleted;
|
||||
return { added, clean: total === 0, deleted, modified, total };
|
||||
} catch {
|
||||
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
|
||||
}
|
||||
return computeGitWorkingTreeStatus(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1133,66 +1049,7 @@ export default class GitController extends ControllerModule {
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to compute against cached refs
|
||||
}
|
||||
try {
|
||||
const { stdout: upstreamOut } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const upstream = upstreamOut.trim();
|
||||
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
|
||||
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
|
||||
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
|
||||
|
||||
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
|
||||
// which may differ from upstream (the branched-off-canary case).
|
||||
let pushTarget: string | undefined;
|
||||
let pushTargetExists = false;
|
||||
try {
|
||||
const { stdout: branchOut } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const branch = branchOut.trim();
|
||||
if (branch) {
|
||||
pushTarget = `origin/${branch}`;
|
||||
try {
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
pushTargetExists = true;
|
||||
} catch {
|
||||
pushTargetExists = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// detached HEAD — leave pushTarget undefined
|
||||
}
|
||||
|
||||
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
|
||||
} catch {
|
||||
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
|
||||
return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
}
|
||||
return computeGitAheadBehind(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/utils/logger', () => ({
|
||||
|
||||
// Mock child_process for the shared package
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
|
||||
import { readdir } from 'fs-extra';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectRepoType } from '../git';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs-extra', () => ({
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,71 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readdir } from 'fs-extra';
|
||||
|
||||
/**
|
||||
* Resolve the actual `.git` directory for a working tree.
|
||||
* Supports both standard layouts and worktree pointer files (`.git` as a regular file
|
||||
* containing `gitdir: <path>`).
|
||||
* Git repo-type / gitdir helpers. The implementations now live in
|
||||
* `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one
|
||||
* copy; re-exported here to keep existing `@/utils/git` import sites stable.
|
||||
*/
|
||||
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitPath = path.join(dirPath, '.git');
|
||||
try {
|
||||
const content = await readFile(gitPath, 'utf8');
|
||||
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
|
||||
if (worktreeMatch) {
|
||||
const resolved = worktreeMatch[1].trim();
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
|
||||
}
|
||||
} catch {
|
||||
// `.git` is a directory (EISDIR) or missing — fall through
|
||||
}
|
||||
try {
|
||||
const stat = await readdir(gitPath);
|
||||
if (stat.length > 0) return gitPath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the common git dir — where shared state like `config` and
|
||||
* `packed-refs` lives. For linked worktrees, `resolveGitDir` returns
|
||||
* `.git/worktrees/<name>/` which has its own `HEAD` but no `config`;
|
||||
* the `commondir` pointer inside it resolves to the main repo's gitdir.
|
||||
*/
|
||||
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return undefined;
|
||||
try {
|
||||
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
|
||||
if (!commondir) return gitDir;
|
||||
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
|
||||
} catch {
|
||||
return gitDir;
|
||||
}
|
||||
};
|
||||
|
||||
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`,
|
||||
// or line start (covers `git@github.com:`, `https://github.com/`,
|
||||
// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching
|
||||
// look-alikes like `evilgithub.com` or `github.com.attacker.com`.
|
||||
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
|
||||
|
||||
/**
|
||||
* Classify a working tree as `git` (plain) / `github` (origin points at github.com) /
|
||||
* `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and
|
||||
* linked worktrees resolve the same as the main repo.
|
||||
*/
|
||||
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
|
||||
const commonDir = await resolveCommonGitDir(dirPath);
|
||||
if (!commonDir) return undefined;
|
||||
try {
|
||||
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
|
||||
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
|
||||
return 'git';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './info';
|
||||
export * from './repoType';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,219 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { createLogger } from '../logger';
|
||||
import { resolveGitDir } from './repoType';
|
||||
import type {
|
||||
DeviceGitInfo,
|
||||
GitAheadBehind,
|
||||
GitBranchInfo,
|
||||
GitLinkedPullRequestResult,
|
||||
GitWorkingTreeStatus,
|
||||
} from './types';
|
||||
|
||||
const log = createLogger('local-file-shell:git');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Current branch short name, or short SHA + `detached` for detached HEAD. */
|
||||
export const getGitBranch = async (dirPath: string): Promise<GitBranchInfo> => {
|
||||
try {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return {};
|
||||
|
||||
const head = (await readFile(`${gitDir}/HEAD`, 'utf8')).trim();
|
||||
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
if (refMatch) return { branch: refMatch[1] };
|
||||
// Detached HEAD — HEAD file contains the full sha
|
||||
if (/^[\da-f]{40}$/i.test(head)) return { branch: head.slice(0, 7), detached: true };
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
|
||||
* Returns `status: 'gh-missing'` when `gh` is unavailable / not authed.
|
||||
*/
|
||||
export const getLinkedPullRequest = async (payload: {
|
||||
branch: string;
|
||||
path: string;
|
||||
}): Promise<GitLinkedPullRequestResult> => {
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch) return { pullRequest: null, status: 'ok' };
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'number,url,title,state',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 8000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
if (parsed.length === 0) return { pullRequest: null, status: 'ok' };
|
||||
const [primary, ...rest] = parsed;
|
||||
return { extraCount: rest.length, pullRequest: primary, status: 'ok' };
|
||||
} catch (error: any) {
|
||||
const code = error?.code;
|
||||
const stderr: string = error?.stderr ?? '';
|
||||
if (code === 'ENOENT') return { pullRequest: null, status: 'gh-missing' };
|
||||
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
log.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
|
||||
return { pullRequest: null, status: 'error' };
|
||||
}
|
||||
};
|
||||
|
||||
/** Bucket dirty files into added / modified / deleted via `git status --porcelain -z`. */
|
||||
export const getGitWorkingTreeStatus = async (dirPath: string): Promise<GitWorkingTreeStatus> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 2) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (x === '?' && y === '?') {
|
||||
added++;
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored — skip
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
deleted++;
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
added++;
|
||||
} else {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
const total = added + modified + deleted;
|
||||
return { added, clean: total === 0, deleted, modified, total };
|
||||
} catch {
|
||||
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Count commits HEAD is ahead/behind its upstream. Does a best-effort `git fetch`
|
||||
* first; swallows fetch failures (offline / no creds) and computes against cached
|
||||
* refs. Returns `hasUpstream: false` when no upstream is configured.
|
||||
*/
|
||||
export const getGitAheadBehind = async (dirPath: string): Promise<GitAheadBehind> => {
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to compute against cached refs
|
||||
}
|
||||
try {
|
||||
const { stdout: upstreamOut } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const upstream = upstreamOut.trim();
|
||||
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
|
||||
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
|
||||
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
|
||||
|
||||
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
|
||||
// which may differ from upstream (the branched-off-canary case).
|
||||
let pushTarget: string | undefined;
|
||||
let pushTargetExists = false;
|
||||
try {
|
||||
const { stdout: branchOut } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const branch = branchOut.trim();
|
||||
if (branch) {
|
||||
pushTarget = `origin/${branch}`;
|
||||
try {
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
pushTargetExists = true;
|
||||
} catch {
|
||||
pushTargetExists = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// detached HEAD — leave pushTarget undefined
|
||||
}
|
||||
|
||||
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
|
||||
} catch {
|
||||
return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate git status (branch + linked PR + working tree + ahead/behind) into one
|
||||
* payload. The single source behind the desktop display, the device `gitInfo` RPC,
|
||||
* and the CLI. PR lookup runs only for a real branch on a GitHub remote.
|
||||
*/
|
||||
export const gitInfo = async (params: {
|
||||
isGithub?: boolean;
|
||||
scope: string;
|
||||
}): Promise<DeviceGitInfo> => {
|
||||
const dirPath = params.scope;
|
||||
const { branch, detached } = await getGitBranch(dirPath);
|
||||
|
||||
let info: DeviceGitInfo['info'] = { branch, detached };
|
||||
if (branch && !detached && params.isGithub) {
|
||||
const pr = await getLinkedPullRequest({ branch, path: dirPath });
|
||||
info = {
|
||||
branch,
|
||||
detached,
|
||||
extraCount: pr.extraCount,
|
||||
ghMissing: pr.status === 'gh-missing',
|
||||
pullRequest: pr.pullRequest,
|
||||
};
|
||||
}
|
||||
|
||||
const [workingStatus, aheadBehind] = await Promise.all([
|
||||
getGitWorkingTreeStatus(dirPath),
|
||||
getGitAheadBehind(dirPath),
|
||||
]);
|
||||
|
||||
return { aheadBehind, info, workingStatus };
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolve the actual `.git` directory for a working tree. Supports both standard
|
||||
* layouts and worktree pointer files (`.git` as a regular file containing
|
||||
* `gitdir: <path>`).
|
||||
*/
|
||||
export const resolveGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitPath = path.join(dirPath, '.git');
|
||||
try {
|
||||
const content = await readFile(gitPath, 'utf8');
|
||||
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
|
||||
if (worktreeMatch) {
|
||||
const resolved = worktreeMatch[1].trim();
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
|
||||
}
|
||||
} catch {
|
||||
// `.git` is a directory (EISDIR) or missing — fall through
|
||||
}
|
||||
try {
|
||||
const entries = await readdir(gitPath);
|
||||
if (entries.length > 0) return gitPath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the common git dir — where shared state like `config` and `packed-refs`
|
||||
* lives. For linked worktrees, `resolveGitDir` returns `.git/worktrees/<name>/`
|
||||
* which has its own `HEAD` but no `config`; the `commondir` pointer inside it
|
||||
* resolves to the main repo's gitdir.
|
||||
*/
|
||||
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return undefined;
|
||||
try {
|
||||
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
|
||||
if (!commondir) return gitDir;
|
||||
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
|
||||
} catch {
|
||||
return gitDir;
|
||||
}
|
||||
};
|
||||
|
||||
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`, or
|
||||
// line start (covers `git@github.com:`, `https://github.com/`, `ssh://git@github.com/`)
|
||||
// and followed by `:` or `/`. Avoids matching look-alikes like `evilgithub.com`.
|
||||
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
|
||||
|
||||
/**
|
||||
* Classify a working tree as `git` (plain) / `github` (origin points at github.com)
|
||||
* / `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules
|
||||
* and linked worktrees resolve the same as the main repo.
|
||||
*/
|
||||
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
|
||||
const commonDir = await resolveCommonGitDir(dirPath);
|
||||
if (!commonDir) return undefined;
|
||||
try {
|
||||
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
|
||||
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
|
||||
return 'git';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Branch short name (or short SHA when detached). */
|
||||
export interface GitBranchInfo {
|
||||
branch?: string;
|
||||
detached?: boolean;
|
||||
}
|
||||
|
||||
export interface GitLinkedPullRequest {
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GitLinkedPullRequestResult {
|
||||
/** Additional open PRs targeting the same head branch, beyond the primary one. */
|
||||
extraCount?: number;
|
||||
/** Null when no open PR is linked to the branch. */
|
||||
pullRequest: GitLinkedPullRequest | null;
|
||||
/** 'ok' — succeeded; 'gh-missing' — gh CLI unavailable / not authed; 'error' — other. */
|
||||
status: 'ok' | 'gh-missing' | 'error';
|
||||
}
|
||||
|
||||
export interface GitWorkingTreeStatus {
|
||||
added: number;
|
||||
clean: boolean;
|
||||
deleted: number;
|
||||
modified: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GitAheadBehind {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
hasUpstream: boolean;
|
||||
pushTarget?: string;
|
||||
pushTargetExists?: boolean;
|
||||
upstream?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate git status for a working directory — the single payload behind both
|
||||
* the desktop git display and the device `gitInfo` RPC (and CLI). Mirrors the
|
||||
* three renderer hooks (`useGitInfo` / `useWorkingTreeStatus` / `useGitAheadBehind`).
|
||||
*/
|
||||
export interface DeviceGitInfo {
|
||||
aheadBehind: GitAheadBehind;
|
||||
info: {
|
||||
branch?: string;
|
||||
detached?: boolean;
|
||||
extraCount?: number;
|
||||
ghMissing?: boolean;
|
||||
pullRequest?: GitLinkedPullRequest | null;
|
||||
};
|
||||
workingStatus: GitWorkingTreeStatus;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './contentSearch';
|
||||
export * from './file';
|
||||
export * from './fileSearch';
|
||||
export * from './git';
|
||||
export { createLogger, type Logger, type LoggerFactory, setLoggerFactory } from './logger';
|
||||
export * from './shell';
|
||||
export type { ToolCategory, ToolDetector } from './toolDetector';
|
||||
|
||||
@@ -75,4 +75,18 @@ export interface LobeAgentAgencyConfig {
|
||||
* any `verifyCriteriaIds` — into its check plan. References `verify_rubrics.id`.
|
||||
*/
|
||||
verifyRubricId?: string;
|
||||
/**
|
||||
* Per-device working directory chosen for this agent. Key = `deviceId` (the
|
||||
* local machine uses its own gateway deviceId, so local and remote share one
|
||||
* model). This is the **agent-level** cwd in the resolution precedence:
|
||||
*
|
||||
* `topic.metadata.workingDirectory`
|
||||
* > `workingDirByDevice[targetDeviceId]`
|
||||
* > `device.defaultCwd`
|
||||
*
|
||||
* Keyed per device so switching the bound device never resolves a path that
|
||||
* only exists on another machine. Persisted (server-synced) so the choice
|
||||
* follows the user across sessions / ends.
|
||||
*/
|
||||
workingDirByDevice?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -146,6 +146,40 @@ export interface WorkspaceInitResult {
|
||||
skills: ProjectSkillMeta[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Git status of a device's working directory, returned by the `gitInfo` device
|
||||
* RPC so a remote device (or web client) can render branch / file changes / PR
|
||||
* the same way the local desktop does. Field shapes mirror the desktop git
|
||||
* service so the UI consumes both paths interchangeably.
|
||||
*/
|
||||
export interface DeviceGitInfo {
|
||||
/** Commit divergence vs the upstream tracking ref. */
|
||||
aheadBehind: {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
hasUpstream: boolean;
|
||||
pushTarget?: string;
|
||||
pushTargetExists?: boolean;
|
||||
upstream?: string;
|
||||
};
|
||||
/** Branch name + linked GitHub pull request (when the repo is a GitHub remote). */
|
||||
info: {
|
||||
branch?: string;
|
||||
detached?: boolean;
|
||||
extraCount?: number;
|
||||
ghMissing?: boolean;
|
||||
pullRequest?: { number: number; state: string; title: string; url: string } | null;
|
||||
};
|
||||
/** Working-tree dirty-file counts. */
|
||||
workingStatus: {
|
||||
added: number;
|
||||
clean: boolean;
|
||||
deleted: number;
|
||||
modified: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for execAgent - execute a single Agent
|
||||
* Either agentId or slug must be provided
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod';
|
||||
import { DeviceModel } from '@/database/models/device';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { preserveWorkspaceCache } from './deviceWorkingDirs';
|
||||
|
||||
@@ -76,6 +76,30 @@ export const deviceRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Git status (branch / file changes / linked PR) for a directory on a remote
|
||||
* device, fetched via the device's `gitInfo` RPC. Lets the UI render a remote
|
||||
* device's git the same as the local desktop. Returns `null` when offline /
|
||||
* the directory isn't a git repo.
|
||||
*/
|
||||
gitInfo: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
isGithub: z.boolean().optional(),
|
||||
scope: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.gitInfo(
|
||||
ctx.userId,
|
||||
input.deviceId,
|
||||
input.scope,
|
||||
input.isGithub,
|
||||
);
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, description, avatar) from the platform
|
||||
* installed on the given device. Used to pre-fill the creation modal.
|
||||
|
||||
@@ -162,7 +162,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: mockDeviceProxy,
|
||||
}));
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ vi.mock('@/server/modules/Mecha', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
get isConfigured() {
|
||||
// Will be overridden per-test via vi.spyOn or re-mock
|
||||
@@ -209,7 +209,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
describe('deviceContext forwarded to createServerAgentToolsEngine', () => {
|
||||
it('should pass deviceContext when gateway is configured', async () => {
|
||||
// Override deviceGateway.isConfigured
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
|
||||
@@ -230,7 +230,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
});
|
||||
|
||||
it('should not pass deviceContext when gateway is not configured', async () => {
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
|
||||
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
@@ -245,7 +245,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
|
||||
describe('RemoteDevice systemRole override', () => {
|
||||
it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => {
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
|
||||
@@ -279,7 +279,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
});
|
||||
|
||||
it('should NOT have RemoteDevice in manifestMap when gateway is not configured', async () => {
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
|
||||
|
||||
// ToolsEngine returns empty manifestMap (RemoteDevice disabled by enableChecker)
|
||||
@@ -301,7 +301,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
|
||||
describe('toolExecutorMap gating on gatewayConfigured (regression for #13769)', () => {
|
||||
it('should mark local-system as client when gateway is NOT configured (standalone Electron)', async () => {
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
|
||||
|
||||
mockGetEnabledPluginManifests.mockReturnValue(
|
||||
@@ -316,7 +316,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
});
|
||||
|
||||
it('should NOT mark local-system as client when gateway IS configured (cloud)', async () => {
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
|
||||
@@ -348,7 +348,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
|
||||
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
|
||||
// Gateway NOT configured → should mark as client
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(false);
|
||||
@@ -374,7 +374,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
// Remote Device proxy to the device registered with the gateway, never
|
||||
// back to the caller. (The Phase 6.4 clientRuntime=desktop
|
||||
// short-circuit that bypassed this gate was removed.)
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
|
||||
@@ -402,7 +402,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
meta: { title: 'Stdio' },
|
||||
};
|
||||
|
||||
const { deviceGateway } = await import('@/server/services/toolExecution/deviceGateway');
|
||||
const { deviceGateway } = await import('@/server/services/deviceGateway');
|
||||
vi.spyOn(deviceGateway, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
|
||||
|
||||
@@ -114,7 +114,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -137,7 +137,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -88,7 +88,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -110,7 +110,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -118,7 +118,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: { isConfigured: false, queryDeviceList: vi.fn().mockResolvedValue([]) },
|
||||
}));
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ vi.mock('@/server/modules/Mecha', () => ({
|
||||
}));
|
||||
|
||||
// Mock deviceGateway
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -110,7 +110,7 @@ vi.mock('@/server/services/file', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
isConfigured: false,
|
||||
queryDeviceList: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
resolveAgentSelfIterationCapability,
|
||||
} from '@/server/services/agentSignal/featureGate';
|
||||
import { shouldSuppressSignal } from '@/server/services/agentSignal/suppressSignal';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
import { DocumentService } from '@/server/services/document';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { resolveAttachmentsByFileIds } from '@/server/services/file/resolveAttachments';
|
||||
@@ -88,7 +89,6 @@ import { HeterogeneousAgentService } from '@/server/services/heterogeneousAgent'
|
||||
import type { ConversationHistoryEntry } from '@/server/services/heterogeneousAgent/cloudHeteroContext';
|
||||
import { KlavisService } from '@/server/services/klavis';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import { resolveDeviceAccessPolicy } from './deviceAccessPolicy';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ImessageClientFactory } from './client';
|
||||
|
||||
const mockExecuteMessageApi = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/server/services/toolExecution/deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
executeMessageApi: mockExecuteMessageApi,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
BlueBubblesSendOptions,
|
||||
} from '@lobechat/chat-adapter-imessage';
|
||||
|
||||
import { deviceGateway } from '@/server/services/toolExecution/deviceGateway';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
const IMESSAGE_MESSAGE_API_TIMEOUT_MS = 60_000;
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Import after mocks are set up
|
||||
import { DeviceGateway } from '../deviceGateway';
|
||||
import { DeviceGateway } from '../index';
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
DEVICE_GATEWAY_SERVICE_TOKEN: undefined as string | undefined,
|
||||
+34
-1
@@ -8,7 +8,7 @@ import {
|
||||
type GatewayMcpStdioParams,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import type { HeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
import type { ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
|
||||
import type { DeviceGitInfo, ProjectSkillMeta, WorkspaceInitResult } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { gatewayEnv } from '@/envs/gateway';
|
||||
@@ -127,6 +127,39 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch git status (branch / file changes / PR) for a directory on a remote
|
||||
* device, via the same generic `invokeRpc` channel as `initWorkspace`. Lets
|
||||
* the UI render a remote device's git the same as the local desktop.
|
||||
*/
|
||||
async gitInfo(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
scope: string,
|
||||
isGithub = false,
|
||||
timeout = 15_000,
|
||||
): Promise<DeviceGitInfo | undefined> {
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitInfo>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'gitInfo', params: { isGithub, scope } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('gitInfo: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('gitInfo: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchAgentRun(params: {
|
||||
agentType: HeterogeneousAgentType;
|
||||
cwd?: string;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildBlockedToolResponse,
|
||||
getConnectorToolPermission,
|
||||
} from '@/libs/mcp/connectorPermissionCheck';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
import { contentBlocksToString } from '@/server/services/mcp/contentProcessor';
|
||||
import {
|
||||
DEFAULT_TOOL_RESULT_MAX_LENGTH,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
import { DiscoverService } from '../discover';
|
||||
import { type MCPService } from '../mcp';
|
||||
import { type BuiltinToolsExecutor } from './builtin';
|
||||
import { deviceGateway } from './deviceGateway';
|
||||
import { classifyToolError } from './errorClassification';
|
||||
import {
|
||||
type ToolExecutionContext,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type ToolExecutionContext } from '../../types';
|
||||
|
||||
// Mock deviceGateway
|
||||
const mockExecuteToolCall = vi.fn();
|
||||
vi.mock('../../deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
executeToolCall: (...args: any[]) => mockExecuteToolCall(...args),
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { type ToolExecutionContext } from '../../types';
|
||||
|
||||
// Mock deviceGateway
|
||||
const mockQueryDeviceList = vi.fn();
|
||||
vi.mock('../../deviceGateway', () => ({
|
||||
vi.mock('@/server/services/deviceGateway', () => ({
|
||||
deviceGateway: {
|
||||
queryDeviceList: (...args: any[]) => mockQueryDeviceList(...args),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
|
||||
import { deviceGateway } from '../deviceGateway';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
RemoteDeviceIdentifier,
|
||||
} from '@lobechat/builtin-tool-remote-device';
|
||||
|
||||
import { deviceGateway } from '../deviceGateway';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
export const remoteDeviceRuntime: ServerRuntimeRegistration = {
|
||||
|
||||
@@ -21,13 +21,13 @@ import { FileModel } from '@/database/models/file';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { filterBuiltinSkills } from '@/helpers/skillFilters';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
import { createSandboxService, normalizeSandboxCommandResult } from '@/server/services/sandbox';
|
||||
import { SkillResourceService } from '@/server/services/skill/resource';
|
||||
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
|
||||
|
||||
import { deviceGateway } from '../deviceGateway';
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const log = debug('lobe-server:skills-runtime');
|
||||
|
||||
Reference in New Issue
Block a user