Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
77581b9
test(host-service): add integration test harness and 48 cases across …
Kitenite Apr 30, 2026
0bf7966
test(host-service): add integration tests for git, pull-requests, wor…
Kitenite Apr 30, 2026
db52694
test(host-service): add terminal, project-setup, git-history integrat…
Kitenite Apr 30, 2026
15e8b34
test(host-service): add workspace create/delete + ws-auth integration…
Kitenite Apr 30, 2026
1047e8a
test(host-service): add workspaceCreation.adopt integration tests
Kitenite Apr 30, 2026
cb62467
test(host-service): add Octokit-mocked github + workspaceCreation tests
Kitenite Apr 30, 2026
a9d1225
test(host-service): expand github mocks + workspace-cleanup branch + …
Kitenite Apr 30, 2026
27c78b1
test(host-service): add chat + auth router integration tests
Kitenite Apr 30, 2026
a316366
test(host-service): add workspaceCreation create + checkout validatio…
Kitenite Apr 30, 2026
ca37c5f
test(host-service): add bug-hunt suite — found .git/config.lock race
Kitenite Apr 30, 2026
8c35881
test(host-service): v2 bug-hunt — found 2 progress-store leaks in wor…
Kitenite Apr 30, 2026
5a4e2d9
fix(host-service): plug progress-store leak in workspaceCreation crea…
Kitenite Apr 30, 2026
15c8e29
fix(host-service): retry git config writes on .git/config.lock conten…
Kitenite Apr 30, 2026
450144e
refactor(host-service tests): extract scenarios + seed + cloud-fake h…
Kitenite Apr 30, 2026
5812eaf
fix(host-service): address PR review — dispose isolation, retry off-b…
Kitenite Apr 30, 2026
705f924
fix(host-service tests): address 2 new PR review findings
Kitenite Apr 30, 2026
4f13464
chore(host-service): wire tests into turbo + match repo naming conven…
Kitenite Apr 30, 2026
53e5a4c
fix(host-service tests): drop unused biome-ignore suppressions
Kitenite Apr 30, 2026
c913cc5
fix(host-service tests): address PR review — teardown guards, tighter…
Kitenite Apr 30, 2026
963a8e3
Merge remote-tracking branch 'origin/main' into host-service-integration
Kitenite Apr 30, 2026
80e683b
chore(host-service tests): trim verbose comments + dead helpers
Kitenite Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/host-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
"dev": "bun run src/serve.ts",
"build:host": "bun run build.ts",
"generate": "drizzle-kit generate",
"test": "bun test --pass-with-no-tests",
"test:integration": "bun test --pass-with-no-tests test/integration",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
Expand Down
79 changes: 61 additions & 18 deletions packages/host-service/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { MiddlewareHandler } from "hono";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createApiClient } from "./api";
import { createDb } from "./db";
import { createDb, type HostDb } from "./db";
import { EventBus, registerEventBusRoute } from "./events";
import type { ApiAuthProvider } from "./providers/auth";
import type { HostAuthProvider } from "./providers/host-auth";
Expand Down Expand Up @@ -35,29 +35,46 @@ export interface CreateAppOptions {
credentials: GitCredentialProvider;
modelResolver: ModelProviderRuntimeResolver;
};
/**
* Test-harness override hooks. Production never sets these — `createApp`
* builds each subsystem itself when omitted. `db` is overridden so tests
* can swap in `bun:sqlite` (better-sqlite3 isn't loadable under Bun;
* prod uses it on bundled Node). `api`, `github`, `chatRuntime`, and
* `chatService` are overridden to keep tests off the network and out of
* mastra storage.
*/
db?: HostDb;
api?: ApiClient;
github?: () => Promise<Octokit>;
chatRuntime?: ChatRuntimeManager;
chatService?: ChatService;
}

export interface CreateAppResult {
app: Hono;
injectWebSocket: ReturnType<typeof createNodeWebSocket>["injectWebSocket"];
api: ApiClient;
dispose: () => Promise<void>;
}

export function createApp(options: CreateAppOptions): CreateAppResult {
const { config, providers } = options;

const api = createApiClient(config.cloudApiUrl, providers.auth);
const db = createDb(config.dbPath, config.migrationsFolder);
const api =
options.api ?? createApiClient(config.cloudApiUrl, providers.auth);
const db = options.db ?? createDb(config.dbPath, config.migrationsFolder);
const git = createGitFactory(providers.credentials);
const github = async () => {
const token = await providers.credentials.getToken("github.com");
if (!token) {
throw new Error(
"No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.",
);
}
return new Octokit({ auth: token });
};
const github =
options.github ??
(async () => {
const token = await providers.credentials.getToken("github.com");
if (!token) {
throw new Error(
"No GitHub token available. Set GITHUB_TOKEN/GH_TOKEN or authenticate via git credential manager.",
);
}
return new Octokit({ auth: token });
});

const pullRequestRuntime = new PullRequestRuntimeManager({
db,
Expand All @@ -66,14 +83,16 @@ export function createApp(options: CreateAppOptions): CreateAppResult {
});
pullRequestRuntime.start();
const filesystem = new WorkspaceFilesystemManager({ db });
const chatRuntime = new ChatRuntimeManager({
db,
runtimeResolver: providers.modelResolver,
});
const chatRuntime =
options.chatRuntime ??
new ChatRuntimeManager({
db,
runtimeResolver: providers.modelResolver,
});
// Provider auth (Anthropic / OpenAI OAuth + API keys) is per-machine, not
// per-workspace. ChatService is a long-lived singleton wrapping mastra's
// auth storage; the `host.auth.*` router proxies to it.
const chatService = new ChatService();
const chatService = options.chatService ?? new ChatService();

const runtime = {
auth: chatService,
Expand Down Expand Up @@ -146,5 +165,29 @@ export function createApp(options: CreateAppOptions): CreateAppResult {
}),
);

return { app, injectWebSocket, api };
const ownsDb = options.db === undefined;
const dispose = async (): Promise<void> => {
// Each step is best-effort and isolated: a throw in one cleanup must
// not skip the others, otherwise a flaky `.stop()` could leak the
// open SQLite handle for the rest of the process lifetime.
try {
pullRequestRuntime.stop();
} catch (err) {
console.warn("[host-service] pullRequestRuntime.stop failed:", err);
}
try {
eventBus.close();
} catch (err) {
console.warn("[host-service] eventBus.close failed:", err);
}
if (ownsDb) {
try {
(db as unknown as { $client?: { close: () => void } }).$client?.close();
Comment on lines +183 to +185
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relying on Drizzle's undocumented $client internal

(db as unknown as { $client?: { close: () => void } }).$client?.close() reaches into an undocumented internal of drizzle-orm. There is no public contract for $client, so a minor Drizzle upgrade could silently skip the close. Consider wrapping db in a thin interface that exposes a close() method at construction time, or exporting a typed HostDb that includes the close method.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/app.ts
Line: 196-198

Comment:
**Relying on Drizzle's undocumented `$client` internal**

`(db as unknown as { $client?: { close: () => void } }).$client?.close()` reaches into an undocumented internal of drizzle-orm. There is no public contract for `$client`, so a minor Drizzle upgrade could silently skip the close. Consider wrapping `db` in a thin interface that exposes a `close()` method at construction time, or exporting a typed `HostDb` that includes the close method.

How can I resolve this? If you propose a fix, please make it concise.

} catch {
// best-effort close; tests should not fail on teardown
}
Comment on lines 167 to +188
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 dispose may silently skip db close on runtime stop errors

pullRequestRuntime.stop() and eventBus.close() are called without try-catch. If either throws, the if (ownsDb) branch that closes the SQLite connection is never reached, leaving the file handle open. In tests the createTestHost wrapper still closes sqlite directly, but in production this means a crash in .stop() can leave the db handle dangling.

Suggested change
return { app, injectWebSocket, api };
const ownsDb = options.db === undefined;
const dispose = async (): Promise<void> => {
pullRequestRuntime.stop();
eventBus.close();
if (ownsDb) {
try {
(db as unknown as { $client?: { close: () => void } }).$client?.close();
} catch {
// best-effort close; tests should not fail on teardown
}
const dispose = async (): Promise<void> => {
try { pullRequestRuntime.stop(); } catch {}
try { eventBus.close(); } catch {}
if (ownsDb) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/app.ts
Line: 191-201

Comment:
**`dispose` may silently skip db close on runtime stop errors**

`pullRequestRuntime.stop()` and `eventBus.close()` are called without try-catch. If either throws, the `if (ownsDb)` branch that closes the SQLite connection is never reached, leaving the file handle open. In tests the `createTestHost` wrapper still closes `sqlite` directly, but in production this means a crash in `.stop()` can leave the db handle dangling.

```suggestion
	const dispose = async (): Promise<void> => {
		try { pullRequestRuntime.stop(); } catch {}
		try { eventBus.close(); } catch {}
		if (ownsDb) {
```

How can I resolve this? If you propose a fix, please make it concise.

}
};

return { app, injectWebSocket, api, dispose };
}
11 changes: 7 additions & 4 deletions packages/host-service/src/trpc/router/git/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
PullRequestReviewThread,
PullRequestState,
} from "./types";
import { gitConfigWrite } from "./utils/config-write";
import {
buildBranch,
countUntrackedFileLines,
Expand Down Expand Up @@ -313,15 +314,17 @@ export const gitRouter = router({
});
}
if (input.baseBranch) {
await git.raw([
await gitConfigWrite(git, [
"config",
`branch.${currentBranch}.base`,
input.baseBranch,
]);
} else {
await git
.raw(["config", "--unset", `branch.${currentBranch}.base`])
.catch(() => {});
await gitConfigWrite(git, [
"config",
"--unset",
`branch.${currentBranch}.base`,
]).catch(() => {});
}
return { baseBranch: input.baseBranch };
}),
Expand Down
43 changes: 43 additions & 0 deletions packages/host-service/src/trpc/router/git/utils/config-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { SimpleGit } from "simple-git";

/**
* Run a `git config` write with bounded retries on `.git/config.lock`
* contention.
*
* `git config` takes a per-config flock that's held for milliseconds.
* Two concurrent writes (e.g. a renderer double-click on the base-branch
* picker, or `setBaseBranch` racing with `workspaceCreation.create`'s
* own config write) cause one to fail with:
*
* error: could not lock config file .git/config: File exists
*
* We catch that specific shape and retry with a short backoff so the
* second writer just waits its turn instead of bubbling a confusing 500
* to the renderer.
*/
export async function gitConfigWrite(
git: SimpleGit,
args: string[],
options: { retries?: number; baseDelayMs?: number } = {},
): Promise<string> {
Comment on lines +18 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No guard that args actually target git config

The function is documented and named as a git config write helper but accepts arbitrary args. A caller passing ["fetch", "--all"] would silently get lock-contention retry behaviour for an unrelated command. Adding an assertion or renaming to something generic (e.g. gitRawWithLockRetry) would prevent misuse. Current call sites are all correct.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/trpc/router/git/utils/config-write.ts
Line: 18-22

Comment:
**No guard that `args` actually target `git config`**

The function is documented and named as a `git config` write helper but accepts arbitrary `args`. A caller passing `["fetch", "--all"]` would silently get lock-contention retry behaviour for an unrelated command. Adding an assertion or renaming to something generic (e.g. `gitRawWithLockRetry`) would prevent misuse. Current call sites are all correct.

How can I resolve this? If you propose a fix, please make it concise.

// `retries` is the number of *additional* attempts after the first try,
// so default 4 == 1 initial + 4 retries (5 total), with backoff
// 30/60/120/240ms between them. Clamped at 0 to keep the loop sane.
const retries = Math.max(0, options.retries ?? 4);
const baseDelayMs = options.baseDelayMs ?? 30;
let lastErr: unknown = new Error("gitConfigWrite: no attempt completed");
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await git.raw(args);
} catch (err) {
lastErr = err;
const message = err instanceof Error ? err.message : String(err);
if (!message.includes("could not lock config file")) throw err;
if (attempt === retries) break;
await new Promise((resolve) =>
setTimeout(resolve, baseDelayMs * 2 ** attempt),
);
}
}
throw lastErr;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { and, eq, ne, or } from "drizzle-orm";
import { workspaces } from "../../../../db/schema";
import type { HostServiceContext } from "../../../../types";
import { protectedProcedure } from "../../../index";
import { gitConfigWrite } from "../../git/utils/config-write";
import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace";
import { adoptInputSchema } from "../schemas";
import {
Expand Down Expand Up @@ -102,14 +103,16 @@ async function recordBaseBranch(
baseBranch: string | undefined,
): Promise<void> {
if (!baseBranch) return;
await git
.raw(["config", `branch.${branch}.base`, baseBranch])
.catch((err) => {
console.warn(
`[workspaceCreation.adopt] failed to record base branch ${baseBranch}:`,
err,
);
});
await gitConfigWrite(git as Parameters<typeof gitConfigWrite>[0], [
"config",
`branch.${branch}.base`,
baseBranch,
]).catch((err) => {
console.warn(
`[workspaceCreation.adopt] failed to record base branch ${baseBranch}:`,
err,
);
});
}

export const adopt = protectedProcedure
Expand Down
Loading
Loading